diff --git a/docs/swagger/swagger-spec.json b/docs/swagger/swagger-spec.json index 99f16ad..e8045ac 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", @@ -187,37 +163,6 @@ "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": { @@ -254,74 +199,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,46 +261,6 @@ ] } }, - "/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": { "get": { "operationId": "TimesheetsController_getPeriodByQuery", @@ -550,228 +387,24 @@ ] } }, - "/timesheets/{id}": { - "get": { - "operationId": "TimesheetsController_findOne", + "/Expenses/upsert/{email}/{date}": { + "put": { + "operationId": "ExpensesController_upsert_by_date", "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" - ] - }, - "delete": { - "operationId": "TimesheetsController_remove", - "parameters": [ - { - "name": "id", + "name": "date", "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" - } - } - ], - "responses": { - "200": { - "description": "" - } - }, - "security": [ - { - "access-token": [] - } - ], - "tags": [ - "Timesheets" - ] - } - }, - "/Expenses": { - "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", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Expense found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "400": { - "description": "Expense not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Find expense", - "tags": [ - "Expenses" - ] - }, - "patch": { - "operationId": "ExpensesController_update", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" + "type": "string" } } ], @@ -780,87 +413,11 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateExpenseDto" + "$ref": "#/components/schemas/UpsertExpenseDto" } } } }, - "responses": { - "201": { - "description": "Expense updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "400": { - "description": "Expense not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Expense shift", - "tags": [ - "Expenses" - ] - }, - "delete": { - "operationId": "ExpensesController_remove", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], - "responses": { - "201": { - "description": "Expense deleted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateExpenseDto" - } - } - } - }, - "400": { - "description": "Expense not found" - } - }, - "security": [ - { - "access-token": [] - } - ], - "summary": "Delete expense", - "tags": [ - "Expenses" - ] - } - }, - "/Expenses/approval/{id}": { - "patch": { - "operationId": "ExpensesController_approve", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "number" - } - } - ], "responses": { "200": { "description": "" @@ -922,200 +479,6 @@ ] } }, - "/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", - "parameters": [ - { - "name": "id", - "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" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateShiftsDto" - } - } - } - }, - "responses": { - "201": { - "description": "Shift updated", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateShiftDto" - } - } - } - }, - "400": { - "description": "Shift not found" - } - }, - "security": [ - { - "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" - ] - } - }, "/shifts/approval/{id}": { "patch": { "operationId": "ShiftsController_approve", @@ -1210,215 +573,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": "" } }, @@ -1460,319 +630,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" - ] - } - }, - "/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", @@ -1967,31 +824,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", @@ -2376,10 +1208,6 @@ "type": "object", "properties": {} }, - "EmployeeProfileItemDto": { - "type": "object", - "properties": {} - }, "UpdateEmployeeDto": { "type": "object", "properties": { @@ -2455,441 +1283,18 @@ "type": "object", "properties": {} }, - "CreateTimesheetDto": { + "UpsertExpenseDto": { "type": "object", "properties": {} }, - "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" - }, - "comment": { - "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", - "comment", - "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" - }, - "comment": { - "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" - } - } - }, "UpsertShiftDto": { "type": "object", "properties": {} }, - "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": { + "UpsertLeaveRequestDto": { "type": "object", "properties": {} }, - "UpdateLeaveRequestsDto": { - "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": "string", - "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": "string", - "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" - } - } - }, "CreateOauthSessionDto": { "type": "object", "properties": { diff --git a/package-lock.json b/package-lock.json index 5b5f67b..0622bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.14.0", + "prisma": "^6.16.3", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", @@ -3148,9 +3148,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.16.3", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.16.3.tgz", + "integrity": "sha512-VlsLnG4oOuKGGMToEeVaRhoTBZu5H3q51jTQXb/diRags3WV0+BQK5MolJTtP6G7COlzoXmWeS11rNBtvg+qFQ==", "devOptional": true, "dependencies": { "c12": "3.1.0", @@ -3160,48 +3160,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.16.3", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.16.3.tgz", + "integrity": "sha512-89DdqWtdKd7qoc9/qJCKLTazj3W3zPEiz0hc7HfZdpjzm21c7orOUB5oHWJsG+4KbV4cWU5pefq3CuDVYF9vgA==", "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.16.3", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.16.3.tgz", + "integrity": "sha512-b+Rl4nzQDcoqe6RIpSHv8f5lLnwdDGvXhHjGDiokObguAAv/O1KaX1Oc69mBW/GFWKQpCkOraobLjU6s1h8HGg==", "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.16.3", + "@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", + "@prisma/fetch-engine": "6.16.3", + "@prisma/get-platform": "6.16.3" } }, "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.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a.tgz", + "integrity": "sha512-fftRmosBex48Ph1v2ll1FrPpirwtPZpNkE5CDCY1Lw2SD2ctyrLlVlHiuxDAAlALwWBOkPbAll4+EaqdGuMhJw==", "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.16.3", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.16.3.tgz", + "integrity": "sha512-bUoRIkVaI+CCaVGrSfcKev0/Mk4ateubqWqGZvQ9uCqFv2ENwWIR3OeNuGin96nZn5+SkebcD7RGgKr/+mJelw==", "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.16.3", + "@prisma/engines-version": "6.16.1-1.bb420e667c1820a8c05a38023385f6cc7ef8e83a", + "@prisma/get-platform": "6.16.3" } }, "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.16.3", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.16.3.tgz", + "integrity": "sha512-X1LxiFXinJ4iQehrodGp0f66Dv6cDL0GbRlcCoLtSu6f4Wi+hgo7eND/afIs5029GQLgNWKZ46vn8hjyXTsHLA==", "devOptional": true, "dependencies": { - "@prisma/debug": "6.14.0" + "@prisma/debug": "6.16.3" } }, "node_modules/@scarf/scarf": { @@ -9450,15 +9450,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": { @@ -9967,9 +9967,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", @@ -10049,14 +10049,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.16.3", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.16.3.tgz", + "integrity": "sha512-4tJq3KB9WRshH5+QmzOLV54YMkNlKOtLKaSdvraI5kC/axF47HuOw6zDM8xrxJ6s9o2WodY654On4XKkrobQdQ==", "devOptional": true, "hasInstallScript": true, "dependencies": { - "@prisma/config": "6.14.0", - "@prisma/engines": "6.14.0" + "@prisma/config": "6.16.3", + "@prisma/engines": "6.16.3" }, "bin": { "prisma": "build/index.js" diff --git a/package.json b/package.json index db16934..68eb03f 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "globals": "^16.0.0", "jest": "^29.7.0", "prettier": "^3.4.2", - "prisma": "^6.14.0", + "prisma": "^6.16.3", "source-map-support": "^0.5.21", "supertest": "^7.0.0", "ts-jest": "^29.2.5", 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/mock-seeds-scripts/07-leave-requests-future.ts b/prisma/mock-seeds-scripts/07-leave-requests-future.ts index c5dc5e2..b96c63d 100644 --- a/prisma/mock-seeds-scripts/07-leave-requests-future.ts +++ b/prisma/mock-seeds-scripts/07-leave-requests-future.ts @@ -1,14 +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)"); + 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)); } @@ -19,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) { @@ -44,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, }); } @@ -75,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 d92a51f..45b1d43 100644 --- a/prisma/mock-seeds-scripts/08-leave-requests-archive.ts +++ b/prisma/mock-seeds-scripts/08-leave-requests-archive.ts @@ -1,7 +1,7 @@ -import { PrismaClient, 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)"); + console.log('?? Seed leave-requests ignor (SKIP_LEAVE_REQUESTS=true)'); process.exit(0); } @@ -15,65 +15,73 @@ function daysAgo(n: number) { } async function main() { - // 1) Récupère tous les employés const employees = await prisma.employees.findMany({ select: { id: true } }); if (!employees.length) { - throw new Error('Aucun employé trouvé. Exécute le seed employees avant celui-ci.'); + throw new Error('Aucun employ trouv. Excute le seed employees avant celui-ci.'); } - // 2) Va chercher les bank codes dont le type est SICK, VACATION ou HOLIDAY const leaveCodes = await prisma.bankCodes.findMany({ - where: { type: { in: ['SICK', 'VACATION'] } }, - select: { id: true, type: true, bank_code: true }, + 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'). Vérifie ta table bank_codes."); + throw new Error("Aucun bank code trouv avec type in ('SICK','VACATION','HOLIDAY'). Vrifie ta table bank_codes."); } const statuses = Object.values(LeaveApprovalStatus); - const created: LeaveRequests[] = []; + 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 }>; - // 3) Crée quelques leave requests const COUNT = 12; for (let i = 0; i < COUNT; i++) { const emp = employees[i % employees.length]; const leaveCode = leaveCodes[Math.floor(Math.random() * leaveCodes.length)]; - const start = daysAgo(120 - i * 3); - const end = Math.random() < 0.6 ? daysAgo(119 - i * 3) : null; + 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: leaveCode.id, - // on stocke le "type" tel qu’il est défini dans bank_codes - leave_type: leaveCode.type as any, - start_date_time: start, - end_date_time: end, + leave_type: leaveCode.type as LeaveTypes, + date, comment: `Past leave #${i + 1} (${leaveCode.type})`, - approval_status: statuses[(i + 2) % statuses.length], + 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, + }); } - // 4) Archive for (const lr of created) { await prisma.leaveRequestsArchive.create({ data: { 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/12-expenses.ts b/prisma/mock-seeds-scripts/12-expenses.ts index c0641a8..00f6f0c 100644 --- a/prisma/mock-seeds-scripts/12-expenses.ts +++ b/prisma/mock-seeds-scripts/12-expenses.ts @@ -143,7 +143,7 @@ async function main() { bank_code_id, date, amount, // string "xx.yy" (2 décimales exactes) - attachement: null, + attachment: null, comment: `Expense ${code} ${amount}$ (emp ${e.id})`, is_approved: Math.random() < 0.65, supervisor_comment: Math.random() < 0.25 ? 'OK' : null, diff --git a/prisma/mock-seeds-scripts/13-expenses-archive.ts b/prisma/mock-seeds-scripts/13-expenses-archive.ts index 86d2c3b..d8e35a2 100644 --- a/prisma/mock-seeds-scripts/13-expenses-archive.ts +++ b/prisma/mock-seeds-scripts/13-expenses-archive.ts @@ -32,7 +32,7 @@ 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, + attachment: null, comment: `Old expense #${i + 1}`, is_approved: true, supervisor_comment: null, @@ -50,7 +50,7 @@ async function main() { bank_code_id: e.bank_code_id, date: e.date, amount: e.amount, - attachement: e.attachement, + 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 d2c344c..4a45c7b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,7 +28,7 @@ model Users { oauth_sessions OAuthSessions[] @relation("UserOAuthSessions") employees_archive EmployeesArchive[] @relation("UsersToEmployeesToArchive") customer_archive CustomersArchive[] @relation("UserToCustomersToArchive") - + preferences Preferences? @relation("UserPreferences") @@map("users") } @@ -105,16 +105,19 @@ 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 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 + date DateTime @db.Date + payable_hours Decimal? @db.Decimal(5,2) + requested_hours Decimal? @db.Decimal(5,2) comment String approval_status LeaveApprovalStatus @default(PENDING) archive LeaveRequestsArchive[] @relation("LeaveRequestToArchive") + @@unique([employee_id, leave_type, date], name: "leave_per_employee_date") + @@index([employee_id, date]) @@map("leave_requests") } @@ -125,11 +128,14 @@ model LeaveRequestsArchive { 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 approval_status LeaveApprovalStatus + @@unique([leave_request_id]) + @@index([employee_id, date]) @@map("leave_requests_archive") } @@ -226,8 +232,10 @@ model Expenses { bank_code_id Int date DateTime @db.Date amount Decimal @db.Money - attachement String? - comment String? + mileage Decimal? + attachment Int? + attachment_record Attachments? @relation("ExpenseAttachment", fields: [attachment], references: [id], onDelete: SetNull) + comment String is_approved Boolean @default(false) supervisor_comment String? @@ -244,8 +252,10 @@ model ExpensesArchive { archived_at DateTime @default(now()) bank_code_id Int date DateTime @db.Date - amount Decimal @db.Money - attachement String? + amount Decimal? @db.Money + mileage Decimal? + attachment Int? + attachment_record Attachments? @relation("ExpenseArchiveAttachment", fields: [attachment], references: [id], onDelete: SetNull) comment String? is_approved Boolean supervisor_comment String? @@ -296,11 +306,27 @@ model Attachments { created_by String created_at DateTime @default(now()) + expenses Expenses[] @relation("ExpenseAttachment") + expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment") + @@index([owner_type, owner_id, created_at]) @@index([sha256]) @@map("attachments") } +model Preferences { + id Int @id @default(autoincrement()) + user Users @relation("UserPreferences", fields: [user_id], references: [id]) + user_id String @unique @db.Uuid + notifications Boolean @default(false) + dark_mode Boolean @default(false) + lang_switch Boolean @default(false) + lefty_mode Boolean @default(false) + + @@map("preferences") +} + + enum AttachmentStatus { ACTIVE DELETED @@ -333,6 +359,7 @@ enum LeaveTypes { PARENTAL // maternite/paternite/adoption LEGAL // obligations legales comme devoir de juree WEDDING // mariage + HOLIDAY // férier @@map("leave_types") } diff --git a/src/app.module.ts b/src/app.module.ts index ebd5c5d..407535a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,30 +1,30 @@ import { BadRequestException, Module, ValidationPipe } from '@nestjs/common'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +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 { 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 { 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 { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { ValidationError } from 'class-validator'; @Module({ imports: [ 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/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/services/archival.service.ts b/src/modules/archival/services/archival.service.ts index bf7a36d..7dbf567 100644 --- a/src/modules/archival/services/archival.service.ts +++ b/src/modules/archival/services/archival.service.ts @@ -1,7 +1,6 @@ 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"; @@ -13,7 +12,6 @@ export class ArchivalService { private readonly timesheetsService: TimesheetsQueryService, private readonly expensesService: ExpensesQueryService, private readonly shiftsService: ShiftsQueryService, - private readonly leaveRequestsService: LeaveRequestsService, ) {} @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 4fce9e0..48e602c 100644 --- a/src/modules/business-logics/services/holiday.service.ts +++ b/src/modules/business-logics/services/holiday.service.ts @@ -1,6 +1,14 @@ import { Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { PrismaService } from "../../../prisma/prisma.service"; 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. +*/ @Injectable() export class HolidayService { @@ -11,7 +19,7 @@ export class HolidayService { //fetch employee_id by email private async resolveEmployeeByEmail(email: string): Promise { const employee = await this.prisma.employees.findFirst({ - where: { + where: { user: { email } }, select: { id: true }, @@ -22,36 +30,49 @@ export class HolidayService { private async computeHoursPrevious4WeeksByEmail(email: string, holiday_date: Date): Promise { const employee_id = await this.resolveEmployeeByEmail(email); - return this.computeHoursPrevious4Weeks(employee_id, holiday_date) + return this.computeHoursPrevious4Weeks(employee_id, holiday_date); } 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_start = new Date(holiday_week_start.getTime() - 4 * WEEK_IN_MS); 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 ) - const valid_codes = ['G1', 'G45', 'G56', 'G104', 'G105', 'G700']; - //fetches all shift of the employee in said window ( 4 previous completed weeks ) + 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; } async calculateHolidayPay( email: string, holiday_date: Date, modifier: number): Promise { - const hours = await this.computeHoursPrevious4WeeksByEmail(email, holiday_date); - const daily_rate = Math.min(hours, 8); - this.logger.debug(`Holiday pay calculation: hours= ${hours.toFixed(2)}`); + 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/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..aaf4e42 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 + //_____________________________________________________________________________________________ + + // @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() - //@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(); - } + // @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(); + // } - @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(':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); + // } - @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); - } + // @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); - } + // @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/employees/controllers/employees.controller.ts b/src/modules/employees/controllers/employees.controller.ts index e46d2cc..b20c78e 100644 --- a/src/modules/employees/controllers/employees.controller.ts +++ b/src/modules/employees/controllers/employees.controller.ts @@ -23,16 +23,7 @@ export class EmployeesController { 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('employee-list') //@RolesAllowed(RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR, RoleEnum.ACCOUNTING) @ApiOperation({summary: 'Find all employees with scoped info' }) @@ -42,34 +33,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') @@ -88,4 +51,47 @@ export class EmployeesController { } return result; } + + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ + + // @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/expenses/controllers/expenses.controller.ts b/src/modules/expenses/controllers/expenses.controller.ts index f4f20cb..c352394 100644 --- a/src/modules/expenses/controllers/expenses.controller.ts +++ b/src/modules/expenses/controllers/expenses.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Query, UseGuards, UsePipes, ValidationPipe } from "@nestjs/common"; +import { Body, Controller, Delete, Get, Param, ParseBoolPipe, ParseIntPipe, Patch, Post, Put, 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"; @@ -8,6 +8,8 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagg 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"; @ApiTags('Expenses') @ApiBearerAuth('access-token') @@ -15,60 +17,73 @@ 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); - } + //_____________________________________________________________________________________________ + // Deprecated or unused methods + //_____________________________________________________________________________________________ - @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); - } + // @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); + // } - @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); - } + // @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); + // } - @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(':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('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); - } + // @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 b840a14..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() - comment?: string; + comment: string; @ApiProperty({ example: 'DENIED, APPROUVED, PENDING, etc...', 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..6ec007e --- /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..3948e70 100644 --- a/src/modules/expenses/expenses.module.ts +++ b/src/modules/expenses/expenses.module.ts @@ -3,12 +3,26 @@ 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 { BankCodesRepo } from "./repos/bank-codes.repo"; +import { TimesheetsRepo } from "./repos/timesheets.repo"; +import { EmployeesRepo } from "./repos/employee.repo"; @Module({ imports: [BusinessLogicsModule], controllers: [ExpensesController], - providers: [ExpensesQueryService, ExpensesCommandService], - exports: [ ExpensesQueryService ], + providers: [ + ExpensesQueryService, + ExpensesCommandService, + BankCodesRepo, + TimesheetsRepo, + EmployeesRepo, + ], + exports: [ + ExpensesQueryService, + BankCodesRepo, + TimesheetsRepo, + EmployeesRepo, + ], }) export class ExpensesModule {} \ No newline at end of file diff --git a/src/modules/expenses/repos/bank-codes.repo.ts b/src/modules/expenses/repos/bank-codes.repo.ts new file mode 100644 index 0000000..1de277d --- /dev/null +++ b/src/modules/expenses/repos/bank-codes.repo.ts @@ -0,0 +1,34 @@ +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 BankCodesRepo { + 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/expenses/repos/employee.repo.ts b/src/modules/expenses/repos/employee.repo.ts new file mode 100644 index 0000000..aeefe53 --- /dev/null +++ b/src/modules/expenses/repos/employee.repo.ts @@ -0,0 +1,32 @@ +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 EmployeesRepo { + constructor(private readonly prisma: PrismaService) {} + + // find employee id by 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; + } +} \ No newline at end of file diff --git a/src/modules/expenses/repos/timesheets.repo.ts b/src/modules/expenses/repos/timesheets.repo.ts new file mode 100644 index 0000000..e140402 --- /dev/null +++ b/src/modules/expenses/repos/timesheets.repo.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; +import { Prisma, PrismaClient } from "@prisma/client"; +import { weekStartMondayUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; +import { PrismaService } from "src/prisma/prisma.service"; + + +type Tx = Prisma.TransactionClient | PrismaClient; + +@Injectable() +export class TimesheetsRepo { + 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 = weekStartMondayUTC(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/expenses/services/expenses-command.service.ts b/src/modules/expenses/services/expenses-command.service.ts index 7a8f722..ae8af6b 100644 --- a/src/modules/expenses/services/expenses-command.service.ts +++ b/src/modules/expenses/services/expenses-command.service.ts @@ -1,13 +1,32 @@ -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 { BankCodesRepo } from "../repos/bank-codes.repo"; +import { TimesheetsRepo } from "../repos/timesheets.repo"; +import { EmployeesRepo } from "../repos/employee.repo"; +import { toDateOnlyUTC } from "src/modules/shifts/helpers/shifts-date-time-helpers"; +import { + BadRequestException, + Injectable, + NotFoundException +} from "@nestjs/common"; +import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces"; +import { + assertAndTrimComment, + computeMileageAmount, + mapDbExpenseToDayResponse, + normalizeType as normalizeTypeUtil +} from "../utils/expenses.utils"; @Injectable() export class ExpensesCommandService extends BaseApprovalService { - constructor(prisma: PrismaService) { super(prisma); } + constructor( + prisma: PrismaService, + private readonly bankCodesRepo: BankCodesRepo, + private readonly timesheetsRepo: TimesheetsRepo, + private readonly employeesRepo: EmployeesRepo, + ) { super(prisma); } protected get delegate() { return this.prisma.expenses; @@ -22,4 +41,273 @@ export class ExpensesCommandService extends BaseApprovalService { this.updateApprovalWithTransaction(transaction, id, isApproved), ); } + + //-------------------- 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 = toDateOnlyUTC(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.resolveEmployeeIdByEmail(email); + + //make sure a timesheet existes + const timesheet_id = await this.ensureTimesheetForDate(employee_id, date_only); + + return this.prisma.$transaction(async (tx) => { + const loadDay = async (): Promise => { + const rows = await tx.expenses.findMany({ + where: { + timesheet_id: timesheet_id, + date: date_only, + }, + include: { + bank_code: { + select: { + type: true, + }, + }, + }, + orderBy: [{ date: 'asc' }, { id: 'asc' }], + }); + + return rows.map((r) => + this.mapDbToDayResponse({ + 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 = this.normalizeType(payload.type); + const comment = this.assertAndTrimComment(payload.comment); + const attachment = this.parseAttachmentId(payload.attachment); + + const { id: bank_code_id, modifier } = await this.lookupBankCodeOrThrow(tx, type); + let amount = this.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 attachmentRow = await tx.attachments.findUnique({ + where: { id: attachment }, + select: { status: true }, + }); + if (!attachmentRow || attachmentRow.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: timesheet_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 oldNorm = await normalizePayload(old_expense); + const existing = await findExactOld(oldNorm); + 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: timesheet_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 oldNorm = await normalizePayload(old_expense); + const existing = await findExactOld(oldNorm); + 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 }; + }); + } + + + //-------------------- helpers -------------------- + private readonly normalizeType = (type: string): string => + normalizeTypeUtil(type); + + private readonly assertAndTrimComment = (comment: string): string => + assertAndTrimComment(comment); + + private readonly 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'); + }; + + + private readonly resolveEmployeeIdByEmail = async (email: string): Promise => + this.employeesRepo.findIdByEmail(email); + + private readonly ensureTimesheetForDate = async ( employee_id: number, date: Date + ): Promise => { + const { id } = await this.timesheetsRepo.ensureForDate(employee_id, date); + return id; + }; + + private readonly lookupBankCodeOrThrow = async ( transaction: Prisma.TransactionClient, type: string + ): Promise<{id: number; modifier: number}> => + this.bankCodesRepo.findByType(type, transaction); + + private readonly 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!); + }; + + private readonly mapDbToDayResponse = (row: { + date: Date; + amount: Prisma.Decimal | number | string; + mileage: Prisma.Decimal | number | string; + comment: string; + is_approved: boolean; + bank_code: { type: string } | null; + }): ExpenseResponse => mapDbExpenseToDayResponse(row); + + } \ 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 42c9679..b719a79 100644 --- a/src/modules/expenses/services/expenses-query.service.ts +++ b/src/modules/expenses/services/expenses-query.service.ts @@ -121,7 +121,7 @@ export class ExpensesQueryService { bank_code_id: exp.bank_code_id, date: exp.date, amount: exp.amount, - attachement: exp.attachement, + attachment: exp.attachment, comment: exp.comment, is_approved: exp.is_approved, supervisor_comment: exp.supervisor_comment, 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..87e2120 --- /dev/null +++ b/src/modules/expenses/utils/expenses.utils.ts @@ -0,0 +1,69 @@ +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, + ); +} + + +//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) }: {}), + }; +} \ No newline at end of file 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..8959e30 100644 --- a/src/modules/leave-requests/leave-requests.module.ts +++ b/src/modules/leave-requests/leave-requests.module.ts @@ -1,13 +1,28 @@ +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"; @Module({ - imports: [BusinessLogicsModule], + imports: [BusinessLogicsModule, ShiftsModule], 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..025833c --- /dev/null +++ b/src/modules/leave-requests/services/holiday-leave-requests.service.ts @@ -0,0 +1,73 @@ +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, normalizeDates, toDateOnly } from '../utils/leave-request.util'; + + +@Injectable() +export class HolidayLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + private readonly holidayService: HolidayService, + private readonly leaveUtils: LeaveRequestsUtils, + ) {} + + async create(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveUtils.resolveBankCodeByType(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..d7e3239 --- /dev/null +++ b/src/modules/leave-requests/services/leave-request.service.ts @@ -0,0 +1,243 @@ +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, normalizeDates, toDateOnly, toISODateKey } from "../utils/leave-request.util"; + +@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, + ) {} + + //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.leaveUtils.resolveEmployeeIdByEmail(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.leaveUtils.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveUtils.resolveBankCodeByType(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..cde2013 --- /dev/null +++ b/src/modules/leave-requests/services/sick-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 { 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, normalizeDates, toDateOnly } from "../utils/leave-request.util"; + +@Injectable() +export class SickLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + private readonly sickService: SickLeaveService, + private readonly leaveUtils: LeaveRequestsUtils, + ) {} + + async create(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveUtils.resolveBankCodeByType(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..31d1081 --- /dev/null +++ b/src/modules/leave-requests/services/vacation-leave-requests.service.ts @@ -0,0 +1,88 @@ + +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, normalizeDates, toDateOnly } from "../utils/leave-request.util"; + +@Injectable() +export class VacationLeaveRequestsService { + constructor( + private readonly prisma: PrismaService, + private readonly vacationService: VacationService, + private readonly leaveUtils: LeaveRequestsUtils, + ) {} + + async create(dto: UpsertLeaveRequestDto): Promise { + const email = dto.email.trim(); + const employee_id = await this.leaveUtils.resolveEmployeeIdByEmail(email); + const bank_code = await this.leaveUtils.resolveBankCodeByType(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..746a568 --- /dev/null +++ b/src/modules/leave-requests/utils/leave-request.util.ts @@ -0,0 +1,124 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { LeaveTypes } from "@prisma/client"; +import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service"; +import { PrismaService } from "src/prisma/prisma.service"; + +@Injectable() +export class LeaveRequestsUtils { + constructor( + private readonly prisma: PrismaService, + private readonly shiftsCommand: ShiftsCommandService, + ){} + + 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; + } + + async resolveBankCodeByType(type: LeaveTypes) { + const bankCode = await this.prisma.bankCodes.findFirst({ + where: { type }, + select: { id: true, bank_code: true, modifier: true }, + }); + if (!bankCode) throw new BadRequestException(`Bank code type "${type}" not found`); + return bankCode; + } + + async syncShift( + email: string, + employee_id: number, + iso_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 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: new Date(iso_date), + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + + await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + old_shift: existing + ? { + 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, + comment: existing.comment ?? undefined, + } + : undefined, + new_shift: { + start_time: toHHmm(start_minutes), + end_time: toHHmm(end_minutes), + is_remote: existing?.is_remote ?? false, + comment: comment ?? existing?.comment ?? "", + type: type, + }, + }); + } + + async removeShift( + email: string, + employee_id: number, + iso_date: string, + type: LeaveTypes, + ) { + const existing = await this.prisma.shifts.findFirst({ + where: { + date: new Date(iso_date), + bank_code: { type }, + timesheet: { employee_id: employee_id }, + }, + include: { bank_code: true }, + }); + if (!existing) return; + + await this.shiftsCommand.upsertShiftsByDate(email, iso_date, { + old_shift: { + 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, + comment: existing.comment ?? undefined, + }, + }); + } + +} + + +export const toDateOnly = (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(toDateOnly(iso))))); \ No newline at end of file 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/pay-periods.module.ts b/src/modules/pay-periods/pay-periods.module.ts index 6772529..fd9106b 100644 --- a/src/modules/pay-periods/pay-periods.module.ts +++ b/src/modules/pay-periods/pay-periods.module.ts @@ -7,6 +7,9 @@ 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 { BankCodesRepo } from "../expenses/repos/bank-codes.repo"; +import { EmployeesRepo } from "../expenses/repos/employee.repo"; +import { TimesheetsRepo } from "../expenses/repos/timesheets.repo"; @Module({ imports: [PrismaModule, TimesheetsModule], @@ -16,12 +19,14 @@ import { ShiftsCommandService } from "../shifts/services/shifts-command.service" TimesheetsCommandService, ExpensesCommandService, ShiftsCommandService, + BankCodesRepo, + TimesheetsRepo, + EmployeesRepo, ], controllers: [PayPeriodsController], exports: [ PayPeriodsQueryService, PayPeriodsCommandService, - PayPeriodsQueryService, ] }) 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..2bfa3e3 --- /dev/null +++ b/src/modules/preferences/dtos/preferences.dto.ts @@ -0,0 +1,16 @@ +import { IsBoolean, IsEmail, IsString } from "class-validator"; + +export class PreferencesDto { + + @IsBoolean() + notifications: boolean; + + @IsBoolean() + dark_mode: boolean; + + @IsBoolean() + lang_switch: boolean; + + @IsBoolean() + lefty_mode: boolean; +} \ 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..94161cb --- /dev/null +++ b/src/modules/preferences/preferences.module.ts @@ -0,0 +1,11 @@ +import { Module } from "@nestjs/common"; +import { PreferencesController } from "./controllers/preferences.controller"; +import { PreferencesService } from "./services/preferences.service"; + +@Module({ +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..cafac84 --- /dev/null +++ b/src/modules/preferences/services/preferences.service.ts @@ -0,0 +1,32 @@ +import { Injectable, NotFoundException } from "@nestjs/common"; +import { Preferences } from "@prisma/client"; +import { PrismaService } from "src/prisma/prisma.service"; +import { PreferencesDto } from "../dtos/preferences.dto"; + +@Injectable() +export class PreferencesService { + constructor(private readonly prisma: PrismaService){} + + async resolveUserIdWithEmail(email: string): Promise { + const user = await this.prisma.users.findFirst({ + where: { email }, + select: { id: true }, + }); + if(!user) throw new NotFoundException(`User with email ${ email } not found`); + return user.id; + } + + async updatePreferences(email: string, dto: PreferencesDto ): Promise { + const user_id = await this.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/shifts/controllers/shifts.controller.ts b/src/modules/shifts/controllers/shifts.controller.ts index b323988..c936026 100644 --- a/src/modules/shifts/controllers/shifts.controller.ts +++ b/src/modules/shifts/controllers/shifts.controller.ts @@ -19,7 +19,6 @@ export class ShiftsController { constructor( private readonly shiftsService: ShiftsQueryService, private readonly shiftsCommandService: ShiftsCommandService, - private readonly shiftsValidationService: ShiftsQueryService, ){} @Put('upsert/:email/:date') @@ -28,53 +27,7 @@ export class ShiftsController { @Param('date') date_param: string, @Body() payload: UpsertShiftDto, ) { - return this.shiftsCommandService.upsertShfitsByDate(email_param, date_param, payload); - } - - @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); + return this.shiftsCommandService.upsertShiftsByDate(email_param, date_param, payload); } @Patch('approval/:id') @@ -85,15 +38,14 @@ export class ShiftsController { @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', @@ -124,5 +76,55 @@ export class ShiftsController { 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/upsert-shift.dto.ts b/src/modules/shifts/dtos/upsert-shift.dto.ts index 83900dd..b82fbb5 100644 --- a/src/modules/shifts/dtos/upsert-shift.dto.ts +++ b/src/modules/shifts/dtos/upsert-shift.dto.ts @@ -1,7 +1,7 @@ import { Type } from "class-transformer"; import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator"; -export const COMMENT_MAX_LENGTH = 512; +export const COMMENT_MAX_LENGTH = 280; export class ShiftPayloadDto { diff --git a/src/modules/shifts/services/shifts-command.service.ts b/src/modules/shifts/services/shifts-command.service.ts index e0c55af..cccfbcc 100644 --- a/src/modules/shifts/services/shifts-command.service.ts +++ b/src/modules/shifts/services/shifts-command.service.ts @@ -20,7 +20,7 @@ export class ShiftsCommandService extends BaseApprovalService { constructor(prisma: PrismaService) { super(prisma); } //create/update/delete master method -async upsertShfitsByDate(email:string, date_string: string, dto: UpsertShiftDto): +async upsertShiftsByDate(email:string, date_string: string, dto: UpsertShiftDto): Promise<{ action: UpsertAction; day: DayShiftResponse[] }> { const { old_shift, new_shift } = dto; diff --git a/src/modules/shifts/services/shifts-query.service.ts b/src/modules/shifts/services/shifts-query.service.ts index 7bc6efe..cd1c286 100644 --- a/src/modules/shifts/services/shifts-query.service.ts +++ b/src/modules/shifts/services/shifts-query.service.ts @@ -99,10 +99,10 @@ export class ShiftsQueryService { 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 }), + ...(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, @@ -115,7 +115,7 @@ export class ShiftsQueryService { 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 }, diff --git a/src/modules/timesheets/controllers/timesheets.controller.ts b/src/modules/timesheets/controllers/timesheets.controller.ts index 2dff5b4..b5d2176 100644 --- a/src/modules/timesheets/controllers/timesheets.controller.ts +++ b/src/modules/timesheets/controllers/timesheets.controller.ts @@ -50,27 +50,34 @@ export class TimesheetsController { } - @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); - } + + + //_____________________________________________________________________________________________ + // 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); + // } - @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); - } + // @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); + // } - @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/services/timesheets-query.service.ts b/src/modules/timesheets/services/timesheets-query.service.ts index 67ed8e2..8af043e 100644 --- a/src/modules/timesheets/services/timesheets-query.service.ts +++ b/src/modules/timesheets/services/timesheets-query.service.ts @@ -1,12 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { PrismaService } from 'src/prisma/prisma.service'; import { Timesheets, TimesheetsArchive } from '@prisma/client'; -import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; -import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; -import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { buildPeriod, endOfDayUTC, toUTCDateOnly } from '../utils/timesheet.helpers'; +import { computeHours, formatDateISO, getWeekEnd, getWeekStart } from 'src/common/utils/date-utils'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { OvertimeService } from 'src/modules/business-logics/services/overtime.service'; +import { TimesheetDto } from '../dtos/overview-timesheet.dto'; +import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto'; import type { ShiftRow, ExpenseRow } from '../utils/timesheet.helpers'; -import { TimesheetDto } from '../dtos/overview-timesheet.dto'; @Injectable() @@ -174,14 +174,14 @@ export class TimesheetsQueryService { const to_HH_mm = (date: Date) => date.toISOString().slice(11, 16); //maps all shifts of selected timesheet - const shifts = timesheet.shift.map((sft) => ({ - bank_type: sft.bank_code?.type ?? '', - date: formatDateISO(sft.date), - start_time: to_HH_mm(sft.start_time), - end_time: to_HH_mm(sft.end_time), - comment: sft.comment ?? '', - is_approved: sft.is_approved ?? false, - is_remote: sft.is_remote ?? false, + const shifts = timesheet.shift.map((shift_row) => ({ + bank_type: shift_row.bank_code?.type ?? '', + date: formatDateISO(shift_row.date), + start_time: to_HH_mm(shift_row.start_time), + end_time: to_HH_mm(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 diff --git a/src/modules/timesheets/timesheets.module.ts b/src/modules/timesheets/timesheets.module.ts index cbfc001..b957fe6 100644 --- a/src/modules/timesheets/timesheets.module.ts +++ b/src/modules/timesheets/timesheets.module.ts @@ -5,6 +5,9 @@ import { BusinessLogicsModule } from 'src/modules/business-logics/business-logic import { TimesheetsCommandService } from './services/timesheets-command.service'; import { ShiftsCommandService } from '../shifts/services/shifts-command.service'; import { ExpensesCommandService } from '../expenses/services/expenses-command.service'; +import { BankCodesRepo } from '../expenses/repos/bank-codes.repo'; +import { TimesheetsRepo } from '../expenses/repos/timesheets.repo'; +import { EmployeesRepo } from '../expenses/repos/employee.repo'; @Module({ imports: [BusinessLogicsModule], @@ -13,7 +16,10 @@ import { ExpensesCommandService } from '../expenses/services/expenses-command.se TimesheetsQueryService, TimesheetsCommandService, ShiftsCommandService, - ExpensesCommandService + ExpensesCommandService, + BankCodesRepo, + TimesheetsRepo, + EmployeesRepo, ], exports: [TimesheetsQueryService], })