diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 2751e71..0cf1209 100644 --- a/docs/swagger/swagger-spec.json +++ b/docs/swagger/swagger-spec.json @@ -77,30 +77,6 @@ ] } }, - "/archives/leaveRequests": { - "get": { - "operationId": "LeaveRequestsArchiveController_findOneArchived", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "200": { - "description": "Archived leaveRequest found" - } - }, - "summary": "Fetch leaveRequest in archives with its Id", - "tags": [ - "LeaveRequests Archives" - ] - } - }, "/archives/shifts": { "get": { "operationId": "ShiftsArchiveController_findOneArchived", @@ -149,77 +125,6 @@ ] } }, - "/employees": { - "post": { - "operationId": "EmployeesController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateEmployeeDto" - } - } - } - }, - "responses": { - "201": { - "description": "Employee created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateEmployeeDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create employee", - "tags": [ - "Employees" - ] - }, - "get": { - "operationId": "EmployeesController_findAll", - "parameters": [], - "responses": { - "200": { - "description": "List of employees found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateEmployeeDto" - } - } - } - } - }, - "400": { - "description": "List of employees not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all employees", - "tags": [ - "Employees" - ] - } - }, "/employees/employee-list": { "get": { "operationId": "EmployeesController_findListEmployees", @@ -254,74 +159,6 @@ } }, "/employees/{email}": { - "get": { - "operationId": "EmployeesController_findOne", - "parameters": [ - { - "name": "email", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Employee found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateEmployeeDto" - } - } - } - }, - "400": { - "description": "Employee not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find employee", - "tags": [ - "Employees" - ] - }, - "delete": { - "operationId": "EmployeesController_remove", - "parameters": [ - { - "name": "email", - "required": true, - "in": "path", - "description": "Email of the employee to delete", - "schema": { - "type": "number" - } - } - ], - "responses": { - "204": { - "description": "Employee deleted" - }, - "404": { - "description": "Employee not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete employee", - "tags": [ - "Employees" - ] - }, "patch": { "operationId": "EmployeesController_updateOrArchiveOrRestore", "parameters": [ @@ -384,85 +221,7 @@ ] } }, - "/employees/profile/{email}": { - "get": { - "operationId": "EmployeesController_findOneProfile", - "parameters": [ - { - "name": "email", - "required": true, - "in": "path", - "description": "Identifier of the employee", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Employee profile found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EmployeeProfileItemDto" - } - } - } - }, - "400": { - "description": "Employee profile not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find employee profile", - "tags": [ - "Employees" - ] - } - }, "/timesheets": { - "post": { - "operationId": "TimesheetsController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } - }, - "responses": { - "201": { - "description": "Timesheet created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create timesheet", - "tags": [ - "Timesheets" - ] - }, "get": { "operationId": "TimesheetsController_getPeriodByQuery", "parameters": [ @@ -506,139 +265,24 @@ ] } }, - "/timesheets/{id}": { + "/timesheets/{email}": { "get": { - "operationId": "TimesheetsController_findOne", + "operationId": "TimesheetsController_getByEmail", "parameters": [ { - "name": "id", + "name": "email", "required": true, "in": "path", "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Timesheet found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } + "type": "string" } }, - "400": { - "description": "Timesheet not found" - } - }, - "security": [ { - "access-token": [] - } - ], - "summary": "Find timesheet", - "tags": [ - "Timesheets" - ] - }, - "patch": { - "operationId": "TimesheetsController_update", - "parameters": [ - { - "name": "id", + "name": "offset", "required": true, - "in": "path", + "in": "query", "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateTimesheetDto" - } - } - } - }, - "responses": { - "201": { - "description": "Timesheet updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } - }, - "400": { - "description": "Timesheet not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Update timesheet", - "tags": [ - "Timesheets" - ] - }, - "delete": { - "operationId": "TimesheetsController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Timesheet deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateTimesheetDto" - } - } - } - }, - "400": { - "description": "Timesheet not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete timesheet", - "tags": [ - "Timesheets" - ] - } - }, - "/timesheets/approval/{id}": { - "patch": { - "operationId": "TimesheetsController_approve", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" + "type": "string" } } ], @@ -657,124 +301,24 @@ ] } }, - "/Expenses": { + "/timesheets/shifts/{email}": { "post": { - "operationId": "ExpensesController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "responses": { - "201": { - "description": "Expense created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create expense", - "tags": [ - "Expenses" - ] - }, - "get": { - "operationId": "ExpensesController_findAll", - "parameters": [], - "responses": { - "201": { - "description": "List of expenses found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - } - }, - "400": { - "description": "List of expenses not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all expenses", - "tags": [ - "Expenses" - ] - } - }, - "/Expenses/{id}": { - "get": { - "operationId": "ExpensesController_findOne", + "operationId": "TimesheetsController_createTimesheetShifts", "parameters": [ { - "name": "id", + "name": "email", "required": true, "in": "path", "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Expense found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } + "type": "string" } }, - "400": { - "description": "Expense not found" - } - }, - "security": [ { - "access-token": [] - } - ], - "summary": "Find expense", - "tags": [ - "Expenses" - ] - }, - "patch": { - "operationId": "ExpensesController_update", - "parameters": [ - { - "name": "id", + "name": "offset", "required": true, - "in": "path", + "in": "query", "schema": { - "type": "number" + "type": "string" } } ], @@ -783,24 +327,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateExpenseDto" + "$ref": "#/components/schemas/CreateWeekShiftsDto" } } } }, "responses": { "201": { - "description": "Expense updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "400": { - "description": "Expense not found" + "description": "" } }, "security": [ @@ -808,36 +342,45 @@ "access-token": [] } ], - "summary": "Expense shift", "tags": [ - "Expenses" + "Timesheets" ] - }, - "delete": { - "operationId": "ExpensesController_remove", + } + }, + "/Expenses/upsert/{email}/{date}": { + "put": { + "operationId": "ExpensesController_upsert_by_date", "parameters": [ { - "name": "id", + "name": "email", "required": true, "in": "path", "schema": { - "type": "number" + "type": "string" + } + }, + { + "name": "date", + "required": true, + "in": "path", + "schema": { + "type": "string" } } ], - "responses": { - "201": { - "description": "Expense deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertExpenseDto" } } - }, - "400": { - "description": "Expense not found" + } + }, + "responses": { + "200": { + "description": "" } }, "security": [ @@ -845,18 +388,33 @@ "access-token": [] } ], - "summary": "Delete expense", "tags": [ "Expenses" ] } }, - "/Expenses/approval/{id}": { - "patch": { - "operationId": "ExpensesController_approve", + "/Expenses/list/{email}/{year}/{period_no}": { + "get": { + "operationId": "ExpensesController_findExpenseListByPayPeriodAndEmail", "parameters": [ { - "name": "id", + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "year", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "period_no", "required": true, "in": "path", "schema": { @@ -879,124 +437,16 @@ ] } }, - "/shifts": { - "post": { - "operationId": "ShiftsController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "responses": { - "201": { - "description": "Shift created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create shift", - "tags": [ - "Shifts" - ] - }, - "get": { - "operationId": "ShiftsController_findAll", - "parameters": [], - "responses": { - "201": { - "description": "List of shifts found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - } - }, - "400": { - "description": "List of shifts not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all shifts", - "tags": [ - "Shifts" - ] - } - }, - "/shifts/{id}": { - "get": { - "operationId": "ShiftsController_findOne", + "/shifts/upsert/{email}": { + "put": { + "operationId": "ShiftsController_upsert_by_date", "parameters": [ { - "name": "id", + "name": "email", "required": true, "in": "path", "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Shift found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "400": { - "description": "Shift not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find shift", - "tags": [ - "Shifts" - ] - }, - "patch": { - "operationId": "ShiftsController_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" + "type": "string" } } ], @@ -1005,24 +455,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateShiftsDto" + "$ref": "#/components/schemas/UpsertShiftDto" } } } }, "responses": { - "201": { - "description": "Shift updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "400": { - "description": "Shift not found" + "200": { + "description": "" } }, "security": [ @@ -1030,44 +470,6 @@ "access-token": [] } ], - "summary": "Update shift", - "tags": [ - "Shifts" - ] - }, - "delete": { - "operationId": "ShiftsController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Shift deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "400": { - "description": "Shift not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete shift", "tags": [ "Shifts" ] @@ -1167,215 +569,22 @@ ] } }, - "/leave-requests": { + "/leave-requests/upsert": { "post": { - "operationId": "LeaveRequestController_create", + "operationId": "LeaveRequestController_upsertLeaveRequest", "parameters": [], "requestBody": { "required": true, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateLeaveRequestsDto" + "$ref": "#/components/schemas/UpsertLeaveRequestDto" } } } }, "responses": { "201": { - "description": "Leave request created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateLeaveRequestsDto" - } - } - } - }, - "400": { - "description": "Incomplete task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create leave request", - "tags": [ - "Leave Requests" - ] - }, - "get": { - "operationId": "LeaveRequestController_findAll", - "parameters": [], - "responses": { - "201": { - "description": "List of Leave requests found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LeaveRequestViewDto" - } - } - } - } - }, - "400": { - "description": "List of leave request not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all leave request", - "tags": [ - "Leave Requests" - ] - } - }, - "/leave-requests/{id}": { - "get": { - "operationId": "LeaveRequestController_findOne", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Leave request found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LeaveRequestViewDto" - } - } - } - }, - "400": { - "description": "Leave request not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find leave request", - "tags": [ - "Leave Requests" - ] - }, - "patch": { - "operationId": "LeaveRequestController_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateLeaveRequestsDto" - } - } - } - }, - "responses": { - "201": { - "description": "Leave request updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LeaveRequestViewDto" - } - } - } - }, - "400": { - "description": "Leave request not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Update leave request", - "tags": [ - "Leave Requests" - ] - }, - "delete": { - "operationId": "LeaveRequestController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Leave request deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateLeaveRequestsDto" - } - } - } - }, - "400": { - "description": "Leave request not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete leave request", - "tags": [ - "Leave Requests" - ] - } - }, - "/leave-requests/approval/{id}": { - "patch": { - "operationId": "LeaveRequestController_updateApproval", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "200": { "description": "" } }, @@ -1417,342 +626,6 @@ ] } }, - "/bank-codes": { - "post": { - "operationId": "BankCodesControllers_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateBankCodeDto" - } - } - } - }, - "responses": { - "201": { - "description": "Bank code successfully created." - }, - "400": { - "description": "Invalid input data." - } - }, - "summary": "Create a new bank code", - "tags": [ - "BankCodesControllers" - ] - }, - "get": { - "operationId": "BankCodesControllers_findAll", - "parameters": [], - "responses": { - "200": { - "description": "List of bank codes." - } - }, - "summary": "Retrieve all bank codes", - "tags": [ - "BankCodesControllers" - ] - } - }, - "/bank-codes/{id}": { - "get": { - "operationId": "BankCodesControllers_findOne", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "404": { - "description": "Bank code not found." - } - }, - "summary": "Retrieve a bank code by its ID", - "tags": [ - "BankCodesControllers" - ] - }, - "patch": { - "operationId": "BankCodesControllers_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateBankCodeDto" - } - } - } - }, - "responses": { - "404": { - "description": "Bank code not found." - } - }, - "summary": "Update an existing bank code", - "tags": [ - "BankCodesControllers" - ] - }, - "delete": { - "operationId": "BankCodesControllers_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "404": { - "description": "Bank code not found." - } - }, - "summary": "Delete a bank code", - "tags": [ - "BankCodesControllers" - ] - } - }, - "/exports/csv": { - "get": { - "operationId": "CsvExportController_exportCsv", - "parameters": [ - { - "name": "period", - "required": true, - "in": "query", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "tags": [ - "CsvExport" - ] - } - }, - "/customers": { - "post": { - "operationId": "CustomersController_create", - "parameters": [], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "responses": { - "201": { - "description": "Customer created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "400": { - "description": "Invalid task or invalid data" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Create customer", - "tags": [ - "Customers" - ] - }, - "get": { - "operationId": "CustomersController_findAll", - "parameters": [], - "responses": { - "201": { - "description": "List of customers found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - } - }, - "400": { - "description": "List of customers not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find all customers", - "tags": [ - "Customers" - ] - } - }, - "/customers/{id}": { - "get": { - "operationId": "CustomersController_findOne", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Customer found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "400": { - "description": "Customer not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find customer", - "tags": [ - "Customers" - ] - }, - "patch": { - "operationId": "CustomersController_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateCustomerDto" - } - } - } - }, - "responses": { - "201": { - "description": "Customer updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "400": { - "description": "Customer not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Update customer", - "tags": [ - "Customers" - ] - }, - "delete": { - "operationId": "CustomersController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Customer deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateCustomerDto" - } - } - } - }, - "400": { - "description": "Customer not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete customer", - "tags": [ - "Customers" - ] - } - }, "/oauth-sessions": { "post": { "operationId": "OauthSessionsController_create", @@ -1947,31 +820,6 @@ ] } }, - "/pay-periods": { - "get": { - "operationId": "PayPeriodsController_findAll", - "parameters": [], - "responses": { - "200": { - "description": "List of pay period found", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/PayPeriodDto" - } - } - } - } - } - }, - "summary": "Find all pay period", - "tags": [ - "pay-periods" - ] - } - }, "/pay-periods/current-and-all": { "get": { "operationId": "PayPeriodsController_getCurrentAndAll", @@ -2216,6 +1064,140 @@ "pay-periods" ] } + }, + "/preferences/{email}": { + "patch": { + "operationId": "PreferencesController_updatePreferences", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreferencesDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Preferences" + ] + } + }, + "/schedule-presets/{email}": { + "put": { + "operationId": "SchedulePresetsController_upsert", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "action", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SchedulePresetsDto" + } + } + } + }, + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "SchedulePresets" + ] + }, + "get": { + "operationId": "SchedulePresetsController_findListByEmail", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "SchedulePresets" + ] + } + }, + "/schedule-presets/apply-presets/{email}": { + "post": { + "operationId": "SchedulePresetsController_applyPresets", + "parameters": [ + { + "name": "email", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "preset", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "start", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "" + } + }, + "tags": [ + "SchedulePresets" + ] + } } }, "info": { @@ -2275,6 +1257,81 @@ } }, "schemas": { + "EmployeeListItemDto": { + "type": "object", + "properties": {} + }, + "UpdateEmployeeDto": { + "type": "object", + "properties": { + "id": { + "type": "number", + "example": 1, + "description": "Unique ID of an employee(primary-key, auto-incremented)" + }, + "user_id": { + "type": "string", + "example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d", + "description": "UUID of the user linked to that employee" + }, + "first_name": { + "type": "string", + "example": "Frodo", + "description": "Employee`s first name" + }, + "last_name": { + "type": "string", + "example": "Baggins", + "description": "Employee`s last name" + }, + "email": { + "type": "string", + "example": "i_cant_do_this_sam@targointernet.com", + "description": "Employee`s email" + }, + "phone_number": { + "type": "string", + "example": "82538437464", + "description": "Employee`s phone number" + }, + "residence": { + "type": "string", + "example": "1 Bagshot Row, Hobbiton, The Shire, Middle-earth", + "description": "Employee`s residence" + }, + "external_payroll_id": { + "type": "number", + "example": 7464, + "description": "external ID for the pay system" + }, + "company_code": { + "type": "number", + "example": 335567447, + "description": "Employee`s company code" + }, + "job_title": { + "type": "string", + "example": "technicient", + "description": "employee`s job title" + }, + "first_work_day": { + "format": "date-time", + "type": "string", + "example": "23/09/3018", + "description": "New hire date or undefined" + }, + "last_work_day": { + "format": "date-time", + "type": "string", + "example": "25/03/3019", + "description": "Termination date (null to restore)" + }, + "supervisor_id": { + "type": "number", + "description": "Supervisor ID" + } + } + }, "CreateEmployeeDto": { "type": "object", "properties": { @@ -2304,7 +1361,7 @@ "description": "Employee`s email" }, "phone_number": { - "type": "number", + "type": "string", "example": "82538437464", "description": "Employee`s phone number" }, @@ -2352,556 +1409,21 @@ "first_work_day" ] }, - "EmployeeListItemDto": { + "CreateWeekShiftsDto": { "type": "object", "properties": {} }, - "EmployeeProfileItemDto": { + "UpsertExpenseDto": { "type": "object", "properties": {} }, - "UpdateEmployeeDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of an employee(primary-key, auto-incremented)" - }, - "user_id": { - "type": "string", - "example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d", - "description": "UUID of the user linked to that employee" - }, - "first_name": { - "type": "string", - "example": "Frodo", - "description": "Employee`s first name" - }, - "last_name": { - "type": "string", - "example": "Baggins", - "description": "Employee`s last name" - }, - "email": { - "type": "string", - "example": "i_cant_do_this_sam@targointernet.com", - "description": "Employee`s email" - }, - "phone_number": { - "type": "number", - "example": "82538437464", - "description": "Employee`s phone number" - }, - "residence": { - "type": "string", - "example": "1 Bagshot Row, Hobbiton, The Shire, Middle-earth", - "description": "Employee`s residence" - }, - "external_payroll_id": { - "type": "number", - "example": 7464, - "description": "external ID for the pay system" - }, - "company_code": { - "type": "number", - "example": 335567447, - "description": "Employee`s company code" - }, - "job_title": { - "type": "string", - "example": "technicient", - "description": "employee`s job title" - }, - "first_work_day": { - "format": "date-time", - "type": "string", - "example": "23/09/3018", - "description": "New hire date or undefined" - }, - "last_work_day": { - "format": "date-time", - "type": "string", - "example": "25/03/3019", - "description": "Termination date (null to restore)" - }, - "supervisor_id": { - "type": "number", - "description": "Supervisor ID" - } - } - }, - "CreateTimesheetDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "timesheet`s unique ID (auto-generated)" - }, - "employee_id": { - "type": "number", - "example": 426433, - "description": "employee`s ID number of linked timsheet" - }, - "is_approved": { - "type": "boolean", - "example": true, - "description": "Timesheet`s status approval" - } - }, - "required": [ - "id", - "employee_id", - "is_approved" - ] - }, - "UpdateTimesheetDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "timesheet`s unique ID (auto-generated)" - }, - "employee_id": { - "type": "number", - "example": 426433, - "description": "employee`s ID number of linked timsheet" - }, - "is_approved": { - "type": "boolean", - "example": true, - "description": "Timesheet`s status approval" - } - } - }, - "CreateExpenseDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of the expense (auto-generated)" - }, - "timesheet_id": { - "type": "number", - "example": 101, - "description": "ID number for a set timesheet" - }, - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of an bank code (link with bank-codes)" - }, - "date": { - "type": "string", - "example": "3018-10-20T00:00:00.000Z", - "description": "Date where the expense was made" - }, - "amount": { - "type": "number", - "example": 17.82, - "description": "amount in $ for a refund" - }, - "description": { - "type": "string", - "example": "Spent for mileage between A and B", - "description": "explain`s why the expense was made" - }, - "is_approved": { - "type": "boolean", - "example": "DENIED, APPROUVED, PENDING, etc...", - "description": "validation status" - }, - "supervisor_comment": { - "type": "string", - "example": "Asked X to go there as an emergency response", - "description": "Supervisro`s justification for the spending of an employee" - } - }, - "required": [ - "id", - "timesheet_id", - "bank_code_id", - "date", - "amount", - "description", - "is_approved", - "supervisor_comment" - ] - }, - "UpdateExpenseDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of the expense (auto-generated)" - }, - "timesheet_id": { - "type": "number", - "example": 101, - "description": "ID number for a set timesheet" - }, - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of an bank code (link with bank-codes)" - }, - "date": { - "type": "string", - "example": "3018-10-20T00:00:00.000Z", - "description": "Date where the expense was made" - }, - "amount": { - "type": "number", - "example": 17.82, - "description": "amount in $ for a refund" - }, - "description": { - "type": "string", - "example": "Spent for mileage between A and B", - "description": "explain`s why the expense was made" - }, - "is_approved": { - "type": "boolean", - "example": "DENIED, APPROUVED, PENDING, etc...", - "description": "validation status" - }, - "supervisor_comment": { - "type": "string", - "example": "Asked X to go there as an emergency response", - "description": "Supervisro`s justification for the spending of an employee" - } - } - }, - "CreateShiftDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of the shift (auto-generated)" - }, - "timesheet_id": { - "type": "number", - "example": 101, - "description": "ID number for a set timesheet" - }, - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of a shift code (link with bank-codes)" - }, - "date": { - "type": "string", - "example": "3018-10-20T00:00:00.000Z", - "description": "Date where the shift takes place" - }, - "start_time": { - "type": "string", - "example": "3018-10-20T08:00:00.000Z", - "description": "Start time of the said shift" - }, - "end_time": { - "type": "string", - "example": "3018-10-20T17:00:00.000Z", - "description": "End time of the said shift" - } - }, - "required": [ - "id", - "timesheet_id", - "bank_code_id", - "date", - "start_time", - "end_time" - ] - }, - "UpdateShiftsDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of the shift (auto-generated)" - }, - "timesheet_id": { - "type": "number", - "example": 101, - "description": "ID number for a set timesheet" - }, - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of a shift code (link with bank-codes)" - }, - "date": { - "type": "string", - "example": "3018-10-20T00:00:00.000Z", - "description": "Date where the shift takes place" - }, - "start_time": { - "type": "string", - "example": "3018-10-20T08:00:00.000Z", - "description": "Start time of the said shift" - }, - "end_time": { - "type": "string", - "example": "3018-10-20T17:00:00.000Z", - "description": "End time of the said shift" - } - } - }, - "CreateLeaveRequestsDto": { - "type": "object", - "properties": { - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of a leave-request code (link with bank-codes)" - }, - "leave_type": { - "type": "string", - "example": "Sick or Vacation or Unpaid or Bereavement or Parental or Legal", - "description": "type of leave request for an accounting perception" - }, - "start_date_time": { - "type": "string", - "example": "22/06/2463", - "description": "Leave request`s start date" - }, - "end_date_time": { - "type": "string", - "example": "25/03/3019", - "description": "Leave request`s end date" - }, - "comment": { - "type": "string", - "example": "My precious", - "description": "Leave request`s comment" - }, - "approval_status": { - "type": "string", - "example": "True or False or Pending or Denied or Cancelled or Escalated", - "description": "Leave request`s approval status" - } - }, - "required": [ - "bank_code_id", - "leave_type", - "start_date_time", - "end_date_time", - "comment", - "approval_status" - ] - }, - "LeaveRequestViewDto": { + "UpsertShiftDto": { "type": "object", "properties": {} }, - "UpdateLeaveRequestsDto": { + "UpsertLeaveRequestDto": { "type": "object", - "properties": { - "bank_code_id": { - "type": "number", - "example": 7, - "description": "ID number of a leave-request code (link with bank-codes)" - }, - "leave_type": { - "type": "string", - "example": "Sick or Vacation or Unpaid or Bereavement or Parental or Legal", - "description": "type of leave request for an accounting perception" - }, - "start_date_time": { - "type": "string", - "example": "22/06/2463", - "description": "Leave request`s start date" - }, - "end_date_time": { - "type": "string", - "example": "25/03/3019", - "description": "Leave request`s end date" - }, - "comment": { - "type": "string", - "example": "My precious", - "description": "Leave request`s comment" - }, - "approval_status": { - "type": "string", - "example": "True or False or Pending or Denied or Cancelled or Escalated", - "description": "Leave request`s approval status" - } - } - }, - "CreateBankCodeDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of a bank-code (auto-generated)", - "readOnly": true - }, - "type": { - "type": "string", - "example": "regular, vacation, emergency, sick, parental, etc", - "description": "Type of codes" - }, - "categorie": { - "type": "string", - "example": "shift, expense, leave", - "description": "categorie of the related code" - }, - "modifier": { - "type": "number", - "example": "0, 0.72, 1, 1.5, 2", - "description": "modifier number to apply to salary" - }, - "bank_code": { - "type": "string", - "example": "G1, G345, G501, G43, G700", - "description": "codes given by the bank" - } - }, - "required": [ - "id", - "type", - "categorie", - "modifier", - "bank_code" - ] - }, - "UpdateBankCodeDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of a bank-code (auto-generated)", - "readOnly": true - }, - "type": { - "type": "string", - "example": "regular, vacation, emergency, sick, parental, etc", - "description": "Type of codes" - }, - "categorie": { - "type": "string", - "example": "shift, expense, leave", - "description": "categorie of the related code" - }, - "modifier": { - "type": "number", - "example": "0, 0.72, 1, 1.5, 2", - "description": "modifier number to apply to salary" - }, - "bank_code": { - "type": "string", - "example": "G1, G345, G501, G43, G700", - "description": "codes given by the bank" - } - } - }, - "CreateCustomerDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of a customer(primary-key, auto-incremented)" - }, - "user_id": { - "type": "string", - "example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d", - "description": "UUID of the user linked to that customer" - }, - "first_name": { - "type": "string", - "example": "Gandalf", - "description": "Customer`s first name" - }, - "last_name": { - "type": "string", - "example": "TheGray", - "description": "Customer`s last name" - }, - "email": { - "type": "string", - "example": "you_shall_not_pass@middleEarth.com", - "description": "Customer`s email" - }, - "phone_number": { - "type": "number", - "example": "8436637464", - "description": "Customer`s phone number" - }, - "residence": { - "type": "string", - "example": "1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ", - "description": "Customer`s residence" - }, - "invoice_id": { - "type": "number", - "example": "4263253", - "description": "Customer`s invoice number" - } - }, - "required": [ - "id", - "user_id", - "first_name", - "last_name", - "email", - "phone_number" - ] - }, - "UpdateCustomerDto": { - "type": "object", - "properties": { - "id": { - "type": "number", - "example": 1, - "description": "Unique ID of a customer(primary-key, auto-incremented)" - }, - "user_id": { - "type": "string", - "example": "0e6e2e1f-b157-4c7c-ae3f-999b3e4f914d", - "description": "UUID of the user linked to that customer" - }, - "first_name": { - "type": "string", - "example": "Gandalf", - "description": "Customer`s first name" - }, - "last_name": { - "type": "string", - "example": "TheGray", - "description": "Customer`s last name" - }, - "email": { - "type": "string", - "example": "you_shall_not_pass@middleEarth.com", - "description": "Customer`s email" - }, - "phone_number": { - "type": "number", - "example": "8436637464", - "description": "Customer`s phone number" - }, - "residence": { - "type": "string", - "example": "1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ", - "description": "Customer`s residence" - }, - "invoice_id": { - "type": "number", - "example": "4263253", - "description": "Customer`s invoice number" - } - } + "properties": {} }, "CreateOauthSessionDto": { "type": "object", @@ -3093,20 +1615,10 @@ "example": 40, "description": "pay-period`s regular hours" }, - "evening_hours": { - "type": "number", + "other_hours": { + "type": "object", "example": 0, - "description": "pay-period`s evening hours" - }, - "emergency_hours": { - "type": "number", - "example": 0, - "description": "pay-period`s emergency hours" - }, - "overtime_hours": { - "type": "number", - "example": 2, - "description": "pay-period`s overtime hours" + "description": "pay-period`s other hours" }, "expenses": { "type": "number", @@ -3127,9 +1639,7 @@ "required": [ "employee_name", "regular_hours", - "evening_hours", - "emergency_hours", - "overtime_hours", + "other_hours", "expenses", "mileage", "is_approved" @@ -3188,6 +1698,14 @@ "label", "employees_overview" ] + }, + "PreferencesDto": { + "type": "object", + "properties": {} + }, + "SchedulePresetsDto": { + "type": "object", + "properties": {} } } } diff --git a/package-lock.json b/package-lock.json index 91fd5eb..4ee606c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,7 +54,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.14.0", + "prisma": "^6.17.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -3633,9 +3633,9 @@ } }, "node_modules/@prisma/config": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.14.0.tgz", - "integrity": "sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.17.0.tgz", + "integrity": "sha512-k8tuChKpkO/Vj7ZEzaQMNflNGbaW4X0r8+PC+W2JaqVRdiS2+ORSv1SrDwNxsb8YyzIQJucXqLGZbgxD97ZhsQ==", "devOptional": true, "dependencies": { "c12": "3.1.0", @@ -3645,48 +3645,48 @@ } }, "node_modules/@prisma/debug": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.14.0.tgz", - "integrity": "sha512-j4Lf+y+5QIJgQD4sJWSbkOD7geKx9CakaLp/TyTy/UDu9Wo0awvWCBH/BAxTHUaCpIl9USA5VS/KJhDqKJSwug==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.17.0.tgz", + "integrity": "sha512-eE2CB32nr1hRqyLVnOAVY6c//iSJ/PN+Yfoa/2sEzLGpORaCg61d+nvdAkYSh+6Y2B8L4BVyzkRMANLD6nnC2g==", "devOptional": true }, "node_modules/@prisma/engines": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.14.0.tgz", - "integrity": "sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.17.0.tgz", + "integrity": "sha512-XhE9v3hDQTNgCYMjogcCYKi7HCEkZf9WwTGuXy8cmY8JUijvU0ap4M7pGLx4pBblkp5EwUsYzw1YLtH7yi0GZw==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/debug": "6.14.0", - "@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", - "@prisma/fetch-engine": "6.14.0", - "@prisma/get-platform": "6.14.0" + "@prisma/debug": "6.17.0", + "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", + "@prisma/fetch-engine": "6.17.0", + "@prisma/get-platform": "6.17.0" } }, "node_modules/@prisma/engines-version": { - "version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", - "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49.tgz", - "integrity": "sha512-EgN9ODJpiX45yvwcngoStp3uQPJ3l+AEVoQ6dMMO2QvmwIlnxfApzKmJQExzdo7/hqQANrz5txHJdGYHzOnGHA==", + "version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a.tgz", + "integrity": "sha512-G0VU4uFDreATgTz4sh3dTtU2C+jn+J6c060ixavWZaUaSRZsNQhSPW26lbfez7GHzR02RGCdqs5UcSuGBC3yLw==", "devOptional": true }, "node_modules/@prisma/fetch-engine": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.14.0.tgz", - "integrity": "sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.17.0.tgz", + "integrity": "sha512-YSl5R3WIAPrmshYPkaaszOsBIWRAovOCHn3y7gkTNGG51LjYW4pi6PFNkGouW6CA06qeTjTbGrDRCgFjnmVWDg==", "devOptional": true, "dependencies": { - "@prisma/debug": "6.14.0", - "@prisma/engines-version": "6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49", - "@prisma/get-platform": "6.14.0" + "@prisma/debug": "6.17.0", + "@prisma/engines-version": "6.17.0-16.c0aafc03b8ef6cdced8654b9a817999e02457d6a", + "@prisma/get-platform": "6.17.0" } }, "node_modules/@prisma/get-platform": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.14.0.tgz", - "integrity": "sha512-7VjuxKNwjnBhKfqPpMeWiHEa2sVjYzmHdl1slW6STuUCe9QnOY0OY1ljGSvz6wpG4U8DfbDqkG1yofd/1GINww==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.17.0.tgz", + "integrity": "sha512-3tEKChrnlmLXPd870oiVfRvj7vVKuxqP349hYaMDsbV4TZd3+lFqw8KTI2Tbq5DopamfNuNqhVCj+R6ZxKKYGQ==", "devOptional": true, "dependencies": { - "@prisma/debug": "6.14.0" + "@prisma/debug": "6.17.0" } }, "node_modules/@scarf/scarf": { @@ -10078,15 +10078,15 @@ } }, "node_modules/nypm": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.1.tgz", - "integrity": "sha512-hlacBiRiv1k9hZFiphPUkfSQ/ZfQzZDzC+8z0wL3lvDAOUu/2NnChkKuMoMjNur/9OpKuz2QsIeiPVN0xM5Q0w==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", "devOptional": true, "dependencies": { "citty": "^0.1.6", "consola": "^3.4.2", "pathe": "^2.0.3", - "pkg-types": "^2.2.0", + "pkg-types": "^2.3.0", "tinyexec": "^1.0.1" }, "bin": { @@ -10595,9 +10595,9 @@ } }, "node_modules/pkg-types": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.2.0.tgz", - "integrity": "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", "devOptional": true, "dependencies": { "confbox": "^0.2.2", @@ -10677,14 +10677,14 @@ } }, "node_modules/prisma": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.14.0.tgz", - "integrity": "sha512-QEuCwxu+Uq9BffFw7in8In+WfbSUN0ewnaSUKloLkbJd42w6EyFckux4M0f7VwwHlM3A8ssaz4OyniCXlsn0WA==", + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.17.0.tgz", + "integrity": "sha512-rcvldz98r+2bVCs0MldQCBaaVJRCj9Ew4IqphLATF89OJcSzwRQpwnKXR+W2+2VjK7/o2x3ffu5+2N3Muu6Dbw==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/config": "6.14.0", - "@prisma/engines": "6.14.0" + "@prisma/config": "6.17.0", + "@prisma/engines": "6.17.0" }, "bin": { "prisma": "build/index.js" diff --git a/package.json b/package.json index b1684ee..edc1be1 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.14.0", + "prisma": "^6.17.0", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", diff --git a/prisma/migrations/20250826165409_changed_phone_type_to_string/migration.sql b/prisma/migrations/20250826165409_changed_phone_type_to_string/migration.sql new file mode 100644 index 0000000..c7ba659 --- /dev/null +++ b/prisma/migrations/20250826165409_changed_phone_type_to_string/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."users" ALTER COLUMN "phone_number" SET DATA TYPE TEXT; diff --git a/prisma/migrations/20250828175750_changed_phone_number_type_to_string/migration.sql b/prisma/migrations/20250828175750_changed_phone_number_type_to_string/migration.sql new file mode 100644 index 0000000..c7ba659 --- /dev/null +++ b/prisma/migrations/20250828175750_changed_phone_number_type_to_string/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."users" ALTER COLUMN "phone_number" SET DATA TYPE TEXT; diff --git a/prisma/migrations/20250829152939_timesheet_week_unique/migration.sql b/prisma/migrations/20250829152939_timesheet_week_unique/migration.sql new file mode 100644 index 0000000..8073704 --- /dev/null +++ b/prisma/migrations/20250829152939_timesheet_week_unique/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - A unique constraint covering the columns `[employee_id,start_date]` on the table `timesheets` will be added. If there are existing duplicate values, this will fail. + - Added the required column `start_date` to the `timesheets` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "public"."timesheets" ADD COLUMN "start_date" DATE NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "timesheets_employee_id_start_date_key" ON "public"."timesheets"("employee_id", "start_date"); diff --git a/prisma/migrations/20250908192545_added_is_remote_to_shifts/migration.sql b/prisma/migrations/20250908192545_added_is_remote_to_shifts/migration.sql new file mode 100644 index 0000000..50adfce --- /dev/null +++ b/prisma/migrations/20250908192545_added_is_remote_to_shifts/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."shifts" ADD COLUMN "is_remote" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/migrations/20250909132655_refactor_description_to_comment_for_shifts_and_expenses/migration.sql b/prisma/migrations/20250909132655_refactor_description_to_comment_for_shifts_and_expenses/migration.sql new file mode 100644 index 0000000..7082aa6 --- /dev/null +++ b/prisma/migrations/20250909132655_refactor_description_to_comment_for_shifts_and_expenses/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `description` on the `expenses` table. All the data in the column will be lost. + - You are about to drop the column `description` on the `expenses_archive` table. All the data in the column will be lost. + - You are about to drop the column `description` on the `shifts` table. All the data in the column will be lost. + - You are about to drop the column `description` on the `shifts_archive` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "public"."expenses" DROP COLUMN "description", +ADD COLUMN "comment" TEXT; + +-- AlterTable +ALTER TABLE "public"."expenses_archive" DROP COLUMN "description", +ADD COLUMN "comment" TEXT; + +-- AlterTable +ALTER TABLE "public"."shifts" DROP COLUMN "description", +ADD COLUMN "comment" TEXT; + +-- AlterTable +ALTER TABLE "public"."shifts_archive" DROP COLUMN "description", +ADD COLUMN "comment" TEXT; diff --git a/prisma/migrations/20251001193437_link_expense_attachments/migration.sql b/prisma/migrations/20251001193437_link_expense_attachments/migration.sql new file mode 100644 index 0000000..a74952c --- /dev/null +++ b/prisma/migrations/20251001193437_link_expense_attachments/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - You are about to drop the column `attachement` on the `expenses` table. All the data in the column will be lost. + - You are about to drop the column `attachement` on the `expenses_archive` table. All the data in the column will be lost. + - Made the column `comment` on table `expenses` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "public"."expenses" DROP COLUMN "attachement", +ADD COLUMN "attachment" INTEGER, +ADD COLUMN "mileage" DECIMAL(65,30), +ALTER COLUMN "comment" SET NOT NULL; + +-- AlterTable +ALTER TABLE "public"."expenses_archive" DROP COLUMN "attachement", +ADD COLUMN "attachment" INTEGER, +ADD COLUMN "mileage" DECIMAL(65,30), +ALTER COLUMN "amount" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "public"."expenses" ADD CONSTRAINT "expenses_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."expenses_archive" ADD CONSTRAINT "expenses_archive_attachment_fkey" FOREIGN KEY ("attachment") REFERENCES "public"."attachments"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20251003151925_added_preferences_table/migration.sql b/prisma/migrations/20251003151925_added_preferences_table/migration.sql new file mode 100644 index 0000000..ab54053 --- /dev/null +++ b/prisma/migrations/20251003151925_added_preferences_table/migration.sql @@ -0,0 +1,56 @@ +/* + Warnings: + + - You are about to drop the column `end_date_time` on the `leave_requests` table. All the data in the column will be lost. + - You are about to drop the column `start_date_time` on the `leave_requests` table. All the data in the column will be lost. + - You are about to drop the column `end_date_time` on the `leave_requests_archive` table. All the data in the column will be lost. + - You are about to drop the column `start_date_time` on the `leave_requests_archive` table. All the data in the column will be lost. + - A unique constraint covering the columns `[employee_id,leave_type,date]` on the table `leave_requests` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[leave_request_id]` on the table `leave_requests_archive` will be added. If there are existing duplicate values, this will fail. + - Added the required column `date` to the `leave_requests` table without a default value. This is not possible if the table is not empty. + - Added the required column `date` to the `leave_requests_archive` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterEnum +ALTER TYPE "leave_types" ADD VALUE 'HOLIDAY'; + +-- AlterTable +ALTER TABLE "leave_requests" DROP COLUMN "end_date_time", +DROP COLUMN "start_date_time", +ADD COLUMN "date" DATE NOT NULL, +ADD COLUMN "payable_hours" DECIMAL(5,2), +ADD COLUMN "requested_hours" DECIMAL(5,2); + +-- AlterTable +ALTER TABLE "leave_requests_archive" DROP COLUMN "end_date_time", +DROP COLUMN "start_date_time", +ADD COLUMN "date" DATE NOT NULL, +ADD COLUMN "payable_hours" DECIMAL(5,2), +ADD COLUMN "requested_hours" DECIMAL(5,2); + +-- CreateTable +CREATE TABLE "preferences" ( + "user_id" UUID NOT NULL, + "notifications" BOOLEAN NOT NULL DEFAULT false, + "dark_mode" BOOLEAN NOT NULL DEFAULT false, + "lang_switch" BOOLEAN NOT NULL DEFAULT false, + "lefty_mode" BOOLEAN NOT NULL DEFAULT false +); + +-- CreateIndex +CREATE UNIQUE INDEX "preferences_user_id_key" ON "preferences"("user_id"); + +-- CreateIndex +CREATE INDEX "leave_requests_employee_id_date_idx" ON "leave_requests"("employee_id", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "leave_requests_employee_id_leave_type_date_key" ON "leave_requests"("employee_id", "leave_type", "date"); + +-- CreateIndex +CREATE INDEX "leave_requests_archive_employee_id_date_idx" ON "leave_requests_archive"("employee_id", "date"); + +-- CreateIndex +CREATE UNIQUE INDEX "leave_requests_archive_leave_request_id_key" ON "leave_requests_archive"("leave_request_id"); + +-- AddForeignKey +ALTER TABLE "preferences" ADD CONSTRAINT "preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql b/prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql new file mode 100644 index 0000000..4bc5fd9 --- /dev/null +++ b/prisma/migrations/20251008152226_added_schedule_presets_and_schedule_preset_shifts_tables_and_weekday_enm/migration.sql @@ -0,0 +1,48 @@ +-- CreateEnum +CREATE TYPE "Weekday" AS ENUM ('SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'); + +-- AlterTable +ALTER TABLE "preferences" ADD COLUMN "id" SERIAL NOT NULL, +ADD CONSTRAINT "preferences_pkey" PRIMARY KEY ("id"); + +-- CreateTable +CREATE TABLE "schedule_presets" ( + "id" SERIAL NOT NULL, + "employee_id" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "is_default" BOOLEAN NOT NULL DEFAULT false, + + CONSTRAINT "schedule_presets_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "schedule_preset_shifts" ( + "id" SERIAL NOT NULL, + "preset_id" INTEGER NOT NULL, + "bank_code_id" INTEGER NOT NULL, + "sort_order" INTEGER NOT NULL, + "start_time" TIME(0) NOT NULL, + "end_time" TIME(0) NOT NULL, + "is_remote" BOOLEAN NOT NULL DEFAULT false, + "week_day" "Weekday" NOT NULL, + + CONSTRAINT "schedule_preset_shifts_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "schedule_presets_employee_id_name_key" ON "schedule_presets"("employee_id", "name"); + +-- CreateIndex +CREATE INDEX "schedule_preset_shifts_preset_id_week_day_idx" ON "schedule_preset_shifts"("preset_id", "week_day"); + +-- CreateIndex +CREATE UNIQUE INDEX "schedule_preset_shifts_preset_id_week_day_sort_order_key" ON "schedule_preset_shifts"("preset_id", "week_day", "sort_order"); + +-- AddForeignKey +ALTER TABLE "schedule_presets" ADD CONSTRAINT "schedule_presets_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_preset_id_fkey" FOREIGN KEY ("preset_id") REFERENCES "schedule_presets"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "schedule_preset_shifts" ADD CONSTRAINT "schedule_preset_shifts_bank_code_id_fkey" FOREIGN KEY ("bank_code_id") REFERENCES "bank_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20251009141338_change_type_mileage/migration.sql b/prisma/migrations/20251009141338_change_type_mileage/migration.sql new file mode 100644 index 0000000..f43e8de --- /dev/null +++ b/prisma/migrations/20251009141338_change_type_mileage/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - You are about to alter the column `mileage` on the `expenses` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Decimal(12,2)`. + - You are about to alter the column `mileage` on the `expenses_archive` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `Decimal(12,2)`. + +*/ +-- AlterTable +ALTER TABLE "expenses" ALTER COLUMN "mileage" SET DATA TYPE DECIMAL(12,2); + +-- AlterTable +ALTER TABLE "expenses_archive" ALTER COLUMN "mileage" SET DATA TYPE DECIMAL(12,2); diff --git a/prisma/migrations/20251010124650_changed_boolean_to_int_for_table_preferences/migration.sql b/prisma/migrations/20251010124650_changed_boolean_to_int_for_table_preferences/migration.sql new file mode 100644 index 0000000..bf8f7c6 --- /dev/null +++ b/prisma/migrations/20251010124650_changed_boolean_to_int_for_table_preferences/migration.sql @@ -0,0 +1,18 @@ +/* + Warnings: + + - The `notifications` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - The `dark_mode` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - The `lang_switch` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column. + - The `lefty_mode` column on the `preferences` table would be dropped and recreated. This will lead to data loss if there is data in the column. + +*/ +-- AlterTable +ALTER TABLE "preferences" DROP COLUMN "notifications", +ADD COLUMN "notifications" INTEGER NOT NULL DEFAULT 0, +DROP COLUMN "dark_mode", +ADD COLUMN "dark_mode" INTEGER NOT NULL DEFAULT 0, +DROP COLUMN "lang_switch", +ADD COLUMN "lang_switch" INTEGER NOT NULL DEFAULT 0, +DROP COLUMN "lefty_mode", +ADD COLUMN "lefty_mode" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/mock-seeds-scripts/01-bankCodes.ts b/prisma/mock-seeds-scripts/01-bankCodes.ts index 6d5cec2..2b1ade9 100644 --- a/prisma/mock-seeds-scripts/01-bankCodes.ts +++ b/prisma/mock-seeds-scripts/01-bankCodes.ts @@ -4,18 +4,19 @@ const prisma = new PrismaClient(); async function main() { const presets = [ - // type, categorie, modifier, bank_code - ['REGULAR' ,'SHIFT', 1.0 , 'G1'], - ['EVENING' ,'SHIFT', 1.25, 'G43'], - ['Emergency','SHIFT', 2 , 'G48'], - ['HOLIDAY' ,'SHIFT', 2.0 , 'G700'], - - ['EXPENSES','EXPENSE', 1.0 , 'G517'], - ['MILEAGE' ,'EXPENSE', 0.72, 'G57'], - ['PER_DIEM','EXPENSE', 1.0 , 'G502'], - - ['SICK' ,'LEAVE', 1.0, 'G105'], - ['VACATION' ,'LEAVE', 1.0, 'G305'], + // type, categorie, modifier, bank_code + ['REGULAR' ,'SHIFT' , 1.0 , 'G1' ], + ['OVERTIME' ,'SHIFT' , 2 , 'G43' ], + ['EMERGENCY' ,'SHIFT' , 2 , 'G48' ], + ['EVENING' ,'SHIFT' , 1.25 , 'G56' ], + ['SICK' ,'SHIFT' , 1.0 , 'G105'], + ['HOLIDAY' ,'SHIFT' , 1.0 , 'G104'], + ['VACATION' ,'SHIFT' , 1.0 , 'G305'], + ['ON_CALL' ,'EXPENSE' , 1.0 , 'G202'], + ['COMMISSION' ,'EXPENSE' , 1.0 , 'G234'], + ['PER_DIEM' ,'EXPENSE' , 1.0 , 'G502'], + ['MILEAGE' ,'EXPENSE' , 0.72 , 'G503'], + ['EXPENSES' ,'EXPENSE' , 1.0 , 'G517'], ]; await prisma.bankCodes.createMany({ diff --git a/prisma/mock-seeds-scripts/02-users.ts b/prisma/mock-seeds-scripts/02-users.ts index 5078b3e..81e30da 100644 --- a/prisma/mock-seeds-scripts/02-users.ts +++ b/prisma/mock-seeds-scripts/02-users.ts @@ -1,71 +1,200 @@ import { PrismaClient, Roles } from '@prisma/client'; const prisma = new PrismaClient(); -const BASE_PHONE = 1_100_000_000; // < 2_147_483_647 + +// base sans underscore, en string +const BASE_PHONE = '1100000000'; function emailFor(i: number) { return `user${i + 1}@example.test`; } async function main() { - // 50 users total: 40 employees + 10 customers - // Roles distribution for the 40 employees: - // 1 ADMIN, 4 SUPERVISOR, 1 HR, 1 ACCOUNTING, 33 EMPLOYEE - // 10 CUSTOMER (non-employees) - const usersData: { + type UserSeed = { first_name: string; last_name: string; email: string; - phone_number: number; + phone_number: string; residence?: string | null; role: Roles; - }[] = []; + }; - const firstNames = ['Alex','Sam','Chris','Jordan','Taylor','Morgan','Jamie','Robin','Avery','Casey']; - const lastNames = ['Smith','Johnson','Williams','Brown','Jones','Miller','Davis','Wilson','Taylor','Clark']; + const usersData: UserSeed[] = []; - // helper to pick - const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; + const firstNames = ['Alex', 'Sam', 'Chris', 'Jordan', 'Taylor', 'Morgan', 'Jamie', 'Robin', 'Avery', 'Casey']; + const lastNames = ['Smith', 'Johnson', 'Williams', 'Brown', 'Jones', 'Miller', 'Davis', 'Wilson', 'Taylor', 'Clark']; - const rolesForEmployees: Roles[] = [ - Roles.ADMIN, - ...Array(4).fill(Roles.SUPERVISOR), - Roles.HR, - Roles.ACCOUNTING, - ...Array(33).fill(Roles.EMPLOYEE), + const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]; + + /** + * Objectif total: 50 users + * - 39 employés génériques (dont ADMIN=1, SUPERVISOR=3, HR=1, ACCOUNTING=1, EMPLOYEE=33) + * - +1 superviseur spécial "User Test" (=> 40 employés) + * - 10 customers + */ + const rolesForEmployees39: Roles[] = [ + Roles.ADMIN, // 1 + ...Array(3).fill(Roles.SUPERVISOR), // 3 supervisors (le 4e sera "User Test") + Roles.HR, // 1 + Roles.ACCOUNTING, // 1 + ...Array(33).fill(Roles.EMPLOYEE), // 33 + // total = 39 ]; - // 40 employees - for (let i = 0; i < 40; i++) { - + // --- 39 employés génériques: user1..user39@example.test + for (let i = 0; i < 39; i++) { const fn = pick(firstNames); const ln = pick(lastNames); usersData.push({ first_name: fn, last_name: ln, email: emailFor(i), - phone_number: BASE_PHONE + i, + phone_number: BASE_PHONE + i.toString(), residence: Math.random() < 0.5 ? 'QC' : 'ON', - role: rolesForEmployees[i], + role: rolesForEmployees39[i], }); } - // 10 customers - for (let i = 40; i < 50; i++) { + // --- 10 customers: user40..user49@example.test + for (let i = 39; i < 49; i++) { const fn = pick(firstNames); const ln = pick(lastNames); usersData.push({ first_name: fn, last_name: ln, email: emailFor(i), - phone_number: BASE_PHONE + i, + phone_number: BASE_PHONE + i.toString(), residence: Math.random() < 0.5 ? 'QC' : 'ON', role: Roles.CUSTOMER, }); } + // 1) Insert des 49 génériques (skipDuplicates pour rejouer le seed sans erreurs) await prisma.users.createMany({ data: usersData, skipDuplicates: true }); - console.log('✓ Users: 50 rows (40 employees, 10 customers)'); + + // 2) Upsert du superviseur spécial "User Test" + const specialEmail = 'user@targointernet.com'; + const specialUser = await prisma.users.upsert({ + where: { email: specialEmail }, + update: { + first_name: 'User', + last_name: 'Test', + role: Roles.SUPERVISOR, + residence: 'QC', + phone_number: BASE_PHONE + '999', + }, + create: { + first_name: 'User', + last_name: 'Test', + email: specialEmail, + role: Roles.SUPERVISOR, + residence: 'QC', + phone_number: BASE_PHONE + '999', + }, + }); + + // 3) Créer/mettre à jour les entrées Employees pour tous les rôles employés + const employeeUsers = await prisma.users.findMany({ + where: { role: { in: [Roles.ADMIN, Roles.SUPERVISOR, Roles.HR, Roles.ACCOUNTING, Roles.EMPLOYEE] } }, + orderBy: { email: 'asc' }, + }); + + const firstWorkDay = new Date('2025-01-06'); // à adapter à ton contexte + + for (let i = 0; i < employeeUsers.length; i++) { + const u = employeeUsers[i]; + await prisma.employees.upsert({ + where: { user_id: u.id }, + update: { + is_supervisor: u.role === Roles.SUPERVISOR, + job_title: u.role, + }, + create: { + user_id: u.id, + is_supervisor: u.role === Roles.SUPERVISOR, + external_payroll_id: 1000 + i, // à adapter + company_code: 1, // à adapter + first_work_day: firstWorkDay, + job_title: u.role, + }, + }); + } + + // 4) Répartition des 33 EMPLOYEE sur 4 superviseurs: 8/8/8/9 (9 pour User Test) + const supervisors = await prisma.employees.findMany({ + where: { is_supervisor: true, user: { role: Roles.SUPERVISOR } }, + include: { user: true }, + orderBy: { id: 'asc' }, + }); + + const userTestSupervisor = supervisors.find((s) => s.user.email === specialEmail); + if (!userTestSupervisor) { + throw new Error('Employee(User Test) introuvable — vérifie le upsert Users/Employees.'); + } + + const plainEmployees = await prisma.employees.findMany({ + where: { is_supervisor: false, user: { role: Roles.EMPLOYEE } }, + orderBy: { id: 'asc' }, + }); + + // Si la configuration est bien 4 superviseurs + 33 employés, on force 8/8/8/9 avec 9 pour User Test. + if (supervisors.length === 4 && plainEmployees.length === 33) { + const others = supervisors.filter((s) => s.id !== userTestSupervisor.id); + // ordre: autres (3) puis User Test en dernier (reçoit 9) + const ordered = [...others, userTestSupervisor]; + + const chunks = [ + plainEmployees.slice(0, 8), // -> sup 0 + plainEmployees.slice(8, 16), // -> sup 1 + plainEmployees.slice(16, 24), // -> sup 2 + plainEmployees.slice(24, 33), // -> sup 3 (User Test) = 9 + ]; + + for (let b = 0; b < chunks.length; b++) { + const sup = ordered[b]; + for (const emp of chunks[b]) { + await prisma.employees.update({ + where: { id: emp.id }, + data: { supervisor_id: sup.id }, + }); + } + } + } else { + // fallback: distribution round-robin si la config diffère + console.warn( + `Répartition fallback (round-robin). Supervisors=${supervisors.length}, Employees=${plainEmployees.length}` + ); + const others = supervisors.filter((s) => s.id !== userTestSupervisor.id); + const ordered = [...others, userTestSupervisor]; + for (let i = 0; i < plainEmployees.length; i++) { + const sup = ordered[i % ordered.length]; + await prisma.employees.update({ + where: { id: plainEmployees[i].id }, + data: { supervisor_id: sup.id }, + }); + } + } + + // 5) Sanity checks + const totalUsers = await prisma.users.count(); + const supCount = await prisma.users.count({ where: { role: Roles.SUPERVISOR } }); + const empCount = await prisma.users.count({ where: { role: Roles.EMPLOYEE } }); + + const countForUserTest = await prisma.employees.count({ + where: { supervisor_id: userTestSupervisor.id, is_supervisor: false }, + }); + + console.log(`✓ Users total: ${totalUsers} (attendu 50)`); + console.log(`✓ Supervisors: ${supCount} (attendu 4)`); + console.log(`✓ Employees : ${empCount} (attendu 33)`); + console.log(`✓ Employés sous User Test: ${countForUserTest} (attendu 9)`); } -main().finally(() => prisma.$disconnect()); +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/prisma/mock-seeds-scripts/03-employees.ts b/prisma/mock-seeds-scripts/03-employees.ts index 3076683..a0267fd 100644 --- a/prisma/mock-seeds-scripts/03-employees.ts +++ b/prisma/mock-seeds-scripts/03-employees.ts @@ -12,7 +12,7 @@ function randomPastDate(yearsBack = 3) { past.setFullYear(now.getFullYear() - yearsBack); const t = randInt(past.getTime(), now.getTime()); const d = new Date(t); - d.setHours(0,0,0,0); + d.setHours(0, 0, 0, 0); return d; } @@ -29,7 +29,12 @@ const jobTitles = [ ]; function randomTitle() { - return jobTitles[randInt(0, jobTitles.length -1)]; + return jobTitles[randInt(0, jobTitles.length - 1)]; +} + +// Sélection aléatoire entre 271583 et 271585 +function randomCompanyCode() { + return Math.random() < 0.5 ? 271583 : 271585; } async function main() { @@ -38,40 +43,41 @@ async function main() { orderBy: { email: 'asc' }, }); - // Create supervisors first - const supervisorUsers = employeeUsers.filter(u => u.role === Roles.SUPERVISOR); - const supervisorEmployeeIds: number[] = []; - - for (const u of supervisorUsers) { - const emp = await prisma.employees.upsert({ - where: { user_id: u.id }, - update: {}, - create: { - user_id: u.id, - external_payroll_id: randInt(10000, 99999), - company_code: randInt(1, 5), - first_work_day: randomPastDate(3), - last_work_day: null, - job_title: randomTitle(), - is_supervisor: true, - }, - }); - supervisorEmployeeIds.push(emp.id); + // 1) Trouver le user qui sera le superviseur fixe + const supervisorUser = await prisma.users.findUnique({ + where: { email: 'user5@example.test' }, + }); + if (!supervisorUser) { + throw new Error("Le user 'user5@example.test' n'existe pas !"); } - // Create remaining employees, assign a random supervisor (admin can have none) + // 2) Créer ou récupérer son employee avec is_supervisor = true + const supervisorEmp = await prisma.employees.upsert({ + where: { user_id: supervisorUser.id }, + update: { is_supervisor: true }, + create: { + user_id: supervisorUser.id, + external_payroll_id: randInt(10000, 99999), + company_code: randomCompanyCode(), + first_work_day: randomPastDate(3), + last_work_day: null, + job_title: randomTitle(), + is_supervisor: true, + }, + }); + + // 3) Créer tous les autres employés avec ce superviseur (sauf ADMIN qui n’a pas de superviseur) for (const u of employeeUsers) { const already = await prisma.employees.findUnique({ where: { user_id: u.id } }); if (already) continue; - const supervisor_id = - u.role === Roles.ADMIN ? null : supervisorEmployeeIds[randInt(0, supervisorEmployeeIds.length - 1)]; + const supervisor_id = u.role === Roles.ADMIN ? null : supervisorEmp.id; await prisma.employees.create({ data: { user_id: u.id, external_payroll_id: randInt(10000, 99999), - company_code: randInt(1, 5), + company_code: randomCompanyCode(), first_work_day: randomPastDate(3), last_work_day: null, supervisor_id, @@ -81,7 +87,7 @@ async function main() { } const total = await prisma.employees.count(); - console.log(`✓ Employees: ${total} rows (with supervisors linked)`); + console.log(`✓ Employees: ${total} rows (supervisor = ${supervisorUser.email})`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/05-employees-archive.ts b/prisma/mock-seeds-scripts/05-employees-archive.ts index 08e457c..1687b23 100644 --- a/prisma/mock-seeds-scripts/05-employees-archive.ts +++ b/prisma/mock-seeds-scripts/05-employees-archive.ts @@ -1,5 +1,10 @@ import { PrismaClient } from '@prisma/client'; +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} + const prisma = new PrismaClient(); function daysAgo(n: number) { diff --git a/prisma/mock-seeds-scripts/06-customers-archive.ts b/prisma/mock-seeds-scripts/06-customers-archive.ts index 2f73067..31a7d01 100644 --- a/prisma/mock-seeds-scripts/06-customers-archive.ts +++ b/prisma/mock-seeds-scripts/06-customers-archive.ts @@ -1,5 +1,11 @@ // prisma/mock-seeds-scripts/06-customers-archive.ts import { PrismaClient } from '@prisma/client'; + +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} + const prisma = new PrismaClient(); async function main() { diff --git a/prisma/mock-seeds-scripts/07-leave-requests-future.ts b/prisma/mock-seeds-scripts/07-leave-requests-future.ts index 54f14b8..b96c63d 100644 --- a/prisma/mock-seeds-scripts/07-leave-requests-future.ts +++ b/prisma/mock-seeds-scripts/07-leave-requests-future.ts @@ -1,9 +1,14 @@ import { PrismaClient, Prisma, LeaveTypes, LeaveApprovalStatus } from '@prisma/client'; +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log('?? Seed leave-requests ignor (SKIP_LEAVE_REQUESTS=true)'); + process.exit(0); +} + const prisma = new PrismaClient(); function dateOn(y: number, m: number, d: number) { - // stocke une date (pour @db.Date) à minuit UTC + // stocke une date (@db.Date) minuit UTC return new Date(Date.UTC(y, m - 1, d, 0, 0, 0)); } @@ -14,7 +19,7 @@ async function main() { const employees = await prisma.employees.findMany({ select: { id: true } }); const bankCodes = await prisma.bankCodes.findMany({ where: { categorie: 'LEAVE' }, - select: { id: true }, + select: { id: true, type: true }, }); if (!employees.length || !bankCodes.length) { @@ -39,30 +44,31 @@ async function main() { LeaveApprovalStatus.ESCALATED, ]; - const futureMonths = [8, 9, 10, 11, 12]; // Août→Déc (1-based) + const futureMonths = [8, 9, 10, 11, 12]; // Aot ? Dc. (1-based) - // ✅ typer rows pour éviter never[] const rows: Prisma.LeaveRequestsCreateManyInput[] = []; for (let i = 0; i < 10; i++) { const emp = employees[i % employees.length]; const m = futureMonths[i % futureMonths.length]; - const start = dateOn(year, m, 5 + i); // 5..14 - if (start <= today) continue; // garantir "futur" + const date = dateOn(year, m, 5 + i); // 5..14 + if (date <= today) continue; // garantir futur - const end = Math.random() < 0.5 ? null : dateOn(year, m, 6 + i); const type = types[i % types.length]; const status = statuses[i % statuses.length]; const bc = bankCodes[i % bankCodes.length]; + const requestedHours = 4 + (i % 5); // 4 ? 8 h + const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null; rows.push({ employee_id: emp.id, bank_code_id: bc.id, leave_type: type, - start_date_time: start, - end_date_time: end, // ok: Date | null - comment: `Future leave #${i + 1}`, + date, + comment: `Future leave #${i + 1} (${bc.type})`, approval_status: status, + requested_hours: requestedHours, + payable_hours: payableHours, }); } @@ -70,7 +76,7 @@ async function main() { await prisma.leaveRequests.createMany({ data: rows }); } - console.log(`✓ LeaveRequests (future): ${rows.length} rows`); + console.log(`? LeaveRequests (future): ${rows.length} rows`); } -main().finally(() => prisma.$disconnect()); +main().finally(() => prisma.$disconnect()); \ No newline at end of file diff --git a/prisma/mock-seeds-scripts/08-leave-requests-archive.ts b/prisma/mock-seeds-scripts/08-leave-requests-archive.ts index 1ef2554..45b1d43 100644 --- a/prisma/mock-seeds-scripts/08-leave-requests-archive.ts +++ b/prisma/mock-seeds-scripts/08-leave-requests-archive.ts @@ -1,42 +1,69 @@ -import { PrismaClient, LeaveTypes, LeaveApprovalStatus, LeaveRequests } from '@prisma/client'; +import { PrismaClient, LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; + +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log('?? Seed leave-requests ignor (SKIP_LEAVE_REQUESTS=true)'); + process.exit(0); +} const prisma = new PrismaClient(); -function daysAgo(n:number) { +function daysAgo(n: number) { const d = new Date(); d.setUTCDate(d.getUTCDate() - n); - d.setUTCHours(0,0,0,0); + d.setUTCHours(0, 0, 0, 0); return d; } async function main() { const employees = await prisma.employees.findMany({ select: { id: true } }); - const bankCodes = await prisma.bankCodes.findMany({ select: { id: true }, where: { categorie: 'LEAVE' } }); + if (!employees.length) { + throw new Error('Aucun employ trouv. Excute le seed employees avant celui-ci.'); + } + + const leaveCodes = await prisma.bankCodes.findMany({ + where: { type: { in: ['SICK', 'VACATION', 'HOLIDAY'] } }, + select: { id: true, type: true }, + }); + if (!leaveCodes.length) { + throw new Error("Aucun bank code trouv avec type in ('SICK','VACATION','HOLIDAY'). Vrifie ta table bank_codes."); + } - const types = Object.values(LeaveTypes); const statuses = Object.values(LeaveApprovalStatus); + const created = [] as Array<{ id: number; employee_id: number; leave_type: LeaveTypes; date: Date; comment: string; approval_status: LeaveApprovalStatus; requested_hours: number; payable_hours: number | null }>; - const created: LeaveRequests[] = []; - - for (let i = 0; i < 10; i++) { + const COUNT = 12; + for (let i = 0; i < COUNT; i++) { const emp = employees[i % employees.length]; - const bc = bankCodes[i % bankCodes.length]; - const start = daysAgo(120 - i * 3); // tous avant aujourd'hui - const end = Math.random() < 0.4 ? null : daysAgo(119 - i * 3); + const leaveCode = leaveCodes[Math.floor(Math.random() * leaveCodes.length)]; + + const date = daysAgo(120 - i * 3); + const status = statuses[(i + 2) % statuses.length]; + const requestedHours = 4 + (i % 5); // 4 ? 8 h + const payableHours = status === LeaveApprovalStatus.APPROVED ? Math.min(requestedHours, 8) : null; const lr = await prisma.leaveRequests.create({ data: { employee_id: emp.id, - bank_code_id: bc.id, - leave_type: types[i % types.length], - start_date_time: start, - end_date_time: end, - comment: `Past leave #${i+1}`, - approval_status: statuses[(i+2) % statuses.length], + bank_code_id: leaveCode.id, + leave_type: leaveCode.type as LeaveTypes, + date, + comment: `Past leave #${i + 1} (${leaveCode.type})`, + approval_status: status, + requested_hours: requestedHours, + payable_hours: payableHours, }, }); - created.push(lr); + created.push({ + id: lr.id, + employee_id: lr.employee_id, + leave_type: lr.leave_type, + date: lr.date, + comment: lr.comment, + approval_status: lr.approval_status, + requested_hours: requestedHours, + payable_hours: payableHours, + }); } for (const lr of created) { @@ -45,15 +72,16 @@ async function main() { leave_request_id: lr.id, employee_id: lr.employee_id, leave_type: lr.leave_type, - start_date_time: lr.start_date_time, - end_date_time: lr.end_date_time, + date: lr.date, comment: lr.comment, approval_status: lr.approval_status, + requested_hours: lr.requested_hours, + payable_hours: lr.payable_hours, }, }); } - console.log(`✓ LeaveRequestsArchive: ${created.length} rows`); + console.log(`? LeaveRequestsArchive: ${created.length} rows`); } -main().finally(() => prisma.$disconnect()); +main().finally(() => prisma.$disconnect()); \ No newline at end of file diff --git a/prisma/mock-seeds-scripts/09-timesheets.ts b/prisma/mock-seeds-scripts/09-timesheets.ts index d0dc15c..f926fb2 100644 --- a/prisma/mock-seeds-scripts/09-timesheets.ts +++ b/prisma/mock-seeds-scripts/09-timesheets.ts @@ -2,26 +2,59 @@ import { PrismaClient, Prisma } from '@prisma/client'; const prisma = new PrismaClient(); +// ====== Config ====== +const PREVIOUS_WEEKS = 16; // nombre de semaines à créer (passé) +const INCLUDE_CURRENT = false; // true si tu veux aussi la semaine courante + +// Lundi (UTC) de la semaine courante +function mondayOfThisWeekUTC(now = new Date()) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const day = d.getUTCDay(); // 0=Dim, 1=Lun, ... + const diffToMonday = (day + 6) % 7; // 0 si lundi + d.setUTCDate(d.getUTCDate() - diffToMonday); + d.setUTCHours(0, 0, 0, 0); + return d; +} +function mondayNWeeksBefore(monday: Date, n: number) { + const d = new Date(monday); + d.setUTCDate(d.getUTCDate() - n * 7); + return d; +} + async function main() { const employees = await prisma.employees.findMany({ select: { id: true } }); + if (!employees.length) { + console.warn('Aucun employé — rien à insérer.'); + return; + } - // ✅ typer rows pour éviter never[] + // Construit la liste des lundis (1 par semaine) + const mondays: Date[] = []; + const mondayThisWeek = mondayOfThisWeekUTC(); + if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); + for (let n = 1; n <= PREVIOUS_WEEKS; n++) { + mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); + } + + // Prépare les lignes (1 timesheet / employé / semaine) const rows: Prisma.TimesheetsCreateManyInput[] = []; - - // 8 timesheets / employee for (const e of employees) { - for (let i = 0; i < 8; i++) { - const is_approved = Math.random() < 0.3; - rows.push({ employee_id: e.id, is_approved }); + for (const monday of mondays) { + rows.push({ + employee_id: e.id, + start_date: monday, + is_approved: Math.random() < 0.3, + } as Prisma.TimesheetsCreateManyInput); } } + // Insert en bulk et ignore les doublons si déjà présents if (rows.length) { - await prisma.timesheets.createMany({ data: rows }); + await prisma.timesheets.createMany({ data: rows, skipDuplicates: true }); } const total = await prisma.timesheets.count(); - console.log(`✓ Timesheets: ${total} rows (added ${rows.length})`); + console.log(`✓ Timesheets: ${total} rows (ajout potentiel: ${rows.length}, ${INCLUDE_CURRENT ? 'courante +' : ''}${PREVIOUS_WEEKS} semaines)`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/10-shifts.ts b/prisma/mock-seeds-scripts/10-shifts.ts index 3fe1791..878bc43 100644 --- a/prisma/mock-seeds-scripts/10-shifts.ts +++ b/prisma/mock-seeds-scripts/10-shifts.ts @@ -2,54 +2,233 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -function timeAt(hour:number, minute:number) { - // stocker une heure (Postgres TIME) via Date (UTC 1970-01-01) +// ====== Config ====== +const PREVIOUS_WEEKS = 5; +const INCLUDE_CURRENT = true; +const INCR = 15; // incrément ferme de 15 minutes (0.25 h) +const DAY_MIN = 5 * 60; // 5h +const DAY_MAX = 11 * 60; // 11h +const HARD_END = 19 * 60 + 30; // 19:30 + +// ====== Helpers temps ====== +function timeAt(hour: number, minute: number) { return new Date(Date.UTC(1970, 0, 1, hour, minute, 0)); } -function daysAgo(n:number) { - const d = new Date(); - d.setUTCDate(d.getUTCDate() - n); - d.setUTCHours(0,0,0,0); +function mondayOfThisWeekUTC(now = new Date()) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const day = d.getUTCDay(); + const diffToMonday = (day + 6) % 7; + d.setUTCDate(d.getUTCDate() - diffToMonday); + d.setUTCHours(0, 0, 0, 0); return d; } +function weekDatesFromMonday(monday: Date) { + return Array.from({ length: 5 }, (_, i) => { + const d = new Date(monday); + d.setUTCDate(monday.getUTCDate() + i); + return d; + }); +} +function mondayNWeeksBefore(monday: Date, n: number) { + const d = new Date(monday); + d.setUTCDate(d.getUTCDate() - n * 7); + return d; +} +function rndInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} +function clamp(n: number, min: number, max: number) { + return Math.min(max, Math.max(min, n)); +} +function addMinutes(h: number, m: number, delta: number) { + const total = h * 60 + m + delta; + const hh = Math.floor(total / 60); + const mm = ((total % 60) + 60) % 60; + return { h: hh, m: mm }; +} +// Aligne vers le multiple de INCR le plus proche +function quantize(mins: number): number { + const q = Math.round(mins / INCR) * INCR; + return q; +} +// Tire un multiple de INCR dans [min,max] (inclus), supposés entiers minutes +function rndQuantized(min: number, max: number): number { + const qmin = Math.ceil(min / INCR); + const qmax = Math.floor(max / INCR); + const q = rndInt(qmin, qmax); + return q * INCR; +} + +// Helper: garantit le timesheet de la semaine (upsert) +async function getOrCreateTimesheet(employee_id: number, start_date: Date) { + return prisma.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date } }, + update: {}, + create: { employee_id, start_date, is_approved: Math.random() < 0.3 }, + select: { id: true }, + }); +} async function main() { - const bankCodes = await prisma.bankCodes.findMany({ where: { categorie: 'SHIFT' }, select: { id: true } }); - if (!bankCodes.length) throw new Error('Need SHIFT bank codes'); + // --- Bank codes (pondérés: surtout G1 = régulier) --- + const BANKS = ['G1', 'G56', 'G48', 'G700', 'G105', 'G305'] as const; + const WEIGHTED_CODES = [ + 'G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1','G1', + 'G56','G48','G104','G105','G305','G1','G1','G1','G1','G1','G1' + ] as const; + + const bcRows = await prisma.bankCodes.findMany({ + where: { bank_code: { in: BANKS as unknown as string[] } }, + select: { id: true, bank_code: true }, + }); + const bcMap = new Map(bcRows.map(b => [b.bank_code, b.id])); + for (const c of BANKS) { + if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`); + } const employees = await prisma.employees.findMany({ select: { id: true } }); + if (!employees.length) { + console.log('Aucun employé — rien à insérer.'); + return; + } - for (const e of employees) { - const tss = await prisma.timesheets.findMany({ - where: { employee_id: e.id }, - select: { id: true }, - }); - if (!tss.length) continue; + const mondayThisWeek = mondayOfThisWeekUTC(); + const mondays: Date[] = []; + if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); + for (let n = 1; n <= PREVIOUS_WEEKS; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); - // 10 shifts / employee - for (let i = 0; i < 10; i++) { - const ts = tss[i % tss.length]; - const bc = bankCodes[i % bankCodes.length]; - const date = daysAgo(7 + i); // la dernière quinzaine - const startH = 8 + (i % 3); // 8..10 - const endH = startH + 7 + (i % 2); // 15..17 + let created = 0; - await prisma.shifts.create({ - data: { - timesheet_id: ts.id, - bank_code_id: bc.id, - description: `Shift ${i + 1} for emp ${e.id}`, - date, - start_time: timeAt(startH, 0), - end_time: timeAt(endH, 0), - is_approved: Math.random() < 0.5, - }, - }); + for (let wi = 0; wi < mondays.length; wi++) { + const monday = mondays[wi]; + const days = weekDatesFromMonday(monday); + + for (let ei = 0; ei < employees.length; ei++) { + const e = employees[ei]; + + // Cible hebdo 35–45h, multiple de 15 min + const weeklyTargetMin = rndQuantized(35 * 60, 45 * 60); + + // Start de base (7:00, 7:15, 7:30, 7:45, 8:00, 8:15, 8:30, 8:45, 9:00 ...) + const baseStartH = 7 + (ei % 3); // 7,8,9 + const baseStartM = ( (ei * 15) % 60 ); // aligné 15 min + + // Planification journalière (5 jours) ~8h ± 45 min, quantisée 15 min + const plannedDaily: number[] = []; + for (let d = 0; d < 5; d++) { + const jitter = rndInt(-3, 3) * INCR; // -45..+45 par pas de 15 + const base = 8 * 60 + jitter; + plannedDaily.push(quantize(clamp(base, DAY_MIN, DAY_MAX))); + } + + // Ajuster le 5e jour pour atteindre la cible hebdo exactement (par pas de 15) + const sumFirst4 = plannedDaily.slice(0, 4).reduce((a, b) => a + b, 0); + plannedDaily[4] = quantize(clamp(weeklyTargetMin - sumFirst4, DAY_MIN, DAY_MAX)); + + // Corriger le petit écart restant (devrait être multiple de 15) en redistribuant ±15 + let diff = weeklyTargetMin - plannedDaily.reduce((a, b) => a + b, 0); + const step = diff > 0 ? INCR : -INCR; + let guard = 100; // anti-boucle + while (diff !== 0 && guard-- > 0) { + for (let d = 0; d < 5 && diff !== 0; d++) { + const next = plannedDaily[d] + step; + if (next >= DAY_MIN && next <= DAY_MAX) { + plannedDaily[d] = next; + diff -= step; + } + } + } + + // Upsert du timesheet (semaine) + const ts = await getOrCreateTimesheet(e.id, mondayOfThisWeekUTC(days[0])); + + for (let di = 0; di < 5; di++) { + const date = days[di]; + const targetWorkMin = plannedDaily[di]; // multiple de 15 + + // Départ ~ base + jitter (par pas de 15 min aussi) + const startJitter = rndInt(-1, 2) * INCR; // -15,0,+15,+30 + const { h: startH, m: startM } = addMinutes(baseStartH, baseStartM, startJitter); + + // Pause: entre 11:00 et 14:00, mais pas avant start+3h ni après start+6h (le tout quantisé 15) + const earliestLunch = Math.max((startH * 60 + startM) + 3 * 60, 11 * 60); + const latestLunch = Math.min((startH * 60 + startM) + 6 * 60, 14 * 60); + const lunchStartMin = rndQuantized(earliestLunch, latestLunch); + const lunchDur = rndQuantized(30, 120); // 30..120 min en pas de 15 + const lunchEndMin = lunchStartMin + lunchDur; + + // Travail = (lunchStart - start) + (end - lunchEnd) + const morningWork = Math.max(0, lunchStartMin - (startH * 60 + startM)); // multiple de 15 + let afternoonWork = Math.max(60, targetWorkMin - morningWork); // multiple de 15 (diff de deux multiples de 15) + if (afternoonWork % INCR !== 0) { + // sécurité (ne devrait pas arriver) + afternoonWork = quantize(afternoonWork); + } + + // Fin de journée (quantisée par construction) + const endMinRaw = lunchEndMin + afternoonWork; + const endMin = Math.min(endMinRaw, HARD_END); + + // Bank codes variés + const bcMorningCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)]; + const bcAfternoonCode = WEIGHTED_CODES[rndInt(0, WEIGHTED_CODES.length - 1)]; + const bcMorningId = bcMap.get(bcMorningCode)!; + const bcAfternoonId = bcMap.get(bcAfternoonCode)!; + + // Shift matin + const lunchStartHM = { h: Math.floor(lunchStartMin / 60), m: lunchStartMin % 60 }; + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id: bcMorningId, + comment: `Matin J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcMorningCode}`, + date, + start_time: timeAt(startH, startM), + end_time: timeAt(lunchStartHM.h, lunchStartHM.m), + is_approved: Math.random() < 0.6, + }, + }); + created++; + + // Shift après-midi (si >= 30 min — sera de toute façon multiple de 15) + const pmDuration = endMin - lunchEndMin; + if (pmDuration >= 30) { + const lunchEndHM = { h: Math.floor(lunchEndMin / 60), m: lunchEndMin % 60 }; + const finalEndHM = { h: Math.floor(endMin / 60), m: endMin % 60 }; + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id: bcAfternoonId, + comment: `Après-midi J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — ${bcAfternoonCode}`, + date, + start_time: timeAt(lunchEndHM.h, lunchEndHM.m), + end_time: timeAt(finalEndHM.h, finalEndHM.m), + is_approved: Math.random() < 0.6, + }, + }); + created++; + } else { + // Fallback très rare : un seul shift couvrant la journée (tout en multiples de 15) + const fallbackEnd = addMinutes(startH, startM, targetWorkMin + lunchDur); + await prisma.shifts.create({ + data: { + timesheet_id: ts.id, + bank_code_id: bcMap.get('G1')!, + comment: `Fallback J${di + 1} (sem ${monday.toISOString().slice(0, 10)}) emp ${e.id} — G1`, + date, + start_time: timeAt(startH, startM), + end_time: timeAt(fallbackEnd.h, fallbackEnd.m), + is_approved: Math.random() < 0.6, + }, + }); + created++; + } + } } } const total = await prisma.shifts.count(); - console.log(`✓ Shifts: ${total} total rows`); + console.log(`✓ Shifts créés: ${created} | total en DB: ${total} (${INCLUDE_CURRENT ? 'inclut semaine courante, ' : ''}${PREVIOUS_WEEKS} sem passées, L→V, 2 shifts/jour, pas de décimaux foireux})`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/11-shifts-archive.ts b/prisma/mock-seeds-scripts/11-shifts-archive.ts index fa22ddb..1c3f5e8 100644 --- a/prisma/mock-seeds-scripts/11-shifts-archive.ts +++ b/prisma/mock-seeds-scripts/11-shifts-archive.ts @@ -1,5 +1,10 @@ import { PrismaClient } from '@prisma/client'; +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} + const prisma = new PrismaClient(); function timeAt(h:number,m:number) { @@ -21,7 +26,7 @@ async function main() { if (!tss.length) continue; const createdShiftIds: number[] = []; - for (let i = 0; i < 30; i++) { + for (let i = 0; i < 8; i++) { const ts = tss[i % tss.length]; const bc = bankCodes[i % bankCodes.length]; const date = daysAgo(200 + i); // bien dans le passé @@ -32,7 +37,7 @@ async function main() { data: { timesheet_id: ts.id, bank_code_id: bc.id, - description: `Archived-era shift ${i + 1} for emp ${e.id}`, + comment: `Archived-era shift ${i + 1} for emp ${e.id}`, date, start_time: timeAt(startH, 0), end_time: timeAt(endH, 0), @@ -50,7 +55,7 @@ async function main() { shift_id: s.id, timesheet_id: s.timesheet_id, bank_code_id: s.bank_code_id, - description: s.description, + comment: s.comment, date: s.date, start_time: s.start_time, end_time: s.end_time, diff --git a/prisma/mock-seeds-scripts/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index 2da1eb5..926a52f 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -2,43 +2,165 @@ import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); -function daysAgo(n:number) { - const d = new Date(); - d.setUTCDate(d.getUTCDate() - n); - d.setUTCHours(0,0,0,0); +// ====== Config ====== +const WEEKS_BACK = 4; // 4 semaines avant + semaine courante +const INCLUDE_CURRENT = true; // inclure la semaine courante +const STEP_CENTS = 25; // montants en quarts de dollar (.00/.25/.50/.75) + +// ====== Helpers dates ====== +function mondayOfThisWeekUTC(now = new Date()) { + const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const day = d.getUTCDay(); + const diffToMonday = (day + 6) % 7; + d.setUTCDate(d.getUTCDate() - diffToMonday); + d.setUTCHours(0, 0, 0, 0); return d; } +function mondayNWeeksBefore(monday: Date, n: number) { + const d = new Date(monday); + d.setUTCDate(monday.getUTCDate() - n * 7); + return d; +} +// L→V (UTC minuit) +function weekDatesMonToFri(monday: Date) { + return Array.from({ length: 5 }, (_, i) => { + const d = new Date(monday); + d.setUTCDate(monday.getUTCDate() + i); + return d; + }); +} + +// ====== Helpers random / amount ====== +function rndInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} +// String "xx.yy" à partir de cents ENTiers (jamais de float) +function centsToAmountString(cents: number): string { + const sign = cents < 0 ? '-' : ''; + const abs = Math.abs(cents); + const dollars = Math.floor(abs / 100); + const c = abs % 100; + return `${sign}${dollars}.${c.toString().padStart(2, '0')}`; +} + +function to2(value: string): string { + // normalise au cas où (sécurité) + return (Math.round(parseFloat(value) * 100) / 100).toFixed(2); +} + +// Tire un multiple de STEP_CENTS entre minCents et maxCents (inclus) +function rndQuantizedCents(minCents: number, maxCents: number, step = STEP_CENTS): number { + const qmin = Math.ceil(minCents / step); + const qmax = Math.floor(maxCents / step); + const q = rndInt(qmin, qmax); + return q * step; +} +function rndAmount(minCents: number, maxCents: number): string { + return centsToAmountString(rndQuantizedCents(minCents, maxCents)); +} + +// ====== Timesheet upsert ====== +async function getOrCreateTimesheet(employee_id: number, start_date: Date) { + return prisma.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date } }, + update: {}, + create: { employee_id, start_date, is_approved: Math.random() < 0.3 }, + select: { id: true }, + }); +} async function main() { - const expenseCodes = await prisma.bankCodes.findMany({ where: { categorie: 'EXPENSE' }, select: { id: true } }); - if (!expenseCodes.length) throw new Error('Need EXPENSE bank codes'); + // Codes d'EXPENSES (exemples) + const BANKS = ['G517', 'G503', 'G502', 'G202'] as const; - const timesheets = await prisma.timesheets.findMany({ select: { id: true } }); - if (!timesheets.length) { - console.warn('No timesheets found; aborting expenses seed.'); + // Précharger les bank codes + const bcRows = await prisma.bankCodes.findMany({ + where: { bank_code: { in: BANKS as unknown as string[] } }, + select: { id: true, bank_code: true }, + }); + const bcMap = new Map(bcRows.map(c => [c.bank_code, c.id])); + for (const c of BANKS) { + if (!bcMap.has(c)) throw new Error(`Bank code manquant: ${c}`); + } + + // Employés + const employees = await prisma.employees.findMany({ select: { id: true } }); + if (!employees.length) { + console.warn('Aucun employé — rien à insérer.'); return; } - // 5 expenses distribuées aléatoirement parmi les employés (via timesheets) - for (let i = 0; i < 5; i++) { - const ts = timesheets[Math.floor(Math.random() * timesheets.length)]; - const bc = expenseCodes[i % expenseCodes.length]; - await prisma.expenses.create({ - data: { - timesheet_id: ts.id, - bank_code_id: bc.id, - date: daysAgo(3 + i), - amount: (50 + i * 10).toFixed(2), - attachement: null, - description: `Expense #${i + 1}`, - is_approved: Math.random() < 0.5, - supervisor_comment: Math.random() < 0.3 ? 'OK' : null, - }, - }); + // Liste des lundis (courant + 4 précédents) + const mondayThisWeek = mondayOfThisWeekUTC(); + const mondays: Date[] = []; + if (INCLUDE_CURRENT) mondays.push(mondayThisWeek); + for (let n = 1; n <= WEEKS_BACK; n++) mondays.push(mondayNWeeksBefore(mondayThisWeek, n)); + + let created = 0; + + for (const monday of mondays) { + const weekDays = weekDatesMonToFri(monday); + const friday = weekDays[4]; + + for (const e of employees) { + // Upsert timesheet pour CETTE semaine/employee + const ts = await getOrCreateTimesheet(e.id, monday); + + // Idempotence: si déjà au moins une expense L→V, on skip la semaine + const already = await prisma.expenses.findFirst({ + where: { timesheet_id: ts.id, date: { gte: monday, lte: friday } }, + select: { id: true }, + }); + if (already) continue; + + // 1 à 3 expenses (jours distincts) + const count = rndInt(1, 3); + const dayIndexes = [0, 1, 2, 3, 4].sort(() => Math.random() - 0.5).slice(0, count); + + for (const idx of dayIndexes) { + const date = weekDays[idx]; + const code = BANKS[rndInt(0, BANKS.length - 1)]; + const bank_code_id = bcMap.get(code)!; + + // Montants (cents) quantisés à 25¢ => aucun flottant binaire plus tard + let amount: string = '0.00'; + let mileage: string = '0.00'; + switch (code) { + case 'G503': // kilométrage + mileage = to2(rndAmount(1000, 7500)); // 10.00 à 75.00 + break; + case 'G502': // per_diem + amount = to2(rndAmount(1500, 3000)); // 15.00 à 30.00 + break; + case 'G202': // on_call /prime de garde + amount = to2(rndAmount(2000, 15000)); // 20.00 à 150.00 + break; + case 'G517': // expenses + default: + amount = to2(rndAmount(500, 5000)); // 5.00 à 50.00 + break; + } + + await prisma.expenses.create({ + data: { + timesheet_id: ts.id, + bank_code_id, + date, + amount, + mileage, + attachment: null, + comment: `Expense ${code} (emp ${e.id})`, + is_approved: Math.random() < 0.65, + supervisor_comment: Math.random() < 0.25 ? 'OK' : null, + }, + }); + created++; + } + } } const total = await prisma.expenses.count(); - console.log(`✓ Expenses: ${total} total rows`); + console.log(`✓ Expenses: ${created} nouvelles lignes, ${total} total rows (sem courante + ${WEEKS_BACK} précédentes, L→V uniquement, montants en quarts de dollar)`); } main().finally(() => prisma.$disconnect()); diff --git a/prisma/mock-seeds-scripts/13-expenses-archive.ts b/prisma/mock-seeds-scripts/13-expenses-archive.ts index 3d87908..d8e35a2 100644 --- a/prisma/mock-seeds-scripts/13-expenses-archive.ts +++ b/prisma/mock-seeds-scripts/13-expenses-archive.ts @@ -1,6 +1,11 @@ // 13-expenses-archive.ts import { PrismaClient, Expenses } from '@prisma/client'; +if (process.env.SKIP_LEAVE_REQUESTS === 'true') { + console.log("⏭ Seed leave-requests ignoré (SKIP_LEAVE_REQUESTS=true)"); + process.exit(0); +} + const prisma = new PrismaClient(); function daysAgo(n:number) { @@ -17,7 +22,7 @@ async function main() { // ✅ typer pour éviter never[] const created: Expenses[] = []; - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 4; i++) { const ts = timesheets[i % timesheets.length]; const bc = expenseCodes[i % expenseCodes.length]; @@ -27,8 +32,8 @@ async function main() { bank_code_id: bc.id, date: daysAgo(60 + i), amount: (20 + i * 3.5).toFixed(2), // ok: Decimal accepte string - attachement: null, - description: `Old expense #${i + 1}`, + attachment: null, + comment: `Old expense #${i + 1}`, is_approved: true, supervisor_comment: null, }, @@ -45,8 +50,8 @@ async function main() { bank_code_id: e.bank_code_id, date: e.date, amount: e.amount, - attachement: e.attachement, - description: e.description, + attachment: e.attachment, + comment: e.comment, is_approved: e.is_approved, supervisor_comment: e.supervisor_comment, }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ebfcdfc..f2e6f06 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -19,50 +19,55 @@ model Users { first_name String last_name String email String @unique - phone_number Int @unique + phone_number String @unique residence String? role Roles @default(GUEST) - employee Employees? @relation("UserEmployee") - customer Customers? @relation("UserCustomer") - oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") - employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") - customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") + employee Employees? @relation("UserEmployee") + customer Customers? @relation("UserCustomer") + oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") + employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") + customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") + preferences Preferences? @relation("UserPreferences") @@map("users") } model Employees { - id Int @id @default(autoincrement()) - user Users @relation("UserEmployee", fields: [user_id], references: [id]) - user_id String @unique @db.Uuid + id Int @id @default(autoincrement()) + user Users @relation("UserEmployee", fields: [user_id], references: [id]) + user_id String @unique @db.Uuid + supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id]) + supervisor_id Int? + external_payroll_id Int company_code Int first_work_day DateTime @db.Date last_work_day DateTime? @db.Date - job_title String? - is_supervisor Boolean @default(false) + job_title String? + is_supervisor Boolean @default(false) - supervisor Employees? @relation("EmployeeSupervisor", fields: [supervisor_id], references: [id]) - supervisor_id Int? - crew Employees[] @relation("EmployeeSupervisor") + crew Employees[] @relation("EmployeeSupervisor") archive EmployeesArchive[] @relation("EmployeeToArchive") timesheet Timesheets[] @relation("TimesheetEmployee") leave_request LeaveRequests[] @relation("LeaveRequestEmployee") supervisor_archive EmployeesArchive[] @relation("EmployeeSupervisorToArchive") + schedule_presets SchedulePresets[] @relation("SchedulePreset") @@map("employees") } model EmployeesArchive { - id Int @id @default(autoincrement()) - employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id]) - employee_id Int - archived_at DateTime @default(now()) + id Int @id @default(autoincrement()) + employee Employees @relation("EmployeeToArchive", fields: [employee_id], references: [id]) + employee_id Int + user_id String @db.Uuid + user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id]) + supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id]) + supervisor_id Int? - user_id String @db.Uuid - user Users @relation("UsersToEmployeesToArchive", fields: [user_id], references: [id]) + archived_at DateTime @default(now()) first_name String last_name String job_title String? @@ -71,8 +76,6 @@ model EmployeesArchive { company_code Int first_work_day DateTime @db.Date last_work_day DateTime @db.Date - supervisor_id Int? - supervisor Employees? @relation("EmployeeSupervisorToArchive", fields: [supervisor_id], references: [id]) @@map("employees_archive") } @@ -92,56 +95,64 @@ model CustomersArchive { id Int @id @default(autoincrement()) customer Customers @relation("CustomerToArchive", fields: [customer_id], references: [id]) customer_id Int - archived_at DateTime @default(now()) - user_id String @db.Uuid user Users @relation("UserToCustomersToArchive", fields: [user_id], references: [id]) + user_id String @db.Uuid - invoice_id Int? @unique + archived_at DateTime @default(now()) + invoice_id Int? @unique @@map("customers_archive") } model LeaveRequests { - id Int @id @default(autoincrement()) - employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id]) - employee_id Int - bank_code BankCodes? @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) - bank_code_id Int - leave_type LeaveTypes - start_date_time DateTime @db.Date - end_date_time DateTime? @db.Date + id Int @id @default(autoincrement()) + employee Employees @relation("LeaveRequestEmployee", fields: [employee_id], references: [id]) + employee_id Int + bank_code BankCodes @relation("LeaveRequestBankCodes", fields: [bank_code_id], references: [id]) + bank_code_id Int + comment String + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5, 2) + requested_hours Decimal? @db.Decimal(5, 2) approval_status LeaveApprovalStatus @default(PENDING) + leave_type LeaveTypes archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive") + @@unique([employee_id, leave_type, date], name: "leave_per_employee_date") + @@index([employee_id, date]) @@map("leave_requests") } model LeaveRequestsArchive { - id Int @id @default(autoincrement()) - leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id]) + id Int @id @default(autoincrement()) + leave_request LeaveRequests @relation("LeaveRequestToArchive", fields: [leave_request_id], references: [id]) leave_request_id Int - archived_at DateTime @default(now()) + + archived_at DateTime @default(now()) employee_id Int - leave_type LeaveTypes - start_date_time DateTime @db.Date - end_date_time DateTime? @db.Date + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5, 2) + requested_hours Decimal? @db.Decimal(5, 2) comment String + leave_type LeaveTypes approval_status LeaveApprovalStatus + @@unique([leave_request_id]) + @@index([employee_id, date]) @@map("leave_requests_archive") } //pay-period vue view PayPeriods { - pay_year Int - pay_period_no Int - payday DateTime @db.Date - period_start DateTime @db.Date - period_end DateTime @db.Date - label String - + pay_year Int + pay_period_no Int + label String + payday DateTime @db.Date + period_start DateTime @db.Date + period_end DateTime @db.Date + @@map("pay_period") } @@ -149,12 +160,15 @@ model Timesheets { id Int @id @default(autoincrement()) employee Employees @relation("TimesheetEmployee", fields: [employee_id], references: [id]) employee_id Int - is_approved Boolean @default(false) + + start_date DateTime @db.Date + is_approved Boolean @default(false) shift Shifts[] @relation("ShiftTimesheet") expense Expenses[] @relation("ExpensesTimesheet") archive TimesheetsArchive[] @relation("TimesheetsToArchive") + @@unique([employee_id, start_date], name: "employee_id_start_date") @@map("timesheets") } @@ -162,24 +176,71 @@ model TimesheetsArchive { id Int @id @default(autoincrement()) timesheet Timesheets @relation("TimesheetsToArchive", fields: [timesheet_id], references: [id]) timesheet_id Int - archive_at DateTime @default(now()) + employee_id Int is_approved Boolean + archive_at DateTime @default(now()) @@map("timesheets_archive") } + + + + + +model SchedulePresets { + id Int @id @default(autoincrement()) + employee Employees @relation("SchedulePreset", fields: [employee_id], references: [id]) + employee_id Int + + name String + is_default Boolean @default(false) + + shifts SchedulePresetShifts[] @relation("SchedulePresetShiftsSchedulePreset") + + @@unique([employee_id, name], name: "unique_preset_name_per_employee") + @@map("schedule_presets") +} + +model SchedulePresetShifts { + id Int @id @default(autoincrement()) + preset SchedulePresets @relation("SchedulePresetShiftsSchedulePreset",fields: [preset_id], references: [id]) + preset_id Int + bank_code BankCodes @relation("SchedulePresetShiftsBankCodes",fields: [bank_code_id], references: [id]) + bank_code_id Int + + sort_order Int + start_time DateTime @db.Time(0) + end_time DateTime @db.Time(0) + is_remote Boolean @default(false) + week_day Weekday + + @@unique([preset_id, week_day, sort_order], name: "unique_preset_shift_per_day_order") + @@index([preset_id, week_day]) + @@map("schedule_preset_shifts") +} + + + + + + + + model Shifts { id Int @id @default(autoincrement()) timesheet Timesheets @relation("ShiftTimesheet", fields: [timesheet_id], references: [id]) timesheet_id Int bank_code BankCodes @relation("ShiftBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int - description String? - date DateTime @db.Date - start_time DateTime @db.Time(0) - end_time DateTime @db.Time(0) - is_approved Boolean @default(false) + + date DateTime @db.Date + start_time DateTime @db.Time(0) + end_time DateTime @db.Time(0) + is_approved Boolean @default(false) + is_remote Boolean @default(false) + comment String? archive ShiftsArchive[] @relation("ShiftsToArchive") @@ -187,16 +248,17 @@ model Shifts { } model ShiftsArchive { - id Int @id @default(autoincrement()) - shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id]) - shift_id Int - archive_at DateTime @default(now()) - timesheet_id Int - bank_code_id Int - description String? + id Int @id @default(autoincrement()) + shift Shifts @relation("ShiftsToArchive", fields: [shift_id], references: [id]) + shift_id Int + date DateTime @db.Date start_time DateTime @db.Time(0) end_time DateTime @db.Time(0) + timesheet_id Int + bank_code_id Int + comment String? + archive_at DateTime @default(now()) @@map("shifts_archive") } @@ -208,25 +270,29 @@ model BankCodes { modifier Float bank_code String - shifts Shifts[] @relation("ShiftBankCodes") - expenses Expenses[] @relation("ExpenseBankCodes") - leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes") + shifts Shifts[] @relation("ShiftBankCodes") + expenses Expenses[] @relation("ExpenseBankCodes") + leaveRequests LeaveRequests[] @relation("LeaveRequestBankCodes") + SchedulePresetShifts SchedulePresetShifts[] @relation("SchedulePresetShiftsBankCodes") @@map("bank_codes") } model Expenses { - id Int @id @default(autoincrement()) - timesheet Timesheets @relation("ExpensesTimesheet", fields: [timesheet_id], references: [id]) + id Int @id @default(autoincrement()) + timesheet Timesheets @relation("ExpensesTimesheet", fields: [timesheet_id], references: [id]) timesheet_id Int - bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) + bank_code BankCodes @relation("ExpenseBankCodes", fields: [bank_code_id], references: [id]) bank_code_id Int - date DateTime @db.Date - amount Decimal @db.Money - attachement String? - description String? - is_approved Boolean @default(false) + attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull) + attachment Int? + + date DateTime @db.Date + amount Decimal @db.Money + mileage Decimal? @db.Decimal(12,2) + comment String supervisor_comment String? + is_approved Boolean @default(false) archive ExpensesArchive[] @relation("ExpensesToArchive") @@ -234,16 +300,19 @@ model Expenses { } model ExpensesArchive { - id Int @id @default(autoincrement()) - expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id]) + id Int @id @default(autoincrement()) + expense Expenses @relation("ExpensesToArchive", fields: [expense_id], references: [id]) expense_id Int + attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull) + attachment Int? + timesheet_id Int archived_at DateTime @default(now()) bank_code_id Int date DateTime @db.Date - amount Decimal @db.Money - attachement String? - description String? + amount Decimal? @db.Money + mileage Decimal? @db.Decimal(12,2) + comment String? is_approved Boolean supervisor_comment String? @@ -269,7 +338,7 @@ model OAuthSessions { } model Blobs { - sha256 String @id @db.Char(64) + sha256 String @id @db.Char(64) size Int mime String storage_path String @@ -282,16 +351,20 @@ model Blobs { } model Attachments { - id Int @id @default(autoincrement()) - sha256 String @db.Char(64) - blob Blobs @relation("AttachmnentBlob",fields: [sha256], references: [sha256], onUpdate: Cascade) - owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc - owner_id String //expense_id, employee_id, etc + id Int @id @default(autoincrement()) + blob Blobs @relation("AttachmnentBlob",fields: [sha256], references: [sha256], onUpdate: Cascade) + sha256 String @db.Char(64) + + owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc + owner_id String //expense_id, employee_id, etc original_name String - status AttachmentStatus @default(ACTIVE) + status AttachmentStatus @default(ACTIVE) retention_policy RetentionPolicy created_by String - created_at DateTime @default(now()) + created_at DateTime @default(now()) + + expenses Expenses[] @relation("ExpenseAttachment") + expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment") AttachmentVariants AttachmentVariants[] @relation("attachmentVariantAttachment") @@ -315,7 +388,20 @@ model AttachmentVariants { @@map("attachment_variants") } -enum AttachmentStatus { +model Preferences { + id Int @id @default(autoincrement()) + user Users @relation("UserPreferences", fields: [user_id], references: [id]) + user_id String @unique @db.Uuid + + notifications Int @default(0) + dark_mode Int @default(0) + lang_switch Int @default(0) + lefty_mode Int @default(0) + + @@map("preferences") +} + +enum AttachmentStatus { ACTIVE DELETED } @@ -347,7 +433,8 @@ enum LeaveTypes { PARENTAL // maternite/paternite/adoption LEGAL // obligations legales comme devoir de juree WEDDING // mariage - + HOLIDAY // férier + @@map("leave_types") } @@ -360,3 +447,13 @@ enum LeaveApprovalStatus { @@map("leave_approval_status") } + +enum Weekday { + SUN + MON + TUE + WED + THU + FRI + SAT +} diff --git a/src/app.module.ts b/src/app.module.ts index c1a20e8..daf8229 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,27 +1,32 @@ -import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { BadRequestException, Module, ValidationPipe } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; import { ArchivalModule } from './modules/archival/archival.module'; import { 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 { CsvExportModule } from './modules/exports/csv-exports.module'; -import { CustomersModule } from './modules/customers/customers.module'; -import { EmployeesModule } from './modules/employees/employees.module'; -import { ExpensesModule } from './modules/expenses/expenses.module'; -import { HealthModule } from './health/health.module'; +// import { CsvExportModule } from './modules/exports/csv-exports.module'; +import { CustomersModule } from './modules/customers/customers.module'; +import { EmployeesModule } from './modules/employees/employees.module'; +import { ExpensesModule } from './modules/expenses/expenses.module'; +import { HealthModule } from './health/health.module'; import { HealthController } from './health/health.controller'; import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module'; import { NotificationsModule } from './modules/notifications/notifications.module'; import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module'; -import { OvertimeService } from './modules/business-logics/services/overtime.service'; +import { OvertimeService } from './modules/business-logics/services/overtime.service'; import { PayperiodsModule } from './modules/pay-periods/pay-periods.module'; -import { PrismaModule } from './prisma/prisma.module'; -import { ScheduleModule } from '@nestjs/schedule'; -import { ShiftsModule } from './modules/shifts/shifts.module'; +import { PreferencesModule } from './modules/preferences/preferences.module'; +import { PrismaModule } from './prisma/prisma.module'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ShiftsModule } from './modules/shifts/shifts.module'; import { TimesheetsModule } from './modules/timesheets/timesheets.module'; -import { UsersModule } from './modules/users-management/users.module'; -import { ConfigModule } from '@nestjs/config'; +import { UsersModule } from './modules/users-management/users.module'; +import { ConfigModule } from '@nestjs/config'; +import { APP_FILTER, APP_PIPE } from '@nestjs/core'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { ValidationError } from 'class-validator'; +import { SchedulePresetsModule } from './modules/schedule-presets/schedule-presets.module'; @Module({ imports: [ @@ -30,7 +35,7 @@ import { ConfigModule } from '@nestjs/config'; BankCodesModule, BusinessLogicsModule, ConfigModule.forRoot({isGlobal: true}), - CsvExportModule, + // CsvExportModule, CustomersModule, EmployeesModule, ExpensesModule, @@ -39,13 +44,38 @@ import { ConfigModule } from '@nestjs/config'; NotificationsModule, OauthSessionsModule, PayperiodsModule, + PreferencesModule, PrismaModule, - ScheduleModule.forRoot(), + ScheduleModule.forRoot(), //cronjobs + SchedulePresetsModule, ShiftsModule, TimesheetsModule, UsersModule, ], controllers: [AppController, HealthController], - providers: [AppService, OvertimeService], + providers: [ + AppService, + OvertimeService, + { + provide: APP_FILTER, + useClass: HttpExceptionFilter + }, + { + provide: APP_PIPE, + useValue: new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + exceptionFactory: (errors: ValidationError[] = [])=> { + const messages = errors.flatMap((e)=> Object.values(e.constraints ?? {})); + return new BadRequestException({ + statusCode: 400, + error: 'Bad Request', + message: messages.length ? messages : errors, + }); + }, + }), + }, + ], }) export class AppModule {} diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..a44c4c9 --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -0,0 +1,24 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from "@nestjs/common"; +import { Request, Response } from 'express'; + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const http_context = host.switchToHttp(); + const response = http_context.getResponse(); + const request = http_context.getRequest(); + const http_status = exception.getStatus(); + + const exception_response = exception.getResponse(); + const normalized = typeof exception_response === 'string' + ? { message: exception_response } + : (exception_response as Record); + const response_body = { + statusCode: http_status, + timestamp: new Date().toISOString(), + path: request.url, + ...normalized, + }; + response.status(http_status).json(response_body); + } +} \ No newline at end of file diff --git a/src/common/utils/date-utils.ts b/src/common/utils/date-utils.ts index e383f98..5d85548 100644 --- a/src/common/utils/date-utils.ts +++ b/src/common/utils/date-utils.ts @@ -49,6 +49,13 @@ export function getYearStart(date:Date): Date { return new Date(date.getFullYear(),0,1,0,0,0,0); } +export function getCurrentWeek(): { start_date_week: Date; end_date_week: Date } { + const now = new Date(); + const start_date_week = getWeekStart(now, 0); + const end_date_week = getWeekEnd(start_date_week); + return { start_date_week, end_date_week }; +} + //cloning methods (helps with notify for overtime in a single day) // export function toDateOnly(day: Date): Date { // const d = new Date(day); diff --git a/src/main.ts b/src/main.ts index 0347cec..88237b6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,6 @@ import { ATT_TMP_DIR } from './config/attachment.config'; // log to be removed p import { ModuleRef, NestFactory, Reflector } from '@nestjs/core'; import { AppModule } from './app.module'; -import { ValidationPipe } from '@nestjs/common'; // import { JwtAuthGuard } from './modules/authentication/guards/jwt-auth.guard'; import { RolesGuard } from './common/guards/roles.guard'; import { OwnershipGuard } from './common/guards/ownership.guard'; @@ -25,13 +24,11 @@ async function bootstrap() { const reflector = app.get(Reflector); //setup Reflector for Roles() - app.useGlobalPipes( - new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true })); app.useGlobalGuards( // new JwtAuthGuard(reflector), //Authentification JWT new RolesGuard(reflector), //deny-by-default and Role-based Access Control new OwnershipGuard(reflector, app.get(ModuleRef)), //Global use of OwnershipGuard, not implemented yet - ); + ); // Authentication and session app.use(session({ diff --git a/src/modules/archival/archival.module.ts b/src/modules/archival/archival.module.ts index 7a8b73a..03f1bf9 100644 --- a/src/modules/archival/archival.module.ts +++ b/src/modules/archival/archival.module.ts @@ -28,7 +28,7 @@ import { EmployeesModule } from "../employees/employees.module"; LeaveRequestsArchiveController, ShiftsArchiveController, TimesheetsArchiveController, - ] + ], }) export class ArchivalModule {} \ No newline at end of file diff --git a/src/modules/archival/controllers/employees-archive.controller.ts b/src/modules/archival/controllers/employees-archive.controller.ts index fa9e911..375a351 100644 --- a/src/modules/archival/controllers/employees-archive.controller.ts +++ b/src/modules/archival/controllers/employees-archive.controller.ts @@ -2,20 +2,20 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { EmployeesArchive, Roles as RoleEnum } from '@prisma/client'; -import { EmployeesService } from "src/modules/employees/services/employees.service"; +import { EmployeesArchivalService } from "src/modules/employees/services/employees-archival.service"; @ApiTags('Employee Archives') // @UseGuards() @Controller('archives/employees') export class EmployeesArchiveController { - constructor(private readonly employeesService: EmployeesService) {} + constructor(private readonly employeesArchiveService: EmployeesArchivalService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiOperation({ summary: 'List of archived employees'}) @ApiResponse({ status: 200, description: 'List of archived employees', isArray: true }) async findAllArchived(): Promise { - return this.employeesService.findAllArchived(); + return this.employeesArchiveService.findAllArchived(); } @Get() @@ -24,7 +24,7 @@ export class EmployeesArchiveController { @ApiResponse({ status: 200, description: 'Archived employee found'}) async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise { try{ - return await this.employeesService.findOneArchived(id); + return await this.employeesArchiveService.findOneArchived(id); }catch { throw new NotFoundException(`Archived employee #${id} not found`); } diff --git a/src/modules/archival/controllers/expenses-archive.controller.ts b/src/modules/archival/controllers/expenses-archive.controller.ts index 7c270fe..e6bd935 100644 --- a/src/modules/archival/controllers/expenses-archive.controller.ts +++ b/src/modules/archival/controllers/expenses-archive.controller.ts @@ -2,13 +2,13 @@ import { UseGuards, Controller, Get, Param, ParseIntPipe, NotFoundException } fr import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger"; import { ExpensesArchive,Roles as RoleEnum } from "@prisma/client"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; +import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service"; @ApiTags('Expense Archives') // @UseGuards() @Controller('archives/expenses') export class ExpensesArchiveController { - constructor(private readonly expensesService: ExpensesQueryService) {} + constructor(private readonly expensesService: ExpensesArchivalService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/archival/controllers/leave-requests-archive.controller.ts b/src/modules/archival/controllers/leave-requests-archive.controller.ts index 51ad8e6..1c5e4be 100644 --- a/src/modules/archival/controllers/leave-requests-archive.controller.ts +++ b/src/modules/archival/controllers/leave-requests-archive.controller.ts @@ -1,33 +1,7 @@ -import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } from "@nestjs/common"; -import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; -import { LeaveRequestsArchive, Roles as RoleEnum } from "@prisma/client"; -import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { LeaveRequestViewDto } from "src/modules/leave-requests/dtos/leave-request.view.dto"; -import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service"; +import { Controller } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; @ApiTags('LeaveRequests Archives') // @UseGuards() @Controller('archives/leaveRequests') -export class LeaveRequestsArchiveController { - constructor(private readonly leaveRequestsService: LeaveRequestsService) {} - - @Get() - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'List of archived leaveRequests'}) - @ApiResponse({ status: 200, description: 'List of archived leaveRequests', isArray: true }) - async findAllArchived(): Promise { - return this.leaveRequestsService.findAllArchived(); - } - - @Get() - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Fetch leaveRequest in archives with its Id'}) - @ApiResponse({ status: 200, description: 'Archived leaveRequest found'}) - async findOneArchived(@Param('id', ParseIntPipe) id: number ): Promise { - try{ - return await this.leaveRequestsService.findOneArchived(id); - }catch { - throw new NotFoundException(`Archived leaveRequest #${id} not found`); - } - } -} \ No newline at end of file +export class LeaveRequestsArchiveController {} \ No newline at end of file diff --git a/src/modules/archival/controllers/shifts-archive.controller.ts b/src/modules/archival/controllers/shifts-archive.controller.ts index fb7204b..e8f92f2 100644 --- a/src/modules/archival/controllers/shifts-archive.controller.ts +++ b/src/modules/archival/controllers/shifts-archive.controller.ts @@ -2,13 +2,13 @@ import { Get, Param, ParseIntPipe, NotFoundException, Controller, UseGuards } fr import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { ShiftsArchive, Roles as RoleEnum } from "@prisma/client"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; +import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service"; @ApiTags('Shift Archives') // @UseGuards() @Controller('archives/shifts') export class ShiftsArchiveController { - constructor(private readonly shiftsService:ShiftsQueryService) {} + constructor(private readonly shiftsService: ShiftsArchivalService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/archival/controllers/timesheets-archive.controller.ts b/src/modules/archival/controllers/timesheets-archive.controller.ts index 0c9d607..7505b66 100644 --- a/src/modules/archival/controllers/timesheets-archive.controller.ts +++ b/src/modules/archival/controllers/timesheets-archive.controller.ts @@ -2,13 +2,13 @@ import { Controller, Get, NotFoundException, Param, ParseIntPipe, UseGuards } fr import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { TimesheetsArchive, Roles as RoleEnum } from '@prisma/client'; -import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; +import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service"; @ApiTags('Timesheet Archives') // @UseGuards() @Controller('archives/timesheets') export class TimesheetsArchiveController { - constructor(private readonly timesheetsService: TimesheetsQueryService) {} + constructor(private readonly timesheetsService: TimesheetArchiveService) {} @Get() //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) diff --git a/src/modules/archival/services/archival.service.ts b/src/modules/archival/services/archival.service.ts index bf7a36d..66be2d0 100644 --- a/src/modules/archival/services/archival.service.ts +++ b/src/modules/archival/services/archival.service.ts @@ -1,19 +1,17 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron } from "@nestjs/schedule"; -import { ExpensesQueryService } from "src/modules/expenses/services/expenses-query.service"; -import { LeaveRequestsService } from "src/modules/leave-requests/services/leave-requests.service"; -import { ShiftsQueryService } from "src/modules/shifts/services/shifts-query.service"; -import { TimesheetsQueryService } from "src/modules/timesheets/services/timesheets-query.service"; +import { ExpensesArchivalService } from "src/modules/expenses/services/expenses-archival.service"; +import { ShiftsArchivalService } from "src/modules/shifts/services/shifts-archival.service"; +import { TimesheetArchiveService } from "src/modules/timesheets/services/timesheet-archive.service"; @Injectable() export class ArchivalService { private readonly logger = new Logger(ArchivalService.name); constructor( - private readonly timesheetsService: TimesheetsQueryService, - private readonly expensesService: ExpensesQueryService, - private readonly shiftsService: ShiftsQueryService, - private readonly leaveRequestsService: LeaveRequestsService, + private readonly timesheetsService: TimesheetArchiveService, + private readonly expensesService: ExpensesArchivalService, + private readonly shiftsService: ShiftsArchivalService, ) {} @Cron('0 0 3 * * 1', {timeZone:'America/Toronto'}) // chaque premier lundi du mois à 03h00 @@ -31,7 +29,7 @@ export class ArchivalService { await this.timesheetsService.archiveOld(); await this.expensesService.archiveOld(); await this.shiftsService.archiveOld(); - await this.leaveRequestsService.archiveExpired(); + // await this.leaveRequestsService.archiveExpired(); this.logger.log('archivation process done'); } catch (err) { this.logger.error('an error occured during archivation process ', err); diff --git a/src/modules/bank-codes/controllers/bank-codes.controller.ts b/src/modules/bank-codes/controllers/bank-codes.controller.ts index cb36ee2..678336c 100644 --- a/src/modules/bank-codes/controllers/bank-codes.controller.ts +++ b/src/modules/bank-codes/controllers/bank-codes.controller.ts @@ -7,40 +7,43 @@ import { ApiBadRequestResponse, ApiNotFoundResponse, ApiOperation, ApiResponse } @Controller('bank-codes') export class BankCodesControllers { constructor(private readonly bankCodesService: BankCodesService) {} + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - @Post() - @ApiOperation({ summary: 'Create a new bank code' }) - @ApiResponse({ status: 201, description: 'Bank code successfully created.' }) - @ApiBadRequestResponse({ description: 'Invalid input data.' }) - create(@Body() dto: CreateBankCodeDto) { - return this.bankCodesService.create(dto); - } + // @Post() + // @ApiOperation({ summary: 'Create a new bank code' }) + // @ApiResponse({ status: 201, description: 'Bank code successfully created.' }) + // @ApiBadRequestResponse({ description: 'Invalid input data.' }) + // create(@Body() dto: CreateBankCodeDto) { + // return this.bankCodesService.create(dto); + // } - @Get() - @ApiOperation({ summary: 'Retrieve all bank codes' }) - @ApiResponse({ status: 200, description: 'List of bank codes.' }) - findAll() { - return this.bankCodesService.findAll(); - } + // @Get() + // @ApiOperation({ summary: 'Retrieve all bank codes' }) + // @ApiResponse({ status: 200, description: 'List of bank codes.' }) + // findAll() { + // return this.bankCodesService.findAll(); + // } - @Get(':id') - @ApiOperation({ summary: 'Retrieve a bank code by its ID' }) - @ApiNotFoundResponse({ description: 'Bank code not found.' }) - findOne(@Param('id', ParseIntPipe) id: number){ - return this.bankCodesService.findOne(id); - } + // @Get(':id') + // @ApiOperation({ summary: 'Retrieve a bank code by its ID' }) + // @ApiNotFoundResponse({ description: 'Bank code not found.' }) + // findOne(@Param('id', ParseIntPipe) id: number){ + // return this.bankCodesService.findOne(id); + // } - @Patch(':id') - @ApiOperation({ summary: 'Update an existing bank code' }) - @ApiNotFoundResponse({ description: 'Bank code not found.' }) - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) { - return this.bankCodesService.update(id, dto) - } + // @Patch(':id') + // @ApiOperation({ summary: 'Update an existing bank code' }) + // @ApiNotFoundResponse({ description: 'Bank code not found.' }) + // update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateBankCodeDto) { + // return this.bankCodesService.update(id, dto) + // } - @Delete(':id') - @ApiOperation({ summary: 'Delete a bank code' }) - @ApiNotFoundResponse({ description: 'Bank code not found.' }) - remove(@Param('id', ParseIntPipe) id: number) { - return this.bankCodesService.remove(id); - } + // @Delete(':id') + // @ApiOperation({ summary: 'Delete a bank code' }) + // @ApiNotFoundResponse({ description: 'Bank code not found.' }) + // remove(@Param('id', ParseIntPipe) id: number) { + // return this.bankCodesService.remove(id); + // } } \ No newline at end of file diff --git a/src/modules/business-logics/services/holiday.service.ts b/src/modules/business-logics/services/holiday.service.ts index daf6e96..15bf4d2 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -1,6 +1,15 @@ -import { Injectable, Logger } from "@nestjs/common"; -import { PrismaService } from "../../../prisma/prisma.service"; +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; import { computeHours, getWeekStart } from "src/common/utils/date-utils"; +import { PrismaService } from "../../../prisma/prisma.service"; + +const WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000; + +/* + le calcul est 1/20 des 4 dernières semaines, précédent la semaine incluant le férier. + Un maximum de 08h00 est allouable pour le férier + Un maximum de 40hrs par semaine est retenue pour faire le calcul. + le bank-code à soumettre à Desjardins doit être le G104 +*/ @Injectable() export class HolidayService { @@ -8,35 +17,63 @@ export class HolidayService { constructor(private readonly prisma: PrismaService) {} - //switch employeeId for email - private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise { - //sets the end of the window to 1ms before the week with the holiday - const holiday_week_start = getWeekStart(holiday_date); - const window_end = new Date(holiday_week_start.getTime() - 1); - //sets the start of the window to 28 days ( 4 completed weeks ) before the week with the holiday - const window_start = new Date(window_end.getTime() - 28 * 24 * 60 * 60000 + 1 ) + //fetch employee_id by email + private async resolveEmployeeByEmail(email: string): Promise { + const employee = await this.prisma.employees.findFirst({ + where: { + user: { email } + }, + select: { id: true }, + }); + if(!employee) throw new NotFoundException(`Employee with email : ${email} not found`); + return employee.id; + } - const valid_codes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700']; - //fetches all shift of the employee in said window ( 4 previous completed weeks ) + private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise { + const employee_id = await this.resolveEmployeeByEmail(email); + return this.computeHoursPrevious4Weeks(employee_id, holiday_date); + } + + private async computeHoursPrevious4Weeks(employee_id: number, holiday_date: Date): Promise { + const holiday_week_start = getWeekStart(holiday_date); + const window_start = new Date(holiday_week_start.getTime() - 4 * WEEK_IN_MS); + const window_end = new Date(holiday_week_start.getTime() - 1); + + const valid_codes = ['G1', 'G43', 'G56', 'G104', 'G105', 'G700']; const shifts = await this.prisma.shifts.findMany({ - where: { timesheet: { employee_id: employee_id } , - date: { gte: window_start, lte: window_end }, - bank_code: { bank_code: { in: valid_codes } }, + where: { + timesheet: { employee_id: employee_id }, + date: { gte: window_start, lte: window_end }, + bank_code: { bank_code: { in: valid_codes } }, }, select: { date: true, start_time: true, end_time: true }, }); - const total_hours = shifts.map(s => computeHours(s.start_time, s.end_time)).reduce((sum, h)=> sum + h, 0); - const daily_hours = total_hours / 20; + const hours_by_week = new Map(); + for(const shift of shifts) { + const hours = computeHours(shift.start_time, shift.end_time); + if(hours <= 0) continue; + const shift_week_start = getWeekStart(shift.date); + const key = shift_week_start.getTime(); + hours_by_week.set(key, (hours_by_week.get(key) ?? 0) + hours); + } - return daily_hours; + let capped_total = 0; + for(let offset = 1; offset <= 4; offset++) { + const week_start = new Date(holiday_week_start.getTime() - offset * WEEK_IN_MS); + const key = week_start.getTime(); + const weekly_hours = hours_by_week.get(key) ?? 0; + capped_total += Math.min(weekly_hours, 40); + } + + const average_daily_hours = capped_total / 20; + return average_daily_hours; } - //switch employeeId for email - async calculateHolidayPay( employee_id: number, holiday_date: Date, modifier: number): Promise { - const hours = await this.computeHoursPrevious4Weeks(employee_id, holiday_date); - const daily_rate = Math.min(hours, 8); - this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`); + async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise { + const average_daily_hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); + const daily_rate = Math.min(average_daily_hours, 8); + this.logger.debug(`Holiday pay calculation: cappedHoursPerDay= ${average_daily_hours.toFixed(2)}, appliedDailyRate= ${daily_rate.toFixed(2)}`); return daily_rate * modifier; } } \ No newline at end of file diff --git a/src/modules/business-logics/services/overtime.service.ts b/src/modules/business-logics/services/overtime.service.ts index 79619b5..6c6d1b0 100644 --- a/src/modules/business-logics/services/overtime.service.ts +++ b/src/modules/business-logics/services/overtime.service.ts @@ -1,55 +1,152 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../../../prisma/prisma.service'; import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils'; +import { Prisma } from '@prisma/client'; @Injectable() export class OvertimeService { private logger = new Logger(OvertimeService.name); - private daily_max = 12; // maximum for regular hours per day - private weekly_max = 80; //maximum for regular hours per week + private daily_max = 8; // maximum for regular hours per day + 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 constructor(private prisma: PrismaService) {} - //calculate Daily overtime - getDailyOvertimeHours(start: Date, end: Date): number { - const hours = computeHours(start, end, 5); - const overtime = Math.max(0, hours - this.daily_max); - this.logger.debug(`getDailyOvertimeHours : ${overtime.toFixed(2)}h (threshold ${this.daily_max})`); - return overtime; + //calculate daily overtime + async getDailyOvertimeHoursForDay(employee_id: number, date: Date): Promise { + 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 - //switch employeeId for email - async getWeeklyOvertimeHours(employeeId: number, refDate: Date): Promise { - const week_start = getWeekStart(refDate); - const week_end = getWeekEnd(week_start); + async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise { + const week_start = getWeekStart(ref_date); + const week_end = getWeekEnd(week_start); - //fetches all shifts containing hours - const shifts = await this.prisma.shifts.findMany({ - where: { timesheet: { employee_id: employeeId, shift: { - every: {date: { gte: week_start, lte: week_end } } + //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 }, + 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 = shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5)) + 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(`weekly total = ${total.toFixed(2)}h, weekly Overtime= ${overtime.toFixed(2)}h`); + this.logger.debug(`[OVERTIME]-[WEEKLY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`); return overtime; } - //apply modifier to overtime hours - calculateOvertimePay(overtime_hours: number, modifier: number): number { - const pay = overtime_hours * modifier; - this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`); + //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 { + //ensures the use of the transaction if needed. fallback to this.prisma if no transaction is detected. + const db = tx ?? this.prisma; - return pay; + //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`); } + //apply modifier to overtime hours + // calculateOvertimePay(overtime_hours: number, modifier: number): number { + // const pay = overtime_hours * modifier; + // this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`); + + // return pay; + // } + } diff --git a/src/modules/business-logics/services/sick-leave.service.ts b/src/modules/business-logics/services/sick-leave.service.ts index 6ebb3d9..6c00113 100644 --- a/src/modules/business-logics/services/sick-leave.service.ts +++ b/src/modules/business-logics/services/sick-leave.service.ts @@ -1,6 +1,6 @@ +import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils"; import { Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../../../prisma/prisma.service"; -import { getYearStart, roundToQuarterHour } from "src/common/utils/date-utils"; @Injectable() export class SickLeaveService { @@ -9,28 +9,38 @@ export class SickLeaveService { private readonly logger = new Logger(SickLeaveService.name); //switch employeeId for email - async calculateSickLeavePay(employee_id: number, reference_date: Date, days_requested: number, modifier: number): - Promise { + async calculateSickLeavePay( + employee_id: number, + reference_date: Date, + days_requested: number, + hours_per_day: number, + modifier: number, + ): Promise { + if (days_requested <= 0 || hours_per_day <= 0 || modifier <= 0) { + return 0; + } + //sets the year to jan 1st to dec 31st const period_start = getYearStart(reference_date); - const period_end = reference_date; + const period_end = reference_date; //fetches all shifts of a selected employee const shifts = await this.prisma.shifts.findMany({ where: { timesheet: { employee_id: employee_id }, - date: { gte: period_start, lte: period_end}, + date: { gte: period_start, lte: period_end }, }, select: { date: true }, }); //count the amount of worked days const worked_dates = new Set( - shifts.map(shift => shift.date.toISOString().slice(0,10)) + shifts.map((shift) => shift.date.toISOString().slice(0, 10)), ); const days_worked = worked_dates.size; - this.logger.debug(`Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} - -> ${period_end.toDateString()}`); + this.logger.debug( + `Sick leave: days worked= ${days_worked} in ${period_start.toDateString()} -> ${period_end.toDateString()}`, + ); //less than 30 worked days returns 0 if (days_worked < 30) { @@ -45,22 +55,31 @@ export class SickLeaveService { const threshold_date = new Date(ordered_dates[29]); // index 29 is the 30th day //calculate each completed month, starting the 1st of the next month - const first_bonus_date = new Date(threshold_date.getFullYear(), threshold_date.getMonth() +1, 1); - let months = (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 + - (period_end.getMonth() - first_bonus_date.getMonth()) + 1; - if(months < 0) months = 0; + const first_bonus_date = new Date( + threshold_date.getFullYear(), + threshold_date.getMonth() + 1, + 1, + ); + let months = + (period_end.getFullYear() - first_bonus_date.getFullYear()) * 12 + + (period_end.getMonth() - first_bonus_date.getMonth()) + + 1; + if (months < 0) months = 0; acquired_days += months; //cap of 10 days if (acquired_days > 10) acquired_days = 10; - this.logger.debug(`Sick leave: threshold Date = ${threshold_date.toDateString()} - , bonusMonths = ${months}, acquired Days = ${acquired_days}`); + this.logger.debug( + `Sick leave: threshold Date = ${threshold_date.toDateString()}, bonusMonths = ${months}, acquired Days = ${acquired_days}`, + ); const payable_days = Math.min(acquired_days, days_requested); - const raw_hours = payable_days * 8 * modifier; - const rounded = roundToQuarterHour(raw_hours) - this.logger.debug(`Sick leave pay: days= ${payable_days}, modifier= ${modifier}, hours= ${rounded}`); + const raw_hours = payable_days * hours_per_day * modifier; + const rounded = roundToQuarterHour(raw_hours); + this.logger.debug( + `Sick leave pay: days= ${payable_days}, hoursPerDay= ${hours_per_day}, modifier= ${modifier}, hours= ${rounded}`, + ); return rounded; } -} \ No newline at end of file +} diff --git a/src/modules/business-logics/services/vacation.service.ts b/src/modules/business-logics/services/vacation.service.ts index f3b3447..9445149 100644 --- a/src/modules/business-logics/services/vacation.service.ts +++ b/src/modules/business-logics/services/vacation.service.ts @@ -6,16 +6,8 @@ export class VacationService { constructor(private readonly prisma: PrismaService) {} private readonly logger = new Logger(VacationService.name); - /** - * Calculate the ammount allowed for vacation days. - * - * @param employee_id employee ID - * @param startDate first day of vacation - * @param daysRequested number of days requested - * @param modifier Coefficient of hours(1) - * @returns amount of payable hours - */ - //switch employeeId for email + + //switch employeeId for email async calculateVacationPay(employee_id: number, start_date: Date, days_requested: number, modifier: number): Promise { //fetch hiring date const employee = await this.prisma.employees.findUnique({ @@ -56,7 +48,7 @@ export class VacationService { const segment_end = boundaries[i+1]; //number of days in said segment - const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day); + const days_in_segment = Math.round((segment_end.getTime() - segment_start.getTime())/ ms_per_day); const years_since_hire = (segment_start.getFullYear() - hire_date.getFullYear()) - (segment_start < new Date(segment_start.getFullYear(), hire_date.getMonth()) ? 1 : 0); let alloc_days: number; diff --git a/src/modules/customers/controllers/customers.controller.ts b/src/modules/customers/controllers/customers.controller.ts index 83122d3..713ebde 100644 --- a/src/modules/customers/controllers/customers.controller.ts +++ b/src/modules/customers/controllers/customers.controller.ts @@ -14,51 +14,55 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg export class CustomersController { constructor(private readonly customersService: CustomersService) {} - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Create customer' }) - @ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto }) - @ApiResponse({ status: 400, description: 'Invalid task or invalid data' }) - create(@Body() dto: CreateCustomerDto): Promise { - return this.customersService.create(dto); - } +//_____________________________________________________________________________________________ +// Deprecated or unused methods +//_____________________________________________________________________________________________ - @Get() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find all customers' }) - @ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of customers not found' }) - findAll(): Promise { - return this.customersService.findAll(); - } + // @Post() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Create customer' }) + // @ApiResponse({ status: 201, description: 'Customer created', type: CreateCustomerDto }) + // @ApiResponse({ status: 400, description: 'Invalid task or invalid data' }) + // create(@Body() dto: CreateCustomerDto): Promise { + // return this.customersService.create(dto); + // } - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find customer' }) - @ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto }) - @ApiResponse({ status: 400, description: 'Customer not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.customersService.findOne(id); - } + // @Get() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find all customers' }) + // @ApiResponse({ status: 201, description: 'List of customers found', type: CreateCustomerDto, isArray: true }) + // @ApiResponse({ status: 400, description: 'List of customers not found' }) + // findAll(): Promise { + // return this.customersService.findAll(); + // } - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Update customer' }) - @ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto }) - @ApiResponse({ status: 400, description: 'Customer not found' }) - update( - @Param('id', ParseIntPipe) id: number, - @Body() dto: UpdateCustomerDto, - ): Promise { - return this.customersService.update(id, dto); - } + // @Get(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find customer' }) + // @ApiResponse({ status: 201, description: 'Customer found', type: CreateCustomerDto }) + // @ApiResponse({ status: 400, description: 'Customer not found' }) + // findOne(@Param('id', ParseIntPipe) id: number): Promise { + // return this.customersService.findOne(id); + // } - @Delete(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Delete customer' }) - @ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto }) - @ApiResponse({ status: 400, description: 'Customer not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise{ - return this.customersService.remove(id); - } + // @Patch(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE,RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Update customer' }) + // @ApiResponse({ status: 201, description: 'Customer updated', type: CreateCustomerDto }) + // @ApiResponse({ status: 400, description: 'Customer not found' }) + // update( + // @Param('id', ParseIntPipe) id: number, + // @Body() dto: UpdateCustomerDto, + // ): Promise { + // return this.customersService.update(id, dto); + // } + + // @Delete(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Delete customer' }) + // @ApiResponse({ status: 201, description: 'Customer deleted', type: CreateCustomerDto }) + // @ApiResponse({ status: 400, description: 'Customer not found' }) + // remove(@Param('id', ParseIntPipe) id: number): Promise{ + // return this.customersService.remove(id); + // } } diff --git a/src/modules/customers/dtos/create-customer.dto.ts b/src/modules/customers/dtos/create-customer.dto.ts index bc35918..8382f20 100644 --- a/src/modules/customers/dtos/create-customer.dto.ts +++ b/src/modules/customers/dtos/create-customer.dto.ts @@ -55,10 +55,8 @@ export class CreateCustomerDto { example: '8436637464', description: 'Customer`s phone number', }) - @Type(() => Number) - @IsInt() - @IsPositive() - phone_number: number; + @IsString() + phone_number: string; @ApiProperty({ example: '1 Ringbearer`s way, Mount Doom city, ME, T1R 1N6 ', diff --git a/src/modules/customers/services/customers.service.ts b/src/modules/customers/services/customers.service.ts index 6163552..b0b68c8 100644 --- a/src/modules/customers/services/customers.service.ts +++ b/src/modules/customers/services/customers.service.ts @@ -1,92 +1,93 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { PrismaService } from 'src/prisma/prisma.service'; -import { CreateCustomerDto } from '../dtos/create-customer.dto'; -import { Customers, Users } from '@prisma/client'; -import { UpdateCustomerDto } from '../dtos/update-customer.dto'; +import { Injectable } from '@nestjs/common'; @Injectable() export class CustomersService { - constructor(private readonly prisma: PrismaService) {} - async create(dto: CreateCustomerDto): Promise { - const { - first_name, - last_name, - email, - phone_number, - residence, - invoice_id, - } = dto; +//_____________________________________________________________________________________________ +// Deprecated or unused methods +//_____________________________________________________________________________________________ - return this.prisma.$transaction(async (transaction) => { - const user: Users = await transaction.users.create({ - data: { - first_name, - last_name, - email, - phone_number, - residence, - }, - }); - return transaction.customers.create({ - data: { - user_id: user.id, - invoice_id, - }, - }); - }); - } +// constructor(private readonly prisma: PrismaService) {} + +// async create(dto: CreateCustomerDto): Promise { +// const { +// first_name, +// last_name, +// email, +// phone_number, +// residence, +// invoice_id, +// } = dto; + +// return this.prisma.$transaction(async (transaction) => { +// const user: Users = await transaction.users.create({ +// data: { +// first_name, +// last_name, +// email, +// phone_number, +// residence, +// }, +// }); +// return transaction.customers.create({ +// data: { +// user_id: user.id, +// invoice_id, +// }, +// }); +// }); +// } - findAll(): Promise { - return this.prisma.customers.findMany({ - include: { user: true }, - }) - } +// findAll(): Promise { +// return this.prisma.customers.findMany({ +// include: { user: true }, +// }) +// } - async findOne(id:number): Promise { - const customer = await this.prisma.customers.findUnique({ - where: { id }, - include: { user: true }, - }); - if(!customer) throw new NotFoundException(`Customer #${id} not found`); - return customer; - } +// async findOne(id:number): Promise { +// const customer = await this.prisma.customers.findUnique({ +// where: { id }, +// include: { user: true }, +// }); +// if(!customer) throw new NotFoundException(`Customer #${id} not found`); +// return customer; +// } -async update(id: number,dto: UpdateCustomerDto): Promise { - const customer = await this.findOne(id); +// async update(id: number,dto: UpdateCustomerDto): Promise { +// const customer = await this.findOne(id); - const { - first_name, - last_name, - email, - phone_number, - residence, - invoice_id, - } = dto; +// const { +// first_name, +// last_name, +// email, +// phone_number, +// residence, +// invoice_id, +// } = dto; - return this.prisma.$transaction(async (transaction) => { - await transaction.users.update({ - where: { id: customer.user_id }, - data: { - ...(first_name !== undefined && { first_name }), - ...(last_name !== undefined && { last_name }), - ...(email !== undefined && { email }), - ...(phone_number !== undefined && { phone_number }), - ...(residence !== undefined && { residence }), - }, - }); +// return this.prisma.$transaction(async (transaction) => { +// await transaction.users.update({ +// where: { id: customer.user_id }, +// data: { +// ...(first_name !== undefined && { first_name }), +// ...(last_name !== undefined && { last_name }), +// ...(email !== undefined && { email }), +// ...(phone_number !== undefined && { phone_number }), +// ...(residence !== undefined && { residence }), +// }, +// }); - return transaction.customers.update({ - where: { id }, - data: { - ...(invoice_id !== undefined && { invoice_id }), - }, - }); - }); -} - - async remove(id: number): Promise { - await this.findOne(id); - return this.prisma.customers.delete({ where: { id }}); - } +// return transaction.customers.update({ +// where: { id }, +// data: { +// ...(invoice_id !== undefined && { invoice_id }), +// }, +// }); +// }); +// } + +// async remove(id: number): Promise { +// await this.findOne(id); +// return this.prisma.customers.delete({ where: { id }}); +// } } diff --git a/src/modules/employees/controllers/employees.controller.ts b/src/modules/employees/controllers/employees.controller.ts index e46d2cc..2026a28 100644 --- a/src/modules/employees/controllers/employees.controller.ts +++ b/src/modules/employees/controllers/employees.controller.ts @@ -1,37 +1,21 @@ -import { Body,Controller,Delete,Get,NotFoundException,Param,ParseIntPipe,Patch,Post,UseGuards } from '@nestjs/common'; -import { Employees, Roles as RoleEnum } from '@prisma/client'; +import { Body,Controller,Get,NotFoundException,Param,Patch } from '@nestjs/common'; import { EmployeesService } from '../services/employees.service'; import { CreateEmployeeDto } from '../dtos/create-employee.dto'; import { UpdateEmployeeDto } from '../dtos/update-employee.dto'; import { RolesAllowed } from '../../../common/decorators/roles.decorators'; import { ApiBearerAuth, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { EmployeeListItemDto } from '../dtos/list-employee.dto'; -import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto'; +import { EmployeesArchivalService } from '../services/employees-archival.service'; @ApiTags('Employees') @ApiBearerAuth('access-token') // @UseGuards() @Controller('employees') export class EmployeesController { - constructor(private readonly employeesService: EmployeesService) {} - - @Post() - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Create employee' }) - @ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateEmployeeDto): Promise { - return this.employeesService.create(dto); - } - - @Get() - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) - @ApiOperation({summary: 'Find all employees' }) - @ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of employees not found' }) - findAll(): Promise { - return this.employeesService.findAll(); - } + constructor( + private readonly employeesService: EmployeesService, + private readonly archiveService: EmployeesArchivalService, + ) {} @Get('employee-list') //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) @@ -42,34 +26,6 @@ export class EmployeesController { return this.employeesService.findListEmployees(); } - @Get(':email') - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING ) - @ApiOperation({summary: 'Find employee' }) - @ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto }) - @ApiResponse({ status: 400, description: 'Employee not found' }) - findOne(@Param('email', ParseIntPipe) email: string): Promise { - return this.employeesService.findOne(email); - } - - @Get('profile/:email') - @ApiOperation({summary: 'Find employee profile' }) - @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' }) - @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto }) - @ApiResponse({ status: 400, description: 'Employee profile not found' }) - findOneProfile(@Param('email') email: string): Promise { - return this.employeesService.findOneProfile(email); - } - - @Delete(':email') - //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR ) - @ApiOperation({summary: 'Delete employee' }) - @ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' }) - @ApiResponse({ status: 204, description: 'Employee deleted' }) - @ApiResponse({ status: 404, description: 'Employee not found' }) - remove(@Param('email', ParseIntPipe) email: string): Promise { - return this.employeesService.remove(email); - } - @Patch(':email') //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) @ApiBearerAuth('access-token') @@ -82,10 +38,61 @@ export class EmployeesController { // if last_work_day is set => archive the employee // else if employee is archived and first_work_day or last_work_day = null => restore //otherwise => standard update - const result = await this.employeesService.patchEmployee(email, dto); + const result = await this.archiveService.patchEmployee(email, dto); if(!result) { throw new NotFoundException(`Employee with email: ${ email } is not found in active or archive.`) } return result; } + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @Post() + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({summary: 'Create employee' }) + // @ApiResponse({ status: 201, description: 'Employee created', type: CreateEmployeeDto }) + // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) + // create(@Body() dto: CreateEmployeeDto): Promise { + // return this.employeesService.create(dto); + // } + // @Get() + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) + // @ApiOperation({summary: 'Find all employees' }) + // @ApiResponse({ status: 200, description: 'List of employees found', type: CreateEmployeeDto, isArray: true }) + // @ApiResponse({ status: 400, description: 'List of employees not found' }) + // findAll(): Promise { + // return this.employeesService.findAll(); + // } + + + // @Get(':email') + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR,RoleEnum.ACCOUNTING ) + // @ApiOperation({summary: 'Find employee' }) + // @ApiResponse({ status: 200, description: 'Employee found', type: CreateEmployeeDto }) + // @ApiResponse({ status: 400, description: 'Employee not found' }) + // findOne(@Param('email', ParseIntPipe) email: string): Promise { + // return this.employeesService.findOne(email); + // } + + // @Get('profile/:email') + // @ApiOperation({summary: 'Find employee profile' }) + // @ApiParam({ name: 'email', type: String, description: 'Identifier of the employee' }) + // @ApiResponse({ status: 200, description: 'Employee profile found', type: EmployeeProfileItemDto }) + // @ApiResponse({ status: 400, description: 'Employee profile not found' }) + // findOneProfile(@Param('email') email: string): Promise { + // return this.employeesService.findOneProfile(email); + // } + + // @Delete(':email') + // //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR ) + // @ApiOperation({summary: 'Delete employee' }) + // @ApiParam({ name: 'email', type: Number, description: 'Email of the employee to delete' }) + // @ApiResponse({ status: 204, description: 'Employee deleted' }) + // @ApiResponse({ status: 404, description: 'Employee not found' }) + // remove(@Param('email', ParseIntPipe) email: string): Promise { + // return this.employeesService.remove(email); + // } + } diff --git a/src/modules/employees/dtos/create-employee.dto.ts b/src/modules/employees/dtos/create-employee.dto.ts index bfdc973..89279ef 100644 --- a/src/modules/employees/dtos/create-employee.dto.ts +++ b/src/modules/employees/dtos/create-employee.dto.ts @@ -62,10 +62,8 @@ export class CreateEmployeeDto { example: '82538437464', description: 'Employee`s phone number', }) - @Type(() => Number) - @IsInt() - @IsPositive() - phone_number: number; + @IsString() + phone_number: string; @ApiProperty({ example: '1 Bagshot Row, Hobbiton, The Shire, Middle-earth', diff --git a/src/modules/employees/dtos/profil-employee.dto.ts b/src/modules/employees/dtos/profil-employee.dto.ts index d790558..c6836cf 100644 --- a/src/modules/employees/dtos/profil-employee.dto.ts +++ b/src/modules/employees/dtos/profil-employee.dto.ts @@ -6,7 +6,7 @@ export class EmployeeProfileItemDto { company_name: number | null; job_title: string | null; email: string | null; - phone_number: number; + phone_number: string; first_work_day: string; last_work_day?: string | null; residence: string | null; diff --git a/src/modules/employees/dtos/update-employee.dto.ts b/src/modules/employees/dtos/update-employee.dto.ts index 517c48f..334a01a 100644 --- a/src/modules/employees/dtos/update-employee.dto.ts +++ b/src/modules/employees/dtos/update-employee.dto.ts @@ -17,6 +17,6 @@ export class UpdateEmployeeDto extends PartialType(CreateEmployeeDto) { @IsOptional() supervisor_id?: number; - @Max(2147483647) - phone_number: number; + @IsOptional() + phone_number: string; } diff --git a/src/modules/employees/employees.module.ts b/src/modules/employees/employees.module.ts index 22e5cc6..66a14a7 100644 --- a/src/modules/employees/employees.module.ts +++ b/src/modules/employees/employees.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common'; import { EmployeesController } from './controllers/employees.controller'; import { EmployeesService } from './services/employees.service'; +import { EmployeesArchivalService } from './services/employees-archival.service'; +import { SharedModule } from '../shared/shared.module'; @Module({ - controllers: [EmployeesController], - providers: [EmployeesService], - exports: [EmployeesService], + controllers: [EmployeesController, SharedModule], + providers: [EmployeesService, EmployeesArchivalService], + exports: [EmployeesService, EmployeesArchivalService], }) export class EmployeesModule {} diff --git a/src/modules/employees/services/employees-archival.service.ts b/src/modules/employees/services/employees-archival.service.ts new file mode 100644 index 0000000..b13fa74 --- /dev/null +++ b/src/modules/employees/services/employees-archival.service.ts @@ -0,0 +1,173 @@ +import { Injectable } from "@nestjs/common"; +import { Employees, EmployeesArchive, Users } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; +import { UpdateEmployeeDto } from "../dtos/update-employee.dto"; +import { toDateOrUndefined, toDateOrNull } from "../utils/employee.utils"; + +@Injectable() +export class EmployeesArchivalService { + constructor(private readonly prisma: PrismaService) { } + + async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise { + // 1) Tenter sur employés actifs + const active = await this.prisma.employees.findFirst({ + where: { user: { email } }, + include: { user: true }, + }); + + if (active) { + // Archivage : si on reçoit un last_work_day défini et que l'employé n’est pas déjà terminé + if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) { + return this.archiveOnTermination(active, dto); + } + + // Sinon, update standard (split Users/Employees) + const { + first_name, + last_name, + email: new_email, + phone_number, + residence, + external_payroll_id, + company_code, + job_title, + first_work_day, + last_work_day, + supervisor_id, + is_supervisor, + } = dto as any; + + const first_work_d = toDateOrUndefined(first_work_day); + const last_work_d = Object.prototype.hasOwnProperty('last_work_day') + ? toDateOrNull(last_work_day ?? null) + : undefined; + + await this.prisma.$transaction(async (transaction) => { + if ( + first_name !== undefined || + last_name !== undefined || + new_email !== undefined || + phone_number !== undefined || + residence !== undefined + ) { + await transaction.users.update({ + where: { id: active.user_id }, + data: { + ...(first_name !== undefined ? { first_name } : {}), + ...(last_name !== undefined ? { last_name } : {}), + ...(email !== undefined ? { email: new_email } : {}), + ...(phone_number !== undefined ? { phone_number } : {}), + ...(residence !== undefined ? { residence } : {}), + }, + }); + + } + + const updated = await transaction.employees.update({ + where: { id: active.id }, + data: { + ...(external_payroll_id !== undefined ? { external_payroll_id } : {}), + ...(company_code !== undefined ? { company_code } : {}), + ...(job_title !== undefined ? { job_title } : {}), + ...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}), + ...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}), + ...(is_supervisor !== undefined ? { is_supervisor } : {}), + ...(supervisor_id !== undefined ? { supervisor_id } : {}), + }, + include: { user: true }, + }); + + return updated; + }); + + return this.prisma.employees.findFirst({ where: { user: { email } } }); + } + + const user = await this.prisma.users.findUnique({ where: { email } }); + if (!user) return null; + // 2) Pas trouvé en actifs → regarder en archive (pour restauration) + const archived = await this.prisma.employeesArchive.findFirst({ + where: { user_id: user.id }, + include: { user: true }, + }); + + if (archived) { + // Condition de restauration : last_work_day === null ou first_work_day fourni + const restore = dto.last_work_day === null || dto.first_work_day != null; + if (restore) { + return this.restoreEmployee(archived, dto); + } + } + // 3) Ni actif, ni archivé → 404 dans le controller + return null; + } + + //transfers the employee to archive and then delete from employees table + private async archiveOnTermination(active: Employees & { user: Users }, dto: UpdateEmployeeDto): Promise { + const last_work_d = toDateOrNull(dto.last_work_day!); + if (!last_work_d) throw new Error('invalide last_work_day for archive'); + return this.prisma.$transaction(async transaction => { + //detach crew from supervisor if employee is a supervisor + await transaction.employees.updateMany({ + where: { supervisor_id: active.id }, + data: { supervisor_id: null }, + }) + const archived = await transaction.employeesArchive.create({ + data: { + employee_id: active.id, + user_id: active.user_id, + first_name: active.user.first_name, + last_name: active.user.last_name, + company_code: active.company_code, + job_title: active.job_title, + first_work_day: active.first_work_day, + last_work_day: last_work_d, + supervisor_id: active.supervisor_id ?? null, + is_supervisor: active.is_supervisor, + external_payroll_id: active.external_payroll_id, + }, + include: { user: true } + }); + //delete from employees table + await transaction.employees.delete({ where: { id: active.id } }); + //return archived employee + return archived + }); + } + + //transfers the employee from archive to the employees table + private async restoreEmployee(archived: EmployeesArchive & { user: Users }, dto: UpdateEmployeeDto): Promise { + // const first_work_d = toDateOrUndefined(dto.first_work_day); + return this.prisma.$transaction(async transaction => { + //restores the archived employee into the employees table + const restored = await transaction.employees.create({ + data: { + user_id: archived.user_id, + company_code: archived.company_code, + job_title: archived.job_title, + first_work_day: archived.first_work_day, + last_work_day: null, + is_supervisor: archived.is_supervisor ?? false, + external_payroll_id: archived.external_payroll_id, + }, + }); + //deleting archived entry by id + await transaction.employeesArchive.delete({ where: { id: archived.id } }); + + //return restored employee + return restored; + }); + } + + //fetches all archived employees + async findAllArchived(): Promise { + return this.prisma.employeesArchive.findMany(); + } + + //fetches an archived employee + async findOneArchived(id: number): Promise { + return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } }); + } + +} + diff --git a/src/modules/employees/services/employees.service.ts b/src/modules/employees/services/employees.service.ts index fd7dab7..3627476 100644 --- a/src/modules/employees/services/employees.service.ts +++ b/src/modules/employees/services/employees.service.ts @@ -1,69 +1,11 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; -import { CreateEmployeeDto } from '../dtos/create-employee.dto'; -import { UpdateEmployeeDto } from '../dtos/update-employee.dto'; -import { Employees, EmployeesArchive, Users } from '@prisma/client'; import { EmployeeListItemDto } from '../dtos/list-employee.dto'; import { EmployeeProfileItemDto } from '../dtos/profil-employee.dto'; -function toDateOrNull(v?: string | null): Date | null { - if (!v) return null; - const day = new Date(v); - return isNaN(day.getTime()) ? null : day; -} -function toDateOrUndefined(v?: string | null): Date | undefined { - const day = toDateOrNull(v ?? undefined); - return day === null ? undefined : day; -} - @Injectable() export class EmployeesService { - constructor(private readonly prisma: PrismaService) {} - - async create(dto: CreateEmployeeDto): Promise { - const { - first_name, - last_name, - email, - phone_number, - residence, - external_payroll_id, - company_code, - job_title, - first_work_day, - last_work_day, - is_supervisor, - } = dto; - - return this.prisma.$transaction(async (transaction) => { - const user: Users = await transaction.users.create({ - data: { - first_name, - last_name, - email, - phone_number, - residence, - }, - }); - return transaction.employees.create({ - data: { - user_id: user.id, - external_payroll_id, - company_code, - job_title, - first_work_day, - last_work_day, - is_supervisor, - }, - }); - }); - } - - findAll(): Promise { - return this.prisma.employees.findMany({ - include: { user: true }, - }); - } + constructor(private readonly prisma: PrismaService) { } findListEmployees(): Promise { return this.prisma.employees.findMany({ @@ -71,331 +13,218 @@ export class EmployeesService { user: { select: { first_name: true, - last_name: true, - email: true, - }, + last_name: true, + email: true, + }, }, supervisor: { select: { user: { select: { first_name: true, - last_name: true, + last_name: true, }, }, }, }, - job_title: true, + job_title: true, company_code: true, } }).then(rows => rows.map(r => ({ - first_name: r.user.first_name, - last_name: r.user.last_name, - employee_full_name: `${r.user.first_name} ${r.user.last_name}`, - email: r.user.email, + first_name: r.user.first_name, + last_name: r.user.last_name, + email: r.user.email, + company_name: r.company_code, + job_title: r.job_title, + employee_full_name: `${r.user.first_name} ${r.user.last_name}`, supervisor_full_name: r.supervisor ? `${r.supervisor.user.first_name} ${r.supervisor.user.last_name}` : null, - company_name: r.company_code, - job_title: r.job_title, - })), + })), ); } - async findOne(email: string): Promise { - const emp = await this.prisma.employees.findFirst({ - where: { user: { email } }, - include: { user: true }, - }); - - //add search for archived employees - if (!emp) { - throw new NotFoundException(`Employee with email: ${email} not found`); - } - return emp; - } - - async findOneProfile(email:string): Promise { + async findOneProfile(email: string): Promise { const emp = await this.prisma.employees.findFirst({ where: { user: { email } }, select: { user: { select: { - first_name: true, - last_name: true, - email: true, + first_name: true, + last_name: true, + email: true, phone_number: true, - residence: true, - }, + residence: true, + }, }, supervisor: { select: { user: { select: { first_name: true, - last_name: true, + last_name: true, }, }, }, }, - job_title: true, - company_code: true, + job_title: true, + company_code: true, first_work_day: true, - last_work_day: true, + last_work_day: true, } }); - if (!emp) throw new NotFoundException(`Employee with email ${email} not found`); + if (!emp) throw new NotFoundException(`Employee with email ${email} not found`); - return { - first_name: emp.user.first_name, - last_name: emp.user.last_name, - employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`, - email: emp.user.email, - residence: emp.user.residence, - phone_number: emp.user.phone_number, - supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null, - company_name: emp.company_code, - job_title: emp.job_title, - first_work_day: emp.first_work_day.toISOString().slice(0,10), - last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0,10) : null, - }; + return { + first_name: emp.user.first_name, + last_name: emp.user.last_name, + email: emp.user.email, + residence: emp.user.residence, + phone_number: emp.user.phone_number, + company_name: emp.company_code, + job_title: emp.job_title, + employee_full_name: `${emp.user.first_name} ${emp.user.last_name}`, + first_work_day: emp.first_work_day.toISOString().slice(0, 10), + last_work_day: emp.last_work_day ? emp.last_work_day.toISOString().slice(0, 10) : null, + supervisor_full_name: emp.supervisor ? `${emp.supervisor.user.first_name}, ${emp.supervisor.user.last_name}` : null, + }; } - async update( - email: string, - dto: UpdateEmployeeDto, - ): Promise { - const emp = await this.findOne(email); + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - const { - first_name, - last_name, - phone_number, - residence, - external_payroll_id, - company_code, - job_title, - first_work_day, - last_work_day, - is_supervisor, - email: new_email, - } = dto; + // async create(dto: CreateEmployeeDto): Promise { + // const { + // first_name, + // last_name, + // email, + // phone_number, + // residence, + // external_payroll_id, + // company_code, + // job_title, + // first_work_day, + // last_work_day, + // is_supervisor, + // } = dto; - return this.prisma.$transaction(async (transaction) => { - if( - first_name !== undefined || - last_name !== undefined || - new_email !== undefined || - phone_number !== undefined || - residence !== undefined - ){ - await transaction.users.update({ - where: { id: emp.user_id }, - data: { - ...(first_name !== undefined && { first_name }), - ...(last_name !== undefined && { last_name }), - ...(email !== undefined && { email }), - ...(phone_number !== undefined && { phone_number }), - ...(residence !== undefined && { residence }), - }, - }); - } + // return this.prisma.$transaction(async (transaction) => { + // const user: Users = await transaction.users.create({ + // data: { + // first_name, + // last_name, + // email, + // phone_number, + // residence, + // }, + // }); + // return transaction.employees.create({ + // data: { + // user_id: user.id, + // external_payroll_id, + // company_code, + // job_title, + // first_work_day, + // last_work_day, + // is_supervisor, + // }, + // }); + // }); + // } - const updated = await transaction.employees.update({ - where: { id: emp.id }, - data: { - ...(external_payroll_id !== undefined && { external_payroll_id }), - ...(company_code !== undefined && { company_code }), - ...(first_work_day !== undefined && { first_work_day }), - ...(last_work_day !== undefined && { last_work_day }), - ...(job_title !== undefined && { job_title }), - ...(is_supervisor !== undefined && { is_supervisor }), - }, - }); - return updated; - }); - } + // findAll(): Promise { + // return this.prisma.employees.findMany({ + // include: { user: true }, + // }); + // } + + // async findOne(email: string): Promise { + // const emp = await this.prisma.employees.findFirst({ + // where: { user: { email } }, + // include: { user: true }, + // }); + + // //add search for archived employees + // if (!emp) { + // throw new NotFoundException(`Employee with email: ${email} not found`); + // } + // return emp; + // } + + // async update( + // email: string, + // dto: UpdateEmployeeDto, + // ): Promise { + // const emp = await this.findOne(email); + + // const { + // first_name, + // last_name, + // phone_number, + // residence, + // external_payroll_id, + // company_code, + // job_title, + // first_work_day, + // last_work_day, + // is_supervisor, + // email: new_email, + // } = dto; + + // return this.prisma.$transaction(async (transaction) => { + // if( + // first_name !== undefined || + // last_name !== undefined || + // new_email !== undefined || + // phone_number !== undefined || + // residence !== undefined + // ){ + // await transaction.users.update({ + // where: { id: emp.user_id }, + // data: { + // ...(first_name !== undefined && { first_name }), + // ...(last_name !== undefined && { last_name }), + // ...(email !== undefined && { email }), + // ...(phone_number !== undefined && { phone_number }), + // ...(residence !== undefined && { residence }), + // }, + // }); + // } + + // const updated = await transaction.employees.update({ + // where: { id: emp.id }, + // data: { + // ...(external_payroll_id !== undefined && { external_payroll_id }), + // ...(company_code !== undefined && { company_code }), + // ...(first_work_day !== undefined && { first_work_day }), + // ...(last_work_day !== undefined && { last_work_day }), + // ...(job_title !== undefined && { job_title }), + // ...(is_supervisor !== undefined && { is_supervisor }), + // }, + // }); + // return updated; + // }); + // } - async remove(email: string): Promise { + // async remove(email: string): Promise { - const emp = await this.findOne(email); + // const emp = await this.findOne(email); - return this.prisma.$transaction(async (transaction) => { - await transaction.employees.updateMany({ - where: { supervisor_id: emp.id }, - data: { supervisor_id: null }, - }); - const deleted_employee = await transaction.employees.delete({ - where: {id: emp.id }, - }); - await transaction.users.delete({ - where: { id: emp.user_id }, - }); - return deleted_employee; - }); - } + // return this.prisma.$transaction(async (transaction) => { + // await transaction.employees.updateMany({ + // where: { supervisor_id: emp.id }, + // data: { supervisor_id: null }, + // }); + // const deleted_employee = await transaction.employees.delete({ + // where: {id: emp.id }, + // }); + // await transaction.users.delete({ + // where: { id: emp.user_id }, + // }); + // return deleted_employee; + // }); + // } - //archivation functions ****************************************************** - -async patchEmployee(email: string, dto: UpdateEmployeeDto): Promise { - // 1) Tenter sur employés actifs - const active = await this.prisma.employees.findFirst({ - where: { user: { email } }, - include: { user: true }, - }); - - if (active) { - // Archivage : si on reçoit un last_work_day défini et que l'employé n’est pas déjà terminé - if (dto.last_work_day !== undefined && active.last_work_day == null && dto.last_work_day !== null) { - return this.archiveOnTermination(active, dto); - } - - // Sinon, update standard (split Users/Employees) - const { - first_name, - last_name, - email: new_email, - phone_number, - residence, - external_payroll_id, - company_code, - job_title, - first_work_day, - last_work_day, - supervisor_id, - is_supervisor, - } = dto as any; - - const first_work_d = toDateOrUndefined(first_work_day); - const last_work_d = Object.prototype.hasOwnProperty('last_work_day') - ? toDateOrNull(last_work_day ?? null) - : undefined; - - await this.prisma.$transaction(async (transaction) => { - if( - first_name !== undefined || - last_name !== undefined || - new_email !== undefined || - phone_number !== undefined || - residence !== undefined - ) { - await transaction.users.update({ - where: { id: active.user_id }, - data: { - ...(first_name !== undefined ? { first_name } : {}), - ...(last_name !== undefined ? { last_name } : {}), - ...(email !== undefined ? { email: new_email }: {}), - ...(phone_number !== undefined ? { phone_number } : {}), - ...(residence !== undefined ? { residence } : {}), - }, - }); - - } - - const updated = await transaction.employees.update({ - where: { id: active.id }, - data: { - ...(external_payroll_id !== undefined ? { external_payroll_id } : {}), - ...(company_code !== undefined ? { company_code } : {}), - ...(job_title !== undefined ? { job_title } : {}), - ...(first_work_d !== undefined ? { first_work_day: first_work_d } : {}), - ...(last_work_d !== undefined ? { last_work_day: last_work_d } : {}), - ...(is_supervisor !== undefined ? { is_supervisor } : {}), - ...(supervisor_id !== undefined ? { supervisor_id } : {}), - }, - include: { user: true }, - }); - - return updated; - }); - - return this.prisma.employees.findFirst({ where: { user: {email} } }); - } - - const user = await this.prisma.users.findUnique({where: {email}}); - if(!user) return null; - // 2) Pas trouvé en actifs → regarder en archive (pour restauration) - const archived = await this.prisma.employeesArchive.findFirst({ - where: { user_id: user.id }, - include: { user: true }, - }); - - if (archived) { - // Condition de restauration : last_work_day === null ou first_work_day fourni - const restore = dto.last_work_day === null || dto.first_work_day != null; - if (restore) { - return this.restoreEmployee(archived, dto); - } - } - // 3) Ni actif, ni archivé → 404 dans le controller - return null; - } - - //transfers the employee to archive and then delete from employees table - private async archiveOnTermination(active: Employees & {user: Users }, dto: UpdateEmployeeDto): Promise { - const last_work_d = toDateOrNull(dto.last_work_day!); - if(!last_work_d) throw new Error('invalide last_work_day for archive'); - return this.prisma.$transaction(async transaction => { - //detach crew from supervisor if employee is a supervisor - await transaction.employees.updateMany({ - where: { supervisor_id: active.id }, - data: { supervisor_id: null }, - }) - const archived = await transaction.employeesArchive.create({ - data: { - employee_id: active.id, - user_id: active.user_id, - first_name: active.user.first_name, - last_name: active.user.last_name, - external_payroll_id: active.external_payroll_id, - company_code: active.company_code, - job_title: active.job_title, - first_work_day: active.first_work_day, - last_work_day: last_work_d, - supervisor_id: active.supervisor_id ?? null, - is_supervisor: active.is_supervisor, - }, - include: { user: true} - }); - //delete from employees table - await transaction.employees.delete({ where: { id: active.id } }); - //return archived employee - return archived - }); - } - //transfers the employee from archive to the employees table - private async restoreEmployee(archived: EmployeesArchive & { user:Users }, dto: UpdateEmployeeDto): Promise { - // const first_work_d = toDateOrUndefined(dto.first_work_day); - return this.prisma.$transaction(async transaction => { - //restores the archived employee into the employees table - const restored = await transaction.employees.create({ - data: { - user_id: archived.user_id, - external_payroll_id: archived.external_payroll_id, - company_code: archived.company_code, - job_title: archived.job_title, - first_work_day: archived.first_work_day, - last_work_day: null, - is_supervisor: archived.is_supervisor ?? false, - }, - }); - //deleting archived entry by id - await transaction.employeesArchive.delete({ where: { id: archived.id } }); - - //return restored employee - return restored; - }); - } - - //fetches all archived employees - async findAllArchived(): Promise { - return this.prisma.employeesArchive.findMany(); - } - - //fetches an archived employee - async findOneArchived(id: number): Promise { - return this.prisma.employeesArchive.findUniqueOrThrow({ where: { id } }); - } - -} +} \ No newline at end of file diff --git a/src/modules/employees/utils/employee.utils.ts b/src/modules/employees/utils/employee.utils.ts new file mode 100644 index 0000000..3534f3d --- /dev/null +++ b/src/modules/employees/utils/employee.utils.ts @@ -0,0 +1,9 @@ +export function toDateOrNull(v?: string | null): Date | null { + if (!v) return null; + const day = new Date(v); + return isNaN(day.getTime()) ? null : day; +} +export function toDateOrUndefined(v?: string | null): Date | undefined { + const day = toDateOrNull(v ?? undefined); + return day === null ? undefined : day; +} \ No newline at end of file diff --git a/src/modules/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index f4f20cb..11bef7f 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -1,13 +1,12 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; -import { ExpensesQueryService } from "../services/expenses-query.service"; -import { CreateExpenseDto } from "../dtos/create-expense.dto"; -import { Expenses } from "@prisma/client"; +import { Body, Controller, Get, Param, Put, } from "@nestjs/common"; import { Roles as RoleEnum } from '.prisma/client'; -import { UpdateExpenseDto } from "../dtos/update-expense.dto"; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { ExpensesCommandService } from "../services/expenses-command.service"; -import { SearchExpensesDto } from "../dtos/search-expense.dto"; +import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; +import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces"; +import { DayExpensesDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; +import { ExpensesQueryService } from "../services/expenses-query.service"; @ApiTags('Expenses') @ApiBearerAuth('access-token') @@ -15,60 +14,82 @@ import { SearchExpensesDto } from "../dtos/search-expense.dto"; @Controller('Expenses') export class ExpensesController { constructor( - private readonly expensesService: ExpensesQueryService, - private readonly expensesApprovalService: ExpensesCommandService, + private readonly query: ExpensesQueryService, + private readonly command: ExpensesCommandService, ) {} - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Create expense' }) - @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateExpenseDto): Promise { - return this.expensesService.create(dto); + @Put('upsert/:email/:date') + async upsert_by_date( + @Param('email') email: string, + @Param('date') date: string, + @Body() dto: UpsertExpenseDto, + ): Promise { + return this.command.upsertExpensesByDate(email, date, dto); } - @Get() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find all expenses' }) - @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of expenses not found' }) - @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - findAll(@Query() filters: SearchExpensesDto): Promise { - return this.expensesService.findAll(filters); + @Get('list/:email/:year/:period_no') + async findExpenseListByPayPeriodAndEmail( + @Param('email') email:string, + @Param('year') year: number, + @Param('period_no') period_no: number, + ): Promise { + return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no); } - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find expense' }) - @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto }) - @ApiResponse({ status: 400, description: 'Expense not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.expensesService.findOne(id); - } + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Expense shift' }) - @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto }) - @ApiResponse({ status: 400, description: 'Expense not found' }) - update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) { - return this.expensesService.update(id,dto); - } + // @Post() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Create expense' }) + // @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto }) + // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) + // create(@Body() dto: CreateExpenseDto): Promise { + // return this.query.create(dto); + // } - @Delete(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Delete expense' }) - @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto }) - @ApiResponse({ status: 400, description: 'Expense not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.expensesService.remove(id); - } + // @Get() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find all expenses' }) + // @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true }) + // @ApiResponse({ status: 400, description: 'List of expenses not found' }) + // @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + // findAll(@Query() filters: SearchExpensesDto): Promise { + // return this.query.findAll(filters); + // } - @Patch('approval/:id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - return this.expensesApprovalService.updateApproval(id, isApproved); - } + // @Get(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find expense' }) + // @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto }) + // @ApiResponse({ status: 400, description: 'Expense not found' }) + // findOne(@Param('id', ParseIntPipe) id: number): Promise { + // return this.query.findOne(id); + // } + + // @Patch(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Expense shift' }) + // @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto }) + // @ApiResponse({ status: 400, description: 'Expense not found' }) + // update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) { + // return this.query.update(id,dto); + // } + + // @Delete(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Delete expense' }) + // @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto }) + // @ApiResponse({ status: 400, description: 'Expense not found' }) + // remove(@Param('id', ParseIntPipe) id: number): Promise { + // return this.query.remove(id); + // } + + // @Patch('approval/:id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + // async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { + // return this.command.updateApproval(id, isApproved); + // } } \ No newline at end of file diff --git a/src/modules/expenses/dtos/create-expense.dto.ts b/src/modules/expenses/dtos/create-expense.dto.ts index 2958e88..d0e4863 100644 --- a/src/modules/expenses/dtos/create-expense.dto.ts +++ b/src/modules/expenses/dtos/create-expense.dto.ts @@ -46,7 +46,7 @@ export class CreateExpenseDto { description:'explain`s why the expense was made' }) @IsString() - description?: string; + comment: string; @ApiProperty({ example: 'DENIED, APPROUVED, PENDING, etc...', diff --git a/src/modules/expenses/dtos/search-expense.dto.ts b/src/modules/expenses/dtos/search-expense.dto.ts index 5058167..3eb7758 100644 --- a/src/modules/expenses/dtos/search-expense.dto.ts +++ b/src/modules/expenses/dtos/search-expense.dto.ts @@ -14,7 +14,7 @@ export class SearchExpensesDto { @IsOptional() @IsString() - description_contains?: string; + comment_contains?: string; @IsOptional() @IsDateString() diff --git a/src/modules/expenses/dtos/upsert-expense.dto.ts b/src/modules/expenses/dtos/upsert-expense.dto.ts new file mode 100644 index 0000000..5bea2c3 --- /dev/null +++ b/src/modules/expenses/dtos/upsert-expense.dto.ts @@ -0,0 +1,59 @@ +import { Transform, Type } from "class-transformer"; +import { + IsNumber, + IsOptional, + IsString, + Matches, + MaxLength, + Min, + ValidateIf, + ValidateNested +} from "class-validator"; + +export class ExpensePayloadDto { + @IsString() + type!: string; + + @ValidateIf(o => (o.type ?? '').toUpperCase() !== 'MILEAGE') + @IsNumber() + @Min(0) + amount?: number; + + @ValidateIf(o => (o.type ?? '').toUpperCase() === 'MILEAGE') + @IsNumber() + @Min(0) + mileage?: number; + + @IsString() + @MaxLength(280) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + comment!: string; + + @IsOptional() + @Transform(({ value }) => { + if (value === null || value === undefined || value === '') return undefined; + if (typeof value === 'number') return value.toString(); + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed.length ? trimmed : undefined; + } + return undefined; + }) + @IsString() + @Matches(/^\d+$/) + @MaxLength(255) + attachment?: string; +} + + +export class UpsertExpenseDto { + @IsOptional() + @ValidateNested() + @Type(()=> ExpensePayloadDto) + old_expense?: ExpensePayloadDto; + + @IsOptional() + @ValidateNested() + @Type(()=> ExpensePayloadDto) + new_expense?: ExpensePayloadDto; +} \ No newline at end of file diff --git a/src/modules/expenses/expenses.module.ts b/src/modules/expenses/expenses.module.ts index 04c3965..6201b91 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -3,12 +3,21 @@ import { Module } from "@nestjs/common"; import { ExpensesQueryService } from "./services/expenses-query.service"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; import { ExpensesCommandService } from "./services/expenses-command.service"; +import { ExpensesArchivalService } from "./services/expenses-archival.service"; +import { SharedModule } from "../shared/shared.module"; @Module({ - imports: [BusinessLogicsModule], + imports: [BusinessLogicsModule, SharedModule], controllers: [ExpensesController], - providers: [ExpensesQueryService, ExpensesCommandService], - exports: [ ExpensesQueryService ], + providers: [ + ExpensesQueryService, + ExpensesArchivalService, + ExpensesCommandService, + ], + exports: [ + ExpensesQueryService, + ExpensesArchivalService, + ], }) export class ExpensesModule {} \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-archival.service.ts b/src/modules/expenses/services/expenses-archival.service.ts new file mode 100644 index 0000000..fc17c63 --- /dev/null +++ b/src/modules/expenses/services/expenses-archival.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from "@nestjs/common"; +import { ExpensesArchive } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class ExpensesArchivalService { + constructor(private readonly prisma: PrismaService){} + + async archiveOld(): Promise { + //fetches archived timesheet's Ids + const archived_timesheets = await this.prisma.timesheetsArchive.findMany({ + select: { timesheet_id: true }, + }); + + const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id); + if(timesheet_ids.length === 0) { + return; + } + + // copy/delete transaction + await this.prisma.$transaction(async transaction => { + //fetches expenses to move to archive + const expenses_to_archive = await transaction.expenses.findMany({ + where: { timesheet_id: { in: timesheet_ids } }, + }); + if(expenses_to_archive.length === 0) { + return; + } + + //copies sent to archive table + await transaction.expensesArchive.createMany({ + data: expenses_to_archive.map(exp => ({ + expense_id: exp.id, + timesheet_id: exp.timesheet_id, + bank_code_id: exp.bank_code_id, + date: exp.date, + amount: exp.amount, + attachment: exp.attachment, + comment: exp.comment, + is_approved: exp.is_approved, + supervisor_comment: exp.supervisor_comment, + })), + }); + + //delete from expenses table + await transaction.expenses.deleteMany({ + where: { id: { in: expenses_to_archive.map(exp => exp.id) } }, + }) + + }) + } + + //fetches all archived timesheets + async findAllArchived(): Promise { + return this.prisma.expensesArchive.findMany(); + } + + //fetches an archived timesheet + async findOneArchived(id: number): Promise { + return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } }); + } +} \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 2fc8777..bda3c90 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -1,13 +1,38 @@ -import { Injectable } from "@nestjs/common"; -import { Expenses, Prisma } from "@prisma/client"; -import { Decimal } from "@prisma/client/runtime/library"; -import { transcode } from "buffer"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; -import { PrismaService } from "src/prisma/prisma.service"; +import { Expenses, Prisma } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; +import { UpsertExpenseDto } from "../dtos/upsert-expense.dto"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; +import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; +import { + BadRequestException, + Injectable, + NotFoundException +} from "@nestjs/common"; +import { + assertAndTrimComment, + computeAmountDecimal, + computeMileageAmount, + mapDbExpenseToDayResponse, + normalizeType, + parseAttachmentId +} from "../utils/expenses.utils"; +import { toDateOnly } from "src/modules/shifts/helpers/shifts-date-time-helpers"; @Injectable() export class ExpensesCommandService extends BaseApprovalService { - constructor(prisma: PrismaService) { super(prisma); } + constructor( + prisma: PrismaService, + private readonly bankCodesResolver: BankCodesResolver, + private readonly timesheetsResolver: EmployeeTimesheetResolver, + private readonly emailResolver: EmailToIdResolver, + ) { super(prisma); } + + //_____________________________________________________________________________________________ + // APPROVAL TX-DELEGATE METHODS + //_____________________________________________________________________________________________ protected get delegate() { return this.prisma.expenses; @@ -23,16 +48,203 @@ export class ExpensesCommandService extends BaseApprovalService { ); } - // deprecated since batch transaction are made with timesheets - // async updateManyWithTx( - // tx: Prisma.TransactionClient, - // ids: number[], - // isApproved: boolean, - // ): Promise { - // const { count } = await tx.expenses.updateMany({ - // where: { id: { in: ids } }, - // data: { is_approved: isApproved }, - // }); - // return count; - // } + //_____________________________________________________________________________________________ + // MASTER CRUD FUNCTION + //_____________________________________________________________________________________________ + readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto, + ): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => { + + //validates if there is an existing expense, at least 1 old or new + const { old_expense, new_expense } = dto ?? {}; + if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided'); + + //validate date format + const date_only = toDateOnly(date); + if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)'); + + //resolve employee_id by email + const employee_id = await this.emailResolver.findIdByEmail(email); + + //make sure a timesheet existes + const timesheet_id = await this.timesheetsResolver.ensureForDate(employee_id, date_only); + if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`) + const {id} = timesheet_id; + + return this.prisma.$transaction(async (tx) => { + const loadDay = async (): Promise => { + const rows = await tx.expenses.findMany({ + where: { + timesheet_id: id, + date: date_only, + }, + include: { + bank_code: { + select: { + type: true, + }, + }, + }, + orderBy: [{ date: 'asc' }, { id: 'asc' }], + }); + + return rows.map((r) => + mapDbExpenseToDayResponse({ + date: r.date, + amount: r.amount ?? 0, + mileage: r.mileage ?? 0, + comment: r.comment, + is_approved: r.is_approved, + bank_code: r.bank_code, + })); + }; + + const normalizePayload = async (payload: { + type: string; + amount?: number; + mileage?: number; + comment: string; + attachment?: string | number; + }): Promise<{ + type: string; + bank_code_id: number; + amount: Prisma.Decimal; + mileage: number | null; + comment: string; + attachment: number | null; + }> => { + const type = normalizeType(payload.type); + const comment = assertAndTrimComment(payload.comment); + const attachment = parseAttachmentId(payload.attachment); + + const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type); + let amount = computeAmountDecimal(type, payload, modifier); + let mileage: number | null = null; + + if (type === 'MILEAGE') { + mileage = Number(payload.mileage ?? 0); + if (!(mileage > 0)) { + throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE'); + } + + const amountNumber = computeMileageAmount(mileage, modifier); + amount = new Prisma.Decimal(amountNumber); + + } else { + if (!(typeof payload.amount === 'number' && payload.amount >= 0)) { + throw new BadRequestException('Amount required for non-MILEAGE expense'); + } + amount = new Prisma.Decimal(payload.amount); + } + + if (attachment !== null) { + const attachment_row = await tx.attachments.findUnique({ + where: { id: attachment }, + select: { status: true }, + }); + if (!attachment_row || attachment_row.status !== 'ACTIVE') { + throw new BadRequestException('Attachment not found or inactive'); + } + } + + return { + type, + bank_code_id, + amount, + mileage, + comment, + attachment + }; + }; + + const findExactOld = async (norm: { + bank_code_id: number; + amount: Prisma.Decimal; + mileage: number | null; + comment: string; + attachment: number | null; + }) => { + return tx.expenses.findFirst({ + where: { + timesheet_id: id, + date: date_only, + bank_code_id: norm.bank_code_id, + amount: norm.amount, + comment: norm.comment, + attachment: norm.attachment, + ...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }), + }, + select: { id: true }, + }); + }; + + let action : UpsertAction; + //_____________________________________________________________________________________________ + // DELETE + //_____________________________________________________________________________________________ + if(old_expense && !new_expense) { + const old_norm = await normalizePayload(old_expense); + const existing = await findExactOld(old_norm); + if(!existing) { + throw new NotFoundException({ + error_code: 'EXPENSE_STALE', + message: 'The expense was modified or deleted by someone else', + }); + } + await tx.expenses.delete({where: { id: existing.id } }); + action = 'delete'; + } + //_____________________________________________________________________________________________ + // CREATE + //_____________________________________________________________________________________________ + else if (!old_expense && new_expense) { + const new_exp = await normalizePayload(new_expense); + await tx.expenses.create({ + data: { + timesheet_id: id, + date: date_only, + bank_code_id: new_exp.bank_code_id, + amount: new_exp.amount, + mileage: new_exp.mileage, + comment: new_exp.comment, + attachment: new_exp.attachment, + is_approved: false, + }, + }); + action = 'create'; + } + //_____________________________________________________________________________________________ + // UPDATE + //_____________________________________________________________________________________________ + else if(old_expense && new_expense) { + const old_norm = await normalizePayload(old_expense); + const existing = await findExactOld(old_norm); + if(!existing) { + throw new NotFoundException({ + error_code: 'EXPENSE_STALE', + message: 'The expense was modified or deleted by someone else', + }); + } + + const new_exp = await normalizePayload(new_expense); + await tx.expenses.update({ + where: { id: existing.id }, + data: { + bank_code_id: new_exp.bank_code_id, + amount: new_exp.amount, + mileage: new_exp.mileage, + comment: new_exp.comment, + attachment: new_exp.attachment, + }, + }); + action = 'update'; + } + else { + throw new BadRequestException('Invalid upsert combination'); + } + + const day = await loadDay(); + + return { action, day }; + }); + } } \ No newline at end of file diff --git a/src/modules/expenses/services/expenses-query.service.ts b/src/modules/expenses/services/expenses-query.service.ts index b0d6191..35cdde5 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -1,148 +1,174 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { CreateExpenseDto } from "../dtos/create-expense.dto"; -import { Expenses, ExpensesArchive } from "@prisma/client"; -import { UpdateExpenseDto } from "../dtos/update-expense.dto"; -import { MileageService } from "src/modules/business-logics/services/mileage.service"; -import { SearchExpensesDto } from "../dtos/search-expense.dto"; -import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; +import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto"; +import { round2, toUTCDateOnly } from "src/modules/timesheets/utils/timesheet.helpers"; +import { EXPENSE_TYPES } from "src/modules/timesheets/types/timesheet.types"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; @Injectable() export class ExpensesQueryService { constructor( private readonly prisma: PrismaService, - private readonly mileageService: MileageService, + private readonly employeeRepo: EmailToIdResolver, ) {} - - async create(dto: CreateExpenseDto): Promise { - const { timesheet_id, bank_code_id, date, amount:rawAmount, - description, is_approved,supervisor_comment} = dto; - //fetches type and modifier - const bank_code = await this.prisma.bankCodes.findUnique({ - where: { id: bank_code_id }, - select: { type: true, modifier: true }, - }); - if(!bank_code) { - throw new NotFoundException(`bank_code #${bank_code_id} not found`) - } + //fetchs all expenses for a selected employee using email, pay-period-year and number + async findExpenseListByPayPeriodAndEmail( + email: string, + year: number, + period_no: number + ): Promise { + //fetch employe_id using email + const employee_id = await this.employeeRepo.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); - //if mileage -> service, otherwise the ratio is amount:1 - let final_amount: number; - if(bank_code.type === 'mileage') { - final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id); - }else { - final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2)); - } - - return this.prisma.expenses.create({ - data: { timesheet_id, bank_code_id, date, amount: final_amount, description, is_approved, supervisor_comment}, - include: { timesheet: { include: { employee: { include: { user: true }}}}, - bank_code: true, + //fetch pay-period using year and period_no + const pay_period = await this.prisma.payPeriods.findFirst({ + where: { + pay_year: year, + pay_period_no: period_no }, - }) - } + select: { period_start: true, period_end: true }, + }); + if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`); - async findAll(filters: SearchExpensesDto): Promise { - const where = buildPrismaWhere(filters); - const expenses = await this.prisma.expenses.findMany({ where }) - return expenses; - } + const start = toUTCDateOnly(pay_period.period_start); + const end = toUTCDateOnly(pay_period.period_end); - async findOne(id: number): Promise { - const expense = await this.prisma.expenses.findUnique({ - where: { id }, - include: { timesheet: { include: { employee: { include: { user:true } } } }, - bank_code: true, + //sets rows data + const rows = await this.prisma.expenses.findMany({ + where: { + date: { gte: start, lte: end }, + timesheet: { is: { employee_id } }, + }, + orderBy: { date: 'asc'}, + select: { + amount: true, + mileage: true, + comment: true, + is_approved: true, + supervisor_comment: true, + bank_code: {select: { type: true } }, }, }); - if (!expense) { - throw new NotFoundException(`Expense #${id} not found`); - } - return expense; - } + + //declare return values + const expenses: ExpenseDto[] = []; + let total_amount = 0; + let total_mileage = 0; - async update(id: number, dto: UpdateExpenseDto): Promise { - await this.findOne(id); - const { timesheet_id, bank_code_id, date, amount, - description, is_approved, supervisor_comment} = dto; - return this.prisma.expenses.update({ - where: { id }, - data: { - ...(timesheet_id !== undefined && { timesheet_id}), - ...(bank_code_id !== undefined && { bank_code_id }), - ...(date !== undefined && { date }), - ...(amount !== undefined && { amount }), - ...(description !== undefined && { description }), - ...(is_approved !== undefined && { is_approved }), - ...(supervisor_comment !== undefined && { supervisor_comment }), - }, - include: { timesheet: { include: { employee: { include: { user: true } } } }, - bank_code: true, - }, - }); - } + //set rows + for(const row of rows) { + const type = (row.bank_code?.type ?? '').toUpperCase(); + const amount = round2(Number(row.amount ?? 0)); + const mileage = round2(Number(row.mileage ?? 0)); - async remove(id: number): Promise { - await this.findOne(id); - return this.prisma.expenses.delete({ where: { id } }); - } - - - //archivation functions ****************************************************** - - async archiveOld(): Promise { - //fetches archived timesheet's Ids - const archived_timesheets = await this.prisma.timesheetsArchive.findMany({ - select: { timesheet_id: true }, - }); - - const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id); - if(timesheet_ids.length === 0) { - return; - } - - // copy/delete transaction - await this.prisma.$transaction(async transaction => { - //fetches expenses to move to archive - const expenses_to_archive = await transaction.expenses.findMany({ - where: { timesheet_id: { in: timesheet_ids } }, - }); - if(expenses_to_archive.length === 0) { - return; + if(type === EXPENSE_TYPES.MILEAGE) { + total_mileage += mileage; + } else { + total_amount += amount; } - //copies sent to archive table - await transaction.expensesArchive.createMany({ - data: expenses_to_archive.map(exp => ({ - expense_id: exp.id, - timesheet_id: exp.timesheet_id, - bank_code_id: exp.bank_code_id, - date: exp.date, - amount: exp.amount, - attachement: exp.attachement, - description: exp.description, - is_approved: exp.is_approved, - supervisor_comment: exp.supervisor_comment, - })), + //fills rows array + expenses.push({ + type, + amount, + mileage, + comment: row.comment ?? '', + is_approved: row.is_approved ?? false, + supervisor_comment: row.supervisor_comment ?? '', }); + } - //delete from expenses table - await transaction.expenses.deleteMany({ - where: { id: { in: expenses_to_archive.map(exp => exp.id) } }, - }) + return { + expenses, + total_expense: round2(total_amount), + total_mileage: round2(total_mileage), + }; +} - }) - } + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // async create(dto: CreateExpenseDto): Promise { + // const { timesheet_id, bank_code_id, date, amount:rawAmount, + // comment, is_approved,supervisor_comment} = dto; + // //fetches type and modifier + // const bank_code = await this.prisma.bankCodes.findUnique({ + // where: { id: bank_code_id }, + // select: { type: true, modifier: true }, + // }); + // if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`); - //fetches all archived timesheets - async findAllArchived(): Promise { - return this.prisma.expensesArchive.findMany(); - } + // //if mileage -> service, otherwise the ratio is amount:1 + // let final_amount: number; + // if(bank_code.type === 'mileage') { + // final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id); + // }else { + // final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2)); + // } + + // return this.prisma.expenses.create({ + // data: { + // timesheet_id, + // bank_code_id, + // date, + // amount: final_amount, + // comment, + // is_approved, + // supervisor_comment + // }, + // include: { timesheet: { include: { employee: { include: { user: true }}}}, + // bank_code: true, + // }, + // }) + // } + + // async findAll(filters: SearchExpensesDto): Promise { + // const where = buildPrismaWhere(filters); + // const expenses = await this.prisma.expenses.findMany({ where }) + // return expenses; + // } + + // async findOne(id: number): Promise { + // const expense = await this.prisma.expenses.findUnique({ + // where: { id }, + // include: { timesheet: { include: { employee: { include: { user:true } } } }, + // bank_code: true, + // }, + // }); + // if (!expense) { + // throw new NotFoundException(`Expense #${id} not found`); + // } + // return expense; + // } + + // async update(id: number, dto: UpdateExpenseDto): Promise { + // await this.findOne(id); + // const { timesheet_id, bank_code_id, date, amount, + // comment, is_approved, supervisor_comment} = dto; + // return this.prisma.expenses.update({ + // where: { id }, + // data: { + // ...(timesheet_id !== undefined && { timesheet_id}), + // ...(bank_code_id !== undefined && { bank_code_id }), + // ...(date !== undefined && { date }), + // ...(amount !== undefined && { amount }), + // ...(comment !== undefined && { comment }), + // ...(is_approved !== undefined && { is_approved }), + // ...(supervisor_comment !== undefined && { supervisor_comment }), + // }, + // include: { timesheet: { include: { employee: { include: { user: true } } } }, + // bank_code: true, + // }, + // }); + // } + + // async remove(id: number): Promise { + // await this.findOne(id); + // return this.prisma.expenses.delete({ where: { id } }); + // } - //fetches an archived timesheet - async findOneArchived(id: number): Promise { - return this.prisma.expensesArchive.findUniqueOrThrow({ where: { id } }); - } } \ No newline at end of file diff --git a/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts new file mode 100644 index 0000000..5f592a5 --- /dev/null +++ b/src/modules/expenses/types and interfaces/expenses.types.interfaces.ts @@ -0,0 +1,14 @@ +export type UpsertAction = 'create' | 'update' | 'delete'; + +export interface ExpenseResponse { + date: string; + type: string; + amount: number; + comment: string; + is_approved: boolean; +}; + +export type UpsertExpenseResult = { + action: UpsertAction; + day: ExpenseResponse[] +}; \ No newline at end of file diff --git a/src/modules/expenses/utils/expenses.utils.ts b/src/modules/expenses/utils/expenses.utils.ts new file mode 100644 index 0000000..6959bde --- /dev/null +++ b/src/modules/expenses/utils/expenses.utils.ts @@ -0,0 +1,111 @@ +import { BadRequestException } from "@nestjs/common"; +import { ExpenseResponse } from "../types and interfaces/expenses.types.interfaces"; +import { Prisma } from "@prisma/client"; + +//uppercase and trim for validation +export function normalizeType(type: string): string { + return (type ?? '').trim().toUpperCase(); +}; + +//required comment after trim +export function assertAndTrimComment(comment: string): string { + const cmt = (comment ?? '').trim(); + if(cmt.length === 0) { + throw new BadRequestException('A comment is required'); + } + return cmt; +}; + +//rounding $ to 2 decimals +export function roundMoney2(num: number): number { + return Math.round((num + Number.EPSILON) * 100)/ 100; +}; + +export function computeMileageAmount(km: number, modifier: number): number { + if(km < 0) throw new BadRequestException('mileage must be positive'); + if(modifier < 0) throw new BadRequestException('modifier must be positive'); + return roundMoney2(km * modifier); +}; + +//compat. types with Prisma.Decimal. work around Prisma import in utils. +export type DecimalLike = + | number + | string + | { toNumber?: () => number } + | { toString?: () => string }; + + +//safe conversion to number +export function toNumberSafe(value: DecimalLike): number { + if(typeof value === 'number') return value; + if(value && typeof (value as any).toNumber === 'function') return (value as any).toNumber(); + return Number( + typeof (value as any)?.toString === 'function' + ? (value as any).toString() + : value, + ); +} + +export const parseAttachmentId = (value: unknown): number | null => { + if (value == null) { + return null; + } + + if (typeof value === 'number') { + if (!Number.isInteger(value) || value <= 0) { + throw new BadRequestException('Invalid attachment id'); + } + return value; + } + + if (typeof value === 'string') { + + const trimmed = value.trim(); + if (!trimmed.length) return null; + if (!/^\d+$/.test(trimmed)) throw new BadRequestException('Invalid attachment id'); + + const parsed = Number(trimmed); + if (parsed <= 0) throw new BadRequestException('Invalid attachment id'); + + return parsed; + } + throw new BadRequestException('Invalid attachment id'); +}; + + +//map of a row for DayExpenseResponse +export function mapDbExpenseToDayResponse(row: { + date: Date; + amount: Prisma.Decimal | number | string | null; + mileage?: Prisma.Decimal | number | string | null; + comment: string; + is_approved: boolean; + bank_code?: { type?: string | null } | null; +}): ExpenseResponse { + const yyyyMmDd = row.date.toISOString().slice(0,10); + const toNum = (value: any)=> (value == null ? 0 : Number(value)); + return { + date: yyyyMmDd, + type: normalizeType(row.bank_code?.type ?? 'UNKNOWN'), + amount: toNum(row.amount), + comment: row.comment, + is_approved: row.is_approved, + ...(row.mileage !== null ? { mileage: toNum(row.mileage) }: {}), + }; +} + + export const computeAmountDecimal = ( + type: string, + payload: { + amount?: number; + mileage?: number; + }, + modifier: number, + ): Prisma.Decimal => { + if(type === 'MILEAGE') { + const km = payload.mileage ?? 0; + const amountNumber = computeMileageAmount(km, modifier); + return new Prisma.Decimal(amountNumber); + } + return new Prisma.Decimal(payload.amount!); + }; diff --git a/src/modules/exports/controllers/csv-exports.controller.ts b/src/modules/exports/controllers/csv-exports.controller.ts index f59e84c..71cde76 100644 --- a/src/modules/exports/controllers/csv-exports.controller.ts +++ b/src/modules/exports/controllers/csv-exports.controller.ts @@ -2,8 +2,9 @@ import { Controller, Get, Header, Query, UseGuards } from "@nestjs/common"; import { RolesGuard } from "src/common/guards/roles.guard"; import { Roles as RoleEnum } from '.prisma/client'; import { CsvExportService } from "../services/csv-exports.service"; -import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto"; +// import { ExportCompany, ExportCsvOptionsDto, ExportType } from "../dtos/export-csv-options.dto"; import { RolesAllowed } from "src/common/decorators/roles.decorators"; +import { ExportCsvOptionsDto } from "../dtos/export-csv-options.dto"; @Controller('exports') @@ -13,33 +14,27 @@ export class CsvExportController { @Get('csv') @Header('Content-Type', 'text/csv; charset=utf-8') - @Header('Content-Dispoition', 'attachment; filename="export.csv"') + @Header('Content-Disposition', 'attachment; filename="export.csv"') //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.ACCOUNTING, RoleEnum.HR) - async exportCsv(@Query() options: ExportCsvOptionsDto, - @Query('period') periodId: string ): Promise { - - //sets default values - const companies = options.companies && options.companies.length ? options.companies : - [ ExportCompany.TARGO, ExportCompany.SOLUCOM]; - const types = options.type && options.type.length ? options.type : - Object.values(ExportType); - - //collects all - const all = await this.csvService.collectTransaction(Number(periodId), companies); - - //filters by type - const filtered = all.filter(r => { - switch (r.bank_code.toLocaleLowerCase()) { - case 'holiday' : return types.includes(ExportType.HOLIDAY); - case 'vacation' : return types.includes(ExportType.VACATION); - case 'sick-leave': return types.includes(ExportType.SICK_LEAVE); - case 'expenses' : return types.includes(ExportType.EXPENSES); - default : return types.includes(ExportType.SHIFTS); + async exportCsv(@Query() query: ExportCsvOptionsDto ): Promise { + const rows = await this.csvService.collectTransaction( + query.year, + query.period_no, + { + approved: query.approved ?? true, + types: { + shifts: query.shifts ?? true, + expenses: query.expenses ?? true, + holiday: query.holiday ?? true, + vacation: query.vacation ?? true, + }, + companies: { + targo: query.targo ?? true, + solucom: query.solucom ?? true, + }, } - }); - - //generating the csv file - return this.csvService.generateCsv(filtered); + ); + return this.csvService.generateCsv(rows); } } \ No newline at end of file diff --git a/src/modules/exports/csv-exports.module.ts b/src/modules/exports/csv-exports.module.ts index 92a5a96..e034c9e 100644 --- a/src/modules/exports/csv-exports.module.ts +++ b/src/modules/exports/csv-exports.module.ts @@ -1,9 +1,10 @@ import { Module } from "@nestjs/common"; import { CsvExportController } from "./controllers/csv-exports.controller"; import { CsvExportService } from "./services/csv-exports.service"; +import { SharedModule } from "../shared/shared.module"; @Module({ - providers:[CsvExportService], + providers:[CsvExportService, SharedModule], controllers: [CsvExportController], }) export class CsvExportModule {} diff --git a/src/modules/exports/dtos/export-csv-options.dto.ts b/src/modules/exports/dtos/export-csv-options.dto.ts index dc969ad..f2a2b49 100644 --- a/src/modules/exports/dtos/export-csv-options.dto.ts +++ b/src/modules/exports/dtos/export-csv-options.dto.ts @@ -1,26 +1,47 @@ -import { IsArray, IsEnum, IsOptional } from "class-validator"; +import { Transform } from "class-transformer"; +import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator"; -export enum ExportType { - SHIFTS = 'Quart de travail', - EXPENSES = 'Depenses', - HOLIDAY = 'Ferie', - VACATION = 'Vacance', - SICK_LEAVE = 'Absence' -} - -export enum ExportCompany { - TARGO = 'Targo', - SOLUCOM = 'Solucom', +function toBoolean(v: any) { + if(typeof v === 'boolean') return v; + if(typeof v === 'string') return ['true', '1', 'on','yes'].includes(v.toLowerCase()); + return false; } export class ExportCsvOptionsDto { - @IsOptional() - @IsArray() - @IsEnum(ExportCompany, { each: true }) - companies?: ExportCompany[]; - @IsOptional() - @IsArray() - @IsEnum(ExportType, { each: true }) - type?: ExportType[]; + @Transform(({ value }) => parseInt(value,10)) + @IsInt() @Min(2023) + year! : number; + + @Transform(({ value }) => parseInt(value,10)) + @IsInt() @Min(1) @Max(26) + period_no!: number; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + approved? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + shifts? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + expenses? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + holiday? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + vacation? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + targo? : boolean = true; + + @IsOptional() @IsBoolean() + @Transform(({ value }) => toBoolean(value)) + solucom? : boolean = true; } \ No newline at end of file diff --git a/src/modules/exports/services/csv-exports.service.ts b/src/modules/exports/services/csv-exports.service.ts index ed9fab2..3035156 100644 --- a/src/modules/exports/services/csv-exports.service.ts +++ b/src/modules/exports/services/csv-exports.service.ts @@ -1,6 +1,5 @@ import { PrismaService } from "src/prisma/prisma.service"; -import { ExportCompany } from "../dtos/export-csv-options.dto"; -import { Injectable, NotFoundException } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; export interface CsvRow { company_code: number; @@ -14,148 +13,240 @@ export interface CsvRow { holiday_date?: string; } +type Filters = { + types: { + shifts: boolean; + expenses: boolean; + holiday: boolean; + vacation: boolean; + }; + companies: { + targo: boolean; + solucom: boolean; + }; + approved: boolean; +}; + @Injectable() export class CsvExportService { constructor(private readonly prisma: PrismaService) {} - async collectTransaction( period_id: number, companies: ExportCompany[], approved: boolean = true): - Promise { - - const company_codes = companies.map(c => c === ExportCompany.TARGO ? 1 : 2); - + async collectTransaction( + year: number, + period_no: number, + filters: Filters, + approved: boolean = true + ): Promise { + //fetch period const period = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no: period_id }, + where: { pay_year: year, pay_period_no: period_no }, + select: { period_start: true, period_end: true }, }); - if(!period) throw new NotFoundException(`Pay period ${period_id} not found`); + if(!period) throw new NotFoundException(`Pay period ${ year }-${ period_no } not found`); - const start_date = period.period_start; - const end_date = period.period_end; + const start = period.period_start; + const end = period.period_end; - const approved_filter = approved ? { is_approved: true } : {}; + //fetch company codes from .env + const company_codes = this.resolveCompanyCodes(filters.companies); + if(company_codes.length === 0) throw new BadRequestException('No company selected'); - //fetching shifts - const shifts = await this.prisma.shifts.findMany({ - where: { - date: { gte: start_date, lte: end_date }, - ...approved_filter, - timesheet: { - employee: { company_code: { in: company_codes} } }, - }, - include: { - bank_code: true, - timesheet: { include: { - employee: { include: { - user:true, - supervisor: { include: { - user:true } } } } } }, - }, - }); + //Flag types + const { shifts: want_shifts, expenses: want_expense, holiday: want_holiday, vacation: want_vacation } = filters.types; + if(!want_shifts && !want_expense && !want_holiday && !want_vacation) { + throw new BadRequestException(' No export type selected '); + } - //fetching expenses - const expenses = await this.prisma.expenses.findMany({ - where: { - date: { gte: start_date, lte: end_date }, - ...approved_filter, - timesheet: { employee: { company_code: { in: company_codes} } }, + const approved_filter = filters.approved? { is_approved: true } : {}; + + const {holiday_code, vacation_code} = this.resolveLeaveCodes(); + + //Prisma queries + const promises: Array> = []; + + if (want_shifts) { + promises.push( this.prisma.shifts.findMany({ + where: { + date: { gte: start, lte: end }, + ...approved_filter, + bank_code: { bank_code: { notIn: [ holiday_code, vacation_code ] } }, + timesheet: { employee: { company_code: { in: company_codes } } }, }, - include: { bank_code: true, - timesheet: { include: { - employee: { include: { - user: true, - supervisor: { include: { - user:true } } } } } }, - }, - }); + select: { + date: true, + start_time: true, + end_time: true, + bank_code: { select: { bank_code: true } }, + timesheet: { select: { + employee: { select: { + company_code: true, + external_payroll_id: true, + user: { select: { first_name: true, last_name: true } }, + }}, + }}, + }, + })); + } else { + promises.push(Promise.resolve([])); + } - //fetching leave requests - const leaves = await this.prisma.leaveRequests.findMany({ - where : { - start_date_time: { gte: start_date, lte: end_date }, - employee: { company_code: { in: company_codes } }, - }, - include: { - bank_code: true, - employee: { include: { - user: true, - supervisor: { include: { - user: true } } } }, - }, - }); + if(want_holiday) { + promises.push( this.prisma.shifts.findMany({ + where: { + date: { gte: start, lte: end }, + ...approved_filter, + bank_code: { bank_code: holiday_code }, + timesheet: { employee: { company_code: { in: company_codes } } }, + }, + select: { + date: true, + start_time: true, + end_time: true, + bank_code: { select: { bank_code: true } }, + timesheet: { select: { + employee: { select: { + company_code: true, + external_payroll_id: true, + user: { select: { first_name: true,last_name: true } }, + } }, + } }, + }, + })); + }else { + promises.push(Promise.resolve([])); + } + if(want_vacation) { + promises.push( this.prisma.shifts.findMany({ + where: { + date: { gte: start, lte: end }, + ...approved_filter, + bank_code: { bank_code: vacation_code }, + timesheet: { employee: { company_code: { in: company_codes } } }, + }, + select: { + date: true, + start_time: true, + end_time: true, + bank_code: { select: { bank_code: true } }, + timesheet: { select: { + employee: { select: { + company_code: true, + external_payroll_id: true, + user: { select: { first_name: true,last_name: true } }, + } }, + } }, + }, + })); + }else { + promises.push(Promise.resolve([])); + } + + if(want_expense) { + promises.push( this.prisma.expenses.findMany({ + where: { + date: { gte: start, lte: end }, + ...approved_filter, + timesheet: { employee: { company_code: { in: company_codes } } }, + }, + select: { + date: true, + amount: true, + bank_code: { select: { bank_code: true } }, + timesheet: { select: { + employee: { select: { + company_code: true, + external_payroll_id: true, + user: { select: { first_name: true, last_name: true } }, + }}, + }}, + }, + })); + } else { + promises.push(Promise.resolve([])); + } + + //array of arrays + const [ base_shifts, holiday_shifts, vacation_shifts, expenses ] = await Promise.all(promises); + //mapping const rows: CsvRow[] = []; - //Shifts Mapping - for (const shift of shifts) { - const emp = shift.timesheet.employee; - const week_number = this.computeWeekNumber(start_date, shift.date); - const hours = this.computeHours(shift.start_time, shift.end_time); - - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: shift.bank_code.bank_code, - quantity_hours: hours, + const map_shifts = (shift: any, is_holiday: boolean) => { + const employee = shift.timesheet.employee; + const week = this.computeWeekNumber(start, shift.date); + return { + company_code: employee.company_code, + external_payroll_id: employee.external_payroll_id, + full_name: `${employee.first_name} ${ employee.last_name}`, + bank_code: shift.bank_code?.bank_code ?? '', + quantity_hours: this.computeHours(shift.start_time, shift.end_time), amount: undefined, - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); - } - - //Expenses Mapping - for (const e of expenses) { - const emp = e.timesheet.employee; - const week_number = this.computeWeekNumber(start_date, e.date); + week_number: week, + pay_date: this.formatDate(end), + holiday_date: is_holiday? this.formatDate(shift.date) : '', + } as CsvRow; + }; + //final mapping of all shifts based filters + for (const shift of base_shifts) rows.push(map_shifts(shift, false)); + for (const shift of holiday_shifts) rows.push(map_shifts(shift, true )); + for (const shift of vacation_shifts) rows.push(map_shifts(shift, false)); + for (const expense of expenses) { + const employee = expense.timesheet.employee; + const week = this.computeWeekNumber(start, expense.date); rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: e.bank_code.bank_code, + company_code: employee.company_code, + external_payroll_id: employee.external_payroll_id, + full_name: `${employee.first_name} ${ employee.last_name}`, + bank_code: expense.bank_code?.bank_code ?? '', quantity_hours: undefined, - amount: Number(e.amount), - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); + amount: Number(expense.amount), + week_number: week, + pay_date: this.formatDate(end), + holiday_date: '', + }) } - //Leaves Mapping - for(const l of leaves) { - if(!l.bank_code) continue; - const emp = l.employee; - const start = l.start_date_time; - const end = l.end_date_time ?? start; - - const week_number = this.computeWeekNumber(start_date, start); - const hours = this.computeHours(start, end); - - rows.push({ - company_code: emp.company_code, - external_payroll_id: emp.external_payroll_id, - full_name: `${emp.user.first_name} ${emp.user.last_name}`, - bank_code: l.bank_code.bank_code, - quantity_hours: hours, - amount: undefined, - week_number, - pay_date: this.formatDate(end_date), - holiday_date: undefined, - }); - } - - //Final Mapping and sorts - return rows.sort((a,b) => { + //Final mapping and sorts + rows.sort((a,b) => { if(a.external_payroll_id !== b.external_payroll_id) { return a.external_payroll_id - b.external_payroll_id; } - if(a.bank_code !== b.bank_code) { - return a.bank_code.localeCompare(b.bank_code); - } - return a.week_number - b.week_number; + const bk_code = String(a.bank_code).localeCompare(String(b.bank_code)); + if(bk_code !== 0) return bk_code; + if(a.bank_code !== b.bank_code) return a.bank_code.localeCompare(b.bank_code); + return 0; }); + + return rows; } + resolveLeaveCodes(): { holiday_code: string; vacation_code: string; } { + const holiday_code = process.env.HOLIDAY_CODE?.trim(); + if(!holiday_code) throw new BadRequestException('Missing Holiday bank code'); + + const vacation_code = process.env.VACATION_CODE?.trim(); + if(!vacation_code) throw new BadRequestException('Missing Vacation bank code'); + + return { holiday_code, vacation_code}; + } + + resolveCompanyCodes(companies: { targo: boolean; solucom: boolean; }): number[] { + const out: number[] = []; + if (companies.targo) { + const code_no = parseInt(process.env.TARGO_NO ?? '', 10); + if(!Number.isFinite(code_no) || code_no <= 0) throw new BadRequestException('Invalid Targo code in env'); + out.push(code_no); + } + if (companies.solucom) { + const code_no = parseInt(process.env.SOLUCOM_NO ?? '', 10); + if(!Number.isFinite(code_no) || code_no <= 0) throw new BadRequestException('Invalid Solucom code in env'); + out.push(code_no); + } + return out; + } + + //csv builder and "mise en page" generateCsv(rows: CsvRow[]): Buffer { const header = [ 'company_code', @@ -169,18 +260,23 @@ export class CsvExportService { 'holiday_date', ].join(',') + '\n'; - const body = rows.map(r => [ - r.company_code, - r.external_payroll_id, - `${r.full_name.replace(/"/g, '""')}"`, - r.bank_code, - r.quantity_hours?.toFixed(2) ?? '', - r.week_number, - r.pay_date, - r.holiday_date ?? '', - ].join(',')).join('\n'); - - return Buffer.from('\uFEFF' + header + body, 'utf8'); + const body = rows.map(row => { + const full_name = `${String(row.full_name).replace(/"/g, '""')}`; + const quantity_hours = (typeof row.quantity_hours === 'number') ? row.quantity_hours.toFixed(2) : ''; + const amount = (typeof row.amount === 'number') ? row.amount.toFixed(2) : ''; + return [ + row.company_code, + row.external_payroll_id, + full_name, + row.bank_code, + quantity_hours, + amount, + row.week_number, + row.pay_date, + row.holiday_date ?? '', + ].join(','); + }).join('\n'); + return Buffer.from('\uFEFF' + header + body, 'utf8'); } @@ -190,9 +286,13 @@ export class CsvExportService { } private computeWeekNumber(start: Date, date: Date): number { - const days = Math.floor((date.getTime() - start.getTime()) / (1000*60*60*24)); + const dayMS = 86400000; + const days = Math.floor((this.toUTC(date).getTime() - this.toUTC(start).getTime())/ dayMS); return Math.floor(days / 7 ) + 1; } + toUTC(date: Date) { + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + } private formatDate(d:Date): string { return d.toISOString().split('T')[0]; diff --git a/src/modules/leave-requests/controllers/leave-requests.controller.ts b/src/modules/leave-requests/controllers/leave-requests.controller.ts index d3251ad..fc934ff 100644 --- a/src/modules/leave-requests/controllers/leave-requests.controller.ts +++ b/src/modules/leave-requests/controllers/leave-requests.controller.ts @@ -1,76 +1,30 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; -import { LeaveRequestsService } from "../services/leave-requests.service"; -import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto"; -import { LeaveRequests } from "@prisma/client"; -import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto"; -import { RolesAllowed } from "src/common/decorators/roles.decorators"; -import { LeaveApprovalStatus, Roles as RoleEnum } from '.prisma/client'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; -import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto"; -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; +import { Body, Controller, Post } from "@nestjs/common"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; +import { LeaveRequestsService } from "../services/leave-request.service"; +import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto"; +import { LeaveTypes } from "@prisma/client"; @ApiTags('Leave Requests') @ApiBearerAuth('access-token') // @UseGuards() @Controller('leave-requests') export class LeaveRequestController { - constructor(private readonly leaveRequetsService: LeaveRequestsService){} + constructor(private readonly leave_service: LeaveRequestsService){} - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Create leave request' }) - @ApiResponse({ status: 201, description: 'Leave request created',type: CreateLeaveRequestsDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateLeaveRequestsDto): Promise { - return this. leaveRequetsService.create(dto); - } + @Post('upsert') + async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) { + const { action, leave_requests } = await this.leave_service.handle(dto); + return { action, leave_requests }; + }q - @Get() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Find all leave request' }) - @ApiResponse({ status: 201, description: 'List of Leave requests found',type: LeaveRequestViewDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of leave request not found' }) - @UsePipes(new ValidationPipe({transform: true, whitelist: true})) - findAll(@Query() filters: SearchLeaveRequestsDto): Promise { - return this.leaveRequetsService.findAll(filters); - } - //remove emp_id and use email - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Find leave request' }) - @ApiResponse({ status: 201, description: 'Leave request found',type: LeaveRequestViewDto }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.leaveRequetsService.findOne(id); - } - //remove emp_id and use email - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Update leave request' }) - @ApiResponse({ status: 201, description: 'Leave request updated',type: LeaveRequestViewDto }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) - update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateLeaveRequestsDto): Promise { - return this.leaveRequetsService.update(id, dto); - } + //TODO: + /* + @Get('archive') + findAllArchived(){...} - //remove emp_id and use email - @Delete(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({summary: 'Delete leave request' }) - @ApiResponse({ status: 201, description: 'Leave request deleted',type: CreateLeaveRequestsDto }) - @ApiResponse({ status: 400, description: 'Leave request not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.leaveRequetsService.remove(id); - } + @Get('archive/:id') + findOneArchived(id){...} + */ - //remove emp_id and use email - @Patch('approval/:id') - updateApproval( @Param('id', ParseIntPipe) id: number, - @Body('is_approved', ParseBoolPipe) is_approved: boolean): Promise { - const approvalStatus = is_approved ? - LeaveApprovalStatus.APPROVED : LeaveApprovalStatus.DENIED; - return this.leaveRequetsService.update(id, { approval_status: approvalStatus }); - } - - } +} diff --git a/src/modules/leave-requests/dtos/create-leave-request.dto.ts b/src/modules/leave-requests/dtos/create-leave-request.dto.ts deleted file mode 100644 index 136c858..0000000 --- a/src/modules/leave-requests/dtos/create-leave-request.dto.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; -import { Type } from "class-transformer"; -import { IsDateString, IsEmail, IsEnum, IsInt, IsISO8601, IsNotEmpty, IsOptional, IsString } from "class-validator"; - -export class CreateLeaveRequestsDto { - - @IsEmail() - email: string; - - @ApiProperty({ - example: 7, - description: 'ID number of a leave-request code (link with bank-codes)', - }) - @Type(()=> Number) - @IsInt() - bank_code_id: number; - - @ApiProperty({ - example: 'Sick or Vacation or Unpaid or Bereavement or Parental or Legal', - description: 'type of leave request for an accounting perception', - }) - @IsEnum(LeaveTypes) - leave_type: LeaveTypes; - - @ApiProperty({ - example: '22/06/2463', - description: 'Leave request`s start date', - }) - @IsISO8601() - start_date_time:string; - - @ApiProperty({ - example: '25/03/3019', - description: 'Leave request`s end date', - }) - @IsOptional() - @IsISO8601() - end_date_time?: string; - - @ApiProperty({ - example: 'My precious', - description: 'Leave request`s comment', - }) - @IsString() - @IsNotEmpty() - comment: string; - - @ApiProperty({ - example: 'True or False or Pending or Denied or Cancelled or Escalated', - description: 'Leave request`s approval status', - }) - @IsEnum(LeaveApprovalStatus) - @IsOptional() - approval_status?: LeaveApprovalStatus; -} diff --git a/src/modules/leave-requests/dtos/leave-request.view.dto.ts b/src/modules/leave-requests/dtos/leave-request-view.dto.ts similarity index 50% rename from src/modules/leave-requests/dtos/leave-request.view.dto.ts rename to src/modules/leave-requests/dtos/leave-request-view.dto.ts index 693368d..7cf3f35 100644 --- a/src/modules/leave-requests/dtos/leave-request.view.dto.ts +++ b/src/modules/leave-requests/dtos/leave-request-view.dto.ts @@ -1,13 +1,14 @@ import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; export class LeaveRequestViewDto { - id!: number; + id: number; leave_type!: LeaveTypes; - start_date_time!: string; - end_date_time!: string | null; - comment!: string | null; + date!: string; + comment!: string; approval_status: LeaveApprovalStatus; email!: string; - employee_full_name: string; - days_requested?: number; + employee_full_name!: string; + payable_hours?: number; + requested_hours?: number; + action?: 'create' | 'update' | 'delete'; } \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/search-leave-request.dto.ts b/src/modules/leave-requests/dtos/search-leave-request.dto.ts deleted file mode 100644 index 15ce8e4..0000000 --- a/src/modules/leave-requests/dtos/search-leave-request.dto.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; -import { Type } from "class-transformer"; -import { IsOptional, IsInt, IsEnum, IsDateString, IsEmail } from "class-validator"; - -export class SearchLeaveRequestsDto { - - @IsEmail() - email: string; - - @IsOptional() - @Type(()=> Number) - @IsInt() - bank_code_id?: number; - - @IsOptional() - @IsEnum(LeaveApprovalStatus) - approval_status?: LeaveApprovalStatus - - @IsOptional() - @IsDateString() - start_date?: string; - - @IsOptional() - @IsDateString() - end_date?: string; - - @IsOptional() - @IsEnum(LeaveTypes) - leave_type?: LeaveTypes; -} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/update-leave-request.dto.ts b/src/modules/leave-requests/dtos/update-leave-request.dto.ts deleted file mode 100644 index ec4bb86..0000000 --- a/src/modules/leave-requests/dtos/update-leave-request.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from "@nestjs/swagger"; -import { CreateLeaveRequestsDto } from "./create-leave-request.dto"; - -export class UpdateLeaveRequestsDto extends PartialType(CreateLeaveRequestsDto){} \ No newline at end of file diff --git a/src/modules/leave-requests/dtos/upsert-leave-request.dto.ts b/src/modules/leave-requests/dtos/upsert-leave-request.dto.ts new file mode 100644 index 0000000..0f420e7 --- /dev/null +++ b/src/modules/leave-requests/dtos/upsert-leave-request.dto.ts @@ -0,0 +1,51 @@ +import { IsEmail, IsArray, ArrayNotEmpty, ArrayUnique, IsISO8601, IsIn, IsOptional, IsString, IsNumber, Min, Max, IsEnum } from "class-validator"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { LeaveRequestViewDto } from "./leave-request-view.dto"; +import { Type } from "class-transformer"; + +//sets wich function to call +export const UPSERT_ACTIONS = ['create', 'update', 'delete'] as const; +export type UpsertAction = (typeof UPSERT_ACTIONS)[number]; + +//sets wich types to use +export const REQUEST_TYPES = Object.values(LeaveTypes) as readonly LeaveTypes[]; +export type RequestTypes = (typeof REQUEST_TYPES)[number]; + +//filter requests by type and action +export interface UpsertResult { + action: UpsertAction; + leave_requests: LeaveRequestViewDto[]; +} + +export class UpsertLeaveRequestDto { + @IsEmail() + email!: string; + + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsISO8601({}, { each: true }) + dates!: string[]; + + @IsOptional() + @IsEnum(LeaveTypes) + type!: string; + + @IsIn(UPSERT_ACTIONS) + action!: UpsertAction; + + @IsOptional() + @IsString() + comment?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber({ maxDecimalPlaces: 2 }) + @Min(0) + @Max(24) + requested_hours?: number; + + @IsOptional() + @IsEnum(LeaveApprovalStatus) + approval_status?: LeaveApprovalStatus +} \ No newline at end of file diff --git a/src/modules/leave-requests/leave-requests.module.ts b/src/modules/leave-requests/leave-requests.module.ts index 7762846..714dad8 100644 --- a/src/modules/leave-requests/leave-requests.module.ts +++ b/src/modules/leave-requests/leave-requests.module.ts @@ -1,13 +1,29 @@ +import { PrismaService } from "src/prisma/prisma.service"; import { LeaveRequestController } from "./controllers/leave-requests.controller"; -import { LeaveRequestsService } from "./services/leave-requests.service"; +import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service"; import { Module } from "@nestjs/common"; import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module"; +import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service"; +import { SickLeaveRequestsService } from "./services/sick-leave-requests.service"; +import { LeaveRequestsService } from "./services/leave-request.service"; +import { ShiftsModule } from "../shifts/shifts.module"; +import { LeaveRequestsUtils } from "./utils/leave-request.util"; +import { SharedModule } from "../shared/shared.module"; @Module({ - imports: [BusinessLogicsModule], + imports: [BusinessLogicsModule, ShiftsModule, SharedModule], controllers: [LeaveRequestController], - providers: [LeaveRequestsService], - exports: [LeaveRequestsService], + providers: [ + VacationLeaveRequestsService, + SickLeaveRequestsService, + HolidayLeaveRequestsService, + LeaveRequestsService, + PrismaService, + LeaveRequestsUtils, + ], + exports: [ + LeaveRequestsService, + ], }) export class LeaveRequestsModule {} \ No newline at end of file diff --git a/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts b/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts index 48d91e0..36d05fa 100644 --- a/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts +++ b/src/modules/leave-requests/mappers/leave-requests-archive.mapper.ts @@ -1,14 +1,20 @@ -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; +import { Prisma } from "@prisma/client"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { LeaveRequestArchiveRow } from "../utils/leave-requests-archive.select"; -const toISO = (date: Date | null): string | null => (date ? date.toISOString().slice(0,10): null); +const toNum = (value?: Prisma.Decimal | null) => value ? Number(value) : undefined; export function mapArchiveRowToView(row: LeaveRequestArchiveRow, email: string, employee_full_name:string): LeaveRequestViewDto { + const isoDate = row.date?.toISOString().slice(0, 10); + if (!isoDate) { + throw new Error(`Leave request #${row.id} has no date set.`); + } return { id: row.id, leave_type: row.leave_type, - start_date_time: toISO(row.start_date_time)!, - end_date_time: toISO(row.end_date_time), + date: isoDate, + payable_hours: toNum(row.payable_hours), + requested_hours: toNum(row.requested_hours), comment: row.comment, approval_status: row.approval_status, email, diff --git a/src/modules/leave-requests/mappers/leave-requests.mapper.ts b/src/modules/leave-requests/mappers/leave-requests.mapper.ts index 4fe2133..e93f94b 100644 --- a/src/modules/leave-requests/mappers/leave-requests.mapper.ts +++ b/src/modules/leave-requests/mappers/leave-requests.mapper.ts @@ -1,19 +1,23 @@ -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; +import { Prisma } from "@prisma/client"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; import { LeaveRequestRow } from "../utils/leave-requests.select"; -function toISODateString(date:Date | null): string | null { - return date ? date.toISOString().slice(0,10) : null; -} +const toNum = (value?: Prisma.Decimal | null) => + value !== null && value !== undefined ? Number(value) : undefined; export function mapRowToView(row: LeaveRequestRow): LeaveRequestViewDto { + const iso_date = row.date?.toISOString().slice(0, 10); + if (!iso_date) throw new Error(`Leave request #${row.id} has no date set.`); + return { id: row.id, leave_type: row.leave_type, - start_date_time: toISODateString(row.start_date_time)!, - end_date_time: toISODateString(row.end_date_time), + date: iso_date, + payable_hours: toNum(row.payable_hours), + requested_hours: toNum(row.requested_hours), comment: row.comment, approval_status: row.approval_status, email: row.employee.user.email, - employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}` - } + employee_full_name: `${row.employee.user.first_name} ${row.employee.user.last_name}`, + }; } \ No newline at end of file diff --git a/src/modules/leave-requests/services/holiday-leave-requests.service.ts b/src/modules/leave-requests/services/holiday-leave-requests.service.ts new file mode 100644 index 0000000..309bfbb --- /dev/null +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -0,0 +1,78 @@ +import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto'; +import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client'; +import { HolidayService } from 'src/modules/business-logics/services/holiday.service'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { mapRowToView } from '../mappers/leave-requests.mapper'; +import { leaveRequestsSelect } from '../utils/leave-requests.select'; +import { LeaveRequestsUtils} from '../utils/leave-request.util'; +import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers'; +import { BankCodesResolver } from 'src/modules/shared/utils/resolve-bank-type-id.utils'; +import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; + + +@Injectable() +export class HolidayLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + private readonly holidayService: HolidayService, + private readonly leaveUtils: LeaveRequestsUtils, + private readonly emailResolver: EmailToIdResolver, + private readonly typeResolver: BankCodesResolver, + ) {} + + async create(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.emailResolver.findIdByEmail(email); + const bank_code = await this.typeResolver.findByType(LeaveTypes.HOLIDAY); + if(!bank_code) throw new NotFoundException(`bank_code not found`); + const dates = normalizeDates(dto.dates); + if (!dates.length) throw new BadRequestException('Dates array must not be empty'); + + const created: LeaveRequestViewDto[] = []; + + for (const iso_date of dates) { + const date = toDateOnly(iso_date); + + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: LeaveTypes.HOLIDAY, + date, + }, + }, + select: { id: true }, + }); + if (existing) { + throw new BadRequestException(`Holiday request already exists for ${iso_date}`); + } + + const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier); + const row = await this.prisma.leaveRequests.create({ + data: { + employee_id: employee_id, + bank_code_id: bank_code.id, + leave_type: LeaveTypes.HOLIDAY, + date, + comment: dto.comment ?? '', + requested_hours: dto.requested_hours ?? 8, + payable_hours: payable, + approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, + }, + select: leaveRequestsSelect, + }); + + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment); + } + + created.push({ ...mapRowToView(row), action: 'create' }); + } + + return { action: 'create', leave_requests: created }; + } +} + diff --git a/src/modules/leave-requests/services/leave-request.service.ts b/src/modules/leave-requests/services/leave-request.service.ts new file mode 100644 index 0000000..d5e3eb7 --- /dev/null +++ b/src/modules/leave-requests/services/leave-request.service.ts @@ -0,0 +1,248 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { roundToQuarterHour } from "src/common/utils/date-utils"; +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; +import { mapRowToView } from "../mappers/leave-requests.mapper"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service"; +import { SickLeaveRequestsService } from "./sick-leave-requests.service"; +import { VacationLeaveRequestsService } from "./vacation-leave-requests.service"; +import { HolidayService } from "src/modules/business-logics/services/holiday.service"; +import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; +import { VacationService } from "src/modules/business-logics/services/vacation.service"; +import { PrismaService } from "src/prisma/prisma.service"; +import { LeaveRequestsUtils } from "../utils/leave-request.util"; +import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; + +@Injectable() +export class LeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + private readonly holidayLeaveService: HolidayLeaveRequestsService, + private readonly holidayService: HolidayService, + private readonly sickLogic: SickLeaveService, + private readonly sickLeaveService: SickLeaveRequestsService, + private readonly vacationLeaveService: VacationLeaveRequestsService, + private readonly vacationLogic: VacationService, + private readonly leaveUtils: LeaveRequestsUtils, + private readonly emailResolver: EmailToIdResolver, + private readonly typeResolver: BankCodesResolver, + ) {} + + //handle distribution to the right service according to the selected type and action + async handle(dto: UpsertLeaveRequestDto): Promise { + switch (dto.type) { + case LeaveTypes.HOLIDAY: + if( dto.action === 'create'){ + return this.holidayLeaveService.create(dto); + } else if (dto.action === 'update') { + return this.update(dto, LeaveTypes.HOLIDAY); + } else if (dto.action === 'delete'){ + return this.delete(dto, LeaveTypes.HOLIDAY); + } + case LeaveTypes.VACATION: + if( dto.action === 'create'){ + return this.vacationLeaveService.create(dto); + } else if (dto.action === 'update') { + return this.update(dto, LeaveTypes.VACATION); + } else if (dto.action === 'delete'){ + return this.delete(dto, LeaveTypes.VACATION); + } + case LeaveTypes.SICK: + if( dto.action === 'create'){ + return this.sickLeaveService.create(dto); + } else if (dto.action === 'update') { + return this.update(dto, LeaveTypes.SICK); + } else if (dto.action === 'delete'){ + return this.delete(dto, LeaveTypes.SICK); + } + default: + throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`); + } + } + + async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { + const email = dto.email.trim(); + const dates = normalizeDates(dto.dates); + const employee_id = await this.emailResolver.findIdByEmail(email); + if (!dates.length) throw new BadRequestException("Dates array must not be empty"); + + const rows = await this.prisma.leaveRequests.findMany({ + where: { + employee_id: employee_id, + leave_type: type, + date: { in: dates.map((d) => toDateOnly(d)) }, + }, + select: leaveRequestsSelect, + }); + + if (rows.length !== dates.length) { + const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate)); + throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`); + } + + for (const row of rows) { + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + const iso = toISODateKey(row.date); + await this.leaveUtils.removeShift(email, employee_id, iso, type); + } + } + + await this.prisma.leaveRequests.deleteMany({ + where: { id: { in: rows.map((row) => row.id) } }, + }); + + const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const })); + return { action: "delete", leave_requests: deleted }; + } + + async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise { + const email = dto.email.trim(); + const employee_id = await this.emailResolver.findIdByEmail(email); + const bank_code = await this.typeResolver.findByType(type); + if(!bank_code) throw new NotFoundException(`bank_code not found`); + const modifier = Number(bank_code.modifier ?? 1); + const dates = normalizeDates(dto.dates); + if (!dates.length) { + throw new BadRequestException("Dates array must not be empty"); + } + + const entries = await Promise.all( + dates.map(async (iso_date) => { + const date = toDateOnly(iso_date); + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: type, + date, + }, + }, + select: leaveRequestsSelect, + }); + if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`); + return { iso_date, date, existing }; + }), + ); + + const updated: LeaveRequestViewDto[] = []; + + if (type === LeaveTypes.SICK) { + const firstExisting = entries[0].existing; + const fallbackRequested = + firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined + ? Number(firstExisting.requested_hours) + : 8; + const requested_hours_per_day = dto.requested_hours ?? fallbackRequested; + const reference_date = entries.reduce( + (latest, entry) => (entry.date > latest ? entry.date : latest), + entries[0].date, + ); + const total_payable_hours = await this.sickLogic.calculateSickLeavePay( + employee_id, + reference_date, + entries.length, + requested_hours_per_day, + modifier, + ); + let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); + const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); + + for (const { iso_date, existing } of entries) { + const previous_status = existing.approval_status; + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + remaining_payable_hours = roundToQuarterHour( + Math.max(0, remaining_payable_hours - payable_rounded), + ); + + const row = await this.prisma.leaveRequests.update({ + where: { id: existing.id }, + data: { + comment: dto.comment ?? existing.comment, + requested_hours: requested_hours_per_day, + payable_hours: payable_rounded, + bank_code_id: bank_code.id, + approval_status: dto.approval_status ?? existing.approval_status, + }, + select: leaveRequestsSelect, + }); + + const was_approved = previous_status === LeaveApprovalStatus.APPROVED; + const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + + if (!was_approved && is_approved) { + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } else if (was_approved && !is_approved) { + await this.leaveUtils.removeShift(email, employee_id, iso_date, type); + } else if (was_approved && is_approved) { + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } + updated.push({ ...mapRowToView(row), action: "update" }); + } + return { action: "update", leave_requests: updated }; + } + + for (const { iso_date, date, existing } of entries) { + const previous_status = existing.approval_status; + const fallbackRequested = + existing.requested_hours !== null && existing.requested_hours !== undefined + ? Number(existing.requested_hours) + : 8; + const requested_hours = dto.requested_hours ?? fallbackRequested; + + let payable: number; + switch (type) { + case LeaveTypes.HOLIDAY: + payable = await this.holidayService.calculateHolidayPay(email, date, modifier); + break; + case LeaveTypes.VACATION: { + const days_requested = requested_hours / 8; + payable = await this.vacationLogic.calculateVacationPay( + employee_id, + date, + Math.max(0, days_requested), + modifier, + ); + break; + } + default: + payable = existing.payable_hours !== null && existing.payable_hours !== undefined + ? Number(existing.payable_hours) + : requested_hours; + } + + const row = await this.prisma.leaveRequests.update({ + where: { id: existing.id }, + data: { + requested_hours, + comment: dto.comment ?? existing.comment, + payable_hours: payable, + bank_code_id: bank_code.id, + approval_status: dto.approval_status ?? existing.approval_status, + }, + select: leaveRequestsSelect, + }); + + const was_approved = previous_status === LeaveApprovalStatus.APPROVED; + const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED; + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + + if (!was_approved && is_approved) { + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } else if (was_approved && !is_approved) { + await this.leaveUtils.removeShift(email, employee_id, iso_date, type); + } else if (was_approved && is_approved) { + await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment); + } + updated.push({ ...mapRowToView(row), action: "update" }); + } + return { action: "update", leave_requests: updated }; + } +} + + diff --git a/src/modules/leave-requests/services/leave-requests.service.ts b/src/modules/leave-requests/services/leave-requests.service.ts deleted file mode 100644 index 1231ed6..0000000 --- a/src/modules/leave-requests/services/leave-requests.service.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { PrismaService } from "src/prisma/prisma.service"; -import { CreateLeaveRequestsDto } from "../dtos/create-leave-request.dto"; -import { LeaveRequests, LeaveRequestsArchive } from "@prisma/client"; -import { UpdateLeaveRequestsDto } from "../dtos/update-leave-request.dto"; -import { HolidayService } from "src/modules/business-logics/services/holiday.service"; -import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; -import { VacationService } from "src/modules/business-logics/services/vacation.service"; -import { SearchLeaveRequestsDto } from "../dtos/search-leave-request.dto"; -import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; -import { LeaveRequestRow, leaveRequestsSelect } from "../utils/leave-requests.select"; -import { mapRowToView } from "../mappers/leave-requests.mapper"; -import { LeaveRequestsArchiveController } from "src/modules/archival/controllers/leave-requests-archive.controller"; -import { LeaveRequestArchiveRow, leaveRequestsArchiveSelect } from "../utils/leave-requests-archive.select"; -import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper"; -import { mapArchiveRowToViewWithDays, mapRowToViewWithDays } from "../utils/leave-request.transform"; - -@Injectable() -export class LeaveRequestsService { - constructor( - private readonly prisma: PrismaService, - private readonly holidayService: HolidayService, - private readonly vacationService: VacationService, - private readonly sickLeaveService: SickLeaveService - ) {} - - //function to avoid using employee_id as identifier in the frontend. - private async resolveEmployeeIdByEmail(email: string): Promise { - const employee = await this.prisma.employees.findFirst({ - where: { user: { email} }, - select: { id:true }, - }); - if(!employee) throw new NotFoundException(`Employee with email ${email} not found`); - return employee.id; - } - - //create a leave-request without the use of employee_id - async create(dto: CreateLeaveRequestsDto): Promise { - const employee_id = await this.resolveEmployeeIdByEmail(dto.email); - const row: LeaveRequestRow = await this.prisma.leaveRequests.create({ - data: { - employee_id, - bank_code_id: dto.bank_code_id, - leave_type: dto.leave_type, - start_date_time: new Date(dto.start_date_time), - end_date_time: dto.end_date_time ? new Date(dto.end_date_time) : null, - comment: dto.comment, - approval_status: dto.approval_status ?? undefined, - }, - select: leaveRequestsSelect, - }); - return mapRowToViewWithDays(row); - } - - //fetches all leave-requests using email - async findAll(filters: SearchLeaveRequestsDto): Promise { - const {start_date, end_date,email, leave_type, approval_status, bank_code_id } = filters; - const where: any = {}; - - if (start_date) where.start_date_time = { ...(where.start_date_time ?? {}), gte: new Date(start_date) }; - if (end_date) where.end_date_time = { ...(where.end_date_time ?? {}), lte: new Date(end_date) }; - if (email) where.employee = { user: { email } }; - if (leave_type) where.leave_type = leave_type; - if (approval_status) where.approval_status = approval_status; - if (typeof bank_code_id === 'number') where.bank_code_id = bank_code_id; - - const rows= await this.prisma.leaveRequests.findMany({ - where, - select: leaveRequestsSelect, - orderBy: { start_date_time: 'desc' }, - }); - - return rows.map(mapRowToViewWithDays); - } - - //fetch 1 leave-request using email - async findOne(id:number): Promise { - const row: LeaveRequestRow | null = await this.prisma.leaveRequests.findUnique({ - where: { id }, - select: leaveRequestsSelect, - }); - if(!row) throw new NotFoundException(`Leave Request #${id} not found`); - return mapRowToViewWithDays(row); - } - - //updates 1 leave-request using email - async update(id: number, dto: UpdateLeaveRequestsDto): Promise { - await this.findOne(id); - const data: Record = {}; - - if(dto.email !== undefined) data.employee_id = await this.resolveEmployeeIdByEmail(dto.email); - if(dto.leave_type !== undefined) data.bank_code_id = dto.bank_code_id; - if(dto.start_date_time !== undefined) data.start_date_time = new Date(dto.start_date_time); - if(dto.end_date_time !== undefined) data.end_date_time = new Date(dto.end_date_time); - if(dto.comment !== undefined) data.comment = dto.comment; - if(dto.approval_status !== undefined) data.approval_status = dto.approval_status; - - const row: LeaveRequestRow = await this.prisma.leaveRequests.update({ - where: { id }, - data, - select: leaveRequestsSelect, - }); - return mapRowToViewWithDays(row); - } - - //removes 1 leave-request using email - async remove(id:number): Promise { - await this.findOne(id); - const row: LeaveRequestRow = await this.prisma.leaveRequests.delete({ - where: { id }, - select: leaveRequestsSelect, - }); - return mapRowToViewWithDays(row); - } - - //archivation functions ****************************************************** - - async archiveExpired(): Promise { - const now = new Date(); - - await this.prisma.$transaction(async transaction => { - //fetches expired leave requests - const expired = await transaction.leaveRequests.findMany({ - where: { end_date_time: { lt: now } }, - }); - if(expired.length === 0) { - return; - } - //copy unto archive table - await transaction.leaveRequestsArchive.createMany({ - data: expired.map(request => ({ - leave_request_id: request.id, - employee_id: request.employee_id, - leave_type: request.leave_type, - start_date_time: request.start_date_time, - end_date_time: request.end_date_time, - comment: request.comment, - approval_status: request.approval_status, - })), - }); - //delete from leave_requests table - await transaction.leaveRequests.deleteMany({ - where: { id: { in: expired.map(request => request.id ) } }, - }); - }); - } - - //fetches all archived leave-requests - async findAllArchived(): Promise { - return this.prisma.leaveRequestsArchive.findMany(); - } - - //remove emp_id and use email - //fetches an archived employee - async findOneArchived(id: number): Promise { - const row: LeaveRequestArchiveRow | null = await this.prisma.leaveRequestsArchive.findUnique({ - where: { id }, - select: leaveRequestsArchiveSelect, - }); - if(!row) throw new NotFoundException(`Archived Leave Request #${id} not found`); - - const emp = await this.prisma.employees.findUnique({ - where: { id: row.employee_id }, - select: { user: {select: { email:true, - first_name: true, - last_name: true, - }}}, - }); - const email = emp?.user.email ?? ""; - const full_name = emp ? `${emp.user.first_name} ${emp.user.last_name}` : ""; - - return mapArchiveRowToViewWithDays(row, email, full_name); - } -} \ No newline at end of file diff --git a/src/modules/leave-requests/services/sick-leave-requests.service.ts b/src/modules/leave-requests/services/sick-leave-requests.service.ts new file mode 100644 index 0000000..dc513fa --- /dev/null +++ b/src/modules/leave-requests/services/sick-leave-requests.service.ts @@ -0,0 +1,98 @@ +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { mapRowToView } from "../mappers/leave-requests.mapper"; +import { PrismaService } from "src/prisma/prisma.service"; +import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service"; +import { roundToQuarterHour } from "src/common/utils/date-utils"; +import { LeaveRequestsUtils } from "../utils/leave-request.util"; +import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; + +@Injectable() +export class SickLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + private readonly sickService: SickLeaveService, + private readonly leaveUtils: LeaveRequestsUtils, + private readonly emailResolver: EmailToIdResolver, + private readonly typeResolver: BankCodesResolver, + ) {} + + async create(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.emailResolver.findIdByEmail(email); + const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK); + if(!bank_code) throw new NotFoundException(`bank_code not found`); + + const modifier = bank_code.modifier ?? 1; + const dates = normalizeDates(dto.dates); + if (!dates.length) throw new BadRequestException("Dates array must not be empty"); + const requested_hours_per_day = dto.requested_hours ?? 8; + + const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) })); + const reference_date = entries.reduce( + (latest, entry) => (entry.date > latest ? entry.date : latest), + entries[0].date, + ); + const total_payable_hours = await this.sickService.calculateSickLeavePay( + employee_id, + reference_date, + entries.length, + requested_hours_per_day, + modifier, + ); + let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); + const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); + + const created: LeaveRequestViewDto[] = []; + + for (const { iso, date } of entries) { + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: LeaveTypes.SICK, + date, + }, + }, + select: { id: true }, + }); + if (existing) { + throw new BadRequestException(`Sick request already exists for ${iso}`); + } + + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + remaining_payable_hours = roundToQuarterHour( + Math.max(0, remaining_payable_hours - payable_rounded), + ); + + const row = await this.prisma.leaveRequests.create({ + data: { + employee_id: employee_id, + bank_code_id: bank_code.id, + leave_type: LeaveTypes.SICK, + comment: dto.comment ?? "", + requested_hours: requested_hours_per_day, + payable_hours: payable_rounded, + approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, + date, + }, + select: leaveRequestsSelect, + }); + + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment); + } + + created.push({ ...mapRowToView(row), action: "create" }); + } + + return { action: "create", leave_requests: created }; + } +} diff --git a/src/modules/leave-requests/services/vacation-leave-requests.service.ts b/src/modules/leave-requests/services/vacation-leave-requests.service.ts new file mode 100644 index 0000000..8d90b6f --- /dev/null +++ b/src/modules/leave-requests/services/vacation-leave-requests.service.ts @@ -0,0 +1,93 @@ + +import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto"; +import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client"; +import { VacationService } from "src/modules/business-logics/services/vacation.service"; +import { PrismaService } from "src/prisma/prisma.service"; +import { mapRowToView } from "../mappers/leave-requests.mapper"; +import { leaveRequestsSelect } from "../utils/leave-requests.select"; +import { roundToQuarterHour } from "src/common/utils/date-utils"; +import { LeaveRequestsUtils } from "../utils/leave-request.util"; +import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; + +@Injectable() +export class VacationLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + private readonly vacationService: VacationService, + private readonly leaveUtils: LeaveRequestsUtils, + private readonly emailResolver: EmailToIdResolver, + private readonly typeResolver: BankCodesResolver, + ) {} + + async create(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.emailResolver.findIdByEmail(email); + const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION); + if(!bank_code) throw new NotFoundException(`bank_code not found`); + + const modifier = bank_code.modifier ?? 1; + const dates = normalizeDates(dto.dates); + const requested_hours_per_day = dto.requested_hours ?? 8; + if (!dates.length) throw new BadRequestException("Dates array must not be empty"); + + const entries = dates + .map((iso) => ({ iso, date: toDateOnly(iso) })) + .sort((a, b) => a.date.getTime() - b.date.getTime()); + const start_date = entries[0].date; + const total_payable_hours = await this.vacationService.calculateVacationPay( + employee_id, + start_date, + entries.length, + modifier, + ); + let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours)); + const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier); + + const created: LeaveRequestViewDto[] = []; + + for (const { iso, date } of entries) { + const existing = await this.prisma.leaveRequests.findUnique({ + where: { + leave_per_employee_date: { + employee_id: employee_id, + leave_type: LeaveTypes.VACATION, + date, + }, + }, + select: { id: true }, + }); + if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`); + + const payable = Math.min(remaining_payable_hours, daily_payable_cap); + const payable_rounded = roundToQuarterHour(Math.max(0, payable)); + remaining_payable_hours = roundToQuarterHour( + Math.max(0, remaining_payable_hours - payable_rounded), + ); + + const row = await this.prisma.leaveRequests.create({ + data: { + employee_id: employee_id, + bank_code_id: bank_code.id, + payable_hours: payable_rounded, + requested_hours: requested_hours_per_day, + leave_type: LeaveTypes.VACATION, + comment: dto.comment ?? "", + approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING, + date, + }, + select: leaveRequestsSelect, + }); + + const hours = Number(row.payable_hours ?? row.requested_hours ?? 0); + if (row.approval_status === LeaveApprovalStatus.APPROVED) { + await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment); + } + created.push({ ...mapRowToView(row), action: "create" }); + } + return { action: "create", leave_requests: created }; + } +} diff --git a/src/modules/leave-requests/utils/leave-request.transform.ts b/src/modules/leave-requests/utils/leave-request.transform.ts index b70c66d..63b9936 100644 --- a/src/modules/leave-requests/utils/leave-request.transform.ts +++ b/src/modules/leave-requests/utils/leave-request.transform.ts @@ -1,32 +1,19 @@ -import { LeaveRequestViewDto } from "../dtos/leave-request.view.dto"; -import { mapArchiveRowToView } from "../mappers/leave-requests-archive.mapper"; -import { mapRowToView } from "../mappers/leave-requests.mapper"; -import { LeaveRequestArchiveRow } from "./leave-requests-archive.select"; -import { LeaveRequestRow } from "./leave-requests.select"; +import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto'; +import { mapArchiveRowToView } from '../mappers/leave-requests-archive.mapper'; +import { mapRowToView } from '../mappers/leave-requests.mapper'; +import { LeaveRequestArchiveRow } from './leave-requests-archive.select'; +import { LeaveRequestRow } from './leave-requests.select'; -function toUTCDateOnly(date: Date): Date { - return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); -} - -const MS_PER_DAY = 86_400_000; -function computeDaysRequested(start_date: Date, end_date?: Date | null): number { - const start = toUTCDateOnly(start_date); - const end = toUTCDateOnly(end_date ?? start_date); - const diff = Math.floor((end.getTime() - start.getTime()) / MS_PER_DAY) + 1; - return Math.max(1, diff); -} - -/** Active (table leave_requests) : map + days_requested */ +/** Active (table leave_requests) : proxy to base mapper */ export function mapRowToViewWithDays(row: LeaveRequestRow): LeaveRequestViewDto { - const view = mapRowToView(row); - view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time); - return view; + return mapRowToView(row); } -/** Archive (table leave_requests_archive) : map + days_requested */ -export function mapArchiveRowToViewWithDays(row: LeaveRequestArchiveRow, email: string, employee_full_name?: string): - LeaveRequestViewDto { - const view = mapArchiveRowToView(row, email, employee_full_name!); - view.days_requested = computeDaysRequested(row.start_date_time, row.end_date_time); - return view; +/** Archive (table leave_requests_archive) : proxy to base mapper */ +export function mapArchiveRowToViewWithDays( + row: LeaveRequestArchiveRow, + email: string, + employee_full_name?: string, +): LeaveRequestViewDto { + return mapArchiveRowToView(row, email, employee_full_name!); } \ No newline at end of file diff --git a/src/modules/leave-requests/utils/leave-request.util.ts b/src/modules/leave-requests/utils/leave-request.util.ts new file mode 100644 index 0000000..11e0c9b --- /dev/null +++ b/src/modules/leave-requests/utils/leave-request.util.ts @@ -0,0 +1,102 @@ +import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; +import { PrismaService } from "src/prisma/prisma.service"; +import { LeaveTypes } from "@prisma/client"; + +@Injectable() +export class LeaveRequestsUtils { + constructor( + private readonly prisma: PrismaService, + private readonly shiftsCommand: ShiftsCommandService, + ){} + + async syncShift( + email: string, + employee_id: number, + date: string, + hours: number, + type: LeaveTypes, + comment?: string, + ) { + if (hours <= 0) return; + + const duration_minutes = Math.round(hours * 60); + if (duration_minutes > 8 * 60) { + throw new BadRequestException("Amount of hours cannot exceed 8 hours per day."); + } + const date_only = toDateOnly(date); + const yyyy_mm_dd = toStringFromDate(date_only); + + + + const start_minutes = 8 * 60; + const end_minutes = start_minutes + duration_minutes; + const toHHmm = (total: number) => + `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`; + + const existing = await this.prisma.shifts.findFirst({ + where: { + date: date_only, + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + + await this.shiftsCommand.upsertShiftsByDate(email, { + old_shift: existing + ? { + date: yyyy_mm_dd, + start_time: existing.start_time.toISOString().slice(11, 16), + end_time: existing.end_time.toISOString().slice(11, 16), + type: existing.bank_code?.type ?? type, + is_remote: existing.is_remote, + is_approved:existing.is_approved, + comment: existing.comment ?? undefined, + } + : undefined, + new_shift: { + date: yyyy_mm_dd, + start_time: toHHmm(start_minutes), + end_time: toHHmm(end_minutes), + is_remote: existing?.is_remote ?? false, + is_approved:existing?.is_approved ?? false, + comment: comment ?? existing?.comment ?? "", + type: type, + }, + }); + } + + async removeShift( + email: string, + employee_id: number, + iso_date: string, + type: LeaveTypes, + ) { + const date_only = toDateOnly(iso_date); + const yyyy_mm_dd = toStringFromDate(date_only); + const existing = await this.prisma.shifts.findFirst({ + where: { + date: date_only, + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + if (!existing) return; + + await this.shiftsCommand.upsertShiftsByDate(email, { + old_shift: { + date: yyyy_mm_dd, + start_time: hhmmFromLocal(existing.start_time), + end_time: hhmmFromLocal(existing.end_time), + type: existing.bank_code?.type ?? type, + is_remote: existing.is_remote, + is_approved:existing.is_approved, + comment: existing.comment ?? undefined, + }, + }); + } + +} diff --git a/src/modules/leave-requests/utils/leave-requests-archive.select.ts b/src/modules/leave-requests/utils/leave-requests-archive.select.ts index 5dbbd36..be06345 100644 --- a/src/modules/leave-requests/utils/leave-requests-archive.select.ts +++ b/src/modules/leave-requests/utils/leave-requests-archive.select.ts @@ -6,11 +6,11 @@ export const leaveRequestsArchiveSelect = { archived_at: true, employee_id: true, leave_type: true, - start_date_time: true, - end_date_time: true, + date: true, + payable_hours: true, + requested_hours: true, comment: true, approval_status: true, - } satisfies Prisma.LeaveRequestsArchiveSelect; export type LeaveRequestArchiveRow = Prisma.LeaveRequestsArchiveGetPayload<{ select: typeof leaveRequestsArchiveSelect}>; \ No newline at end of file diff --git a/src/modules/leave-requests/utils/leave-requests.select.ts b/src/modules/leave-requests/utils/leave-requests.select.ts index 9636334..e48a930 100644 --- a/src/modules/leave-requests/utils/leave-requests.select.ts +++ b/src/modules/leave-requests/utils/leave-requests.select.ts @@ -5,8 +5,9 @@ export const leaveRequestsSelect = { id: true, bank_code_id: true, leave_type: true, - start_date_time: true, - end_date_time: true, + date: true, + payable_hours: true, + requested_hours: true, comment: true, approval_status: true, employee: { select: { diff --git a/src/modules/pay-periods/controllers/pay-periods.controller.ts b/src/modules/pay-periods/controllers/pay-periods.controller.ts index c87c658..c12f810 100644 --- a/src/modules/pay-periods/controllers/pay-periods.controller.ts +++ b/src/modules/pay-periods/controllers/pay-periods.controller.ts @@ -18,13 +18,6 @@ export class PayPeriodsController { private readonly commandService: PayPeriodsCommandService, ) {} - @Get() - @ApiOperation({ summary: 'Find all pay period' }) - @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true }) - async findAll(): Promise { - return this.queryService.findAll(); - } - @Get('current-and-all') @ApiOperation({summary: 'Return current pay period and the full list'}) @ApiQuery({name: 'date', required:false, example: '2025-08-11', description:'Override for resolving the current period'}) @@ -95,4 +88,16 @@ export class PayPeriodsController { ): Promise { return this.queryService.getOverviewByYearPeriod(year, period_no); } + + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @Get() + // @ApiOperation({ summary: 'Find all pay period' }) + // @ApiResponse({status: 200,description: 'List of pay period found', type: PayPeriodDto, isArray: true }) + // async findAll(): Promise { + // return this.queryService.findAll(); + // } } diff --git a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts index 8213be9..1ea6937 100644 --- a/src/modules/pay-periods/dtos/overview-employee-period.dto.ts +++ b/src/modules/pay-periods/dtos/overview-employee-period.dto.ts @@ -1,44 +1,54 @@ import { ApiProperty } from '@nestjs/swagger'; export class EmployeePeriodOverviewDto { - // @ApiProperty({ - // example: 42, - // description: "Employees.id (clé primaire num.)", - // }) - // @Allow() - // @IsOptional() - // employee_id: number; + // @ApiProperty({ + // example: 42, + // description: "Employees.id (clé primaire num.)", + // }) + // @Allow() + // @IsOptional() + // employee_id: number; - email:string; + email: string; - @ApiProperty({ - example: 'Alex Dupont', - description: 'Nom complet de lemployé', - }) - employee_name: string; + @ApiProperty({ + example: 'Alex Dupont', + description: 'Nom complet de lemployé', + }) + employee_name: string; - @ApiProperty({ example: 40, description: 'pay-period`s regular hours' }) - regular_hours: number; + @ApiProperty({ example: 40, description: 'pay-period`s regular hours' }) + regular_hours: number; - @ApiProperty({ example: 0, description: 'pay-period`s evening hours' }) - evening_hours: number; + @ApiProperty({ example: 0, description: 'pay-period`s other hours' }) + other_hours: { + evening_hours: number; - @ApiProperty({ example: 0, description: 'pay-period`s emergency hours' }) - emergency_hours: number; + emergency_hours: number; - @ApiProperty({ example: 2, description: 'pay-period`s overtime hours' }) - overtime_hours: number; + overtime_hours: number; - @ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' }) - expenses: number; + sick_hours: number; - @ApiProperty({ example: 40, description: 'pay-period total mileages (km)' }) - mileage: number; + holiday_hours: number; - @ApiProperty({ - example: true, - description: 'Tous les timesheets de la période sont approuvés pour cet employé', - }) - is_approved: boolean; + vacation_hours: number; + }; + + total_hours: number; + + @ApiProperty({ example: 420.69, description: 'pay-period`s total expenses ($)' }) + expenses: number; + + @ApiProperty({ example: 40, description: 'pay-period total mileages (km)' }) + mileage: number; + + @ApiProperty({ + example: true, + description: 'Tous les timesheets de la période sont approuvés pour cet employé', + }) + is_approved: boolean; + + is_remote: boolean; } diff --git a/src/modules/pay-periods/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index 6772529..c5606db 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -7,21 +7,24 @@ import { TimesheetsModule } from "../timesheets/timesheets.module"; import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service"; import { ExpensesCommandService } from "../expenses/services/expenses-command.service"; import { ShiftsCommandService } from "../shifts/services/shifts-command.service"; +import { SharedModule } from "../shared/shared.module"; +import { PrismaService } from "src/prisma/prisma.service"; +import { BusinessLogicsModule } from "../business-logics/business-logics.module"; @Module({ - imports: [PrismaModule, TimesheetsModule], + imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule], providers: [ PayPeriodsQueryService, PayPeriodsCommandService, TimesheetsCommandService, ExpensesCommandService, ShiftsCommandService, + PrismaService, ], controllers: [PayPeriodsController], exports: [ PayPeriodsQueryService, PayPeriodsCommandService, - PayPeriodsQueryService, ] }) diff --git a/src/modules/pay-periods/services/pay-periods-command.service.ts b/src/modules/pay-periods/services/pay-periods-command.service.ts index 8cffe2c..df9bfed 100644 --- a/src/modules/pay-periods/services/pay-periods-command.service.ts +++ b/src/modules/pay-periods/services/pay-periods-command.service.ts @@ -68,38 +68,4 @@ export class PayPeriodsCommandService { }); return {updated}; } - - //function to approve a single pay-period of a single employee (deprecated) - // async approvalPayPeriod(pay_year: number , period_no: number): Promise { - // const period = await this.prisma.payPeriods.findFirst({ - // where: { pay_year, pay_period_no: period_no}, - // }); - // if (!period) throw new NotFoundException(`PayPeriod #${pay_year}-${period_no} not found`); - - // //fetches timesheet of selected period if the timesheet has atleast 1 shift or 1 expense - // const timesheet_ist = await this.prisma.timesheets.findMany({ - // where: { - // OR: [ - // { shift: {some: { date: { gte: period.period_start, - // lte: period.period_end, - // }, - // }}, - // }, - // { expense: { some: { date: { gte: period.period_start, - // lte: period.period_end, - // }, - // }}, - // }, - // ], - // }, - // select: { id: true }, - // }); - - // //approval of both timesheet (cascading to the approval of related shifts and expenses) - // await this.prisma.$transaction(async (transaction)=> { - // for(const {id} of timesheet_ist) { - // await this.timesheets_approval.updateApprovalWithTransaction(transaction,id, true); - // } - // }) - // } } \ No newline at end of file diff --git a/src/modules/pay-periods/services/pay-periods-query.service.ts b/src/modules/pay-periods/services/pay-periods-query.service.ts index 8e20b08..0e6aac0 100644 --- a/src/modules/pay-periods/services/pay-periods-query.service.ts +++ b/src/modules/pay-periods/services/pay-periods-query.service.ts @@ -9,347 +9,399 @@ import { mapPayPeriodToDto } from "../mappers/pay-periods.mapper"; @Injectable() export class PayPeriodsQueryService { - constructor( private readonly prisma: PrismaService) {} + constructor(private readonly prisma: PrismaService) { } - async getOverview(pay_period_no: number): Promise { - const period = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no }, - orderBy: { pay_year: "desc" }, - }); - if (!period) throw new NotFoundException(`Period #${pay_period_no} not found`); + async getOverview(pay_period_no: number): Promise { + const period = await this.prisma.payPeriods.findFirst({ + where: { pay_period_no }, + orderBy: { pay_year: "desc" }, + }); + if (!period) throw new NotFoundException(`Period #${pay_period_no} not found`); - return this.buildOverview({ - period_start: period.period_start, - period_end : period.period_end, - payday : period.payday, - period_no : period.pay_period_no, - pay_year : period.pay_year, - label : period.label, - }); - } - - async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise { - const period = computePeriod(pay_year, period_no); - return this.buildOverview({ - period_start: period.period_start, - period_end : period.period_end, - period_no : period.period_no, - pay_year : period.pay_year, - payday : period.payday, - label :period.label, - } as any); - } - - private async resolveCrew(supervisor_id: number, include_subtree: boolean): - Promise> { - const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = []; - - let frontier = await this.prisma.employees.findMany({ - where: { supervisor_id: supervisor_id }, - select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, - }); - result.push(...frontier.map(emp => ({ - id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email - }))); - - if (!include_subtree) return result; - - while (frontier.length) { - const parent_ids = frontier.map(emp => emp.id); - const next = await this.prisma.employees.findMany({ - where: { supervisor_id: { in: parent_ids } }, - select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, - }); - if (next.length === 0) break; - result.push(...next.map(emp => ({ - id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email - }))); - frontier = next; + return this.buildOverview({ + period_start: period.period_start, + period_end: period.period_end, + payday: period.payday, + period_no: period.pay_period_no, + pay_year: period.pay_year, + label: period.label, + }); } - return result; - } - async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise> { - const crew = await this.resolveCrew(supervisor_id, include_subtree); - return new Set(crew.map(crew_member => crew_member.email).filter(Boolean)); - } - - async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean): - Promise { - // 1) Search for the period - const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } }); - if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`); + async getOverviewByYearPeriod(pay_year: number, period_no: number): Promise { + const period = computePeriod(pay_year, period_no); + return this.buildOverview({ + period_start: period.period_start, + period_end: period.period_end, + period_no: period.period_no, + pay_year: period.pay_year, + payday: period.payday, + label: period.label, + } as any); + } - // 2) fetch supervisor - const supervisor = await this.prisma.employees.findFirst({ - where: { user: { email: email }}, - select: { - id: true, - is_supervisor: true, - }, - }); + //find crew member associated with supervisor + private async resolveCrew(supervisor_id: number, include_subtree: boolean): + Promise> { + const result: Array<{ id: number; first_name: string; last_name: string; email: string; }> = []; - if (!supervisor) throw new NotFoundException('No employee record linked to current user'); - if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor'); + let frontier = await this.prisma.employees.findMany({ + where: { supervisor_id: supervisor_id }, + select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, + }); + result.push(...frontier.map(emp => ({ + id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email + }))); - // 3)fetchs crew members - const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }] - const crew_ids = crew.map(c => c.id); - // seed names map for employee without data - const seed_names = new Map( - crew.map(crew => [ - crew.id, - { name:`${crew.first_name} ${crew.last_name}`.trim(), - email: crew.email } - ] - ) - ); + if (!include_subtree) return result; - // 4) overview build - return this.buildOverview({ - period_no : period.pay_period_no, - period_start: period.period_start, - period_end : period.period_end, - payday : period.payday, - pay_year : period.pay_year, - label : period.label, - }, { filtered_employee_ids: crew_ids, seed_names }); - } + while (frontier.length) { + const parent_ids = frontier.map(emp => emp.id); + const next = await this.prisma.employees.findMany({ + where: { supervisor_id: { in: parent_ids } }, + select: { id: true, user: { select: { first_name: true, last_name: true, email: true } } }, + }); + if (next.length === 0) break; + result.push(...next.map(emp => ({ + id: emp.id, first_name: emp.user.first_name, last_name: emp.user.last_name, email: emp.user.email + }))); + frontier = next; + } + return result; + } - private async buildOverview( - period: { period_start: string | Date; period_end: string | Date; payday: string | Date; - period_no: number; pay_year: number; label: string; }, - options?: { filtered_employee_ids?: number[]; seed_names?: Map} - ): Promise { - const toDateString = (d: Date) => d.toISOString().slice(0, 10); - const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number)); + //fetchs crew emails + async resolveCrewEmails(supervisor_id: number, include_subtree: boolean): Promise> { + const crew = await this.resolveCrew(supervisor_id, include_subtree); + return new Set(crew.map(crew_member => crew_member.email).filter(Boolean)); + } - const start = period.period_start instanceof Date - ? period.period_start - : new Date(`${period.period_start}T00:00:00.000Z`); + async getCrewOverview(pay_year: number, period_no: number, email: string, include_subtree: boolean): + Promise { + // 1) Search for the period + const period = await this.prisma.payPeriods.findFirst({ where: { pay_year, pay_period_no: period_no } }); + if (!period) throw new NotFoundException(`Pay period ${pay_year}-${period_no} not found`); - const end = period.period_end instanceof Date - ? period.period_end - : new Date(`${period.period_end}T00:00:00.000Z`); + // 2) fetch supervisor + const supervisor = await this.prisma.employees.findFirst({ + where: { user: { email: email } }, + select: { + id: true, + is_supervisor: true, + }, + }); - const payd = period.payday instanceof Date - ? period.payday - : new Date (`${period.payday}T00:00:00.000Z`); + if (!supervisor) throw new NotFoundException('No employee record linked to current user'); + if (!supervisor.is_supervisor) throw new ForbiddenException('Employee is not a supervisor'); - //restrictEmployeeIds = filter for shifts and expenses by employees - const where_employee = options?.filtered_employee_ids?.length ? { employee_id: { in: options.filtered_employee_ids } }: {}; + // 3)fetchs crew members + const crew = await this.resolveCrew(supervisor.id, include_subtree); // [{ id, first_name, last_name }] + const crew_ids = crew.map(c => c.id); + // seed names map for employee without data + const seed_names = new Map( + crew.map(crew => [ + crew.id, + { + name: `${crew.first_name} ${crew.last_name}`.trim(), + email: crew.email + } + ] + ) + ); - // SHIFTS (filtered by crew) - const shifts = await this.prisma.shifts.findMany({ - where: { - date: { gte: start, lte: end }, - timesheet: where_employee, - }, - select: { - start_time: true, - end_time: true, - timesheet: { select: { - is_approved: true, - employee: { select: { - id: true, - user: { select: { - first_name: true, - last_name: true, - email: true, - } }, - } }, + // 4) overview build + return this.buildOverview({ + period_no: period.pay_period_no, + period_start: period.period_start, + period_end: period.period_end, + payday: period.payday, + pay_year: period.pay_year, + label: period.label, + //add is_approved + }, { filtered_employee_ids: crew_ids, seed_names }); + } + + private async buildOverview( + period: { + period_start: string | Date; period_end: string | Date; payday: string | Date; + period_no: number; pay_year: number; label: string; + }, //add is_approved + options?: { filtered_employee_ids?: number[]; seed_names?: Map } + ): Promise { + const toDateString = (d: Date) => d.toISOString().slice(0, 10); + const toMoney = (v: any) => (typeof v === "object" && "toNumber" in v ? v.toNumber() : (v as number)); + + const start = period.period_start instanceof Date + ? period.period_start + : new Date(`${period.period_start}T00:00:00.000Z`); + + const end = period.period_end instanceof Date + ? period.period_end + : new Date(`${period.period_end}T00:00:00.000Z`); + + const payd = period.payday instanceof Date + ? period.payday + : new Date(`${period.payday}T00:00:00.000Z`); + + //restrictEmployeeIds = filter for shifts and expenses by employees + const where_employee = options?.filtered_employee_ids?.length ? { employee_id: { in: options.filtered_employee_ids } } : {}; + + // SHIFTS (filtered by crew) + const shifts = await this.prisma.shifts.findMany({ + where: { + date: { gte: start, lte: end }, + timesheet: where_employee, + }, + select: { + start_time: true, + end_time: true, + is_remote: true, + timesheet: { + select: { + is_approved: true, + employee: { + select: { + id: true, + user: { + select: { + first_name: true, + last_name: true, + email: true, + } + }, + } + }, }, - }, - bank_code: { select: { categorie: true } }, - }, - }); - - // EXPENSES (filtered by crew) - const expenses = await this.prisma.expenses.findMany({ - where: { - date: { gte: start, lte: end }, - timesheet: where_employee, - }, - select: { - amount: true, - timesheet: { select: { - is_approved: true, - employee: { select: { - id: true, - user: { select: { - first_name: true, - last_name: true, - email: true, - } }, - } }, - } }, - bank_code: { select: { categorie: true, modifier: true } }, - }, - }); - - const by_employee = new Map(); - - // seed for employee without data - if (options?.seed_names) { - for (const [id, {name, email}] of options.seed_names.entries()) { - by_employee.set(id, { - email, - employee_name: name, - regular_hours: 0, - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - expenses: 0, - mileage: 0, - is_approved: true, + }, + bank_code: { select: { categorie: true, type: true } }, + }, }); - } - } - const ensure = (id: number, name: string, email: string) => { - if (!by_employee.has(id)) { - by_employee.set(id, { - email, - employee_name: name, - regular_hours: 0, - evening_hours: 0, - emergency_hours: 0, - overtime_hours: 0, - expenses: 0, - mileage: 0, - is_approved: true, + // EXPENSES (filtered by crew) + const expenses = await this.prisma.expenses.findMany({ + where: { + date: { gte: start, lte: end }, + timesheet: where_employee, + }, + select: { + amount: true, + timesheet: { + select: { + is_approved: true, + employee: { + select: { + id: true, + user: { + select: { + first_name: true, + last_name: true, + email: true, + } + }, + } + }, + } + }, + bank_code: { select: { categorie: true, modifier: true, type: true } }, + }, }); - } - return by_employee.get(id)!; - }; - for (const shift of shifts) { - const employee = shift.timesheet.employee; - const name = `${employee.user.first_name} ${employee.user.last_name}`.trim(); - const record = ensure(employee.id, name, employee.user.email); + const by_employee = new Map(); - const hours = computeHours(shift.start_time, shift.end_time); - const categorie = (shift.bank_code?.categorie || "REGULAR").toUpperCase(); - switch (categorie) { - case "EVENING": record.evening_hours += hours; break; - case "EMERGENCY": - case "URGENT": record.emergency_hours += hours; break; - case "OVERTIME": record.overtime_hours += hours; break; - default: record.regular_hours += hours; break; - } - record.is_approved = record.is_approved && shift.timesheet.is_approved; + // seed for employee without data + if (options?.seed_names) { + for (const [id, { name, email }] of options.seed_names.entries()) { + by_employee.set(id, { + email, + employee_name: name, + regular_hours: 0, + other_hours: { + evening_hours: 0, + emergency_hours: 0, + overtime_hours: 0, + sick_hours: 0, + holiday_hours: 0, + vacation_hours: 0, + }, + total_hours: 0, + expenses: 0, + mileage: 0, + is_approved: true, + is_remote: true, + }); + } + } + + const ensure = (id: number, name: string, email: string) => { + if (!by_employee.has(id)) { + by_employee.set(id, { + email, + employee_name: name, + regular_hours: 0, + other_hours: { + evening_hours: 0, + emergency_hours: 0, + overtime_hours: 0, + sick_hours: 0, + holiday_hours: 0, + vacation_hours: 0, + }, + total_hours: 0, + expenses: 0, + mileage: 0, + is_approved: true, + is_remote: true, + }); + } + return by_employee.get(id)!; + }; + + for (const shift of shifts) { + const employee = shift.timesheet.employee; + const name = `${employee.user.first_name} ${employee.user.last_name}`.trim(); + const record = ensure(employee.id, name, employee.user.email); + + const hours = computeHours(shift.start_time, shift.end_time); + const type = (shift.bank_code?.type ?? '').toUpperCase(); + switch (type) { + case "EVENING": record.other_hours.evening_hours += hours; + record.total_hours += hours; + break; + case "EMERGENCY": record.other_hours.emergency_hours += hours; + record.total_hours += hours; + break; + case "OVERTIME": record.other_hours.overtime_hours += hours; + record.total_hours += hours; + break; + case "SICK": record.other_hours.sick_hours += hours; + record.total_hours += hours; + break; + case "HOLIDAY": record.other_hours.holiday_hours += hours; + record.total_hours += hours; + break; + case "VACATION": record.other_hours.vacation_hours += hours; + record.total_hours += hours; + break; + case "REGULAR": record.regular_hours += hours; + record.total_hours += hours; + break; + } + + record.is_approved = record.is_approved && shift.timesheet.is_approved; + record.is_remote = record.is_remote || !!shift.is_remote; + } + + for (const expense of expenses) { + const exp = expense.timesheet.employee; + const name = `${exp.user.first_name} ${exp.user.last_name}`.trim(); + const record = ensure(exp.id, name, exp.user.email); + + const amount = toMoney(expense.amount); + record.expenses += amount; + + const type = (expense.bank_code?.type || "").toUpperCase(); + const rate = expense.bank_code?.modifier ?? 0; + if (type === "MILEAGE" && rate > 0) { + record.mileage += Math.round((amount / rate) * 100) / 100; + } + record.is_approved = record.is_approved && expense.timesheet.is_approved; + } + + const employees_overview = Array.from(by_employee.values()).sort((a, b) => + a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), + ); + + return { + pay_period_no: period.period_no, + pay_year: period.pay_year, + payday: toDateString(payd), + period_start: toDateString(start), + period_end: toDateString(end), + label: period.label, + employees_overview, + }; } - for (const expense of expenses) { - const exp = expense.timesheet.employee; - const name = `${exp.user.first_name} ${exp.user.last_name}`.trim(); - const record = ensure(exp.id, name, exp.user.email); - - const amount = toMoney(expense.amount); - record.expenses += amount; - - const categorie = (expense.bank_code?.categorie || "").toUpperCase(); - const rate = expense.bank_code?.modifier ?? 0; - if (categorie === "MILEAGE" && rate > 0) { - record.mileage += amount / rate; - } - record.is_approved = record.is_approved && expense.timesheet.is_approved; + async getSupervisor(email: string) { + return this.prisma.employees.findFirst({ + where: { user: { email } }, + select: { id: true, is_supervisor: true }, + }); } - const employees_overview = Array.from(by_employee.values()).sort((a, b) => - a.employee_name.localeCompare(b.employee_name, "fr", { sensitivity: "base" }), - ); - - return { - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: toDateString(payd), - period_start: toDateString(start), - period_end: toDateString(end), - label: period.label, - employees_overview, - }; - } - - async getSupervisor(email:string) { - return this.prisma.employees.findFirst({ - where: { user: { email } }, - select: { id: true, is_supervisor: true }, - }); - } - - async findAll(): Promise { - const currentPayYear = payYearOfDate(new Date()); - return listPayYear(currentPayYear).map(period =>({ - pay_period_no: period.period_no, - pay_year: period.pay_year, - payday: period.payday, - period_start: period.period_start, - period_end: period.period_end, - label: period.label, - })); - } - - async findOne(period_no: number): Promise { - const row = await this.prisma.payPeriods.findFirst({ - where: { pay_period_no: period_no }, - orderBy: { pay_year: "desc" }, - }); - if (!row) throw new NotFoundException(`Pay period #${period_no} not found`); - return mapPayPeriodToDto(row); - } - - async findCurrent(date?: string): Promise { - const iso_day = date ?? new Date().toISOString().slice(0,10); - return this.findByDate(iso_day); - } - - async findOneByYearPeriod(pay_year: number, period_no: number): Promise { - const row = await this.prisma.payPeriods.findFirst({ - where: { pay_year, pay_period_no: period_no }, - }); - if(row) return mapPayPeriodToDto(row); - - // fallback for outside of view periods - const period = computePeriod(pay_year, period_no); - return { - pay_period_no: period.period_no, - pay_year: period.pay_year, - period_start: period.period_start, - payday: period.payday, - period_end: period.period_end, - label: period.label + async findAll(): Promise { + const currentPayYear = payYearOfDate(new Date()); + return listPayYear(currentPayYear).map(period => ({ + pay_period_no: period.period_no, + pay_year: period.pay_year, + payday: period.payday, + period_start: period.period_start, + period_end: period.period_end, + label: period.label, + //add is_approved + })); } - } - //function to cherry pick a Date to find a period - async findByDate(date: string): Promise { - const dt = new Date(date); - const row = await this.prisma.payPeriods.findFirst({ - where: { period_start: { lte: dt }, period_end: { gte: dt } }, - }); - if(row) return mapPayPeriodToDto(row); - - //fallback for outwside view periods - const pay_year = payYearOfDate(date); - const periods = listPayYear(pay_year); - const hit = periods.find(period => date >= period.period_start && date <= period.period_end); - if(!hit) throw new NotFoundException(`No period found for ${date}`); - - return { - pay_period_no: hit.period_no, - pay_year : hit.pay_year, - period_start : hit.period_start, - period_end : hit.period_end, - payday : hit.payday, - label : hit.label + async findOne(period_no: number): Promise { + const row = await this.prisma.payPeriods.findFirst({ + where: { pay_period_no: period_no }, + orderBy: { pay_year: "desc" }, + }); + if (!row) throw new NotFoundException(`Pay period #${period_no} not found`); + return mapPayPeriodToDto(row); } - } - async getPeriodWindow(pay_year: number, period_no: number) { - return this.prisma.payPeriods.findFirst({ - where: {pay_year, pay_period_no: period_no }, - select: { period_start: true, period_end: true }, - }); - } + async findCurrent(date?: string): Promise { + const iso_day = date ?? new Date().toISOString().slice(0, 10); + return this.findByDate(iso_day); + } + + async findOneByYearPeriod(pay_year: number, period_no: number): Promise { + const row = await this.prisma.payPeriods.findFirst({ + where: { pay_year, pay_period_no: period_no }, + }); + if (row) return mapPayPeriodToDto(row); + + // fallback for outside of view periods + const period = computePeriod(pay_year, period_no); + return { + pay_period_no: period.period_no, + pay_year: period.pay_year, + period_start: period.period_start, + payday: period.payday, + period_end: period.period_end, + label: period.label + } + } + + //function to cherry pick a Date to find a period + async findByDate(date: string): Promise { + const dt = new Date(date); + const row = await this.prisma.payPeriods.findFirst({ + where: { period_start: { lte: dt }, period_end: { gte: dt } }, + }); + if (row) return mapPayPeriodToDto(row); + + //fallback for outwside view periods + const pay_year = payYearOfDate(date); + const periods = listPayYear(pay_year); + const hit = periods.find(period => date >= period.period_start && date <= period.period_end); + if (!hit) throw new NotFoundException(`No period found for ${date}`); + + return { + pay_period_no: hit.period_no, + pay_year: hit.pay_year, + period_start: hit.period_start, + period_end: hit.period_end, + payday: hit.payday, + label: hit.label + } + } + + async getPeriodWindow(pay_year: number, period_no: number) { + return this.prisma.payPeriods.findFirst({ + where: { pay_year, pay_period_no: period_no }, + select: { period_start: true, period_end: true }, + }); + } } diff --git a/src/modules/preferences/controllers/preferences.controller.ts b/src/modules/preferences/controllers/preferences.controller.ts new file mode 100644 index 0000000..ae5af16 --- /dev/null +++ b/src/modules/preferences/controllers/preferences.controller.ts @@ -0,0 +1,14 @@ +import { Body, Controller, Param, Patch } from "@nestjs/common"; +import { PreferencesService } from "../services/preferences.service"; +import { PreferencesDto } from "../dtos/preferences.dto"; + +@Controller('preferences') +export class PreferencesController { + constructor(private readonly service: PreferencesService){} + + @Patch(':email') + async updatePreferences(@Param('email') email: string, @Body()payload: PreferencesDto) { + return this.service.updatePreferences(email, payload); + } + +} \ No newline at end of file diff --git a/src/modules/preferences/dtos/preferences.dto.ts b/src/modules/preferences/dtos/preferences.dto.ts new file mode 100644 index 0000000..5b1377e --- /dev/null +++ b/src/modules/preferences/dtos/preferences.dto.ts @@ -0,0 +1,16 @@ +import { IsInt } from "class-validator"; + +export class PreferencesDto { + + @IsInt() + notifications: number; + + @IsInt() + dark_mode: number; + + @IsInt() + lang_switch: number; + + @IsInt() + lefty_mode: number; +} \ No newline at end of file diff --git a/src/modules/preferences/preferences.module.ts b/src/modules/preferences/preferences.module.ts new file mode 100644 index 0000000..4fe0227 --- /dev/null +++ b/src/modules/preferences/preferences.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common"; +import { PreferencesController } from "./controllers/preferences.controller"; +import { PreferencesService } from "./services/preferences.service"; +import { SharedModule } from "../shared/shared.module"; + +@Module({ + imports: [SharedModule], + controllers: [ PreferencesController ], + providers: [ PreferencesService ], + exports: [ PreferencesService ], +}) + +export class PreferencesModule {} \ No newline at end of file diff --git a/src/modules/preferences/services/preferences.service.ts b/src/modules/preferences/services/preferences.service.ts new file mode 100644 index 0000000..89d6484 --- /dev/null +++ b/src/modules/preferences/services/preferences.service.ts @@ -0,0 +1,27 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Preferences } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; +import { PreferencesDto } from "../dtos/preferences.dto"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; + +@Injectable() +export class PreferencesService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmailToIdResolver , + ){} + + async updatePreferences(email: string, dto: PreferencesDto ): Promise { + const user_id = await this.emailResolver.resolveUserIdWithEmail(email); + return this.prisma.preferences.update({ + where: { user_id }, + data: { + notifications: dto.notifications, + dark_mode: dto.dark_mode, + lang_switch: dto.lang_switch, + lefty_mode: dto.lefty_mode, + }, + include: { user: true }, + }); + } +} \ No newline at end of file diff --git a/src/modules/schedule-presets/controller/schedule-presets.controller.ts b/src/modules/schedule-presets/controller/schedule-presets.controller.ts new file mode 100644 index 0000000..b031c67 --- /dev/null +++ b/src/modules/schedule-presets/controller/schedule-presets.controller.ts @@ -0,0 +1,44 @@ +import { BadRequestException, Body, Controller, Get, NotFoundException, Param, Post, Put, Query } from "@nestjs/common"; +import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto"; +import { SchedulePresetsCommandService } from "../services/schedule-presets-command.service"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; +import { SchedulePresetsQueryService } from "../services/schedule-presets-query.service"; + +@Controller('schedule-presets') +export class SchedulePresetsController { + constructor( + private readonly commandService: SchedulePresetsCommandService, + private readonly queryService: SchedulePresetsQueryService, + ){} + + //used to create, update or delete a schedule preset + @Put(':email') + async upsert( + @Param('email') email: string, + @Query('action') action: UpsertAction, + @Body() dto: SchedulePresetsDto, + ) { + const actions: UpsertAction[] = ['create','update','delete']; + if(!actions) throw new NotFoundException(`No action found for ${actions}`) + return this.commandService.upsertSchedulePreset(email, action, dto); + } + + //used to show the list of available schedule presets + @Get(':email') + async findListByEmail( + @Param('email') email: string, + ) { + return this.queryService.findSchedulePresetsByEmail(email); + } + //used to apply a preset to a timesheet + @Post('/apply-presets/:email') + async applyPresets( + @Param('email') email: string, + @Query('preset') preset_name: string, + @Query('start') start_date: string, + ) { + if(!preset_name?.trim()) throw new BadRequestException('Query "preset" is required'); + if(!start_date?.trim()) throw new BadRequestException('Query "start" is required YYYY-MM-DD'); + return this.applyPresets(email, preset_name, start_date); + } +} \ No newline at end of file diff --git a/src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts b/src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts new file mode 100644 index 0000000..33c06cd --- /dev/null +++ b/src/modules/schedule-presets/dtos/create-schedule-preset-shifts.dto.ts @@ -0,0 +1,26 @@ +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Matches, Min } from "class-validator"; +import { Weekday } from "@prisma/client"; + +export class SchedulePresetShiftsDto { + @IsEnum(Weekday) + week_day!: Weekday; + + @IsInt() + @Min(1) + sort_order!: number; + + @IsString() + type!: string; + + @IsString() + @Matches(HH_MM_REGEX) + start_time!: string; + + @IsString() + @Matches(HH_MM_REGEX) + end_time!: string; + + @IsOptional() + @IsBoolean() + is_remote?: boolean; +} \ No newline at end of file diff --git a/src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts b/src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts new file mode 100644 index 0000000..7bd822f --- /dev/null +++ b/src/modules/schedule-presets/dtos/create-schedule-presets.dto.ts @@ -0,0 +1,15 @@ +import { ArrayMinSize, IsArray, IsBoolean, IsEmail, IsOptional, IsString } from "class-validator"; +import { SchedulePresetShiftsDto } from "./create-schedule-preset-shifts.dto"; + +export class SchedulePresetsDto { + @IsString() + name!: string; + + @IsBoolean() + @IsOptional() + is_default: boolean; + + @IsArray() + @ArrayMinSize(1) + preset_shifts: SchedulePresetShiftsDto[]; +} \ No newline at end of file diff --git a/src/modules/schedule-presets/mappers/schedule-presets.mappers.ts b/src/modules/schedule-presets/mappers/schedule-presets.mappers.ts new file mode 100644 index 0000000..10a9faf --- /dev/null +++ b/src/modules/schedule-presets/mappers/schedule-presets.mappers.ts @@ -0,0 +1,3 @@ +import { Weekday } from "@prisma/client"; + +export const WEEKDAY: Weekday[] = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; \ No newline at end of file diff --git a/src/modules/schedule-presets/schedule-presets.module.ts b/src/modules/schedule-presets/schedule-presets.module.ts new file mode 100644 index 0000000..2e25a6d --- /dev/null +++ b/src/modules/schedule-presets/schedule-presets.module.ts @@ -0,0 +1,23 @@ +import { Module } from "@nestjs/common"; +import { SchedulePresetsCommandService } from "./services/schedule-presets-command.service"; +import { SchedulePresetsQueryService } from "./services/schedule-presets-query.service"; +import { SchedulePresetsController } from "./controller/schedule-presets.controller"; +import { PrismaService } from "src/prisma/prisma.service"; +import { SchedulePresetsApplyService } from "./services/schedule-presets-apply.service"; +import { SharedModule } from "../shared/shared.module"; + +@Module({ + imports: [SharedModule], + controllers: [SchedulePresetsController], + providers: [ + PrismaService, + SchedulePresetsCommandService, + SchedulePresetsQueryService, + SchedulePresetsApplyService, + ], + exports:[ + SchedulePresetsCommandService, + SchedulePresetsQueryService, + SchedulePresetsApplyService, + ], +}) export class SchedulePresetsModule {} \ No newline at end of file diff --git a/src/modules/schedule-presets/services/schedule-presets-apply.service.ts b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts new file mode 100644 index 0000000..fa2bce1 --- /dev/null +++ b/src/modules/schedule-presets/services/schedule-presets-apply.service.ts @@ -0,0 +1,128 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { ApplyResult } from "../types/schedule-presets.types"; +import { Prisma, Weekday } from "@prisma/client"; +import { WEEKDAY } from "../mappers/schedule-presets.mappers"; + +@Injectable() +export class SchedulePresetsApplyService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmailToIdResolver, + ) {} + + async applyToTimesheet( + email: string, + preset_name: string, + start_date_iso: string, + ): Promise { + if(!preset_name?.trim()) throw new BadRequestException('A preset_name is required'); + if(!DATE_ISO_FORMAT.test(start_date_iso)) throw new BadRequestException('start_date must be of format :YYYY-MM-DD'); + + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + + const preset = await this.prisma.schedulePresets.findFirst({ + where: { employee_id, name: preset_name }, + include: { + shifts: { + orderBy: [{ week_day: 'asc'}, { sort_order: 'asc'}], + select: { + week_day: true, + sort_order: true, + start_time: true, + end_time: true, + is_remote: true, + bank_code_id: true, + }, + }, + }, + }); + if(!preset) throw new NotFoundException(`Preset ${preset} not found`); + + const start_date = new Date(`${start_date_iso}T00:00:00.000Z`); + const timesheet = await this.prisma.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date: start_date} }, + update: {}, + create: { employee_id, start_date: start_date }, + select: { id: true }, + }); + + //index shifts by weekday + const index_by_day = new Map(); + for (const shift of preset.shifts) { + const list = index_by_day.get(shift.week_day) ?? []; + list.push(shift); + index_by_day.set(shift.week_day, list); + } + + const addDays = (date: Date, days: number) => + new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days)); + + const overlaps = (aStart: Date, aEnd: Date, bStart: Date, bEnd: Date) => + aStart.getTime() < bEnd.getTime() && aEnd.getTime() > bStart.getTime(); + + let created = 0; + let skipped = 0; + + await this.prisma.$transaction(async (tx) => { + for(let i = 0; i < 7; i++) { + const date = addDays(start_date, i); + const week_day = WEEKDAY[date.getUTCDay()]; + const shifts = index_by_day.get(week_day) ?? []; + + if(shifts.length === 0) continue; + + const existing = await tx.shifts.findMany({ + where: { timesheet_id: timesheet.id, date: date }, + orderBy: { start_time: 'asc' }, + select: { + start_time: true, + end_time: true, + bank_code_id: true, + is_remote: true, + comment: true, + }, + }); + + const payload: Prisma.ShiftsCreateManyInput[] = []; + + for(const shift of shifts) { + if(shift.end_time.getTime() <= shift.start_time.getTime()) { + throw new ConflictException(`Invalid time range in preset day: ${week_day}, order: ${shift.sort_order}`); + } + const conflict = existing.find((existe)=> overlaps( + shift.start_time, shift.end_time , + existe.start_time, existe.end_time, + )); + if(conflict) { + throw new ConflictException({ + error_code: 'SHIFT_OVERLAP_WITH_EXISTING', + mesage: `Preset shift overlaps existing shift on ${start_date} + ${i}(week day ${week_day})`, + conflict: { + existing_start: conflict.start_time.toISOString().slice(11,16), + existing_end: conflict.end_time.toISOString().slice(11,16), + }, + }); + } + payload.push({ + timesheet_id: timesheet.id, + date: date, + start_time: shift.start_time, + end_time: shift.end_time, + is_remote: shift.is_remote, + comment: null, + bank_code_id: shift.bank_code_id, + }); + } + if(payload.length) { + const response = await tx.shifts.createMany({ data: payload, skipDuplicates: true }); + created += response.count; + skipped += payload.length - response.count; + } + } + }); + return { timesheet_id: timesheet.id, created, skipped }; + } +} \ No newline at end of file diff --git a/src/modules/schedule-presets/services/schedule-presets-command.service.ts b/src/modules/schedule-presets/services/schedule-presets-command.service.ts new file mode 100644 index 0000000..0c2a8bb --- /dev/null +++ b/src/modules/schedule-presets/services/schedule-presets-command.service.ts @@ -0,0 +1,238 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { UpsertAction } from "src/modules/shared/types/upsert-actions.types"; +import { PrismaService } from "src/prisma/prisma.service"; +import { SchedulePresetsDto } from "../dtos/create-schedule-presets.dto"; +import { Prisma, Weekday } from "@prisma/client"; + +@Injectable() +export class SchedulePresetsCommandService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmailToIdResolver, + private readonly typeResolver : BankCodesResolver, + ){} + + //_________________________________________________________________ + // MASTER CRUD FUNCTION + //_________________________________________________________________ + async upsertSchedulePreset( + email: string, + action: UpsertAction, + dto: SchedulePresetsDto, + ): Promise<{ + action: UpsertAction; + preset_id?: number; + total_items?: number; + }>{ + if(!dto.name?.trim()) throw new BadRequestException(`A Name is required`); + + //resolve employee_id using email + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`employee with email: ${email} not found`); + + //DELETE + if(action === 'delete') { + return this.deletePreset(employee_id, dto.name); + } + + if(!Array.isArray(dto.preset_shifts) || dto.preset_shifts.length === 0) { + throw new BadRequestException(`Empty array, no detected shifts`); + } + const shifts_data = await this.resolveAndBuildPresetShifts(dto); + + //CREATE AND UPDATE + if(action === 'create') { + return this.createPreset(employee_id, dto, shifts_data); + } else if (action === 'update') { + return this.updatePreset(employee_id, dto, shifts_data); + } + throw new BadRequestException(`Unknown action: ${ action }`); + } + + //_________________________________________________________________ + // CREATE + //_________________________________________________________________ + private async createPreset( + employee_id: number, + dto: SchedulePresetsDto, + shifts_data: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[], + ): Promise<{ + action: UpsertAction; + preset_id: number; + total_items: number; + }> { + try { + const result = await this.prisma.$transaction(async (tx)=> { + if(dto.is_default) { + await tx.schedulePresets.updateMany({ + where: { employee_id, is_default: true }, + data: { is_default: false }, + }); + } + const created = await tx.schedulePresets.create({ + data: { + employee_id, + name: dto.name, + is_default: !!dto.is_default, + shifts: { create: shifts_data}, + }, + include: { shifts: true }, + }); + return created; + }); + return { action: 'create', preset_id: result.id, total_items: result.shifts.length }; + } catch (error: unknown) { + if(error instanceof Prisma.PrismaClientKnownRequestError){ + if(error?.code === 'P2002') { + throw new ConflictException(`The name ${dto.name} is already used for another schedule preset`); + } + if (error.code === 'P2003' || error.code === 'P2011') { + throw new ConflictException('Invalid constraint on preset shifts'); + } + } + throw error; + } + } + + //_________________________________________________________________ + // UPDATE + //_________________________________________________________________ + private async updatePreset( + employee_id: number, + dto: SchedulePresetsDto, + shifts_data: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[], + ): Promise<{ + action: UpsertAction; + preset_id?: number; + total_items?: number; + }> { + const existing = await this.prisma.schedulePresets.findFirst({ + where: { employee_id, name: dto.name }, + select: { id:true, is_default: true }, + }); + if(!existing) throw new NotFoundException(`Preset "${dto.name}" not found`); + + try { + const result = await this.prisma.$transaction(async (tx) => { + if(typeof dto.is_default === 'boolean'){ + if(dto.is_default) { + await tx.schedulePresets.updateMany({ + where: { employee_id, is_default: true, NOT: { id: existing.id } }, + data: { is_default: false }, + }); + } + await tx.schedulePresets.update({ + where: { id: existing.id }, + data: { is_default: dto.is_default }, + }); + } + + await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } }); + + const create_many_data: Prisma.SchedulePresetShiftsCreateManyInput[] = + shifts_data.map((shift)=> { + if(!shift.bank_code || !('connect' in shift.bank_code) || typeof shift.bank_code.connect?.id !=='number'){ + throw new NotFoundException(`Bank code is required for updates( ${shift.week_day}, ${shift.sort_order})`); + } + const bank_code_id = shift.bank_code.connect.id; + return { + preset_id: existing.id, + week_day: shift.week_day, + sort_order: shift.sort_order, + start_time: shift.start_time, + end_time: shift.end_time, + is_remote: shift.is_remote ?? false, + bank_code_id: bank_code_id, + }; + }); + await tx.schedulePresetShifts.createMany({data: create_many_data}); + + const count = await tx.schedulePresetShifts.count({ where: { preset_id: existing.id } }); + return { id: existing.id, total: count }; + }); + return { action: 'update', preset_id: result.id, total_items: result.total }; + } catch (error: unknown){ + if(error instanceof Prisma.PrismaClientKnownRequestError){ + if(error?.code === 'P2003' || error?.code === 'P2011') { + throw new ConflictException(`Invalid constraint on preset shifts`); + } + } + throw error; + } + } + + //_________________________________________________________________ + // DELETE + //_________________________________________________________________ + private async deletePreset( + employee_id: number, + name: string, + ): Promise<{ + action: UpsertAction; + preset_id?: number; + total_items?: number; + }> { + const existing = await this.prisma.schedulePresets.findFirst({ + where: { employee_id, name }, + select: { id: true }, + }); + if(!existing) throw new NotFoundException(`Preset "${name}" not found`); + await this.prisma.$transaction(async (tx) => { + await tx.schedulePresetShifts.deleteMany({ where: { preset_id: existing.id } }); + await tx.schedulePresets.delete({where: { id: existing.id } }); + }); + return { action: 'delete', preset_id: existing.id, total_items: 0 }; + } + + //PRIVATE HELPER + //resolve bank_code_id using type and convert hours to TIME and valid shifts end/start + private async resolveAndBuildPresetShifts( + dto: SchedulePresetsDto + ): Promise{ + + if(!dto.preset_shifts?.length) throw new NotFoundException(`Empty or preset shifts not found`); + + const types = Array.from(new Set(dto.preset_shifts.map((shift)=> shift.type))); + const bank_code_set = new Map(); + + for (const type of types) { + const { id } = await this.typeResolver.findByType(type); + bank_code_set.set(type, id) + } + const toTime = (hhmm: string) => new Date(`1970-01-01T${hhmm}:00.000Z`); + + const pair_set = new Set(); + for (const shift of dto.preset_shifts) { + const key = `${shift.week_day}:${shift.sort_order}`; + if (pair_set.has(key)) { + throw new ConflictException(`Duplicate shift for day/order (${shift.week_day}, ${shift.sort_order})`); + } + pair_set.add(key); + } + + const items: Prisma.SchedulePresetShiftsCreateWithoutPresetInput[] = dto.preset_shifts.map((shift)=> { + const bank_code_id = bank_code_set.get(shift.type); + if(!bank_code_id) throw new NotFoundException(`Bank code not found for type ${shift.type}`); + if (!shift.start_time || !shift.end_time) { + throw new BadRequestException(`start_time and end_time are required for (${shift.week_day}, ${shift.sort_order})`); + } + const start = toTime(shift.start_time); + const end = toTime(shift.end_time); + if(end.getTime() <= start.getTime()) { + throw new ConflictException(`end_time must be > start_time ( day: ${shift.week_day}, order: ${shift.sort_order})`); + } + + return { + week_day: shift.week_day as Weekday, + sort_order: shift.sort_order, + bank_code: { connect: { id: bank_code_id} }, + start_time: start, + end_time: end, + is_remote: !!shift.is_remote, + }; + }); + return items; + } +} diff --git a/src/modules/schedule-presets/services/schedule-presets-query.service.ts b/src/modules/schedule-presets/services/schedule-presets-query.service.ts new file mode 100644 index 0000000..7ccb0f0 --- /dev/null +++ b/src/modules/schedule-presets/services/schedule-presets-query.service.ts @@ -0,0 +1,51 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { PrismaService } from "src/prisma/prisma.service"; +import { PresetResponse, ShiftResponse } from "../types/schedule-presets.types"; +import { Prisma } from "@prisma/client"; + +@Injectable() +export class SchedulePresetsQueryService { + constructor( + private readonly prisma: PrismaService, + private readonly emailResolver: EmailToIdResolver, + ){} + + async findSchedulePresetsByEmail(email:string): Promise { + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + + try { + const presets = await this.prisma.schedulePresets.findMany({ + where: { employee_id }, + orderBy: [{is_default: 'desc' }, { name: 'asc' }], + include: { + shifts: { + orderBy: [{week_day:'asc'}, { sort_order: 'asc'}], + include: { bank_code: { select: { type: true } } }, + }, + }, + }); + const hhmm = (date: Date) => date.toISOString().slice(11,16); + + const response: PresetResponse[] = presets.map((preset) => ({ + id: preset.id, + name: preset.name, + is_default: preset.is_default, + shifts: preset.shifts.map((shift)=> ({ + week_day: shift.week_day, + sort_order: shift.sort_order, + start_time: hhmm(shift.start_time), + end_time: hhmm(shift.end_time), + is_remote: shift.is_remote, + type: shift.bank_code?.type, + })), + })); + return response; + } catch ( error: unknown) { + if(error instanceof Prisma.PrismaClientKnownRequestError) {} + throw error; + } + } + +} \ No newline at end of file diff --git a/src/modules/schedule-presets/types/schedule-presets.types.ts b/src/modules/schedule-presets/types/schedule-presets.types.ts new file mode 100644 index 0000000..ea2a3cd --- /dev/null +++ b/src/modules/schedule-presets/types/schedule-presets.types.ts @@ -0,0 +1,21 @@ +export type ShiftResponse = { + week_day: string; + sort_order: number; + start_time: string; + end_time: string; + is_remote: boolean; + type: string; +}; + +export type PresetResponse = { + id: number; + name: string; + is_default: boolean; + shifts: ShiftResponse[]; +} + +export type ApplyResult = { + timesheet_id: number; + created: number; + skipped: number; +} \ No newline at end of file diff --git a/src/modules/shared/constants/regex.constant.ts b/src/modules/shared/constants/regex.constant.ts new file mode 100644 index 0000000..30f77c1 --- /dev/null +++ b/src/modules/shared/constants/regex.constant.ts @@ -0,0 +1,2 @@ +const HH_MM_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; +const DATE_ISO_FORMAT = /^\d{4}-\d{2}-\d{2}$/; \ No newline at end of file diff --git a/src/modules/shared/helpers/date-time.helpers.ts b/src/modules/shared/helpers/date-time.helpers.ts new file mode 100644 index 0000000..6716321 --- /dev/null +++ b/src/modules/shared/helpers/date-time.helpers.ts @@ -0,0 +1,34 @@ +import { BadRequestException } from "@nestjs/common"; + +export const hhmmFromLocal = (d: Date) => + `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; + +export const toDateOnly = (s: string): Date => { + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) { + const y = Number(s.slice(0,4)); + const m = Number(s.slice(5,7)) - 1; + const d = Number(s.slice(8,10)); + return new Date(y, m, d, 0, 0, 0, 0); + } + const dt = new Date(s); + if (Number.isNaN(dt.getTime())) throw new BadRequestException(`Invalid date: ${s}`); + return new Date(dt.getFullYear(), dt.getMonth(), dt.getDate(), 0,0,0,0); +}; + +export const toStringFromDate = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; + + +export const toISOtoDateOnly = (iso: string): Date => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`Invalid date: ${iso}`); + } + date.setHours(0, 0, 0, 0); + return date; +}; + +export const toISODateKey = (date: Date): string => date.toISOString().slice(0, 10); + +export const normalizeDates = (dates: string[]): string[] => + Array.from(new Set(dates.map((iso) => toISODateKey(toISOtoDateOnly(iso))))); \ No newline at end of file diff --git a/src/modules/shared/shared.module.ts b/src/modules/shared/shared.module.ts new file mode 100644 index 0000000..adf0b68 --- /dev/null +++ b/src/modules/shared/shared.module.ts @@ -0,0 +1,22 @@ +import { Module } from "@nestjs/common"; +import { EmailToIdResolver } from "./utils/resolve-email-id.utils"; +import { EmployeeTimesheetResolver } from "./utils/resolve-employee-timesheet.utils"; +import { FullNameResolver } from "./utils/resolve-full-name.utils"; +import { BankCodesResolver } from "./utils/resolve-bank-type-id.utils"; +import { PrismaModule } from "src/prisma/prisma.module"; + +@Module({ +imports: [PrismaModule], +providers: [ + FullNameResolver, + EmailToIdResolver, + BankCodesResolver, + EmployeeTimesheetResolver, +], +exports: [ + FullNameResolver, + EmailToIdResolver, + BankCodesResolver, + EmployeeTimesheetResolver, +], +}) export class SharedModule {} \ No newline at end of file diff --git a/src/modules/shared/types/upsert-actions.types.ts b/src/modules/shared/types/upsert-actions.types.ts new file mode 100644 index 0000000..9342d75 --- /dev/null +++ b/src/modules/shared/types/upsert-actions.types.ts @@ -0,0 +1 @@ +export type UpsertAction = 'create' | 'update' | 'delete'; \ No newline at end of file diff --git a/src/modules/shared/utils/resolve-bank-type-id.utils.ts b/src/modules/shared/utils/resolve-bank-type-id.utils.ts new file mode 100644 index 0000000..039543f --- /dev/null +++ b/src/modules/shared/utils/resolve-bank-type-id.utils.ts @@ -0,0 +1,23 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class BankCodesResolver { + constructor(private readonly prisma: PrismaService) {} + + //find id and modifier by type + readonly findByType = async ( type: string, client?: Tx + ): Promise<{id:number; modifier: number }> => { + const db = client ?? this.prisma; + const bank = await db.bankCodes.findFirst({ + where: { type }, + select: { id: true, modifier: true }, + }); + + if(!bank) throw new NotFoundException(`Unknown bank code type: ${type}`); + return { id: bank.id, modifier: bank.modifier }; + }; +} \ No newline at end of file diff --git a/src/modules/shared/utils/resolve-email-id.utils.ts b/src/modules/shared/utils/resolve-email-id.utils.ts new file mode 100644 index 0000000..543f377 --- /dev/null +++ b/src/modules/shared/utils/resolve-email-id.utils.ts @@ -0,0 +1,35 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class EmailToIdResolver { + + constructor(private readonly prisma: PrismaService) {} + + // find employee_id using email + readonly findIdByEmail = async ( email: string, client?: Tx + ): Promise => { + const db = client ?? this.prisma; + const employee = await db.employees.findFirst({ + where: { user: { email } }, + select: { id: true }, + }); + if(!employee)throw new NotFoundException(`Employee with email: ${email} not found`); + return employee.id; + } + + // find user_id using email + readonly resolveUserIdWithEmail = async (email: string, client?: Tx + ): Promise => { + const db = client ?? this.prisma; + const user = await db.users.findFirst({ + where: { email }, + select: { id: true }, + }); + if(!user) throw new NotFoundException(`User with email ${ email } not found`); + return user.id; + } +} \ No newline at end of file diff --git a/src/modules/shared/utils/resolve-employee-timesheet.utils.ts b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts new file mode 100644 index 0000000..eb3b305 --- /dev/null +++ b/src/modules/shared/utils/resolve-employee-timesheet.utils.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { weekStartSunday } from "src/modules/shifts/helpers/shifts-date-time-helpers"; +import { PrismaService } from "src/prisma/prisma.service"; + + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class EmployeeTimesheetResolver { + constructor(private readonly prisma: PrismaService) {} + + //find an existing timesheet linked to the employee + readonly ensureForDate = async (employee_id: number, date: Date, client?: Tx, + ): Promise<{id: number; start_date: Date }> => { + const db = client ?? this.prisma; + const startOfWeek = weekStartSunday(date); + const existing = await db.timesheets.findFirst({ + where: { + employee_id: employee_id, + start_date: startOfWeek, + }, + select: { + id: true, + start_date: true, + }, + }); + if(existing) return existing; + + const created = await db.timesheets.create({ + data: { + employee_id: employee_id, + start_date: startOfWeek, + }, + select: { + id: true, + start_date: true, + }, + }); + return created; + } +} \ No newline at end of file diff --git a/src/modules/shared/utils/resolve-full-name.utils.ts b/src/modules/shared/utils/resolve-full-name.utils.ts new file mode 100644 index 0000000..ef6669b --- /dev/null +++ b/src/modules/shared/utils/resolve-full-name.utils.ts @@ -0,0 +1,22 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class FullNameResolver { + constructor(private readonly prisma: PrismaService){} + + readonly resolveFullName = async (employee_id: number, client?: Tx): Promise =>{ + const db = client ?? this.prisma; + const employee = await db.employees.findUnique({ + where: { id: employee_id }, + select: { user: { select: {first_name: true, last_name: true} } }, + }); + if(!employee) throw new NotFoundException(`Unknown user with name: ${employee_id}`) + + const full_name = ( employee.user.first_name + " " + employee.user.last_name ) || " "; + return full_name; + } +} \ No newline at end of file diff --git a/src/modules/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index e1c4292..45545dd 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -1,14 +1,12 @@ -import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; -import { Shifts } from "@prisma/client"; -import { CreateShiftDto } from "../dtos/create-shift.dto"; -import { UpdateShiftsDto } from "../dtos/update-shift.dto"; +import { Body, Controller, 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, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { ShiftsCommandService } from "../services/shifts-command.service"; -import { SearchShiftsDto } from "../dtos/search-shift.dto"; -import { OverviewRow, ShiftsQueryService } from "../services/shifts-query.service"; +import { ShiftsQueryService } from "../services/shifts-query.service"; import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; +import { UpsertShiftDto } from "../dtos/upsert-shift.dto"; +import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface"; @ApiTags('Shifts') @ApiBearerAuth('access-token') @@ -17,73 +15,33 @@ import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto"; export class ShiftsController { constructor( private readonly shiftsService: ShiftsQueryService, - private readonly shiftsApprovalService: ShiftsCommandService, - private readonly shiftsValidationService: ShiftsQueryService, + private readonly shiftsCommandService: ShiftsCommandService, ){} - - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Create shift' }) - @ApiResponse({ status: 201, description: 'Shift created',type: CreateShiftDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateShiftDto): Promise { - return this.shiftsService.create(dto); - } - @Get() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find all shifts' }) - @ApiResponse({ status: 201, description: 'List of shifts found',type: CreateShiftDto, isArray: true }) - @ApiResponse({ status: 400, description: 'List of shifts not found' }) - @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) - findAll(@Query() filters: SearchShiftsDto) { - return this.shiftsService.findAll(filters); - } - - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find shift' }) - @ApiResponse({ status: 201, description: 'Shift found',type: CreateShiftDto }) - @ApiResponse({ status: 400, description: 'Shift not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.shiftsService.findOne(id); - } - - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Update shift' }) - @ApiResponse({ status: 201, description: 'Shift updated',type: CreateShiftDto }) - @ApiResponse({ status: 400, description: 'Shift not found' }) - update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise { - return this.shiftsService.update(id, dto); - } - - @Delete(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Delete shift' }) - @ApiResponse({ status: 201, description: 'Shift deleted',type: CreateShiftDto }) - @ApiResponse({ status: 400, description: 'Shift not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.shiftsService.remove(id); + @Put('upsert/:email') + async upsert_by_date( + @Param('email') email_param: string, + @Body() payload: UpsertShiftDto, + ) { + return this.shiftsCommandService.upsertShiftsByDate(email_param, 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.shiftsApprovalService.updateApproval(id, isApproved); + return this.shiftsCommandService.updateApproval(id, isApproved); } @Get('summary') async getSummary( @Query() query: GetShiftsOverviewDto): Promise { - return this.shiftsValidationService.getSummary(query.period_id); + 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{ - const rows = await this.shiftsValidationService.getSummary(query.period_id); - + const rows = await this.shiftsService.getSummary(query.period_id); //CSV Headers const header = [ 'full_name', @@ -108,11 +66,61 @@ export class ShiftsController { r.total_overtime_hrs.toFixed(2), r.total_expenses.toFixed(2), r.total_mileage.toFixed(2), - r.is_validated, + r.is_approved, ].join(','); }).join('\n'); return Buffer.from('\uFEFF' + header + body, 'utf8'); } + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @Post() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Create shift' }) + // @ApiResponse({ status: 201, description: 'Shift created',type: CreateShiftDto }) + // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) + // create(@Body() dto: CreateShiftDto): Promise { + // return this.shiftsService.create(dto); + // } + + // @Get() + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find all shifts' }) + // @ApiResponse({ status: 201, description: 'List of shifts found',type: CreateShiftDto, isArray: true }) + // @ApiResponse({ status: 400, description: 'List of shifts not found' }) + // @UsePipes(new ValidationPipe({ transform: true, whitelist: true })) + // findAll(@Query() filters: SearchShiftsDto) { + // return this.shiftsService.findAll(filters); + // } + + // @Get(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find shift' }) + // @ApiResponse({ status: 201, description: 'Shift found',type: CreateShiftDto }) + // @ApiResponse({ status: 400, description: 'Shift not found' }) + // findOne(@Param('id', ParseIntPipe) id: number): Promise { + // return this.shiftsService.findOne(id); + // } + + // @Patch(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Update shift' }) + // @ApiResponse({ status: 201, description: 'Shift updated',type: CreateShiftDto }) + // @ApiResponse({ status: 400, description: 'Shift not found' }) + // update(@Param('id', ParseIntPipe) id: number,@Body() dto: UpdateShiftsDto): Promise { + // return this.shiftsService.update(id, dto); + // } + + // @Delete(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Delete shift' }) + // @ApiResponse({ status: 201, description: 'Shift deleted',type: CreateShiftDto }) + // @ApiResponse({ status: 400, description: 'Shift not found' }) + // remove(@Param('id', ParseIntPipe) id: number): Promise { + // return this.shiftsService.remove(id); + // } } \ No newline at end of file diff --git a/src/modules/shifts/dtos/create-shift.dto.ts b/src/modules/shifts/dtos/create-shift.dto.ts index c3451d2..0fa93ab 100644 --- a/src/modules/shifts/dtos/create-shift.dto.ts +++ b/src/modules/shifts/dtos/create-shift.dto.ts @@ -48,5 +48,5 @@ export class CreateShiftDto { end_time: string; @IsString() - description: string; + comment: string; } diff --git a/src/modules/shifts/dtos/search-shift.dto.ts b/src/modules/shifts/dtos/search-shift.dto.ts index 4693a51..233a9b6 100644 --- a/src/modules/shifts/dtos/search-shift.dto.ts +++ b/src/modules/shifts/dtos/search-shift.dto.ts @@ -14,7 +14,7 @@ export class SearchShiftsDto { @IsOptional() @IsString() - description_contains?: string; + comment_contains?: string; @IsOptional() @IsDateString() diff --git a/src/modules/shifts/dtos/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts new file mode 100644 index 0000000..7809571 --- /dev/null +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -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; +}; \ No newline at end of file diff --git a/src/modules/shifts/helpers/shifts-date-time-helpers.ts b/src/modules/shifts/helpers/shifts-date-time-helpers.ts new file mode 100644 index 0000000..d5ba369 --- /dev/null +++ b/src/modules/shifts/helpers/shifts-date-time-helpers.ts @@ -0,0 +1,25 @@ +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(dateLocal: Date): Date { + const start = new Date(dateLocal.getFullYear(), dateLocal.getMonth(), dateLocal.getDate()); + const dow = start.getDay(); // 0 = dimanche + start.setDate(start.getDate() - dow); + start.setHours(0, 0, 0, 0); + return start; +} + +export function formatHHmm(t: Date): string { + const hh = String(t.getHours()).padStart(2, '0'); + const mm = String(t.getMinutes()).padStart(2, '0'); + return `${hh}:${mm}`; +} diff --git a/src/modules/shifts/services/shifts-archival.service.ts b/src/modules/shifts/services/shifts-archival.service.ts new file mode 100644 index 0000000..667ba3a --- /dev/null +++ b/src/modules/shifts/services/shifts-archival.service.ts @@ -0,0 +1,59 @@ +import { ShiftsArchive } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + +export class ShiftsArchivalService { + constructor(private readonly prisma: PrismaService){} + + async archiveOld(): Promise { + //fetches archived timesheet's Ids + const archived_timesheets = await this.prisma.timesheetsArchive.findMany({ + select: { timesheet_id: true }, + }); + + const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id); + if(timesheet_ids.length === 0) { + return; + } + + // copy/delete transaction + await this.prisma.$transaction(async transaction => { + //fetches shifts to move to archive + const shifts_to_archive = await transaction.shifts.findMany({ + where: { timesheet_id: { in: timesheet_ids } }, + }); + if(shifts_to_archive.length === 0) { + return; + } + + //copies sent to archive table + await transaction.shiftsArchive.createMany({ + data: shifts_to_archive.map(shift => ({ + shift_id: shift.id, + timesheet_id: shift.timesheet_id, + bank_code_id: shift.bank_code_id, + comment: shift.comment ?? undefined, + date: shift.date, + start_time: shift.start_time, + end_time: shift.end_time, + })), + }); + + //delete from shifts table + await transaction.shifts.deleteMany({ + where: { id: { in: shifts_to_archive.map(shift => shift.id) } }, + }) + + }) + } + + //fetches all archived timesheets + async findAllArchived(): Promise { + return this.prisma.shiftsArchive.findMany(); + } + + //fetches an archived timesheet + async findOneArchived(id: number): Promise { + return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } }); + } + +} \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index c3de439..85e79a1 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -1,12 +1,29 @@ -import { Injectable } from "@nestjs/common"; -import { Prisma, Shifts } from "@prisma/client"; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException, UnprocessableEntityException } from "@nestjs/common"; +import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils"; +import { DayShiftResponse, UpsertAction } from "../types-and-interfaces/shifts-upsert.types"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-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 { PrismaService } from "src/prisma/prisma.service"; +import { OvertimeService } from "src/modules/business-logics/services/overtime.service"; +import { formatHHmm, toDateOnly, weekStartSunday } from "../helpers/shifts-date-time-helpers"; @Injectable() export class ShiftsCommandService extends BaseApprovalService { - constructor(prisma: PrismaService) { super(prisma); } + private readonly logger = new Logger(ShiftsCommandService.name); + constructor( + prisma: PrismaService, + private readonly emailResolver: EmailToIdResolver, + private readonly bankTypeResolver: BankCodesResolver, + private readonly overtimeService: OvertimeService, + ) { super(prisma); } + +//_____________________________________________________________________________________________ +// APPROVAL AND DELEGATE METHODS +//_____________________________________________________________________________________________ protected get delegate() { return this.prisma.shifts; } @@ -21,16 +38,196 @@ export class ShiftsCommandService extends BaseApprovalService { ); } - // deprecated since batch transaction are made with timesheets - // async updateManyWithTx( - // tx: Prisma.TransactionClient, - // ids: number[], - // isApproved: boolean, - // ): Promise { - // const { count } = await tx.shifts.updateMany({ - // where: { id: { in: ids } }, - // data: { is_approved: isApproved }, - // }); - // return count; - // } +//_____________________________________________________________________________________________ +// MASTER CRUD METHOD +//_____________________________________________________________________________________________ + async upsertShiftsByDate(email:string, dto: UpsertShiftDto): + Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { + const { old_shift, new_shift } = dto; + + if(!dto.old_shift && !dto.new_shift) { + throw new BadRequestException('At least one of old or new shift must be provided'); + } + + const date = new_shift?.date ?? old_shift?.date; + if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift"); + if (old_shift?.date + && new_shift?.date + && old_shift.date + !== new_shift.date) throw new BadRequestException("old_shift.date and new_shift.date must be identical"); + + const date_only = toDateOnly(date); + const employee_id = await this.emailResolver.findIdByEmail(email); + + return this.prisma.$transaction(async (tx) => { + const start_of_week = weekStartSunday(date_only); + + const timesheet = await tx.timesheets.upsert({ + where: { employee_id_start_date: { employee_id, start_date: start_of_week } }, + update: {}, + create: { employee_id, start_date: start_of_week }, + select: { id: true }, + }); + + //validation/sanitation + //resolve bank_code_id using type + const old_norm_shift = old_shift ? await normalizeShiftPayload(old_shift) : undefined; + if (old_norm_shift && old_norm_shift.end_time.getTime() <= old_norm_shift.start_time.getTime()) { + throw new UnprocessableEntityException(' old_shift.end_time must be > old_shift.start_time'); + } + const old_bank_code_id: number | undefined = old_norm_shift ? (await this.bankTypeResolver.findByType(old_norm_shift.type, tx))?.id : undefined; + + + const new_norm_shift = new_shift ? await normalizeShiftPayload(new_shift) : undefined; + if (new_norm_shift && new_norm_shift.end_time.getTime() <= new_norm_shift.start_time.getTime()) { + throw new UnprocessableEntityException(' new_shift.end_time must be > new_shift.start_time'); + } + const new_bank_code_id: number | undefined = new_norm_shift ? (await this.bankTypeResolver.findByType(new_norm_shift.type, tx))?.id : undefined; + + + //fetch all shifts in a single day and verify possible overlaps + const day_shifts = await tx.shifts.findMany({ + where: { timesheet_id: timesheet.id, date: date_only }, + include: { bank_code: true }, + orderBy: { start_time: 'asc'}, + }); + + + const findExactOldShift = async ()=> { + if(!old_norm_shift || old_bank_code_id === undefined) return undefined; + const old_comment = old_norm_shift.comment ?? null; + + return await tx.shifts.findFirst({ + where: { + timesheet_id: timesheet.id, + date: date_only, + start_time: old_norm_shift.start_time, + end_time: old_norm_shift.end_time, + is_remote: old_norm_shift.is_remote, + is_approved: old_norm_shift.is_approved, + comment: old_comment, + bank_code_id: old_bank_code_id, + }, + select: { id: true }, + }); + }; + + //checks for overlaping shifts + const assertNoOverlap = (exclude_shift_id?: number)=> { + if (!new_norm_shift) return; + const overlap_with = day_shifts.filter((shift)=> { + if(exclude_shift_id && shift.id === exclude_shift_id) return false; + return overlaps( + new_norm_shift.start_time.getTime(), + new_norm_shift.end_time.getTime(), + shift.start_time.getTime(), + shift.end_time.getTime(), + ); + }); + + if(overlap_with.length > 0) { + const conflicts = overlap_with.map((shift)=> ({ + start_time: formatHHmm(shift.start_time), + end_time: formatHHmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + })); + throw new ConflictException({ error_code: 'SHIFT_OVERLAP', message: 'New shift overlaps with existing shift(s)', conflicts}); + } + }; + let action: UpsertAction; + //_____________________________________________________________________________________________ + // DELETE + //_____________________________________________________________________________________________ + if ( old_shift && !new_shift ) { + if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`); + const existing = await findExactOldShift(); + if(!existing) { + throw new NotFoundException({ + error_code: 'SHIFT_STALE', + message: 'The shift was modified or deleted by someone else', + }); + } + await tx.shifts.delete({ where: { id: existing.id } } ); + action = 'deleted'; + } + //_____________________________________________________________________________________________ + // CREATE + //_____________________________________________________________________________________________ + else if (!old_shift && new_shift) { + if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); + assertNoOverlap(); + 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, + comment: new_norm_shift!.comment ?? null, + bank_code_id: new_bank_code_id!, + }, + }); + action = 'created'; + } + //_____________________________________________________________________________________________ + // UPDATE + //_____________________________________________________________________________________________ + else if (old_shift && new_shift){ + if (old_bank_code_id === undefined) throw new NotFoundException(`bank code not found for old_shift.type: ${old_norm_shift?.type ?? ''}`); + if (new_bank_code_id === undefined) throw new NotFoundException(`bank code not found for new_shift.type: ${new_norm_shift?.type ?? ''}`); + const existing = await findExactOldShift(); + if(!existing) throw new NotFoundException({ error_code: 'SHIFT_STALE', message: 'The shift was modified or deleted by someone else'}); + assertNoOverlap(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, + }, + }); + action = 'updated'; + } else throw new BadRequestException('At least one of old_shift or new_shift must be provided'); + + //switches regular hours to overtime hours when exceeds 40hrs per week. + await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx); + + //Reload the day (truth source) + const fresh_day = await tx.shifts.findMany({ + where: { + date: date_only, + timesheet_id: timesheet.id, + }, + include: { bank_code: true }, + orderBy: { start_time: 'asc' }, + }); + + try { + const [ daily_overtime, weekly_overtime ] = await Promise.all([ + this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only), + this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only), + ]); + this.logger.debug(`[OVERTIME] employee_id= ${employee_id}, date=${date_only.toISOString().slice(0,10)} + | daily= ${daily_overtime.toFixed(2)}h, weekly: ${weekly_overtime.toFixed(2)}h, (action:${action})`); + } catch (error) { + this.logger.warn(`Failed to compute overtime after ${action} : ${(error as Error).message}`); + } + + return { + action, + day: fresh_day.map((shift) => ({ + start_time: formatHHmm(shift.start_time), + end_time: formatHHmm(shift.end_time), + type: shift.bank_code?.type ?? 'UNKNOWN', + is_remote: shift.is_remote, + comment: shift.comment ?? null, + })), + }; + }); + } } \ No newline at end of file diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index f1bb5f7..bfe3fe8 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -1,25 +1,10 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/prisma/prisma.service"; -import { CreateShiftDto } from "../dtos/create-shift.dto"; -import { Shifts, ShiftsArchive } from "@prisma/client"; -import { UpdateShiftsDto } from "../dtos/update-shift.dto"; -import { buildPrismaWhere } from "src/common/shared/build-prisma-where"; -import { SearchShiftsDto } from "../dtos/search-shift.dto"; import { NotificationsService } from "src/modules/notifications/services/notifications.service"; -import { computeHours, hoursBetweenSameDay } from "src/common/utils/date-utils"; +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); - -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_validated: boolean; -} +// const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12); @Injectable() export class ShiftsQueryService { @@ -28,94 +13,7 @@ export class ShiftsQueryService { private readonly notifs: NotificationsService, ) {} - async create(dto: CreateShiftDto): Promise { - const { timesheet_id, bank_code_id, date, start_time, end_time, description } = dto; - - //shift creation - const shift = await this.prisma.shifts.create({ - data: { timesheet_id, bank_code_id, date, start_time, end_time, description }, - include: { timesheet: { include: { employee: { include: { user: true } } } }, - bank_code: true, - }, - }); - - //fetches all shifts of the same day to check for daily overtime - const same_day_shifts = await this.prisma.shifts.findMany({ - where: { timesheet_id, date }, - select: { id: true, date: true, start_time: true, end_time: true }, - }); - - //sums hours of the day - const total_hours = same_day_shifts.reduce((sum, s) => { - return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time); - }, 0 ); - - //Notify if total hours > 8 for a single day - if(total_hours > DAILY_LIMIT_HOURS ) { - const user_id = String(shift.timesheet.employee.user.id); - const date_label = new Date(date).toLocaleDateString('fr-CA'); - this.notifs.notify(user_id, { - type: 'shift.overtime.daily', - severity: 'warn', - message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label} - (total: ${total_hours.toFixed(2)}h).`, - ts: new Date().toISOString(), - meta: { - timesheet_id, - date: new Date(date).toISOString(), - total_hours, - threshold: DAILY_LIMIT_HOURS, - last_shift_id: shift.id - }, - }); - } - return shift; - } - - async findAll(filters: SearchShiftsDto): Promise { - const where = buildPrismaWhere(filters); - const shifts = await this.prisma.shifts.findMany({ where }) - return shifts; - } - - async findOne(id: number): Promise { - const shift = await this.prisma.shifts.findUnique({ - where: { id }, - include: { timesheet: { include: { employee: { include: { user: true } } } }, - bank_code: true, - }, - }); - if(!shift) { - throw new NotFoundException(`Shift #${id} not found`); - } - return shift; - } - - async update(id: number, dto: UpdateShiftsDto): Promise { - await this.findOne(id); - const { timesheet_id, bank_code_id, date,start_time,end_time, description} = dto; - return this.prisma.shifts.update({ - where: { id }, - data: { - ...(timesheet_id !== undefined && { timesheet_id }), - ...(bank_code_id !== undefined && { bank_code_id }), - ...(date !== undefined && { date }), - ...(start_time !== undefined && { start_time }), - ...(end_time !== undefined && { end_time }), - ...(description !== undefined && { description }), - }, - include: { timesheet: { include: { employee: { include: { user: true } } } }, - bank_code: true, - }, - }); - } - - async remove(id: number): Promise { - await this.findOne(id); - return this.prisma.shifts.delete({ where: { id } }); - } - - async getSummary(period_id: number): Promise { + async getSummary(period_id: number): Promise { //fetch pay-period to display const period = await this.prisma.payPeriods.findFirst({ where: { pay_period_no: period_id }, @@ -168,7 +66,7 @@ export class ShiftsQueryService { total_overtime_hrs: 0, total_expenses: 0, total_mileage: 0, - is_validated: false, + is_approved: false, }; } const hours = computeHours(shift.start_time, shift.end_time); @@ -200,7 +98,7 @@ export class ShiftsQueryService { total_overtime_hrs: 0, total_expenses: 0, total_mileage: 0, - is_validated: false, + is_approved: false, }; } const amount = Number(exp.amount); @@ -214,58 +112,94 @@ export class ShiftsQueryService { return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name)); } - //archivation functions ****************************************************** + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - async archiveOld(): Promise { - //fetches archived timesheet's Ids - const archived_timesheets = await this.prisma.timesheetsArchive.findMany({ - select: { timesheet_id: true }, - }); + // async update(id: number, dto: UpdateShiftsDto): Promise { + // await this.findOne(id); + // const { timesheet_id, bank_code_id, date,start_time,end_time, comment} = dto; + // return this.prisma.shifts.update({ + // where: { id }, + // data: { + // ...(timesheet_id !== undefined && { timesheet_id }), + // ...(bank_code_id !== undefined && { bank_code_id }), + // ...(date !== undefined && { date }), + // ...(start_time !== undefined && { start_time }), + // ...(end_time !== undefined && { end_time }), + // ...(comment !== undefined && { comment }), + // }, + // include: { timesheet: { include: { employee: { include: { user: true } } } }, + // bank_code: true, + // }, + // }); + // } - const timesheet_ids = archived_timesheets.map(sheet => sheet.timesheet_id); - if(timesheet_ids.length === 0) { - return; - } + // async remove(id: number): Promise { + // await this.findOne(id); + // return this.prisma.shifts.delete({ where: { id } }); + // } - // copy/delete transaction - await this.prisma.$transaction(async transaction => { - //fetches shifts to move to archive - const shifts_to_archive = await transaction.shifts.findMany({ - where: { timesheet_id: { in: timesheet_ids } }, - }); - if(shifts_to_archive.length === 0) { - return; - } + // async create(dto: CreateShiftDto): Promise { +// const { timesheet_id, bank_code_id, date, start_time, end_time, comment } = dto; - //copies sent to archive table - await transaction.shiftsArchive.createMany({ - data: shifts_to_archive.map(shift => ({ - shift_id: shift.id, - timesheet_id: shift.timesheet_id, - bank_code_id: shift.bank_code_id, - description: shift.description ?? undefined, - date: shift.date, - start_time: shift.start_time, - end_time: shift.end_time, - })), - }); +// //shift creation +// const shift = await this.prisma.shifts.create({ +// data: { timesheet_id, bank_code_id, date, start_time, end_time, comment }, +// include: { timesheet: { include: { employee: { include: { user: true } } } }, +// bank_code: true, +// }, +// }); - //delete from shifts table - await transaction.shifts.deleteMany({ - where: { id: { in: shifts_to_archive.map(shift => shift.id) } }, - }) +// //fetches all shifts of the same day to check for daily overtime +// const same_day_shifts = await this.prisma.shifts.findMany({ +// where: { timesheet_id, date }, +// select: { id: true, date: true, start_time: true, end_time: true }, +// }); - }) - } +// //sums hours of the day +// const total_hours = same_day_shifts.reduce((sum, s) => { +// return sum + hoursBetweenSameDay(s.date, s.start_time, s.end_time); +// }, 0 ); - //fetches all archived timesheets - async findAllArchived(): Promise { - return this.prisma.shiftsArchive.findMany(); - } +// //Notify if total hours > 8 for a single day +// if(total_hours > DAILY_LIMIT_HOURS ) { +// const user_id = String(shift.timesheet.employee.user.id); +// const date_label = new Date(date).toLocaleDateString('fr-CA'); +// this.notifs.notify(user_id, { +// type: 'shift.overtime.daily', +// severity: 'warn', +// message: `Tu viens de dépasser ${DAILY_LIMIT_HOURS.toFixed(2)}h pour la journée du ${date_label} +// (total: ${total_hours.toFixed(2)}h).`, +// ts: new Date().toISOString(), +// meta: { +// timesheet_id, +// date: new Date(date).toISOString(), +// total_hours, +// threshold: DAILY_LIMIT_HOURS, +// last_shift_id: shift.id +// }, +// }); +// } +// return shift; +// } +// async findAll(filters: SearchShiftsDto): Promise { +// const where = buildPrismaWhere(filters); +// const shifts = await this.prisma.shifts.findMany({ where }) +// return shifts; +// } - //fetches an archived timesheet - async findOneArchived(id: number): Promise { - return this.prisma.shiftsArchive.findUniqueOrThrow({ where: { id } }); - } +// async findOne(id: number): Promise { +// const shift = await this.prisma.shifts.findUnique({ +// where: { id }, +// include: { timesheet: { include: { employee: { include: { user: true } } } }, +// bank_code: true, +// }, +// }); +// if(!shift) { +// throw new NotFoundException(`Shift #${id} not found`); +// } +// return shift; +// } } \ No newline at end of file diff --git a/src/modules/shifts/shifts.module.ts b/src/modules/shifts/shifts.module.ts index 7c0e3ef..8d1346c 100644 --- a/src/modules/shifts/shifts.module.ts +++ b/src/modules/shifts/shifts.module.ts @@ -4,11 +4,25 @@ import { BusinessLogicsModule } from 'src/modules/business-logics/business-logic 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 { SharedModule } from '../shared/shared.module'; @Module({ - imports: [BusinessLogicsModule, NotificationsModule], - controllers: [ShiftsController], - providers: [ShiftsQueryService, ShiftsCommandService], - exports: [ShiftsQueryService, ShiftsCommandService], + imports: [ + BusinessLogicsModule, + NotificationsModule, + SharedModule + ], + controllers: [ShiftsController], + providers: [ + ShiftsQueryService, + ShiftsCommandService, + ShiftsArchivalService, + ], + exports: [ + ShiftsQueryService, + ShiftsCommandService, + ShiftsArchivalService, + ], }) export class ShiftsModule {} diff --git a/src/modules/shifts/types-and-interfaces/shifts-overview-row.interface.ts b/src/modules/shifts/types-and-interfaces/shifts-overview-row.interface.ts new file mode 100644 index 0000000..145885b --- /dev/null +++ b/src/modules/shifts/types-and-interfaces/shifts-overview-row.interface.ts @@ -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; +} \ No newline at end of file diff --git a/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts b/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts new file mode 100644 index 0000000..85e6212 --- /dev/null +++ b/src/modules/shifts/types-and-interfaces/shifts-upsert.types.ts @@ -0,0 +1,9 @@ +export type DayShiftResponse = { + start_time: string; + end_time: string; + type: string; + is_remote: boolean; + comment: string | null; +} + +export type UpsertAction = 'created' | 'updated' | 'deleted'; \ No newline at end of file diff --git a/src/modules/shifts/utils/shifts.utils.ts b/src/modules/shifts/utils/shifts.utils.ts new file mode 100644 index 0000000..43c569f --- /dev/null +++ b/src/modules/shifts/utils/shifts.utils.ts @@ -0,0 +1,39 @@ +import { NotFoundException } from "@nestjs/common"; +import { ShiftPayloadDto } from "../dtos/upsert-shift.dto"; +import { timeFromHHMM } from "../helpers/shifts-date-time-helpers"; + +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 { + 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: ShiftPayloadDto) { + //normalize shift's infos + const date = payload.date; + const start_time = timeFromHHMM(payload.start_time); + const end_time = timeFromHHMM(payload.end_time ); + const type = (payload.type || '').trim().toUpperCase(); + const is_remote = payload.is_remote === true; + const is_approved = payload.is_approved === false; + //normalize comment + const raw_comment = payload.comment ?? null; + const trimmed = typeof raw_comment === 'string' ? raw_comment.trim() : null; + const comment = trimmed && trimmed.length > 0 ? trimmed: null; + + return { date, start_time, end_time, type, is_remote, is_approved, comment }; + } \ No newline at end of file diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index eb495b8..98350ab 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -1,14 +1,12 @@ -import { BadRequestException, Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common'; import { TimesheetsQueryService } from '../services/timesheets-query.service'; -import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; -import { Timesheets } from '@prisma/client'; -import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto'; +import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto'; import { RolesAllowed } from "src/common/decorators/roles.decorators"; import { Roles as RoleEnum } from '.prisma/client'; -import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { TimesheetsCommandService } from '../services/timesheets-command.service'; -import { SearchTimesheetDto } from '../dtos/search-timesheet.dto'; -import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; + @ApiTags('Timesheets') @ApiBearerAuth('access-token') @@ -20,15 +18,6 @@ export class TimesheetsController { private readonly timesheetsCommand: TimesheetsCommandService, ) {} - @Post() - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Create timesheet' }) - @ApiResponse({ status: 201, description: 'Timesheet created', type: CreateTimesheetDto }) - @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' }) - create(@Body() dto: CreateTimesheetDto): Promise { - return this.timesheetsQuery.create(dto); - } - @Get() //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) async getPeriodByQuery( @@ -36,43 +25,55 @@ export class TimesheetsController { @Query('period_no', ParseIntPipe ) period_no: number, @Query('email') email?: string ): Promise { - if(!email || !email.trim()) throw new BadRequestException('Query param "email" is mandatory for this route.'); - return this.timesheetsQuery.findAll(year, period_no, email.trim()); + 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 { + const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0; + return this.timesheetsQuery.getTimesheetByEmail(email, week_offset); } - @Get(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Find timesheet' }) - @ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto }) - @ApiResponse({ status: 400, description: 'Timesheet not found' }) - findOne(@Param('id', ParseIntPipe) id: number): Promise { - return this.timesheetsQuery.findOne(id); + @Post('shifts/:email') + async createTimesheetShifts( + @Param('email') email: string, + @Body() dto: CreateWeekShiftsDto, + @Query('offset') offset?: string, + ): Promise { + const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0; + return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset); } + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @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.timesheetsCommand.updateApproval(id, isApproved); + // } - @Patch(':id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Update timesheet' }) - @ApiResponse({ status: 201, description: 'Timesheet updated', type: CreateTimesheetDto }) - @ApiResponse({ status: 400, description: 'Timesheet not found' }) - update( - @Param('id', ParseIntPipe) id:number, - @Body() dto: UpdateTimesheetDto, - ): Promise { - return this.timesheetsQuery.update(id, dto); - } + // @Get(':id') + // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Find timesheet' }) + // @ApiResponse({ status: 201, description: 'Timesheet found', type: CreateTimesheetDto }) + // @ApiResponse({ status: 400, description: 'Timesheet not found' }) + // findOne(@Param('id', ParseIntPipe) id: number): Promise { + // return this.timesheetsQuery.findOne(id); + // } + + // @Delete(':id') + // // @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) + // @ApiOperation({ summary: 'Delete timesheet' }) + // @ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto }) + // @ApiResponse({ status: 400, description: 'Timesheet not found' }) + // remove(@Param('id', ParseIntPipe) id: number): Promise { + // return this.timesheetsQuery.remove(id); + // } - @Delete(':id') - // @RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - @ApiOperation({ summary: 'Delete timesheet' }) - @ApiResponse({ status: 201, description: 'Timesheet deleted', type: CreateTimesheetDto }) - @ApiResponse({ status: 400, description: 'Timesheet not found' }) - remove(@Param('id', ParseIntPipe) id: number): Promise { - return this.timesheetsQuery.remove(id); - } - - @Patch('approval/:id') - //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR) - async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) { - return this.timesheetsCommand.updateApproval(id, isApproved); - } } diff --git a/src/modules/timesheets/dtos/create-timesheet.dto.ts b/src/modules/timesheets/dtos/create-timesheet.dto.ts index 6a1ace2..a6fd0b2 100644 --- a/src/modules/timesheets/dtos/create-timesheet.dto.ts +++ b/src/modules/timesheets/dtos/create-timesheet.dto.ts @@ -1,28 +1,33 @@ -import { ApiProperty } from "@nestjs/swagger"; import { Type } from "class-transformer"; -import { Allow, IsBoolean, IsInt, IsOptional } from "class-validator"; +import { IsArray, IsOptional, IsString, Length, Matches, ValidateNested } from "class-validator"; export class CreateTimesheetDto { - @ApiProperty({ - example: 1, - description: 'timesheet`s unique ID (auto-generated)', - }) - @Allow() - id?: number; - @ApiProperty({ - example: 426433, - description: 'employee`s ID number of linked timsheet', - }) - @Type(() => Number) - @IsInt() - employee_id: number; + @IsString() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + date!: string; - @ApiProperty({ - example: true, - description: 'Timesheet`s status approval', - }) - @IsOptional() - @IsBoolean() - is_approved?: boolean; + @IsString() + @Length(1,64) + type!: string; + + @IsString() + @Matches(/^\d{2}:\d{2}$/) + start_time!: string; + + @IsString() + @Matches(/^\d{2}:\d{2}$/) + end_time!: string; + + @IsOptional() + @IsString() + @Length(0,512) + comment?: string; +} + +export class CreateWeekShiftsDto { + @IsArray() + @ValidateNested({each:true}) + @Type(()=> CreateTimesheetDto) + shifts!: CreateTimesheetDto[]; } diff --git a/src/modules/timesheets/dtos/timesheet-period.dto.ts b/src/modules/timesheets/dtos/timesheet-period.dto.ts index 71a8e2f..333716b 100644 --- a/src/modules/timesheets/dtos/timesheet-period.dto.ts +++ b/src/modules/timesheets/dtos/timesheet-period.dto.ts @@ -1,32 +1,60 @@ -export class ShiftDto { - start: string; - end : string; +export class TimesheetDto { + start_day: string; + end_day: string; + label: string; + shifts: ShiftDto[]; + expenses: ExpenseDto[] is_approved: boolean; } -export class ExpenseDto { - amount: number; +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 { - cash: ExpenseDto[] = []; - km : ExpenseDto[] = []; - [otherType:string]: ExpenseDto[] | any; //pour si on ajoute d'autre type de dépenses + expenses: ExpenseDto[] = []; + total_mileage: number; + total_expense: number; } export class WeekDto { is_approved: boolean; shifts: { - sun: DayShiftsDto; - mon: DayShiftsDto; - tue: DayShiftsDto; - wed: DayShiftsDto; - thu: DayShiftsDto; - fri: DayShiftsDto; - sat: DayShiftsDto; + sun: DetailedShifts; + mon: DetailedShifts; + tue: DetailedShifts; + wed: DetailedShifts; + thu: DetailedShifts; + fri: DetailedShifts; + sat: DetailedShifts; } expenses: { sun: DayExpensesDto; @@ -40,6 +68,8 @@ export class WeekDto { } export class TimesheetPeriodDto { - week1: WeekDto; - week2: WeekDto; + weeks: WeekDto[]; + employee_full_name: string; } + + diff --git a/src/modules/timesheets/dtos/update-timesheet.dto.ts b/src/modules/timesheets/dtos/update-timesheet.dto.ts deleted file mode 100644 index d621e6a..0000000 --- a/src/modules/timesheets/dtos/update-timesheet.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from "@nestjs/swagger"; -import { CreateTimesheetDto } from "./create-timesheet.dto"; - -export class UpdateTimesheetDto extends PartialType(CreateTimesheetDto) {} diff --git a/src/modules/timesheets/mappers/timesheet.mappers.ts b/src/modules/timesheets/mappers/timesheet.mappers.ts new file mode 100644 index 0000000..c9f04c4 --- /dev/null +++ b/src/modules/timesheets/mappers/timesheet.mappers.ts @@ -0,0 +1,55 @@ +import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto"; +import { ExpensesAmount } from "../types/timesheet.types"; +import { addDays, shortDate } from "../utils/timesheet.helpers"; + +// 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, +}); \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheet-archive.service.ts b/src/modules/timesheets/services/timesheet-archive.service.ts new file mode 100644 index 0000000..4988c75 --- /dev/null +++ b/src/modules/timesheets/services/timesheet-archive.service.ts @@ -0,0 +1,52 @@ +import { TimesheetsArchive } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; + + +export class TimesheetArchiveService { + constructor(private readonly prisma: PrismaService){} + + async archiveOld(): Promise { + //calcul du cutoff pour archivation + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - 6) + + await this.prisma.$transaction(async transaction => { + //fetches all timesheets to cutoff + const oldSheets = await transaction.timesheets.findMany({ + where: { shift: { some: { date: { lt: cutoff } } }, + }, + select: { + id: true, + employee_id: true, + is_approved: true, + }, + }); + if( oldSheets.length === 0) { + return; + } + + //preping data for archivation + const archiveDate = oldSheets.map(sheet => ({ + timesheet_id: sheet.id, + employee_id: sheet.employee_id, + is_approved: sheet.is_approved, + })); + + //copying data from timesheets table to archive table + await transaction.timesheetsArchive.createMany({ data: archiveDate }); + + //removing data from timesheets table + await transaction.timesheets.deleteMany({ where: { id: { in: oldSheets.map(s => s.id) } } }); + }); + } + + //fetches all archived timesheets + async findAllArchived(): Promise { + return this.prisma.timesheetsArchive.findMany(); + } + + //fetches an archived timesheet + async findOneArchived(id: number): Promise { + return this.prisma.timesheetsArchive.findUniqueOrThrow({ where: { id } }); + } +} \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-command.service.ts b/src/modules/timesheets/services/timesheets-command.service.ts index abce079..b6de4cb 100644 --- a/src/modules/timesheets/services/timesheets-command.service.ts +++ b/src/modules/timesheets/services/timesheets-command.service.ts @@ -1,16 +1,32 @@ - -import { Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { Prisma, Timesheets } from "@prisma/client"; import { BaseApprovalService } from "src/common/shared/base-approval.service"; import { PrismaService } from "src/prisma/prisma.service"; +import { TimesheetsQueryService } from "./timesheets-query.service"; +import { CreateTimesheetDto } from "../dtos/create-timesheet.dto"; +import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils"; +import { parseISODate, parseHHmm } from "../utils/timesheet.helpers"; +import { TimesheetDto } from "../dtos/timesheet-period.dto"; +import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils"; +import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-employee-timesheet.utils"; +import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils"; @Injectable() export class TimesheetsCommandService extends BaseApprovalService{ - constructor(prisma: PrismaService) {super(prisma);} - + 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; } @@ -23,18 +39,60 @@ export class TimesheetsCommandService extends BaseApprovalService{ async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise { 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; } +//_____________________________________________________________________________________________ +// +//_____________________________________________________________________________________________ + + async createWeekShiftsAndReturnOverview( + email:string, + shifts: CreateTimesheetDto[], + week_offset = 0, + ): Promise { + //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.ensureForDate(employee_id, 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); + } } \ No newline at end of file diff --git a/src/modules/timesheets/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 0fe08ee..1092c3b 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,190 +1,225 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { PrismaService } from 'src/prisma/prisma.service'; -import { CreateTimesheetDto } from '../dtos/create-timesheet.dto'; -import { Timesheets, TimesheetsArchive } from '@prisma/client'; -import { UpdateTimesheetDto } from '../dtos/update-timesheet.dto'; -import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; -import { computeHours } from 'src/common/utils/date-utils'; -import { buildPrismaWhere } from 'src/common/shared/build-prisma-where'; -import { SearchTimesheetDto } from '../dtos/search-timesheet.dto'; -import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; -import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; +import { endOfDayUTC, toHHmm, toUTCDateOnly } from '../utils/timesheet.helpers'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { TimesheetDto, TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { ShiftRow, ExpenseRow } from '../types/timesheet.types'; +import { buildPeriod } from '../utils/timesheet.utils'; +import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils'; +import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils'; -// deprecated (used with old findAll) const ROUND_TO = 5; -type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; -type ExpenseRow = { date: Date; amount: number; type: string; is_approved?: boolean }; @Injectable() export class TimesheetsQueryService { constructor( private readonly prisma: PrismaService, - private readonly overtime: OvertimeService, + private readonly emailResolver: EmailToIdResolver, + private readonly fullNameResolver: FullNameResolver ) {} - - - async create(dto : CreateTimesheetDto): Promise { - const { employee_id, is_approved } = dto; - return this.prisma.timesheets.create({ - data: { employee_id, is_approved: is_approved ?? false }, - include: { - employee: { include: { user: true } - }, - }, - }); - } - async findAll(year: number, period_no: number, email: string): Promise { - //finds the employee - const employee = await this.prisma.employees.findFirst({ - where: { user: { is: { email } } }, - select: { id: true }, - }); - if(!employee) throw new NotFoundException(`no employee with email ${email} found`); + //finds the employee using email + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`employee with email : ${email} not found`); + + //finds the employee full name using employee_id + const full_name = await this.fullNameResolver.resolveFullName(employee_id); + if(!full_name) throw new NotFoundException(`employee with id: ${employee_id} not found`) //finds the period const period = await this.prisma.payPeriods.findFirst({ - where: { pay_year: year, pay_period_no: period_no }, - select: { period_start: true, period_end: true }, + where: { + pay_year: year, + pay_period_no: period_no + }, + select: { + period_start: true, + period_end: true + }, }); if(!period) throw new NotFoundException(`Period ${year}-${period_no} not found`); const from = toUTCDateOnly(period.period_start); const to = endOfDayUTC(period.period_end); - //collects data from shifts and expenses - const [ raw_shifts, raw_expenses] = await Promise.all([ - this.prisma.shifts.findMany({ - where: { - timesheet: { is: { employee_id: employee.id } }, - date: { gte: from, lte: to }, - }, - select: { date: true,start_time: true, end_time: true, is_approved: true }, - orderBy: [{date: 'asc'}, { start_time: 'asc' }], - }), - this.prisma.expenses.findMany({ - where: { - timesheet: { is: { employee_id: employee.id } }, - date: { gte: from, lte: to }, - }, - select: { date: true, amount: true, is_approved: true, bank_code: { - select: { type: true } }, - }, - orderBy: { date: 'asc' }, - }), - ]); + const raw_shifts = await this.prisma.shifts.findMany({ + where: { + timesheet: { is: { employee_id: employee_id } }, + date: { gte: from, lte: to }, + }, + select: { + date: true, + start_time: true, + end_time: true, + comment: true, + is_approved: true, + is_remote: true, + bank_code: { select: { type: true } }, + }, + orderBy:[ { date:'asc'}, { start_time: 'asc'} ], + }); - //Shift data mapping + const raw_expenses = await this.prisma.expenses.findMany({ + where: { + timesheet: { is: { employee_id: employee_id } }, + date: { gte: from, lte: to }, + }, + select: { + date: true, + amount: true, + mileage: true, + comment: true, + is_approved: true, + supervisor_comment: true, + bank_code: { select: { type: true } }, + }, + orderBy: { date: 'asc' }, + }); + + const toNum = (value: any) => + value && typeof value.toNumber === 'function' ? value.toNumber() : + typeof value === 'number' ? value : + value ? Number(value) : 0; + + // data mapping const shifts: ShiftRow[] = raw_shifts.map(shift => ({ - date: shift.date, - start_time: shift.start_time, - end_time: shift.end_time, + date: shift.date, + start_time: shift.start_time, + end_time: shift.end_time, + comment: shift.comment ?? '', is_approved: shift.is_approved ?? true, + is_remote: shift.is_remote ?? true, + type: String(shift.bank_code?.type ?? '').toUpperCase(), })); const expenses: ExpenseRow[] = raw_expenses.map(expense => ({ - date: expense.date, - amount: typeof (expense.amount as any)?.toNumber() === 'function' ? - (expense.amount as any).toNumber() : Number(expense.amount), - type: expense.bank_code?.type ?? 'CASH', + type: String(expense.bank_code?.type ?? '').toUpperCase(), + date: expense.date, + amount: toNum(expense.amount), + mileage: toNum(expense.mileage), + comment: expense.comment ?? '', is_approved: expense.is_approved ?? true, + supervisor_comment: expense.supervisor_comment ?? '', })); - return buildPeriod(period.period_start, period.period_end, shifts , expenses); + return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name); } - async findOne(id: number): Promise { + async getTimesheetByEmail(email: string, week_offset = 0): Promise { + const employee_id = await this.emailResolver.findIdByEmail(email); + if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`); + + //sets current week Sunday -> Saturday + const base = new Date(); + const offset = new Date(base); + offset.setDate(offset.getDate() + (week_offset * 7)); + + const start_date_week = getWeekStart(offset, 0); + const end_date_week = getWeekEnd(start_date_week); + const start_day = formatDateISO(start_date_week); + const end_day = formatDateISO(end_date_week); + + //build the label MM/DD/YYYY.MM/DD.YYYY + const mm_dd = (date: Date) => `${String(date.getFullYear())}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2,'0')}`; + const label = `${mm_dd(start_date_week)}.${mm_dd(end_date_week)}`; + + //fetch timesheet shifts and expenses const timesheet = await this.prisma.timesheets.findUnique({ - where: { id }, - include: { - shift: { include: { bank_code: true } }, - expense: { include: { bank_code: true } }, - employee: { include: { user: true } }, - }, + where: { + employee_id_start_date: { + employee_id: employee_id, + start_date: start_date_week, + }, + }, + include: { + shift: { + include: { bank_code: true }, + orderBy: [{ date: 'asc'}, { start_time: 'asc'}], + }, + expense: { + include: { bank_code: true }, + orderBy: [{date: 'asc'}], + }, + }, }); + + //returns an empty timesheet if not found if(!timesheet) { - throw new NotFoundException(`Timesheet #${id} not found`); + return { + is_approved: false, + start_day, + end_day, + label, + shifts:[], + expenses: [], + } as TimesheetDto; } - const detailedShifts = timesheet.shift.map( s => { - const hours = computeHours(s.start_time, s.end_time); - const regularHours = Math.min(8, hours); - const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time); - const payRegular = regularHours * s.bank_code.modifier; - const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, s.bank_code.modifier); - return { ...s, hours, payRegular, payOvertime }; - }); - const weeklyOvertimeHours = detailedShifts.length - ? await this.overtime.getWeeklyOvertimeHours( - timesheet.employee_id, - timesheet.shift[0].date): 0; - return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; + //maps all shifts of selected timesheet + const shifts = timesheet.shift.map((shift_row) => ({ + type: shift_row.bank_code?.type ?? '', + date: formatDateISO(shift_row.date), + start_time: toHHmm(shift_row.start_time), + end_time: toHHmm(shift_row.end_time), + comment: shift_row.comment ?? '', + is_approved: shift_row.is_approved ?? false, + is_remote: shift_row.is_remote ?? false, + })); + + //maps all expenses of selected timsheet + const expenses = timesheet.expense.map((exp) => ({ + type: exp.bank_code?.type ?? '', + date: formatDateISO(exp.date), + amount: Number(exp.amount) || 0, + mileage: exp.mileage != null ? Number(exp.mileage) : 0, + comment: exp.comment ?? '', + is_approved: exp.is_approved ?? false, + supervisor_comment: exp.supervisor_comment ?? '', + })); + + return { + start_day, + end_day, + label, + shifts, + expenses, + is_approved: timesheet.is_approved, + } as TimesheetDto; } + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - async update(id: number, dto:UpdateTimesheetDto): Promise { - await this.findOne(id); - const { employee_id, is_approved } = dto; - return this.prisma.timesheets.update({ - where: { id }, - data: { - ...(employee_id !== undefined && { employee_id }), - ...(is_approved !== undefined && { is_approved }), - }, - include: { employee: { include: { user: true } }, - }, - }); - } + // async findOne(id: number): Promise { + // const timesheet = await this.prisma.timesheets.findUnique({ + // where: { id }, + // include: { + // shift: { include: { bank_code: true } }, + // expense: { include: { bank_code: true } }, + // employee: { include: { user: true } }, + // }, + // }); + // if(!timesheet) { + // throw new NotFoundException(`Timesheet #${id} not found`); + // } - async remove(id: number): Promise { - await this.findOne(id); - return this.prisma.timesheets.delete({ where: { id } }); - } + // const detailedShifts = timesheet.shift.map( s => { + // const hours = computeHours(s.start_time, s.end_time); + // const regularHours = Math.min(8, hours); + // const dailyOvertime = this.overtime.getDailyOvertimeHours(s.start_time, s.end_time); + // const payRegular = regularHours * s.bank_code.modifier; + // const payOvertime = this.overtime.calculateOvertimePay(dailyOvertime, s.bank_code.modifier); + // return { ...s, hours, payRegular, payOvertime }; + // }); + // const weeklyOvertimeHours = detailedShifts.length + // ? await this.overtime.getWeeklyOvertimeHours( + // timesheet.employee_id, + // timesheet.shift[0].date): 0; + // return { ...timesheet, shift: detailedShifts, weeklyOvertimeHours }; + // } - -//archivation functions ****************************************************** - - async archiveOld(): Promise { - //calcul du cutoff pour archivation - const cutoff = new Date(); - cutoff.setMonth(cutoff.getMonth() - 6) - - await this.prisma.$transaction(async transaction => { - //fetches all timesheets to cutoff - const oldSheets = await transaction.timesheets.findMany({ - where: { shift: { every: { date: { lt: cutoff } } }, - }, - select: { - id: true, - employee_id: true, - is_approved: true, - }, - }); - if( oldSheets.length === 0) { - return; - } - - //preping data for archivation - const archiveDate = oldSheets.map(sheet => ({ - timesheet_id: sheet.id, - employee_id: sheet.employee_id, - is_approved: sheet.is_approved, - })); - - //copying data from timesheets table to archive table - await transaction.timesheetsArchive.createMany({ data: archiveDate }); - - //removing data from timesheets table - await transaction.timesheets.deleteMany({ where: { id: { in: oldSheets.map(s => s.id) } } }); - }); - } - - //fetches all archived timesheets - async findAllArchived(): Promise { - return this.prisma.timesheetsArchive.findMany(); - } - - //fetches an archived timesheet - async findOneArchived(id: number): Promise { - return this.prisma.timesheetsArchive.findUniqueOrThrow({ where: { id } }); - } + // async remove(id: number): Promise { + // await this.findOne(id); + // return this.prisma.timesheets.delete({ where: { id } }); + // } } diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index cbfc001..7824aa4 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -1,20 +1,30 @@ -import { Module } from '@nestjs/common'; -import { TimesheetsController } from './controllers/timesheets.controller'; -import { TimesheetsQueryService } from './services/timesheets-query.service'; -import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; +import { TimesheetsController } from './controllers/timesheets.controller'; +import { TimesheetsQueryService } from './services/timesheets-query.service'; +import { TimesheetArchiveService } from './services/timesheet-archive.service'; import { TimesheetsCommandService } from './services/timesheets-command.service'; -import { ShiftsCommandService } from '../shifts/services/shifts-command.service'; -import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; +import { ShiftsCommandService } from '../shifts/services/shifts-command.service'; +import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; +import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module'; +import { SharedModule } from '../shared/shared.module'; +import { Module } from '@nestjs/common'; @Module({ - imports: [BusinessLogicsModule], - controllers: [TimesheetsController], - providers: [ - TimesheetsQueryService, - TimesheetsCommandService, - ShiftsCommandService, - ExpensesCommandService - ], - exports: [TimesheetsQueryService], + imports: [ + BusinessLogicsModule, + SharedModule + ], + controllers: [TimesheetsController], + providers: [ + TimesheetsQueryService, + TimesheetsCommandService, + ShiftsCommandService, + ExpensesCommandService, + TimesheetArchiveService, + ], + exports: [ + TimesheetsQueryService, + TimesheetArchiveService, + TimesheetsCommandService + ], }) export class TimesheetsModule {} diff --git a/src/modules/timesheets/types/timesheet.types.ts b/src/modules/timesheets/types/timesheet.types.ts new file mode 100644 index 0000000..e33f86b --- /dev/null +++ b/src/modules/timesheets/types/timesheet.types.ts @@ -0,0 +1,69 @@ +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; +}; + +//Date & Format +export const MS_PER_DAY = 86_400_000; +export const MS_PER_HOUR = 3_600_000; + +// 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; +}; \ No newline at end of file diff --git a/src/modules/timesheets/utils/timesheet.helpers.ts b/src/modules/timesheets/utils/timesheet.helpers.ts index 6dfef18..3ba862d 100644 --- a/src/modules/timesheets/utils/timesheet.helpers.ts +++ b/src/modules/timesheets/utils/timesheet.helpers.ts @@ -1,19 +1,5 @@ -import { DayExpensesDto, DayShiftsDto, ShiftDto, TimesheetPeriodDto, WeekDto } from "../dtos/timesheet-period.dto"; +import { MS_PER_DAY, DayKey, DAY_KEYS } from "../types/timesheet.types"; -//makes the strings indexes for arrays -export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const; -export type DayKey = 'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat'; - -//DB line types -type ShiftRow = { date: Date; start_time: Date; end_time: Date; is_approved?: boolean }; -type ExpenseRow = {date: Date; amount: number; type: string; is_approved?: boolean }; - -export function dayKeyFromDate(date: Date, useUTC = true): DayKey { - const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday - return DAY_KEYS[index]; -} - -const MS_PER_DAY = 86_400_000; export function toUTCDateOnly(date: Date | string): Date { const d = new Date(date); @@ -34,12 +20,6 @@ export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): bool return time >= start.getTime() && time <= end_inclusive.getTime(); } -export function dayIndex(week_start: Date, date: Date): 1|2|3|4|5|6|7 { - const diff = Math.floor((toUTCDateOnly(date).getTime() - toUTCDateOnly(week_start).getTime())/ MS_PER_DAY); - const index = Math.min(6, Math.max(0, diff)) + 1; - return index as 1|2|3|4|5|6|7; -} - export function toTimeString(date: Date): string { const hours = String(date.getUTCHours()).padStart(2,'0'); const minutes = String(date.getUTCMinutes()).padStart(2,'0'); @@ -50,85 +30,27 @@ export function round2(num: number) { return Math.round(num * 100) / 100; } -export function makeEmptyDayShifts(): DayShiftsDto { return []; } - -export function makeEmptyDayExpenses(): DayExpensesDto { return {cash: [], km: []}; } - -export function makeEmptyWeek(): WeekDto { - return { - is_approved: true, - shifts: { - sun: makeEmptyDayShifts(), - mon: makeEmptyDayShifts(), - tue: makeEmptyDayShifts(), - wed: makeEmptyDayShifts(), - thu: makeEmptyDayShifts(), - fri: makeEmptyDayShifts(), - sat: makeEmptyDayShifts(), - }, - expenses: { - sun: makeEmptyDayExpenses(), - mon: makeEmptyDayExpenses(), - tue: makeEmptyDayExpenses(), - wed: makeEmptyDayExpenses(), - thu: makeEmptyDayExpenses(), - fri: makeEmptyDayExpenses(), - sat: makeEmptyDayExpenses(), - }, - }; +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 makeEmptyPeriod(): TimesheetPeriodDto { - return { week1: makeEmptyWeek(), week2: makeEmptyWeek() }; +export function dayKeyFromDate(date: Date, useUTC = true): DayKey { + const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday + return DAY_KEYS[index]; } -//needs ajusting according to DB's data for expenses types -export function normalizeExpenseBucket(db_type: string): 'km' | 'cash' { - const type = db_type.trim().toUpperCase(); - if(type.includes('KM') || type.includes('MILEAGE')) return 'km'; - return 'cash'; +export const toHHmm = (date: Date) => date.toISOString().slice(11, 16); + +//create shifts within timesheet's week - employee overview functions +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 buildWeek( week_start: Date, week_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): WeekDto { - const week = makeEmptyWeek(); - let all_approved = true; - - //Shifts mapped and filtered by dates - 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].push({ - start: toTimeString(shift.start_time), - end : toTimeString(shift.end_time), - is_approved: shift.is_approved ?? true, - } as ShiftDto); - all_approved = all_approved && (shift.is_approved ?? true); - } - - //Expenses mapped and filtered by dates - 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 bucket = normalizeExpenseBucket(expense.type); - if (!Array.isArray(week.expenses[key][bucket])) week.expenses[key][bucket] = []; - week.expenses[key][bucket].push({ - amount: round2(expense.amount), - is_approved: expense.is_approved ?? true, - }); - all_approved = all_approved && (expense.is_approved ?? true); - } - week.is_approved = all_approved; - return week; +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 function buildPeriod( period_start: Date, period_end: Date, shifts: ShiftRow[], expenses: ExpenseRow[]): 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); - - return { - week1: buildWeek(week1_start, week1_end, shifts, expenses), - week2: buildWeek(week2_start, week2_end, shifts, expenses), - }; -} \ No newline at end of file diff --git a/src/modules/timesheets/utils/timesheet.utils.ts b/src/modules/timesheets/utils/timesheet.utils.ts new file mode 100644 index 0000000..fa09eac --- /dev/null +++ b/src/modules/timesheets/utils/timesheet.utils.ts @@ -0,0 +1,154 @@ +import { + DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow, MS_PER_HOUR, + SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount +} from "../types/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 { makeAmounts, makeEmptyWeek } from "../mappers/timesheet.mappers"; +import { toDateString } from "src/modules/pay-periods/utils/pay-year.util"; + +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> = DAY_KEYS.reduce((acc, key) => { + acc[key] = []; return acc; + }, {} as Record>); + + const day_hours: Record = DAY_KEYS.reduce((acc, key) => { + acc[key] = make_hours(); return acc; + }, {} as Record); + + const day_amounts: Record = DAY_KEYS.reduce((acc, key) => { + acc[key] = makeAmounts(); return acc; + }, {} as Record); + + const day_expense_rows: Record = 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); + + //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, + }; +} \ No newline at end of file diff --git a/src/modules/users-management/dtos/user.dto.ts b/src/modules/users-management/dtos/user.dto.ts index 24cf8e4..8598b1f 100644 --- a/src/modules/users-management/dtos/user.dto.ts +++ b/src/modules/users-management/dtos/user.dto.ts @@ -30,7 +30,7 @@ export class UserDto { example: 5141234567, description: 'Unique phone number', }) - phone_number: number; + phone_number: string; @ApiProperty({ example: 'Minas Tirith, Gondor',