Merge branch 'main' of git.targo.ca:Targo/targo_backend
This commit is contained in:
commit
dd15a6dc14
|
|
@ -29,99 +29,31 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/archives/employees": {
|
"/auth/v1/login": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "EmployeesArchiveController_findOneArchived",
|
"operationId": "AuthController_login",
|
||||||
"parameters": [
|
"parameters": [],
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Archived employee found"
|
"description": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summary": "Fetch employee in archives with its Id",
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"Employee Archives"
|
"Auth"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/archives/expenses": {
|
"/auth/callback": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "ExpensesArchiveController_findOneArchived",
|
"operationId": "AuthController_loginCallback",
|
||||||
"parameters": [
|
"parameters": [],
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Archived expense found"
|
"description": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summary": "Fetch expense in archives with its Id",
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"Expense Archives"
|
"Auth"
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/archives/shifts": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "ShiftsArchiveController_findOneArchived",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Archived shift found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Fetch shift in archives with its Id",
|
|
||||||
"tags": [
|
|
||||||
"Shift Archives"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/archives/timesheets": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "TimesheetsArchiveController_findOneArchived",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Archived timesheet found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Fetch timesheet in archives with its Id",
|
|
||||||
"tags": [
|
|
||||||
"Timesheet Archives"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -221,6 +153,8 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
"/employees/profile/{email}": {
|
"/employees/profile/{email}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "EmployeesController_findOneProfile",
|
"operationId": "EmployeesController_findOneProfile",
|
||||||
|
|
@ -635,6 +569,7 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
|
||||||
"/notifications/summary": {
|
"/notifications/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "NotificationsController_summary",
|
"operationId": "NotificationsController_summary",
|
||||||
|
|
@ -663,6 +598,8 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
"/leave-requests/upsert": {
|
"/leave-requests/upsert": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "LeaveRequestController_upsertLeaveRequest",
|
"operationId": "LeaveRequestController_upsertLeaveRequest",
|
||||||
|
|
@ -734,6 +671,7 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
|
||||||
"/oauth-sessions": {
|
"/oauth-sessions": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "OauthSessionsController_create",
|
"operationId": "OauthSessionsController_create",
|
||||||
|
|
@ -928,251 +866,6 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/pay-periods/current-and-all": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "PayPeriodsController_getCurrentAndAll",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "date",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"description": "Override for resolving the current period",
|
|
||||||
"schema": {
|
|
||||||
"example": "2025-08-11",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Find current and all pay periods",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/PayPeriodBundleDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Return current pay period and the full list",
|
|
||||||
"tags": [
|
|
||||||
"pay-periods"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/pay-periods/date/{date}": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "PayPeriodsController_findByDate",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "date",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Pay period found for the selected date",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/PayPeriodDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Pay period not found for the selected date"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Resolve a period by a date within it",
|
|
||||||
"tags": [
|
|
||||||
"pay-periods"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/pay-periods/{year}/{periodNumber}": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "PayPeriodsController_findOneByYear",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "year",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"example": 2024,
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "periodNumber",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"description": "1..26",
|
|
||||||
"schema": {
|
|
||||||
"example": 1,
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Pay period found",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/PayPeriodDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Pay period not found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Find pay period by year and period number",
|
|
||||||
"tags": [
|
|
||||||
"pay-periods"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/pay-periods/crew/bulk-approval": {
|
|
||||||
"patch": {
|
|
||||||
"operationId": "PayPeriodsController_bulkApproval",
|
|
||||||
"parameters": [],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/BulkCrewApprovalDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Pay period approved"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Approve all selected timesheets in the period",
|
|
||||||
"tags": [
|
|
||||||
"pay-periods"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/pay-periods/{year}/{periodNumber}/{email}": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "PayPeriodsController_getCrewOverview",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "year",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"example": 2024,
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "periodNumber",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"description": "1..26",
|
|
||||||
"schema": {
|
|
||||||
"example": 1,
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "email",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "includeSubtree",
|
|
||||||
"required": false,
|
|
||||||
"in": "query",
|
|
||||||
"description": "Include indirect reports",
|
|
||||||
"schema": {
|
|
||||||
"example": false,
|
|
||||||
"type": "boolean"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Crew overview",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/PayPeriodOverviewDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Pay period not found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Supervisor crew overview for a given pay period",
|
|
||||||
"tags": [
|
|
||||||
"pay-periods"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/pay-periods/overview/{year}/{periodNumber}": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "PayPeriodsController_getOverviewByYear",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "year",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"schema": {
|
|
||||||
"example": 2024,
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "periodNumber",
|
|
||||||
"required": true,
|
|
||||||
"in": "path",
|
|
||||||
"description": "1..26",
|
|
||||||
"schema": {
|
|
||||||
"example": 1,
|
|
||||||
"type": "number"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Pay period overview found",
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/PayPeriodOverviewDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Pay period not found"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"summary": "Detailed view of a pay period by year + number",
|
|
||||||
"tags": [
|
|
||||||
"pay-periods"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/preferences/{email}": {
|
"/preferences/{email}": {
|
||||||
"patch": {
|
"patch": {
|
||||||
"operationId": "PreferencesController_updatePreferences",
|
"operationId": "PreferencesController_updatePreferences",
|
||||||
|
|
@ -1306,6 +999,123 @@
|
||||||
"SchedulePresets"
|
"SchedulePresets"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/shift": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "ShiftController_getShiftsByIds",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "shift_ids",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Shift"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"operationId": "ShiftController_updateBatch",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Shift"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/shift/{timesheet_id}": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "ShiftController_createBatch",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "timesheet_id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Shift"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/shift/{shift_id}": {
|
||||||
|
"delete": {
|
||||||
|
"operationId": "ShiftController_remove",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "shift_id",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Shift"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/timesheets": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "TimesheetController_getTimesheetByIds",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "timesheet_ids",
|
||||||
|
"required": true,
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Timesheet"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"info": {
|
"info": {
|
||||||
|
|
@ -1517,6 +1327,8 @@
|
||||||
"first_work_day"
|
"first_work_day"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
<<<<<<< HEAD
|
||||||
|
=======
|
||||||
"EmployeeProfileItemDto": {
|
"EmployeeProfileItemDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {}
|
||||||
|
|
@ -1537,6 +1349,7 @@
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {}
|
||||||
},
|
},
|
||||||
|
>>>>>>> 88f7c0cb0e3824f6faed91058a7d55a9cca048a7
|
||||||
"CreateOauthSessionDto": {
|
"CreateOauthSessionDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -1645,172 +1458,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"PayPeriodDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"pay_period_no": {
|
|
||||||
"type": "number",
|
|
||||||
"example": 1,
|
|
||||||
"description": "numéro cyclique de la période entre 1 et 26"
|
|
||||||
},
|
|
||||||
"period_start": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2023-12-17",
|
|
||||||
"format": "date"
|
|
||||||
},
|
|
||||||
"period_end": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2023-12-30",
|
|
||||||
"format": "date"
|
|
||||||
},
|
|
||||||
"payday": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2023-01-04",
|
|
||||||
"format": "date"
|
|
||||||
},
|
|
||||||
"pay_year": {
|
|
||||||
"type": "number",
|
|
||||||
"example": 2023
|
|
||||||
},
|
|
||||||
"label": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2023-12-17 → 2023-12-30"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"pay_period_no",
|
|
||||||
"period_start",
|
|
||||||
"period_end",
|
|
||||||
"payday",
|
|
||||||
"pay_year",
|
|
||||||
"label"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"PayPeriodBundleDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"current": {
|
|
||||||
"description": "Current pay period (resolved from date)",
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/PayPeriodDto"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"periods": {
|
|
||||||
"description": "All pay periods",
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/PayPeriodDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"current",
|
|
||||||
"periods"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"BulkCrewApprovalDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {}
|
|
||||||
},
|
|
||||||
"EmployeePeriodOverviewDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"employee_name": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Alex Dupont",
|
|
||||||
"description": "Nom complet de lemployé"
|
|
||||||
},
|
|
||||||
"regular_hours": {
|
|
||||||
"type": "number",
|
|
||||||
"example": 40,
|
|
||||||
"description": "pay-period`s regular hours"
|
|
||||||
},
|
|
||||||
"other_hours": {
|
|
||||||
"type": "object",
|
|
||||||
"example": 0,
|
|
||||||
"description": "pay-period`s other hours"
|
|
||||||
},
|
|
||||||
"expenses": {
|
|
||||||
"type": "number",
|
|
||||||
"example": 420.69,
|
|
||||||
"description": "pay-period`s total expenses ($)"
|
|
||||||
},
|
|
||||||
"mileage": {
|
|
||||||
"type": "number",
|
|
||||||
"example": 40,
|
|
||||||
"description": "pay-period total mileages (km)"
|
|
||||||
},
|
|
||||||
"is_approved": {
|
|
||||||
"type": "boolean",
|
|
||||||
"example": true,
|
|
||||||
"description": "Tous les timesheets de la période sont approuvés pour cet employé"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"employee_name",
|
|
||||||
"regular_hours",
|
|
||||||
"other_hours",
|
|
||||||
"expenses",
|
|
||||||
"mileage",
|
|
||||||
"is_approved"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"PayPeriodOverviewDto": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"pay_period_no": {
|
|
||||||
"type": "number",
|
|
||||||
"example": 1,
|
|
||||||
"description": "Period number (1–26)"
|
|
||||||
},
|
|
||||||
"pay_year": {
|
|
||||||
"type": "number",
|
|
||||||
"example": 2023,
|
|
||||||
"description": "Calendar year of the period"
|
|
||||||
},
|
|
||||||
"period_start": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2023-12-17",
|
|
||||||
"format": "date",
|
|
||||||
"description": "Period start date (YYYY-MM-DD)"
|
|
||||||
},
|
|
||||||
"period_end": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2023-12-30",
|
|
||||||
"format": "date",
|
|
||||||
"description": "Period end date (YYYY-MM-DD)"
|
|
||||||
},
|
|
||||||
"payday": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2023-12-30",
|
|
||||||
"format": "date",
|
|
||||||
"description": "Period pay day(YYYY-MM-DD)"
|
|
||||||
},
|
|
||||||
"label": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2023-12-17 → 2023-12-30",
|
|
||||||
"description": "Human-readable label"
|
|
||||||
},
|
|
||||||
"employees_overview": {
|
|
||||||
"description": "Per-employee overview for the period",
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/EmployeePeriodOverviewDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"pay_period_no",
|
|
||||||
"pay_year",
|
|
||||||
"period_start",
|
|
||||||
"period_end",
|
|
||||||
"payday",
|
|
||||||
"label",
|
|
||||||
"employees_overview"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"PreferencesDto": {
|
"PreferencesDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {}
|
"properties": {}
|
||||||
|
|
|
||||||
1030
package-lock.json
generated
1030
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
|
|
@ -12,6 +12,7 @@
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main",
|
"start:prod": "node dist/main",
|
||||||
|
"start:variants": "node dist/attachments/workers/variants.worker.js",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
|
|
@ -48,17 +49,20 @@
|
||||||
"@nestjs/platform-express": "^11.1.6",
|
"@nestjs/platform-express": "^11.1.6",
|
||||||
"@nestjs/schedule": "^6.0.0",
|
"@nestjs/schedule": "^6.0.0",
|
||||||
"@nestjs/swagger": "^11.2.0",
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@prisma/client": "^6.14.0",
|
"@prisma/client": "^6.17.1",
|
||||||
|
"bullmq": "^5.58.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.2",
|
"class-validator": "^0.14.2",
|
||||||
"express-session": "^1.18.2",
|
"express-session": "^1.18.2",
|
||||||
"file-type": "^21.0.0",
|
"file-type": "^21.0.0",
|
||||||
|
"ioredis": "^5.7.0",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.0.2",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-openidconnect": "^0.1.2",
|
"passport-openidconnect": "^0.1.2",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.34.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|
@ -82,7 +86,7 @@
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "^6.17.0",
|
"prisma": "^6.17.1",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."attachment_variants" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"attachment_id" INTEGER NOT NULL,
|
||||||
|
"variant" TEXT NOT NULL,
|
||||||
|
"patch" TEXT NOT NULL,
|
||||||
|
"bytes" INTEGER NOT NULL,
|
||||||
|
"width" INTEGER,
|
||||||
|
"height" INTEGER,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "attachment_variants_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "attachment_variants_attachment_id_variant_key" ON "public"."attachment_variants"("attachment_id", "variant");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."attachment_variants" ADD CONSTRAINT "attachment_variants_attachment_id_fkey" FOREIGN KEY ("attachment_id") REFERENCES "public"."attachments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
@ -340,14 +340,14 @@ model Blobs {
|
||||||
refcount Int @default(0)
|
refcount Int @default(0)
|
||||||
created_at DateTime @default(now())
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
attachments Attachments[]
|
attachments Attachments[] @relation("AttachmnentBlob")
|
||||||
|
|
||||||
@@map("blobs")
|
@@map("blobs")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Attachments {
|
model Attachments {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
blob Blobs @relation(fields: [sha256], references: [sha256], onUpdate: Cascade)
|
blob Blobs @relation("AttachmnentBlob",fields: [sha256], references: [sha256], onUpdate: Cascade)
|
||||||
sha256 String @db.Char(64)
|
sha256 String @db.Char(64)
|
||||||
|
|
||||||
owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc
|
owner_type String //EXPENSES ou éventuellement autre chose comme scan ONU ou photos d'employés, etc
|
||||||
|
|
@ -361,11 +361,28 @@ model Attachments {
|
||||||
expenses Expenses[] @relation("ExpenseAttachment")
|
expenses Expenses[] @relation("ExpenseAttachment")
|
||||||
expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment")
|
expenses_archive ExpensesArchive[] @relation("ExpenseArchiveAttachment")
|
||||||
|
|
||||||
|
AttachmentVariants AttachmentVariants[] @relation("attachmentVariantAttachment")
|
||||||
|
|
||||||
@@index([owner_type, owner_id, created_at])
|
@@index([owner_type, owner_id, created_at])
|
||||||
@@index([sha256])
|
@@index([sha256])
|
||||||
@@map("attachments")
|
@@map("attachments")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AttachmentVariants {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
attachment_id Int
|
||||||
|
attachment Attachments @relation("attachmentVariantAttachment",fields: [attachment_id], references: [id], onDelete: Cascade)
|
||||||
|
variant String
|
||||||
|
patch String
|
||||||
|
bytes Int
|
||||||
|
width Int?
|
||||||
|
height Int?
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
|
||||||
|
@@unique([attachment_id, variant])
|
||||||
|
@@map("attachment_variants")
|
||||||
|
}
|
||||||
|
|
||||||
model Preferences {
|
model Preferences {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
user Users @relation("UserPreferences", fields: [user_id], references: [id])
|
user Users @relation("UserPreferences", fields: [user_id], references: [id])
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,21 @@
|
||||||
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, Module, ValidationPipe } from '@nestjs/common';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { ArchivalModule } from './modules/archival/archival.module';
|
// import { ArchivalModule } from './modules/archival/archival.module';
|
||||||
import { AuthenticationModule } from './modules/authentication/auth.module';
|
import { AuthenticationModule } from './modules/authentication/auth.module';
|
||||||
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
|
import { BankCodesModule } from './modules/bank-codes/bank-codes.module';
|
||||||
import { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
|
import { BusinessLogicsModule } from './modules/business-logics/business-logics.module';
|
||||||
// import { CsvExportModule } from './modules/exports/csv-exports.module';
|
// import { CsvExportModule } from './modules/exports/csv-exports.module';
|
||||||
import { CustomersModule } from './modules/customers/customers.module';
|
import { CustomersModule } from './modules/customers/customers.module';
|
||||||
import { EmployeesModule } from './modules/employees/employees.module';
|
import { EmployeesModule } from './modules/employees/employees.module';
|
||||||
import { ExpensesModule } from './modules/expenses/expenses.module';
|
// import { ExpensesModule } from './modules/expenses/expenses.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { HealthController } from './health/health.controller';
|
import { HealthController } from './health/health.controller';
|
||||||
import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module';
|
// import { LeaveRequestsModule } from './modules/leave-requests/leave-requests.module';
|
||||||
import { NotificationsModule } from './modules/notifications/notifications.module';
|
import { NotificationsModule } from './modules/notifications/notifications.module';
|
||||||
import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module';
|
import { OauthSessionsModule } from './modules/oauth-sessions/oauth-sessions.module';
|
||||||
import { OvertimeService } from './modules/business-logics/services/overtime.service';
|
import { OvertimeService } from './modules/business-logics/services/overtime.service';
|
||||||
import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
|
// import { PayperiodsModule } from './modules/pay-periods/pay-periods.module';
|
||||||
import { PreferencesModule } from './modules/preferences/preferences.module';
|
import { PreferencesModule } from './modules/preferences/preferences.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { ScheduleModule } from '@nestjs/schedule';
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
|
@ -30,7 +30,7 @@ import { SchedulePresetsModule } from './modules/schedule-presets/schedule-prese
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ArchivalModule,
|
// ArchivalModule,
|
||||||
AuthenticationModule,
|
AuthenticationModule,
|
||||||
BankCodesModule,
|
BankCodesModule,
|
||||||
BusinessLogicsModule,
|
BusinessLogicsModule,
|
||||||
|
|
@ -38,12 +38,12 @@ import { SchedulePresetsModule } from './modules/schedule-presets/schedule-prese
|
||||||
// CsvExportModule,
|
// CsvExportModule,
|
||||||
CustomersModule,
|
CustomersModule,
|
||||||
EmployeesModule,
|
EmployeesModule,
|
||||||
ExpensesModule,
|
// ExpensesModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
LeaveRequestsModule,
|
// LeaveRequestsModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
OauthSessionsModule,
|
OauthSessionsModule,
|
||||||
PayperiodsModule,
|
// PayperiodsModule,
|
||||||
PreferencesModule,
|
PreferencesModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
ScheduleModule.forRoot(), //cronjobs
|
ScheduleModule.forRoot(), //cronjobs
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,34 @@
|
||||||
import { Module } from "@nestjs/common";
|
// import { Module } from "@nestjs/common";
|
||||||
import { ScheduleModule } from "@nestjs/schedule";
|
// import { ScheduleModule } from "@nestjs/schedule";
|
||||||
import { TimesheetsModule } from "../timesheets/timesheets.module";
|
// import { TimesheetsModule } from "../timesheets/timesheets.module";
|
||||||
import { ExpensesModule } from "../expenses/expenses.module";
|
// import { ExpensesModule } from "../expenses/expenses.module";
|
||||||
import { ShiftsModule } from "../shifts/shifts.module";
|
// import { ShiftsModule } from "../shifts/shifts.module";
|
||||||
import { LeaveRequestsModule } from "../leave-requests/leave-requests.module";
|
// import { LeaveRequestsModule } from "../leave-requests/leave-requests.module";
|
||||||
import { ArchivalService } from "./services/archival.service";
|
// import { ArchivalService } from "./services/archival.service";
|
||||||
import { EmployeesArchiveController } from "./controllers/employees-archive.controller";
|
// import { EmployeesArchiveController } from "./controllers/employees-archive.controller";
|
||||||
import { ExpensesArchiveController } from "./controllers/expenses-archive.controller";
|
// import { ExpensesArchiveController } from "./controllers/expenses-archive.controller";
|
||||||
import { LeaveRequestsArchiveController } from "./controllers/leave-requests-archive.controller";
|
// import { LeaveRequestsArchiveController } from "./controllers/leave-requests-archive.controller";
|
||||||
import { ShiftsArchiveController } from "./controllers/shifts-archive.controller";
|
// import { ShiftsArchiveController } from "./controllers/shifts-archive.controller";
|
||||||
import { TimesheetsArchiveController } from "./controllers/timesheets-archive.controller";
|
// import { TimesheetsArchiveController } from "./controllers/timesheets-archive.controller";
|
||||||
import { EmployeesModule } from "../employees/employees.module";
|
// import { EmployeesModule } from "../employees/employees.module";
|
||||||
|
|
||||||
@Module({
|
// @Module({
|
||||||
imports: [
|
// imports: [
|
||||||
EmployeesModule,
|
// EmployeesModule,
|
||||||
ScheduleModule,
|
// ScheduleModule,
|
||||||
TimesheetsModule,
|
// TimesheetsModule,
|
||||||
ExpensesModule,
|
// ExpensesModule,
|
||||||
ShiftsModule,
|
// ShiftsModule,
|
||||||
LeaveRequestsModule,
|
// LeaveRequestsModule,
|
||||||
],
|
// ],
|
||||||
providers: [ArchivalService],
|
// providers: [ArchivalService],
|
||||||
controllers: [
|
// controllers: [
|
||||||
EmployeesArchiveController,
|
// EmployeesArchiveController,
|
||||||
ExpensesArchiveController,
|
// ExpensesArchiveController,
|
||||||
LeaveRequestsArchiveController,
|
// LeaveRequestsArchiveController,
|
||||||
ShiftsArchiveController,
|
// ShiftsArchiveController,
|
||||||
TimesheetsArchiveController,
|
// TimesheetsArchiveController,
|
||||||
],
|
// ],
|
||||||
})
|
// })
|
||||||
|
|
||||||
export class ArchivalModule {}
|
// export class ArchivalModule {}
|
||||||
19
src/modules/attachments/attachments.module.ts
Normal file
19
src/modules/attachments/attachments.module.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { ScheduleModule } from "@nestjs/schedule";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { ArchivalAttachmentService } from "./services/archival-attachment.service";
|
||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { GarbargeCollectorService } from "./services/garbage-collector.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ScheduleModule.forRoot()],
|
||||||
|
providers: [
|
||||||
|
PrismaService,
|
||||||
|
ArchivalAttachmentService,
|
||||||
|
GarbargeCollectorService,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
ArchivalAttachmentService,
|
||||||
|
GarbargeCollectorService
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ArchivalAttachmentModule {}
|
||||||
|
|
@ -2,11 +2,14 @@ import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
import { DiskStorageService } from "../services/disk-storage.service";
|
import { DiskStorageService } from "../services/disk-storage.service";
|
||||||
import {
|
import {
|
||||||
Controller,NotFoundException, UseInterceptors, Post, Get, Param, Res,
|
Controller,NotFoundException, UseInterceptors, Post, Get, Param, Res,
|
||||||
UploadedFile, BadRequestException, UnsupportedMediaTypeException, Body, Delete
|
UploadedFile, BadRequestException, UnsupportedMediaTypeException, Body, Delete,
|
||||||
|
Query,
|
||||||
|
DefaultValuePipe,
|
||||||
|
ParseIntPipe
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { maxUploadBytes, allowedMimes } from "../config/upload.config";
|
import { maxUploadBytes, allowedMimes } from "../config/upload.config";
|
||||||
import { memoryStorage } from 'multer';
|
import { memoryStorage } from 'multer';
|
||||||
import { fileTypeFromBuffer } from "file-type";
|
import { fileTypeFromBuffer, fileTypeFromFile } from "file-type";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { UploadMetaAttachmentsDto } from "../dtos/upload-meta-attachments.dto";
|
import { UploadMetaAttachmentsDto } from "../dtos/upload-meta-attachments.dto";
|
||||||
|
|
@ -15,27 +18,35 @@ import * as path from 'node:path';
|
||||||
import { promises as fsp } from 'node:fs';
|
import { promises as fsp } from 'node:fs';
|
||||||
import { createReadStream } from "node:fs";
|
import { createReadStream } from "node:fs";
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
import { VariantsQueue } from "../services/variants.queue";
|
||||||
|
import { AdminSearchDto } from "../dtos/admin-search.dto";
|
||||||
|
|
||||||
@Controller('attachments')
|
@Controller('attachments')
|
||||||
export class AttachmentsController {
|
export class AttachmentsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly disk: DiskStorageService,
|
private readonly disk: DiskStorageService,
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly variantsQ: VariantsQueue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async getById(@Param('id') id: string, @Res() res: Response) {
|
async getById(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('variant') variant: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
const num_id = Number(id);
|
const num_id = Number(id);
|
||||||
if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid id');
|
if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid id');
|
||||||
|
|
||||||
const att = await this.prisma.attachments.findUnique({
|
const attachment = await this.prisma.attachments.findUnique({
|
||||||
where: { id: num_id },
|
where: { id: num_id },
|
||||||
include: { blob: true },
|
include: { blob: true },
|
||||||
});
|
});
|
||||||
if (!att) throw new NotFoundException();
|
if (!attachment) throw new NotFoundException();
|
||||||
|
|
||||||
|
const relative = variant ? `${attachment.blob.storage_path}.${variant}` : attachment.blob.storage_path;
|
||||||
const abs = path.join(resolveAttachmentsRoot(), att.blob.storage_path);
|
const abs = path.join(resolveAttachmentsRoot(), relative);
|
||||||
|
|
||||||
let stat;
|
let stat;
|
||||||
try {
|
try {
|
||||||
stat = await fsp.stat(abs);
|
stat = await fsp.stat(abs);
|
||||||
|
|
@ -43,9 +54,14 @@ export class AttachmentsController {
|
||||||
throw new NotFoundException('File not found');
|
throw new NotFoundException('File not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
res.set('Content-Type', att.blob.mime);
|
let mime = attachment.blob.mime;
|
||||||
|
try {
|
||||||
|
const kind = await fileTypeFromFile(abs);
|
||||||
|
if(kind?.mime) mime = kind.mime;
|
||||||
|
} catch {}
|
||||||
|
res.set('Content-Type', mime);
|
||||||
res.set('Content-Length', String(stat.size));
|
res.set('Content-Length', String(stat.size));
|
||||||
res.set('ETag', `"sha256-${att.blob.sha256}"`);
|
res.set('ETag', `"sha256-${attachment.blob.sha256}${variant ? '.'+variant : ''}"`);
|
||||||
res.set('Last-Modified', stat.mtime.toUTCString());
|
res.set('Last-Modified', stat.mtime.toUTCString());
|
||||||
res.set('Cache-Control', 'private, max-age=31536000, immutable');
|
res.set('Cache-Control', 'private, max-age=31536000, immutable');
|
||||||
res.set('X-Content-Type-Options', 'nosniff');
|
res.set('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
@ -53,7 +69,17 @@ export class AttachmentsController {
|
||||||
createReadStream(abs).pipe(res);
|
createReadStream(abs).pipe(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEV version, uncomment once connected to DB and distant server
|
@Get('variants/:id')
|
||||||
|
async listVariants(@Param('id')id: string) {
|
||||||
|
const num_id = Number(id);
|
||||||
|
if(!Number.isFinite(num_id)) throw new NotFoundException('Invalid variant id');
|
||||||
|
return this.prisma.attachmentVariants.findMany({
|
||||||
|
where: { attachment_id: num_id },
|
||||||
|
orderBy: { variant: 'asc'},
|
||||||
|
select: { variant: true, bytes: true, width: true, height: true, patch: true, created_at: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
async remove(@Param('id') id: string) {
|
async remove(@Param('id') id: string) {
|
||||||
const result = await this.prisma.$transaction(async (tx) => {
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
|
|
@ -136,6 +162,8 @@ export class AttachmentsController {
|
||||||
return att;
|
return att;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.variantsQ.enqueue(attachment.id, detected_mime);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
|
|
@ -148,4 +176,39 @@ export class AttachmentsController {
|
||||||
owner_id: attachment.owner_id,
|
owner_id: attachment.owner_id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
@Get('/admin/search')
|
||||||
|
async adminSearch(
|
||||||
|
@Query() query: AdminSearchDto ) {
|
||||||
|
const where: any = {};
|
||||||
|
if (query.owner_type) where.owner_type = query.owner_type;
|
||||||
|
if (query.owner_id) where.owner_id = query.owner_id;
|
||||||
|
|
||||||
|
if (query.date_from || query.date_to) {
|
||||||
|
where.created_at = {};
|
||||||
|
if (query.date_from) where.created_at.gte = new Date(query.date_from + 'T00:00:00Z');
|
||||||
|
if (query.date_to) where.created_at.lte = new Date(query.date_to + 'T23:59:59Z');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = query.page ?? 1;
|
||||||
|
const page_size = query.page_size ?? 50;
|
||||||
|
const skip = (page - 1)* page_size;
|
||||||
|
const take = page_size;
|
||||||
|
|
||||||
|
const [items, total] = await this.prisma.$transaction([
|
||||||
|
this.prisma.attachments.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { created_at: 'desc' },
|
||||||
|
skip, take,
|
||||||
|
include: {
|
||||||
|
blob: {
|
||||||
|
select: { mime: true, size: true, storage_path: true, sha256: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.attachments.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { page, page_size: take, total, items };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
34
src/modules/attachments/dtos/admin-search.dto.ts
Normal file
34
src/modules/attachments/dtos/admin-search.dto.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
import { IsInt, IsISO8601, IsOptional, IsString, Max, Min } from "class-validator";
|
||||||
|
|
||||||
|
export class AdminSearchDto {
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
owner_type?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
owner_id?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
date_from?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsISO8601()
|
||||||
|
date_to?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(()=> Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(()=> Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(200)
|
||||||
|
page_size?: number = 50;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { Cron } from "@nestjs/schedule";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ArchivalAttachmentService {
|
||||||
|
private readonly logger = new Logger(ArchivalAttachmentService.name)
|
||||||
|
private readonly batch_size = Number(process.env.ARCHIVE_BATCH_SIZE || 1000);
|
||||||
|
private readonly cron_expression = process.env.ARCHIVE_CRON || '0 3 * * 1';
|
||||||
|
|
||||||
|
constructor( private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
private startOfYear(): Date {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(Date.UTC(now.getUTCFullYear(), 0, 1, 0, 0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cron(function (this: ArchivalAttachmentService) { return this.cron_expression; } as any)
|
||||||
|
async runScheduled() {
|
||||||
|
await this.archiveCutoffToStartOfYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
//archive everything before current year
|
||||||
|
async archiveCutoffToStartOfYear() {
|
||||||
|
const cutoff = this.startOfYear();
|
||||||
|
this.logger.log(`Archival: cutoff=${cutoff.toISOString()} batch=${this.batch_size}`);
|
||||||
|
|
||||||
|
let moved = 0, total = 0, i = 0;
|
||||||
|
do {
|
||||||
|
moved = await this.archiveBatch(cutoff, this.batch_size);
|
||||||
|
total += moved;
|
||||||
|
i++;
|
||||||
|
if(moved > 0) this.logger.log(`Batch #${i}: moved ${moved}`);
|
||||||
|
}while (moved === this.batch_size);
|
||||||
|
|
||||||
|
this.logger.log(`Archival done: total moved : ${total}`);
|
||||||
|
return { moved: total };
|
||||||
|
}
|
||||||
|
|
||||||
|
//only moves table content to archive and not blobs.
|
||||||
|
private async archiveBatch(cutoff: Date, batch_size: number): Promise<number> {
|
||||||
|
const moved = await this.prisma.$executeRaw`
|
||||||
|
WITH moved AS (
|
||||||
|
DELETE FROM "attachments"
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM "attachments"
|
||||||
|
WHERE created_at < ${cutoff}
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT ${batch_size}
|
||||||
|
)
|
||||||
|
RETURNING id, sha256, owner_type, owner_id, original_name, status, retention_policy, created_by, created_at
|
||||||
|
)
|
||||||
|
INSERT INTO archive.attachments_archive
|
||||||
|
(id, sha256, owner_type, owner_id, original_name, status, retention_policy, created_by, created_at)
|
||||||
|
SELECT * FROM moved;
|
||||||
|
`;
|
||||||
|
return Number(moved) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ export class DiskStorageService {
|
||||||
|
|
||||||
private casPath(hash: string) {
|
private casPath(hash: string) {
|
||||||
const a = hash.slice(0,2), b = hash.slice(2,4);
|
const a = hash.slice(0,2), b = hash.slice(2,4);
|
||||||
return `sha256/${a}/${b}/${hash}`; //relatif pour stockage dans la DB
|
return `sha256/${a}/${b}/${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
//chemin absolue du storage
|
//chemin absolue du storage
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
|
import { Cron } from "@nestjs/schedule";
|
||||||
|
import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { promises as fsp } from 'node:fs';
|
||||||
|
import { resolveAttachmentsRoot } from "src/config/attachment.config";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GarbargeCollectorService {
|
||||||
|
private readonly logger = new Logger(GarbargeCollectorService.name);
|
||||||
|
|
||||||
|
//.env refs
|
||||||
|
private readonly batch_size = Number(process.env.GC_BATCH_SIZE || 500);
|
||||||
|
private readonly cron_expression = process.env.GC_CRON || '15 4 * * *'; // everyday at 04:15 AM
|
||||||
|
|
||||||
|
//fetchs root of storage
|
||||||
|
private readonly root = resolveAttachmentsRoot();
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
//planif for the Cronjob
|
||||||
|
@Cron(function(this:GarbargeCollectorService) { return this.cron_expression; } as any)
|
||||||
|
async runScheduled() {
|
||||||
|
await this.collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
//Manage Garbage collecting by batch of elements until a batch != full
|
||||||
|
async collect() {
|
||||||
|
let total = 0, round = 0;
|
||||||
|
//infinit loop (;;) with break
|
||||||
|
for(;;) {
|
||||||
|
round++;
|
||||||
|
const num = await this.collectBatch();
|
||||||
|
total += num;
|
||||||
|
this.logger.log(`Garbage Collector round #${round} removed ${num}`);
|
||||||
|
if(num < this.batch_size) break; //breaks if not a full batch
|
||||||
|
}
|
||||||
|
this.logger.log(`Garbage Collecting done: total removed ${total}`);
|
||||||
|
return { removed:total };
|
||||||
|
}
|
||||||
|
|
||||||
|
//Manage a single lot of orphan blobs
|
||||||
|
private async collectBatch(): Promise<number> {
|
||||||
|
const blobs = await this.prisma.blobs.findMany({
|
||||||
|
where: { refcount: { lte: 0 } },
|
||||||
|
select: { sha256: true, storage_path: true },
|
||||||
|
take: this.batch_size,
|
||||||
|
});
|
||||||
|
if(blobs.length === 0) return 0;
|
||||||
|
|
||||||
|
// delete original file and all its variants <hash> in the same file
|
||||||
|
await Promise.all(
|
||||||
|
blobs.map(async (blob)=> {
|
||||||
|
const absolute_path = path.join(this.root, blob.storage_path);
|
||||||
|
await this.deleteFileIfExists(absolute_path); //tries to delete original file if found
|
||||||
|
|
||||||
|
const dir = path.dirname(absolute_path);
|
||||||
|
const base = path.basename(absolute_path);
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(dir, { withFileTypes: true});
|
||||||
|
const targets = entries.filter(entry => entry.isFile() && entry.name.startsWith(base + '.'))
|
||||||
|
.map(entry => path.join(dir, entry.name));
|
||||||
|
//deletes all variants
|
||||||
|
await Promise.all(targets.map(target => this.deleteFileIfExists(target)));
|
||||||
|
} catch {}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
//deletes blobs lignes if file is deleted
|
||||||
|
const hashes = blobs.map(blob => blob.sha256);
|
||||||
|
await this.prisma.blobs.deleteMany({where: { sha256: { in: hashes } } });
|
||||||
|
return blobs.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
//helper: deletes path if exists and ignore errors
|
||||||
|
private async deleteFileIfExists(path: string) {
|
||||||
|
try { await fsp.unlink(path); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/modules/attachments/services/variants.queue.ts
Normal file
20
src/modules/attachments/services/variants.queue.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Queue } from "bullmq";
|
||||||
|
|
||||||
|
export class VariantsQueue {
|
||||||
|
private queue : Queue;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const name = `${process.env.BULL_PREFIX || 'attachments'}:variants`;
|
||||||
|
this.queue = new Queue(name, { connection: { url: process.env.REDIS_URL! } });
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(attachment_id: number, mime: string) {
|
||||||
|
if(!mime.startsWith('image/')) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return this.queue.add('generate',
|
||||||
|
{ attachment_id, mime },
|
||||||
|
{ attempts: 3, backoff: { type: 'exponential', delay:2000 } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/modules/attachments/workers/variants.worker.ts
Normal file
54
src/modules/attachments/workers/variants.worker.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { Worker } from 'bullmq';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { promises as fsp } from 'node:fs';
|
||||||
|
import { resolveAttachmentsRoot } from 'src/config/attachment.config';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
const q_name = `${process.env.BULL_PREFIX || 'attachments'}:variants`;
|
||||||
|
const root = resolveAttachmentsRoot();
|
||||||
|
|
||||||
|
const variants = [
|
||||||
|
{ name: 'thumb.jpeg', build: (s:sharp.Sharp) => s.rotate().jpeg({quality:80}).resize({width:128}) },
|
||||||
|
{ name: '256w.webp' , build: (s:sharp.Sharp) => s.rotate().webp({quality:80}).resize({width:256}) },
|
||||||
|
{ name: '1024w.webp', build: (s:sharp.Sharp) => s.rotate().webp({quality:82}).resize({width:1024}) },
|
||||||
|
]
|
||||||
|
|
||||||
|
new Worker(q_name, async job => {
|
||||||
|
const attachment_id: number = job.data.attachmentId ?? job.data.attachment_id;
|
||||||
|
if (!attachment_id) return;
|
||||||
|
|
||||||
|
const attachment = await prisma.attachments.findUnique({
|
||||||
|
where: { id: attachment_id },
|
||||||
|
include: { blob: true },
|
||||||
|
});
|
||||||
|
if(!attachment) return;
|
||||||
|
|
||||||
|
const source_abs = path.join(root, attachment.blob.storage_path);
|
||||||
|
|
||||||
|
for(const variant of variants) {
|
||||||
|
const relative = `${attachment.blob.storage_path}.${variant.name}`;
|
||||||
|
const out_Abs = path.join(root, relative);
|
||||||
|
|
||||||
|
//try for idem paths
|
||||||
|
try{ await fsp.stat(out_Abs); continue; } catch{}
|
||||||
|
|
||||||
|
await fsp.mkdir(path.dirname(out_Abs), { recursive: true });
|
||||||
|
|
||||||
|
//generate variant
|
||||||
|
await variant.build(sharp(source_abs)).toFile(out_Abs);
|
||||||
|
|
||||||
|
//meta data of generated variant file
|
||||||
|
const meta = await sharp(out_Abs).metadata();
|
||||||
|
const bytes = (await fsp.stat(out_Abs)).size;
|
||||||
|
await prisma.attachmentVariants.upsert({
|
||||||
|
where: { attachment_id_variant: { attachment_id: attachment_id, variant: variant.name } },
|
||||||
|
update: { path: relative, bytes, width: meta.width ?? null, height: meta.height ?? null },
|
||||||
|
create: { path: relative, bytes, width: meta.width ?? null, height: meta.height ?? null, attachment_id: attachment_id, variant: variant.name },
|
||||||
|
} as any );
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
connection: { url: process.env.REDIS_URL }, concurrency: 3 }
|
||||||
|
);
|
||||||
|
|
@ -1,152 +1,247 @@
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils';
|
import { getWeekStart, getWeekEnd, computeHours } from 'src/common/utils/date-utils';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma, PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
type Tx = Prisma.TransactionClient | PrismaClient;
|
||||||
|
|
||||||
|
export type WeekOvertimeSummary = {
|
||||||
|
week_start:string;
|
||||||
|
week_end: string;
|
||||||
|
week_total_hours: number;
|
||||||
|
weekly_overtime: number;
|
||||||
|
daily_overtime_kept: number;
|
||||||
|
total_overtime: number;
|
||||||
|
breakdown: Array<{
|
||||||
|
date:string;
|
||||||
|
day_hours: number;
|
||||||
|
day_overtime: number;
|
||||||
|
daily_kept: number;
|
||||||
|
running_total_before: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OvertimeService {
|
export class OvertimeService {
|
||||||
|
|
||||||
private logger = new Logger(OvertimeService.name);
|
private logger = new Logger(OvertimeService.name);
|
||||||
private daily_max = 8; // maximum for regular hours per day
|
private daily_max = 8; // maximum for regular hours per day
|
||||||
private weekly_max = 40; //maximum for regular hours per week
|
private weekly_max = 40; // maximum for regular hours per week
|
||||||
private INCLUDED_TYPES = ['EMERGENCY', 'EVENING','OVERTIME','REGULAR'] as const; // included types for weekly overtime calculation
|
private INCLUDED_TYPES = ['EMERGENCY', 'EVENING','OVERTIME','REGULAR'] as const; // included types for weekly overtime calculation
|
||||||
|
|
||||||
constructor(private prisma: PrismaService) {}
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
//calculate daily overtime
|
async getWeekOvertimeSummary( timesheet_id: number, date: Date, tx?: Tx ): Promise<WeekOvertimeSummary>{
|
||||||
async getDailyOvertimeHours(employee_id: number, date: Date): Promise<number> {
|
|
||||||
const shifts = await this.prisma.shifts.findMany({
|
|
||||||
where: { date: date, timesheet: { employee_id: employee_id } },
|
|
||||||
select: { start_time: true, end_time: true },
|
|
||||||
});
|
|
||||||
const total = shifts.map((shift)=>
|
|
||||||
computeHours(shift.start_time, shift.end_time, 5)).reduce((sum, hours)=> sum + hours, 0);
|
|
||||||
const overtime = Math.max(0, total - this.daily_max);
|
|
||||||
|
|
||||||
this.logger.debug(`[OVERTIME]-[DAILY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
|
|
||||||
return overtime;
|
|
||||||
}
|
|
||||||
|
|
||||||
//calculate Weekly overtime
|
|
||||||
async getWeeklyOvertimeHours(employee_id: number, ref_date: Date): Promise<number> {
|
|
||||||
const week_start = getWeekStart(ref_date);
|
|
||||||
const week_end = getWeekEnd(week_start);
|
|
||||||
|
|
||||||
//fetches all shifts from INCLUDED_TYPES array
|
|
||||||
const included_shifts = await this.prisma.shifts.findMany({
|
|
||||||
where: {
|
|
||||||
date: { gte:week_start, lte: week_end },
|
|
||||||
timesheet: { employee_id },
|
|
||||||
bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
|
|
||||||
},
|
|
||||||
select: { start_time: true, end_time: true },
|
|
||||||
orderBy: [{date: 'asc'}, {start_time:'asc'}],
|
|
||||||
});
|
|
||||||
|
|
||||||
//calculate total hours of those shifts minus weekly Max to find total overtime hours
|
|
||||||
const total = included_shifts.map(shift => computeHours(shift.start_time, shift.end_time, 5))
|
|
||||||
.reduce((sum, hours)=> sum+hours, 0);
|
|
||||||
const overtime = Math.max(0, total - this.weekly_max);
|
|
||||||
|
|
||||||
this.logger.debug(`[OVERTIME]-[WEEKLY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
|
|
||||||
return overtime;
|
|
||||||
}
|
|
||||||
|
|
||||||
//transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift
|
|
||||||
async transformRegularHoursToWeeklyOvertime(
|
|
||||||
employee_id: number,
|
|
||||||
ref_date: Date,
|
|
||||||
tx?: Prisma.TransactionClient,
|
|
||||||
): Promise<void> {
|
|
||||||
//ensures the use of the transaction if needed. fallback to this.prisma if no transaction is detected.
|
|
||||||
const db = tx ?? this.prisma;
|
const db = tx ?? this.prisma;
|
||||||
|
|
||||||
//calculate weekly overtime
|
const week_start = getWeekStart(date);
|
||||||
const overtime_hours = await this.getWeeklyOvertimeHours(employee_id, ref_date);
|
|
||||||
if(overtime_hours <= 0) return;
|
|
||||||
|
|
||||||
const convert_to_minutes = Math.round(overtime_hours * 60);
|
|
||||||
|
|
||||||
const [regular, overtime] = await Promise.all([
|
|
||||||
db.bankCodes.findFirst({where: { type: 'REGULAR' }, select: { id: true } }),
|
|
||||||
db.bankCodes.findFirst({where: { type: 'OVERTIME'}, select: { id: true } }),
|
|
||||||
]);
|
|
||||||
if(!regular || !overtime) return;
|
|
||||||
|
|
||||||
const week_start = getWeekStart(ref_date);
|
|
||||||
const week_end = getWeekEnd(week_start);
|
const week_end = getWeekEnd(week_start);
|
||||||
|
|
||||||
//gets all regular shifts and order them by desc
|
const shifts = await db.shifts.findMany({
|
||||||
const regular_shifts_desc = await db.shifts.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
date: { gte:week_start, lte: week_end },
|
timesheet_id,
|
||||||
timesheet: { employee_id },
|
date: { gte: week_start, lte: week_end },
|
||||||
bank_code_id: regular.id,
|
bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
|
||||||
},
|
},
|
||||||
select: {
|
select: { date: true, start_time: true, end_time: true },
|
||||||
id: true,
|
orderBy: [{date: 'asc'}, {start_time: 'asc'}],
|
||||||
timesheet_id: true,
|
|
||||||
date: true,
|
|
||||||
start_time: true,
|
|
||||||
end_time: true,
|
|
||||||
is_remote: true,
|
|
||||||
comment: true,
|
|
||||||
},
|
|
||||||
orderBy: [{date: 'desc'}, {start_time:'desc'}],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let remaining_minutes = convert_to_minutes;
|
const day_totals = new Map<string, number>();
|
||||||
|
for (const shift of shifts){
|
||||||
for(const shift of regular_shifts_desc) {
|
const key = shift.date.toISOString().slice(0,10);
|
||||||
if(remaining_minutes <= 0) break;
|
const hours = computeHours(shift.start_time, shift.end_time, 5);
|
||||||
|
day_totals.set(key, (day_totals.get(key) ?? 0) + hours);
|
||||||
const start = shift.start_time;
|
|
||||||
const end = shift.end_time;
|
|
||||||
const duration_in_minutes = Math.max(0, Math.round((end.getTime() - start.getTime())/60000));
|
|
||||||
if(duration_in_minutes === 0) continue;
|
|
||||||
|
|
||||||
if(duration_in_minutes <= remaining_minutes) {
|
|
||||||
await db.shifts.update({
|
|
||||||
where: { id: shift.id },
|
|
||||||
data: { bank_code_id: overtime.id },
|
|
||||||
});
|
|
||||||
remaining_minutes -= duration_in_minutes;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
//sets the start_time of the new overtime shift
|
|
||||||
const new_overtime_start = new Date(end.getTime() - remaining_minutes * 60000);
|
|
||||||
|
|
||||||
//shorten the regular shift
|
|
||||||
await db.shifts.update({
|
|
||||||
where: { id: shift.id },
|
|
||||||
data: { end_time: new_overtime_start },
|
|
||||||
});
|
|
||||||
|
|
||||||
//creates the new overtime shift to replace the shorten regular shift
|
|
||||||
await db.shifts.create({
|
|
||||||
data: {
|
|
||||||
timesheet_id: shift.timesheet_id,
|
|
||||||
date: shift.date,
|
|
||||||
start_time: new_overtime_start,
|
|
||||||
end_time: end,
|
|
||||||
is_remote: shift.is_remote,
|
|
||||||
comment: shift.comment,
|
|
||||||
bank_code_id: overtime.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
remaining_minutes = 0;
|
|
||||||
}
|
}
|
||||||
this.logger.debug(`[OVERTIME]-[WEEKLY]-[TRANSFORM] emp=${employee_id}
|
|
||||||
week: ${week_start.toISOString().slice(0,10)}..${week_end.toISOString().slice(0,10)}
|
const days: string[] = [];
|
||||||
converted= ${(convert_to_minutes-remaining_minutes)/60}h`);
|
for(let i = 0; i < 7; i++){
|
||||||
|
const day = new Date(week_start.getTime() + i * 24 * 60 * 60 * 1000);
|
||||||
|
days.push(day.toISOString().slice(0,10));
|
||||||
|
}
|
||||||
|
|
||||||
|
const week_total_hours = [ ...day_totals.values()].reduce((a,b) => a + b, 0);
|
||||||
|
const weekly_overtime = Math.max(0, week_total_hours - this.weekly_max);
|
||||||
|
|
||||||
|
let running = 0;
|
||||||
|
let daily_kept_sum = 0;
|
||||||
|
const breakdown: WeekOvertimeSummary['breakdown'] = [];
|
||||||
|
|
||||||
|
for (const key of days) {
|
||||||
|
const day_hours = day_totals.get(key) ?? 0;
|
||||||
|
const day_overtime = Math.max(0, day_hours - this.daily_max);
|
||||||
|
|
||||||
|
const cap_before_40 = Math.max(0, this.weekly_max - running);
|
||||||
|
const daily_kept = Math.min(day_overtime, cap_before_40);
|
||||||
|
|
||||||
|
breakdown.push({
|
||||||
|
date: key,
|
||||||
|
day_hours,
|
||||||
|
day_overtime,
|
||||||
|
daily_kept,
|
||||||
|
running_total_before: running,
|
||||||
|
});
|
||||||
|
|
||||||
|
daily_kept_sum += daily_kept;
|
||||||
|
running += day_hours;
|
||||||
|
}
|
||||||
|
const total_overtime = weekly_overtime + daily_kept_sum;
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`[OVERTIME][SUMMARY][ts=${timesheet_id}] week=${week_start.toISOString().slice(0,10)}..${week_end
|
||||||
|
.toISOString()
|
||||||
|
.slice(0,10)} week_total=${week_total_hours.toFixed(2)}h weekly=${weekly_overtime.toFixed(
|
||||||
|
2,
|
||||||
|
)}h daily_kept=${daily_kept_sum.toFixed(2)}h total=${total_overtime.toFixed(2)}h`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
week_start: week_start.toISOString().slice(0, 10),
|
||||||
|
week_end: week_end.toISOString().slice(0, 10),
|
||||||
|
week_total_hours,
|
||||||
|
weekly_overtime,
|
||||||
|
daily_overtime_kept: daily_kept_sum,
|
||||||
|
total_overtime,
|
||||||
|
breakdown,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
//apply modifier to overtime hours
|
// //calculate daily overtime
|
||||||
// calculateOvertimePay(overtime_hours: number, modifier: number): number {
|
// async getDailyOvertimeHours(timesheet_id: number, date: Date): Promise<number> {
|
||||||
// const pay = overtime_hours * modifier;
|
// const shifts = await this.prisma.shifts.findMany({
|
||||||
// this.logger.debug(`Overtime payable hours = ${pay.toFixed(2)} (hours ${overtime_hours}, modifier ${modifier})`);
|
// where: {
|
||||||
|
// timesheet_id,
|
||||||
|
// date: date,
|
||||||
|
// bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
|
||||||
|
// },
|
||||||
|
// select: { start_time: true, end_time: true },
|
||||||
|
// orderBy: [{ start_time: 'asc' }],
|
||||||
|
// });
|
||||||
|
|
||||||
// return pay;
|
// const total = shifts.map((shift)=>
|
||||||
|
// computeHours(shift.start_time, shift.end_time, 5)).
|
||||||
|
// reduce((sum, hours)=> sum + hours, 0);
|
||||||
|
|
||||||
|
// const overtime = Math.max(0, total - this.daily_max);
|
||||||
|
|
||||||
|
// this.logger.debug(`[OVERTIME]-[DAILY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
|
||||||
|
// return overtime;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// //calculate Weekly overtime
|
||||||
|
// async getWeeklyOvertimeHours(timesheet_id: number, ref_date: Date): Promise<number> {
|
||||||
|
// const week_start = getWeekStart(ref_date);
|
||||||
|
// const week_end = getWeekEnd(week_start);
|
||||||
|
|
||||||
|
// //fetches all shifts from INCLUDED_TYPES array
|
||||||
|
// const included_shifts = await this.prisma.shifts.findMany({
|
||||||
|
// where: {
|
||||||
|
// timesheet_id,
|
||||||
|
// date: { gte:week_start, lte: week_end },
|
||||||
|
// bank_code: { type: { in: this.INCLUDED_TYPES as unknown as string[] } },
|
||||||
|
// },
|
||||||
|
// select: { start_time: true, end_time: true },
|
||||||
|
// orderBy: [{date: 'asc'}, {start_time:'asc'}],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// //calculate total hours of those shifts minus weekly Max to find total overtime hours
|
||||||
|
// const total = included_shifts.map(shift =>
|
||||||
|
// computeHours(shift.start_time, shift.end_time, 5)).
|
||||||
|
// reduce((sum, hours)=> sum+hours, 0);
|
||||||
|
|
||||||
|
// const overtime = Math.max(0, total - this.weekly_max);
|
||||||
|
|
||||||
|
// this.logger.debug(`[OVERTIME]-[WEEKLY] total=${total.toFixed(2)}h, overtime= ${overtime.toFixed(2)}h`);
|
||||||
|
// return overtime;
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// //transform REGULAR shifts to OVERTIME when exceed 40hrs of included_types of shift
|
||||||
|
// async transformRegularHoursToWeeklyOvertime(
|
||||||
|
// employee_id: number,
|
||||||
|
// ref_date: Date,
|
||||||
|
// tx?: Prisma.TransactionClient,
|
||||||
|
// ): Promise<void> {
|
||||||
|
// //ensures the use of the transaction if needed. fallback to this.prisma if no transaction is detected.
|
||||||
|
// const db = tx ?? this.prisma;
|
||||||
|
|
||||||
|
// //calculate weekly overtime
|
||||||
|
// const overtime_hours = await this.getWeeklyOvertimeHours(employee_id, ref_date);
|
||||||
|
// if(overtime_hours <= 0) return;
|
||||||
|
|
||||||
|
// const convert_to_minutes = Math.round(overtime_hours * 60);
|
||||||
|
|
||||||
|
// const [regular, overtime] = await Promise.all([
|
||||||
|
// db.bankCodes.findFirst({where: { type: 'REGULAR' }, select: { id: true } }),
|
||||||
|
// db.bankCodes.findFirst({where: { type: 'OVERTIME'}, select: { id: true } }),
|
||||||
|
// ]);
|
||||||
|
// if(!regular || !overtime) return;
|
||||||
|
|
||||||
|
// const week_start = getWeekStart(ref_date);
|
||||||
|
// const week_end = getWeekEnd(week_start);
|
||||||
|
|
||||||
|
// //gets all regular shifts and order them by desc
|
||||||
|
// const regular_shifts_desc = await db.shifts.findMany({
|
||||||
|
// where: {
|
||||||
|
// date: { gte:week_start, lte: week_end },
|
||||||
|
// timesheet: { employee_id },
|
||||||
|
// bank_code_id: regular.id,
|
||||||
|
// },
|
||||||
|
// select: {
|
||||||
|
// id: true,
|
||||||
|
// timesheet_id: true,
|
||||||
|
// date: true,
|
||||||
|
// start_time: true,
|
||||||
|
// end_time: true,
|
||||||
|
// is_remote: true,
|
||||||
|
// comment: true,
|
||||||
|
// },
|
||||||
|
// orderBy: [{date: 'desc'}, {start_time:'desc'}],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// let remaining_minutes = convert_to_minutes;
|
||||||
|
|
||||||
|
// for(const shift of regular_shifts_desc) {
|
||||||
|
// if(remaining_minutes <= 0) break;
|
||||||
|
|
||||||
|
// const start = shift.start_time;
|
||||||
|
// const end = shift.end_time;
|
||||||
|
// const duration_in_minutes = Math.max(0, Math.round((end.getTime() - start.getTime())/60000));
|
||||||
|
// if(duration_in_minutes === 0) continue;
|
||||||
|
|
||||||
|
// if(duration_in_minutes <= remaining_minutes) {
|
||||||
|
// await db.shifts.update({
|
||||||
|
// where: { id: shift.id },
|
||||||
|
// data: { bank_code_id: overtime.id },
|
||||||
|
// });
|
||||||
|
// remaining_minutes -= duration_in_minutes;
|
||||||
|
// continue;
|
||||||
|
// }
|
||||||
|
// //sets the start_time of the new overtime shift
|
||||||
|
// const new_overtime_start = new Date(end.getTime() - remaining_minutes * 60000);
|
||||||
|
|
||||||
|
// //shorten the regular shift
|
||||||
|
// await db.shifts.update({
|
||||||
|
// where: { id: shift.id },
|
||||||
|
// data: { end_time: new_overtime_start },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// //creates the new overtime shift to replace the shorten regular shift
|
||||||
|
// await db.shifts.create({
|
||||||
|
// data: {
|
||||||
|
// timesheet_id: shift.timesheet_id,
|
||||||
|
// date: shift.date,
|
||||||
|
// start_time: new_overtime_start,
|
||||||
|
// end_time: end,
|
||||||
|
// is_remote: shift.is_remote,
|
||||||
|
// comment: shift.comment,
|
||||||
|
// bank_code_id: overtime.id,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// remaining_minutes = 0;
|
||||||
|
// }
|
||||||
|
// this.logger.debug(`[OVERTIME]-[WEEKLY]-[TRANSFORM] emp=${employee_id}
|
||||||
|
// week: ${week_start.toISOString().slice(0,10)}..${week_end.toISOString().slice(0,10)}
|
||||||
|
// converted= ${(convert_to_minutes-remaining_minutes)/60}h`);
|
||||||
// }
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,95 +1,95 @@
|
||||||
import { Body, Controller, Get, Param, Put, } from "@nestjs/common";
|
// import { Body, Controller, Get, Param, Put, } from "@nestjs/common";
|
||||||
import { Roles as RoleEnum } from '.prisma/client';
|
// import { Roles as RoleEnum } from '.prisma/client';
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
// import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
// import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||||
import { ExpensesCommandService } from "../services/expenses-command.service";
|
// import { ExpensesCommandService } from "../services/expenses-command.service";
|
||||||
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
|
// import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
|
||||||
import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
|
// import { UpsertExpenseResult } from "../types and interfaces/expenses.types.interfaces";
|
||||||
import { DayExpensesDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
|
// import { DayExpensesDto } from "src/modules/timesheets/~misc_deprecated-files/timesheet-period.dto";
|
||||||
import { ExpensesQueryService } from "../services/expenses-query.service";
|
// import { ExpensesQueryService } from "../services/expenses-query.service";
|
||||||
|
|
||||||
@ApiTags('Expenses')
|
// @ApiTags('Expenses')
|
||||||
@ApiBearerAuth('access-token')
|
// @ApiBearerAuth('access-token')
|
||||||
// @UseGuards()
|
// // @UseGuards()
|
||||||
@Controller('Expenses')
|
// @Controller('Expenses')
|
||||||
export class ExpensesController {
|
// export class ExpensesController {
|
||||||
constructor(
|
// constructor(
|
||||||
private readonly query: ExpensesQueryService,
|
// private readonly query: ExpensesQueryService,
|
||||||
private readonly command: ExpensesCommandService,
|
// private readonly command: ExpensesCommandService,
|
||||||
) {}
|
// ) {}
|
||||||
|
|
||||||
@Put('upsert/:email/:date')
|
// @Put('upsert/:email/:date')
|
||||||
async upsert_by_date(
|
// async upsert_by_date(
|
||||||
@Param('email') email: string,
|
// @Param('email') email: string,
|
||||||
@Param('date') date: string,
|
// @Param('date') date: string,
|
||||||
@Body() dto: UpsertExpenseDto,
|
// @Body() dto: UpsertExpenseDto,
|
||||||
): Promise<UpsertExpenseResult> {
|
// ): Promise<UpsertExpenseResult> {
|
||||||
return this.command.upsertExpensesByDate(email, date, dto);
|
// return this.command.upsertExpensesByDate(email, date, dto);
|
||||||
}
|
// }
|
||||||
|
|
||||||
@Get('list/:email/:year/:period_no')
|
// @Get('list/:email/:year/:period_no')
|
||||||
async findExpenseListByPayPeriodAndEmail(
|
// async findExpenseListByPayPeriodAndEmail(
|
||||||
@Param('email') email:string,
|
// @Param('email') email:string,
|
||||||
@Param('year') year: number,
|
// @Param('year') year: number,
|
||||||
@Param('period_no') period_no: number,
|
// @Param('period_no') period_no: number,
|
||||||
): Promise<DayExpensesDto> {
|
// ): Promise<DayExpensesDto> {
|
||||||
return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no);
|
// return this.query.findExpenseListByPayPeriodAndEmail(email, year, period_no);
|
||||||
}
|
// }
|
||||||
|
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
// Deprecated or unused methods
|
// // Deprecated or unused methods
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
|
|
||||||
// @Post()
|
// // @Post()
|
||||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
// @ApiOperation({ summary: 'Create expense' })
|
// // @ApiOperation({ summary: 'Create expense' })
|
||||||
// @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
|
// // @ApiResponse({ status: 201, description: 'Expense created',type: CreateExpenseDto })
|
||||||
// @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
// // @ApiResponse({ status: 400, description: 'Incomplete task or invalid data' })
|
||||||
// create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
|
// // create(@Body() dto: CreateExpenseDto): Promise<Expenses> {
|
||||||
// return this.query.create(dto);
|
// // return this.query.create(dto);
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// @Get()
|
// // @Get()
|
||||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
// @ApiOperation({ summary: 'Find all expenses' })
|
// // @ApiOperation({ summary: 'Find all expenses' })
|
||||||
// @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
|
// // @ApiResponse({ status: 201, description: 'List of expenses found',type: CreateExpenseDto, isArray: true })
|
||||||
// @ApiResponse({ status: 400, description: 'List of expenses not found' })
|
// // @ApiResponse({ status: 400, description: 'List of expenses not found' })
|
||||||
// @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
// // @UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
// findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
|
// // findAll(@Query() filters: SearchExpensesDto): Promise<Expenses[]> {
|
||||||
// return this.query.findAll(filters);
|
// // return this.query.findAll(filters);
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// @Get(':id')
|
// // @Get(':id')
|
||||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
// @ApiOperation({ summary: 'Find expense' })
|
// // @ApiOperation({ summary: 'Find expense' })
|
||||||
// @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
|
// // @ApiResponse({ status: 201, description: 'Expense found',type: CreateExpenseDto })
|
||||||
// @ApiResponse({ status: 400, description: 'Expense not found' })
|
// // @ApiResponse({ status: 400, description: 'Expense not found' })
|
||||||
// findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
|
// // findOne(@Param('id', ParseIntPipe) id: number): Promise <Expenses> {
|
||||||
// return this.query.findOne(id);
|
// // return this.query.findOne(id);
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// @Patch(':id')
|
// // @Patch(':id')
|
||||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
// @ApiOperation({ summary: 'Expense shift' })
|
// // @ApiOperation({ summary: 'Expense shift' })
|
||||||
// @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
|
// // @ApiResponse({ status: 201, description: 'Expense updated',type: CreateExpenseDto })
|
||||||
// @ApiResponse({ status: 400, description: 'Expense not found' })
|
// // @ApiResponse({ status: 400, description: 'Expense not found' })
|
||||||
// update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
|
// // update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseDto) {
|
||||||
// return this.query.update(id,dto);
|
// // return this.query.update(id,dto);
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// @Delete(':id')
|
// // @Delete(':id')
|
||||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
// @ApiOperation({ summary: 'Delete expense' })
|
// // @ApiOperation({ summary: 'Delete expense' })
|
||||||
// @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
|
// // @ApiResponse({ status: 201, description: 'Expense deleted',type: CreateExpenseDto })
|
||||||
// @ApiResponse({ status: 400, description: 'Expense not found' })
|
// // @ApiResponse({ status: 400, description: 'Expense not found' })
|
||||||
// remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
|
// // remove(@Param('id', ParseIntPipe) id: number): Promise<Expenses> {
|
||||||
// return this.query.remove(id);
|
// // return this.query.remove(id);
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// @Patch('approval/:id')
|
// // @Patch('approval/:id')
|
||||||
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
// // //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
// async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
// // async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
||||||
// return this.command.updateApproval(id, isApproved);
|
// // return this.command.updateApproval(id, isApproved);
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
}
|
// }
|
||||||
|
|
@ -1,23 +1,23 @@
|
||||||
import { ExpensesController } from "./controllers/expenses.controller";
|
// import { ExpensesController } from "./controllers/expenses.controller";
|
||||||
import { Module } from "@nestjs/common";
|
// import { Module } from "@nestjs/common";
|
||||||
import { ExpensesQueryService } from "./services/expenses-query.service";
|
// import { ExpensesQueryService } from "./services/expenses-query.service";
|
||||||
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
// import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
||||||
import { ExpensesCommandService } from "./services/expenses-command.service";
|
// import { ExpensesCommandService } from "./services/expenses-command.service";
|
||||||
import { ExpensesArchivalService } from "./services/expenses-archival.service";
|
// import { ExpensesArchivalService } from "./services/expenses-archival.service";
|
||||||
import { SharedModule } from "../shared/shared.module";
|
// import { SharedModule } from "../shared/shared.module";
|
||||||
|
|
||||||
@Module({
|
// @Module({
|
||||||
imports: [BusinessLogicsModule, SharedModule],
|
// imports: [BusinessLogicsModule, SharedModule],
|
||||||
controllers: [ExpensesController],
|
// controllers: [ExpensesController],
|
||||||
providers: [
|
// providers: [
|
||||||
ExpensesQueryService,
|
// ExpensesQueryService,
|
||||||
ExpensesArchivalService,
|
// ExpensesArchivalService,
|
||||||
ExpensesCommandService,
|
// ExpensesCommandService,
|
||||||
],
|
// ],
|
||||||
exports: [
|
// exports: [
|
||||||
ExpensesQueryService,
|
// ExpensesQueryService,
|
||||||
ExpensesArchivalService,
|
// ExpensesArchivalService,
|
||||||
],
|
// ],
|
||||||
})
|
// })
|
||||||
|
|
||||||
export class ExpensesModule {}
|
// export class ExpensesModule {}
|
||||||
|
|
@ -1,250 +1,249 @@
|
||||||
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
// import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||||
import { Expenses, Prisma } from "@prisma/client";
|
// import { Expenses, Prisma } from "@prisma/client";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
|
// import { UpsertExpenseDto } from "../dtos/upsert-expense.dto";
|
||||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
|
// import { ExpenseResponse, UpsertAction } from "../types and interfaces/expenses.types.interfaces";
|
||||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
|
// import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
|
||||||
import {
|
// import {
|
||||||
BadRequestException,
|
// BadRequestException,
|
||||||
Injectable,
|
// Injectable,
|
||||||
NotFoundException
|
// NotFoundException
|
||||||
} from "@nestjs/common";
|
// } from "@nestjs/common";
|
||||||
import {
|
// import {
|
||||||
assertAndTrimComment,
|
// assertAndTrimComment,
|
||||||
computeAmountDecimal,
|
// computeAmountDecimal,
|
||||||
computeMileageAmount,
|
// computeMileageAmount,
|
||||||
mapDbExpenseToDayResponse,
|
// mapDbExpenseToDayResponse,
|
||||||
normalizeType,
|
// normalizeType,
|
||||||
parseAttachmentId
|
// parseAttachmentId
|
||||||
} from "../utils/expenses.utils";
|
// } from "../utils/expenses.utils";
|
||||||
import { toDateOnly } from "src/modules/shifts/helpers/shifts-date-time-helpers";
|
|
||||||
|
|
||||||
@Injectable()
|
// @Injectable()
|
||||||
export class ExpensesCommandService extends BaseApprovalService<Expenses> {
|
// export class ExpensesCommandService extends BaseApprovalService<Expenses> {
|
||||||
constructor(
|
// constructor(
|
||||||
prisma: PrismaService,
|
// prisma: PrismaService,
|
||||||
private readonly bankCodesResolver: BankCodesResolver,
|
// private readonly bankCodesResolver: BankCodesResolver,
|
||||||
private readonly timesheetsResolver: EmployeeTimesheetResolver,
|
// private readonly timesheetsResolver: EmployeeTimesheetResolver,
|
||||||
private readonly emailResolver: EmailToIdResolver,
|
// private readonly emailResolver: EmailToIdResolver,
|
||||||
) { super(prisma); }
|
// ) { super(prisma); }
|
||||||
|
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
// APPROVAL TX-DELEGATE METHODS
|
// // APPROVAL TX-DELEGATE METHODS
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
|
|
||||||
protected get delegate() {
|
// protected get delegate() {
|
||||||
return this.prisma.expenses;
|
// return this.prisma.expenses;
|
||||||
}
|
// }
|
||||||
|
|
||||||
protected delegateFor(transaction: Prisma.TransactionClient){
|
// protected delegateFor(transaction: Prisma.TransactionClient){
|
||||||
return transaction.expenses;
|
// return transaction.expenses;
|
||||||
}
|
// }
|
||||||
|
|
||||||
async updateApproval(id: number, isApproved: boolean): Promise<Expenses> {
|
// async updateApproval(id: number, isApproved: boolean): Promise<Expenses> {
|
||||||
return this.prisma.$transaction((transaction) =>
|
// return this.prisma.$transaction((transaction) =>
|
||||||
this.updateApprovalWithTransaction(transaction, id, isApproved),
|
// this.updateApprovalWithTransaction(transaction, id, isApproved),
|
||||||
);
|
// );
|
||||||
}
|
// }
|
||||||
|
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
// MASTER CRUD FUNCTION
|
// // MASTER CRUD FUNCTION
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
|
// readonly upsertExpensesByDate = async (email: string, date: string, dto: UpsertExpenseDto,
|
||||||
): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
|
// ): Promise<{ action:UpsertAction; day: ExpenseResponse[] }> => {
|
||||||
|
|
||||||
//validates if there is an existing expense, at least 1 old or new
|
// //validates if there is an existing expense, at least 1 old or new
|
||||||
const { old_expense, new_expense } = dto ?? {};
|
// const { old_expense, new_expense } = dto ?? {};
|
||||||
if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
|
// if(!old_expense && !new_expense) throw new BadRequestException('At least one expense must be provided');
|
||||||
|
|
||||||
//validate date format
|
// //validate date format
|
||||||
const date_only = toDateOnly(date);
|
// const date_only = toDateOnly(date);
|
||||||
if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
|
// if(Number.isNaN(date_only.getTime())) throw new BadRequestException('Invalid date format (expected: YYYY-MM-DD)');
|
||||||
|
|
||||||
//resolve employee_id by email
|
// //resolve employee_id by email
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
// const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
|
||||||
//make sure a timesheet existes
|
// //make sure a timesheet existes
|
||||||
const timesheet_id = await this.timesheetsResolver.findTimesheetIdByEmail(email, date_only);
|
// const timesheet_id = await this.timesheetsResolver.findTimesheetIdByEmail(email, date_only);
|
||||||
if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
|
// if(!timesheet_id) throw new NotFoundException(`no timesheet found for employee #${employee_id}`)
|
||||||
const {id} = timesheet_id;
|
// const {id} = timesheet_id;
|
||||||
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
// return this.prisma.$transaction(async (tx) => {
|
||||||
const loadDay = async (): Promise<ExpenseResponse[]> => {
|
// const loadDay = async (): Promise<ExpenseResponse[]> => {
|
||||||
const rows = await tx.expenses.findMany({
|
// const rows = await tx.expenses.findMany({
|
||||||
where: {
|
// where: {
|
||||||
timesheet_id: id,
|
// timesheet_id: id,
|
||||||
date: date_only,
|
// date: date_only,
|
||||||
},
|
// },
|
||||||
include: {
|
// include: {
|
||||||
bank_code: {
|
// bank_code: {
|
||||||
select: {
|
// select: {
|
||||||
type: true,
|
// type: true,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
orderBy: [{ date: 'asc' }, { id: 'asc' }],
|
// orderBy: [{ date: 'asc' }, { id: 'asc' }],
|
||||||
});
|
// });
|
||||||
|
|
||||||
return rows.map((r) =>
|
// return rows.map((r) =>
|
||||||
mapDbExpenseToDayResponse({
|
// mapDbExpenseToDayResponse({
|
||||||
date: r.date,
|
// date: r.date,
|
||||||
amount: r.amount ?? 0,
|
// amount: r.amount ?? 0,
|
||||||
mileage: r.mileage ?? 0,
|
// mileage: r.mileage ?? 0,
|
||||||
comment: r.comment,
|
// comment: r.comment,
|
||||||
is_approved: r.is_approved,
|
// is_approved: r.is_approved,
|
||||||
bank_code: r.bank_code,
|
// bank_code: r.bank_code,
|
||||||
}));
|
// }));
|
||||||
};
|
// };
|
||||||
|
|
||||||
const normalizePayload = async (payload: {
|
// const normalizePayload = async (payload: {
|
||||||
type: string;
|
// type: string;
|
||||||
amount?: number;
|
// amount?: number;
|
||||||
mileage?: number;
|
// mileage?: number;
|
||||||
comment: string;
|
// comment: string;
|
||||||
attachment?: string | number;
|
// attachment?: string | number;
|
||||||
}): Promise<{
|
// }): Promise<{
|
||||||
type: string;
|
// type: string;
|
||||||
bank_code_id: number;
|
// bank_code_id: number;
|
||||||
amount: Prisma.Decimal;
|
// amount: Prisma.Decimal;
|
||||||
mileage: number | null;
|
// mileage: number | null;
|
||||||
comment: string;
|
// comment: string;
|
||||||
attachment: number | null;
|
// attachment: number | null;
|
||||||
}> => {
|
// }> => {
|
||||||
const type = normalizeType(payload.type);
|
// const type = normalizeType(payload.type);
|
||||||
const comment = assertAndTrimComment(payload.comment);
|
// const comment = assertAndTrimComment(payload.comment);
|
||||||
const attachment = parseAttachmentId(payload.attachment);
|
// const attachment = parseAttachmentId(payload.attachment);
|
||||||
|
|
||||||
const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type);
|
// const { id: bank_code_id, modifier } = await this.bankCodesResolver.findByType(type);
|
||||||
let amount = computeAmountDecimal(type, payload, modifier);
|
// let amount = computeAmountDecimal(type, payload, modifier);
|
||||||
let mileage: number | null = null;
|
// let mileage: number | null = null;
|
||||||
|
|
||||||
if (type === 'MILEAGE') {
|
// if (type === 'MILEAGE') {
|
||||||
mileage = Number(payload.mileage ?? 0);
|
// mileage = Number(payload.mileage ?? 0);
|
||||||
if (!(mileage > 0)) {
|
// if (!(mileage > 0)) {
|
||||||
throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE');
|
// throw new BadRequestException('Mileage required and must be > 0 for type MILEAGE');
|
||||||
}
|
// }
|
||||||
|
|
||||||
const amountNumber = computeMileageAmount(mileage, modifier);
|
// const amountNumber = computeMileageAmount(mileage, modifier);
|
||||||
amount = new Prisma.Decimal(amountNumber);
|
// amount = new Prisma.Decimal(amountNumber);
|
||||||
|
|
||||||
} else {
|
// } else {
|
||||||
if (!(typeof payload.amount === 'number' && payload.amount >= 0)) {
|
// if (!(typeof payload.amount === 'number' && payload.amount >= 0)) {
|
||||||
throw new BadRequestException('Amount required for non-MILEAGE expense');
|
// throw new BadRequestException('Amount required for non-MILEAGE expense');
|
||||||
}
|
// }
|
||||||
amount = new Prisma.Decimal(payload.amount);
|
// amount = new Prisma.Decimal(payload.amount);
|
||||||
}
|
// }
|
||||||
|
|
||||||
if (attachment !== null) {
|
// if (attachment !== null) {
|
||||||
const attachment_row = await tx.attachments.findUnique({
|
// const attachment_row = await tx.attachments.findUnique({
|
||||||
where: { id: attachment },
|
// where: { id: attachment },
|
||||||
select: { status: true },
|
// select: { status: true },
|
||||||
});
|
// });
|
||||||
if (!attachment_row || attachment_row.status !== 'ACTIVE') {
|
// if (!attachment_row || attachment_row.status !== 'ACTIVE') {
|
||||||
throw new BadRequestException('Attachment not found or inactive');
|
// throw new BadRequestException('Attachment not found or inactive');
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
return {
|
// return {
|
||||||
type,
|
// type,
|
||||||
bank_code_id,
|
// bank_code_id,
|
||||||
amount,
|
// amount,
|
||||||
mileage,
|
// mileage,
|
||||||
comment,
|
// comment,
|
||||||
attachment
|
// attachment
|
||||||
};
|
// };
|
||||||
};
|
// };
|
||||||
|
|
||||||
const findExactOld = async (norm: {
|
// const findExactOld = async (norm: {
|
||||||
bank_code_id: number;
|
// bank_code_id: number;
|
||||||
amount: Prisma.Decimal;
|
// amount: Prisma.Decimal;
|
||||||
mileage: number | null;
|
// mileage: number | null;
|
||||||
comment: string;
|
// comment: string;
|
||||||
attachment: number | null;
|
// attachment: number | null;
|
||||||
}) => {
|
// }) => {
|
||||||
return tx.expenses.findFirst({
|
// return tx.expenses.findFirst({
|
||||||
where: {
|
// where: {
|
||||||
timesheet_id: id,
|
// timesheet_id: id,
|
||||||
date: date_only,
|
// date: date_only,
|
||||||
bank_code_id: norm.bank_code_id,
|
// bank_code_id: norm.bank_code_id,
|
||||||
amount: norm.amount,
|
// amount: norm.amount,
|
||||||
comment: norm.comment,
|
// comment: norm.comment,
|
||||||
attachment: norm.attachment,
|
// attachment: norm.attachment,
|
||||||
...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }),
|
// ...(norm.mileage !== null ? { mileage: norm.mileage } : { mileage: null }),
|
||||||
},
|
// },
|
||||||
select: { id: true },
|
// select: { id: true },
|
||||||
});
|
// });
|
||||||
};
|
// };
|
||||||
|
|
||||||
let action : UpsertAction;
|
// let action : UpsertAction;
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
// DELETE
|
// // DELETE
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
if(old_expense && !new_expense) {
|
// if(old_expense && !new_expense) {
|
||||||
const old_norm = await normalizePayload(old_expense);
|
// const old_norm = await normalizePayload(old_expense);
|
||||||
const existing = await findExactOld(old_norm);
|
// const existing = await findExactOld(old_norm);
|
||||||
if(!existing) {
|
// if(!existing) {
|
||||||
throw new NotFoundException({
|
// throw new NotFoundException({
|
||||||
error_code: 'EXPENSE_STALE',
|
// error_code: 'EXPENSE_STALE',
|
||||||
message: 'The expense was modified or deleted by someone else',
|
// message: 'The expense was modified or deleted by someone else',
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
await tx.expenses.delete({where: { id: existing.id } });
|
// await tx.expenses.delete({where: { id: existing.id } });
|
||||||
action = 'delete';
|
// action = 'delete';
|
||||||
}
|
// }
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
// CREATE
|
// // CREATE
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
else if (!old_expense && new_expense) {
|
// else if (!old_expense && new_expense) {
|
||||||
const new_exp = await normalizePayload(new_expense);
|
// const new_exp = await normalizePayload(new_expense);
|
||||||
await tx.expenses.create({
|
// await tx.expenses.create({
|
||||||
data: {
|
// data: {
|
||||||
timesheet_id: id,
|
// timesheet_id: id,
|
||||||
date: date_only,
|
// date: date_only,
|
||||||
bank_code_id: new_exp.bank_code_id,
|
// bank_code_id: new_exp.bank_code_id,
|
||||||
amount: new_exp.amount,
|
// amount: new_exp.amount,
|
||||||
mileage: new_exp.mileage,
|
// mileage: new_exp.mileage,
|
||||||
comment: new_exp.comment,
|
// comment: new_exp.comment,
|
||||||
attachment: new_exp.attachment,
|
// attachment: new_exp.attachment,
|
||||||
is_approved: false,
|
// is_approved: false,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
action = 'create';
|
// action = 'create';
|
||||||
}
|
// }
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
// UPDATE
|
// // UPDATE
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
else if(old_expense && new_expense) {
|
// else if(old_expense && new_expense) {
|
||||||
const old_norm = await normalizePayload(old_expense);
|
// const old_norm = await normalizePayload(old_expense);
|
||||||
const existing = await findExactOld(old_norm);
|
// const existing = await findExactOld(old_norm);
|
||||||
if(!existing) {
|
// if(!existing) {
|
||||||
throw new NotFoundException({
|
// throw new NotFoundException({
|
||||||
error_code: 'EXPENSE_STALE',
|
// error_code: 'EXPENSE_STALE',
|
||||||
message: 'The expense was modified or deleted by someone else',
|
// message: 'The expense was modified or deleted by someone else',
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
const new_exp = await normalizePayload(new_expense);
|
// const new_exp = await normalizePayload(new_expense);
|
||||||
await tx.expenses.update({
|
// await tx.expenses.update({
|
||||||
where: { id: existing.id },
|
// where: { id: existing.id },
|
||||||
data: {
|
// data: {
|
||||||
bank_code_id: new_exp.bank_code_id,
|
// bank_code_id: new_exp.bank_code_id,
|
||||||
amount: new_exp.amount,
|
// amount: new_exp.amount,
|
||||||
mileage: new_exp.mileage,
|
// mileage: new_exp.mileage,
|
||||||
comment: new_exp.comment,
|
// comment: new_exp.comment,
|
||||||
attachment: new_exp.attachment,
|
// attachment: new_exp.attachment,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
action = 'update';
|
// action = 'update';
|
||||||
}
|
// }
|
||||||
else {
|
// else {
|
||||||
throw new BadRequestException('Invalid upsert combination');
|
// throw new BadRequestException('Invalid upsert combination');
|
||||||
}
|
// }
|
||||||
|
|
||||||
const day = await loadDay();
|
// const day = await loadDay();
|
||||||
|
|
||||||
return { action, day };
|
// return { action, day };
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
@ -1,174 +1,171 @@
|
||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
// import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { DayExpensesDto as ExpenseListResponseDto, ExpenseDto } from "src/modules/timesheets/dtos/timesheet-period.dto";
|
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
import { round2, toUTCDateOnly } from "src/modules/timesheets/utils-helpers-others/timesheet.helpers";
|
|
||||||
import { EXPENSE_TYPES } from "src/modules/timesheets/utils-helpers-others/timesheet.types";
|
|
||||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
|
||||||
|
|
||||||
@Injectable()
|
// @Injectable()
|
||||||
export class ExpensesQueryService {
|
// export class ExpensesQueryService {
|
||||||
constructor(
|
// constructor(
|
||||||
private readonly prisma: PrismaService,
|
// private readonly prisma: PrismaService,
|
||||||
private readonly employeeRepo: EmailToIdResolver,
|
// private readonly employeeRepo: EmailToIdResolver,
|
||||||
) {}
|
// ) {}
|
||||||
|
|
||||||
|
|
||||||
//fetchs all expenses for a selected employee using email, pay-period-year and number
|
// //fetchs all expenses for a selected employee using email, pay-period-year and number
|
||||||
async findExpenseListByPayPeriodAndEmail(
|
// async findExpenseListByPayPeriodAndEmail(
|
||||||
email: string,
|
// email: string,
|
||||||
year: number,
|
// year: number,
|
||||||
period_no: number
|
// period_no: number
|
||||||
): Promise<ExpenseListResponseDto> {
|
// ): Promise<ExpenseListResponseDto> {
|
||||||
//fetch employe_id using email
|
// //fetch employe_id using email
|
||||||
const employee_id = await this.employeeRepo.findIdByEmail(email);
|
// const employee_id = await this.employeeRepo.findIdByEmail(email);
|
||||||
if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
|
// if(!employee_id) throw new NotFoundException(`Employee with email: ${email} not found`);
|
||||||
|
|
||||||
//fetch pay-period using year and period_no
|
// //fetch pay-period using year and period_no
|
||||||
const pay_period = await this.prisma.payPeriods.findFirst({
|
// const pay_period = await this.prisma.payPeriods.findFirst({
|
||||||
where: {
|
// where: {
|
||||||
pay_year: year,
|
// pay_year: year,
|
||||||
pay_period_no: period_no
|
// pay_period_no: period_no
|
||||||
},
|
// },
|
||||||
select: { period_start: true, period_end: true },
|
// select: { period_start: true, period_end: true },
|
||||||
});
|
// });
|
||||||
if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`);
|
// if(!pay_period) throw new NotFoundException(`Pay period ${year}- ${period_no} not found`);
|
||||||
|
|
||||||
const start = toUTCDateOnly(pay_period.period_start);
|
// const start = toUTCDateOnly(pay_period.period_start);
|
||||||
const end = toUTCDateOnly(pay_period.period_end);
|
// const end = toUTCDateOnly(pay_period.period_end);
|
||||||
|
|
||||||
//sets rows data
|
// //sets rows data
|
||||||
const rows = await this.prisma.expenses.findMany({
|
// const rows = await this.prisma.expenses.findMany({
|
||||||
where: {
|
// where: {
|
||||||
date: { gte: start, lte: end },
|
// date: { gte: start, lte: end },
|
||||||
timesheet: { is: { employee_id } },
|
// timesheet: { is: { employee_id } },
|
||||||
},
|
// },
|
||||||
orderBy: { date: 'asc'},
|
// orderBy: { date: 'asc'},
|
||||||
select: {
|
// select: {
|
||||||
amount: true,
|
// amount: true,
|
||||||
mileage: true,
|
// mileage: true,
|
||||||
comment: true,
|
// comment: true,
|
||||||
is_approved: true,
|
// is_approved: true,
|
||||||
supervisor_comment: true,
|
// supervisor_comment: true,
|
||||||
bank_code: {select: { type: true } },
|
// bank_code: {select: { type: true } },
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
|
||||||
//declare return values
|
// //declare return values
|
||||||
const expenses: ExpenseDto[] = [];
|
// const expenses: ExpenseDto[] = [];
|
||||||
let total_amount = 0;
|
// let total_amount = 0;
|
||||||
let total_mileage = 0;
|
// let total_mileage = 0;
|
||||||
|
|
||||||
//set rows
|
// //set rows
|
||||||
for(const row of rows) {
|
// for(const row of rows) {
|
||||||
const type = (row.bank_code?.type ?? '').toUpperCase();
|
// const type = (row.bank_code?.type ?? '').toUpperCase();
|
||||||
const amount = round2(Number(row.amount ?? 0));
|
// const amount = round2(Number(row.amount ?? 0));
|
||||||
const mileage = round2(Number(row.mileage ?? 0));
|
// const mileage = round2(Number(row.mileage ?? 0));
|
||||||
|
|
||||||
if(type === EXPENSE_TYPES.MILEAGE) {
|
// if(type === EXPENSE_TYPES.MILEAGE) {
|
||||||
total_mileage += mileage;
|
// total_mileage += mileage;
|
||||||
} else {
|
// } else {
|
||||||
total_amount += amount;
|
// total_amount += amount;
|
||||||
}
|
// }
|
||||||
|
|
||||||
//fills rows array
|
// //fills rows array
|
||||||
expenses.push({
|
// expenses.push({
|
||||||
type,
|
// type,
|
||||||
amount,
|
// amount,
|
||||||
mileage,
|
// mileage,
|
||||||
comment: row.comment ?? '',
|
// comment: row.comment ?? '',
|
||||||
is_approved: row.is_approved ?? false,
|
// is_approved: row.is_approved ?? false,
|
||||||
supervisor_comment: row.supervisor_comment ?? '',
|
// supervisor_comment: row.supervisor_comment ?? '',
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
return {
|
// return {
|
||||||
expenses,
|
// expenses,
|
||||||
total_expense: round2(total_amount),
|
// total_expense: round2(total_amount),
|
||||||
total_mileage: round2(total_mileage),
|
// total_mileage: round2(total_mileage),
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
|
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
// Deprecated or unused methods
|
// // Deprecated or unused methods
|
||||||
//_____________________________________________________________________________________________
|
// //_____________________________________________________________________________________________
|
||||||
|
|
||||||
// async create(dto: CreateExpenseDto): Promise<Expenses> {
|
// // async create(dto: CreateExpenseDto): Promise<Expenses> {
|
||||||
// const { timesheet_id, bank_code_id, date, amount:rawAmount,
|
// // const { timesheet_id, bank_code_id, date, amount:rawAmount,
|
||||||
// comment, is_approved,supervisor_comment} = dto;
|
// // comment, is_approved,supervisor_comment} = dto;
|
||||||
// //fetches type and modifier
|
// // //fetches type and modifier
|
||||||
// const bank_code = await this.prisma.bankCodes.findUnique({
|
// // const bank_code = await this.prisma.bankCodes.findUnique({
|
||||||
// where: { id: bank_code_id },
|
// // where: { id: bank_code_id },
|
||||||
// select: { type: true, modifier: true },
|
// // select: { type: true, modifier: true },
|
||||||
// });
|
// // });
|
||||||
// if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`);
|
// // if(!bank_code) throw new NotFoundException(`bank_code #${bank_code_id} not found`);
|
||||||
|
|
||||||
// //if mileage -> service, otherwise the ratio is amount:1
|
// // //if mileage -> service, otherwise the ratio is amount:1
|
||||||
// let final_amount: number;
|
// // let final_amount: number;
|
||||||
// if(bank_code.type === 'mileage') {
|
// // if(bank_code.type === 'mileage') {
|
||||||
// final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
|
// // final_amount = await this.mileageService.calculateReimbursement(rawAmount, bank_code_id);
|
||||||
// }else {
|
// // }else {
|
||||||
// final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
|
// // final_amount = parseFloat( (rawAmount * bank_code.modifier).toFixed(2));
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// return this.prisma.expenses.create({
|
// // return this.prisma.expenses.create({
|
||||||
// data: {
|
// // data: {
|
||||||
// timesheet_id,
|
// // timesheet_id,
|
||||||
// bank_code_id,
|
// // bank_code_id,
|
||||||
// date,
|
// // date,
|
||||||
// amount: final_amount,
|
// // amount: final_amount,
|
||||||
// comment,
|
// // comment,
|
||||||
// is_approved,
|
// // is_approved,
|
||||||
// supervisor_comment
|
// // supervisor_comment
|
||||||
// },
|
// // },
|
||||||
// include: { timesheet: { include: { employee: { include: { user: true }}}},
|
// // include: { timesheet: { include: { employee: { include: { user: true }}}},
|
||||||
// bank_code: true,
|
// // bank_code: true,
|
||||||
// },
|
// // },
|
||||||
// })
|
// // })
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
|
// // async findAll(filters: SearchExpensesDto): Promise<Expenses[]> {
|
||||||
// const where = buildPrismaWhere(filters);
|
// // const where = buildPrismaWhere(filters);
|
||||||
// const expenses = await this.prisma.expenses.findMany({ where })
|
// // const expenses = await this.prisma.expenses.findMany({ where })
|
||||||
// return expenses;
|
// // return expenses;
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// async findOne(id: number): Promise<Expenses> {
|
// // async findOne(id: number): Promise<Expenses> {
|
||||||
// const expense = await this.prisma.expenses.findUnique({
|
// // const expense = await this.prisma.expenses.findUnique({
|
||||||
// where: { id },
|
// // where: { id },
|
||||||
// include: { timesheet: { include: { employee: { include: { user:true } } } },
|
// // include: { timesheet: { include: { employee: { include: { user:true } } } },
|
||||||
// bank_code: true,
|
// // bank_code: true,
|
||||||
// },
|
// // },
|
||||||
// });
|
// // });
|
||||||
// if (!expense) {
|
// // if (!expense) {
|
||||||
// throw new NotFoundException(`Expense #${id} not found`);
|
// // throw new NotFoundException(`Expense #${id} not found`);
|
||||||
// }
|
// // }
|
||||||
// return expense;
|
// // return expense;
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
|
// // async update(id: number, dto: UpdateExpenseDto): Promise<Expenses> {
|
||||||
// await this.findOne(id);
|
// // await this.findOne(id);
|
||||||
// const { timesheet_id, bank_code_id, date, amount,
|
// // const { timesheet_id, bank_code_id, date, amount,
|
||||||
// comment, is_approved, supervisor_comment} = dto;
|
// // comment, is_approved, supervisor_comment} = dto;
|
||||||
// return this.prisma.expenses.update({
|
// // return this.prisma.expenses.update({
|
||||||
// where: { id },
|
// // where: { id },
|
||||||
// data: {
|
// // data: {
|
||||||
// ...(timesheet_id !== undefined && { timesheet_id}),
|
// // ...(timesheet_id !== undefined && { timesheet_id}),
|
||||||
// ...(bank_code_id !== undefined && { bank_code_id }),
|
// // ...(bank_code_id !== undefined && { bank_code_id }),
|
||||||
// ...(date !== undefined && { date }),
|
// // ...(date !== undefined && { date }),
|
||||||
// ...(amount !== undefined && { amount }),
|
// // ...(amount !== undefined && { amount }),
|
||||||
// ...(comment !== undefined && { comment }),
|
// // ...(comment !== undefined && { comment }),
|
||||||
// ...(is_approved !== undefined && { is_approved }),
|
// // ...(is_approved !== undefined && { is_approved }),
|
||||||
// ...(supervisor_comment !== undefined && { supervisor_comment }),
|
// // ...(supervisor_comment !== undefined && { supervisor_comment }),
|
||||||
// },
|
// // },
|
||||||
// include: { timesheet: { include: { employee: { include: { user: true } } } },
|
// // include: { timesheet: { include: { employee: { include: { user: true } } } },
|
||||||
// bank_code: true,
|
// // bank_code: true,
|
||||||
// },
|
// // },
|
||||||
// });
|
// // });
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
// async remove(id: number): Promise<Expenses> {
|
// // async remove(id: number): Promise<Expenses> {
|
||||||
// await this.findOne(id);
|
// // await this.findOne(id);
|
||||||
// return this.prisma.expenses.delete({ where: { id } });
|
// // return this.prisma.expenses.delete({ where: { id } });
|
||||||
// }
|
// // }
|
||||||
|
|
||||||
}
|
// }
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
import { Body, Controller, Post } from "@nestjs/common";
|
// import { Body, Controller, Post } from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
// import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
import { LeaveRequestsService } from "../services/leave-request.service";
|
// import { LeaveRequestsService } from "../services/leave-request.service";
|
||||||
import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
|
// import { UpsertLeaveRequestDto } from "../dtos/upsert-leave-request.dto";
|
||||||
import { LeaveTypes } from "@prisma/client";
|
// import { LeaveTypes } from "@prisma/client";
|
||||||
|
|
||||||
@ApiTags('Leave Requests')
|
// @ApiTags('Leave Requests')
|
||||||
@ApiBearerAuth('access-token')
|
// @ApiBearerAuth('access-token')
|
||||||
// @UseGuards()
|
// // @UseGuards()
|
||||||
@Controller('leave-requests')
|
// @Controller('leave-requests')
|
||||||
export class LeaveRequestController {
|
// export class LeaveRequestController {
|
||||||
constructor(private readonly leave_service: LeaveRequestsService){}
|
// constructor(private readonly leave_service: LeaveRequestsService){}
|
||||||
|
|
||||||
@Post('upsert')
|
// @Post('upsert')
|
||||||
async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
|
// async upsertLeaveRequest(@Body() dto: UpsertLeaveRequestDto) {
|
||||||
const { action, leave_requests } = await this.leave_service.handle(dto);
|
// const { action, leave_requests } = await this.leave_service.handle(dto);
|
||||||
return { action, leave_requests };
|
// return { action, leave_requests };
|
||||||
}q
|
// }q
|
||||||
|
|
||||||
//TODO:
|
// //TODO:
|
||||||
/*
|
// /*
|
||||||
@Get('archive')
|
// @Get('archive')
|
||||||
findAllArchived(){...}
|
// findAllArchived(){...}
|
||||||
|
|
||||||
@Get('archive/:id')
|
// @Get('archive/:id')
|
||||||
findOneArchived(id){...}
|
// findOneArchived(id){...}
|
||||||
*/
|
// */
|
||||||
|
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,29 @@
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { LeaveRequestController } from "./controllers/leave-requests.controller";
|
// import { LeaveRequestController } from "./controllers/leave-requests.controller";
|
||||||
import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
|
// import { HolidayLeaveRequestsService } from "./services/holiday-leave-requests.service";
|
||||||
import { Module } from "@nestjs/common";
|
// import { Module } from "@nestjs/common";
|
||||||
import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
// import { BusinessLogicsModule } from "src/modules/business-logics/business-logics.module";
|
||||||
import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service";
|
// import { VacationLeaveRequestsService } from "./services/vacation-leave-requests.service";
|
||||||
import { SickLeaveRequestsService } from "./services/sick-leave-requests.service";
|
// import { SickLeaveRequestsService } from "./services/sick-leave-requests.service";
|
||||||
import { LeaveRequestsService } from "./services/leave-request.service";
|
// import { LeaveRequestsService } from "./services/leave-request.service";
|
||||||
import { ShiftsModule } from "../shifts/shifts.module";
|
// import { ShiftsModule } from "../shifts/shifts.module";
|
||||||
import { LeaveRequestsUtils } from "./utils/leave-request.util";
|
// import { LeaveRequestsUtils } from "./utils/leave-request.util";
|
||||||
import { SharedModule } from "../shared/shared.module";
|
// import { SharedModule } from "../shared/shared.module";
|
||||||
|
|
||||||
@Module({
|
// @Module({
|
||||||
imports: [BusinessLogicsModule, ShiftsModule, SharedModule],
|
// imports: [BusinessLogicsModule, ShiftsModule, SharedModule],
|
||||||
controllers: [LeaveRequestController],
|
// controllers: [LeaveRequestController],
|
||||||
providers: [
|
// providers: [
|
||||||
VacationLeaveRequestsService,
|
// VacationLeaveRequestsService,
|
||||||
SickLeaveRequestsService,
|
// SickLeaveRequestsService,
|
||||||
HolidayLeaveRequestsService,
|
// HolidayLeaveRequestsService,
|
||||||
LeaveRequestsService,
|
// LeaveRequestsService,
|
||||||
PrismaService,
|
// PrismaService,
|
||||||
LeaveRequestsUtils,
|
// LeaveRequestsUtils,
|
||||||
],
|
// ],
|
||||||
exports: [
|
// exports: [
|
||||||
LeaveRequestsService,
|
// LeaveRequestsService,
|
||||||
],
|
// ],
|
||||||
})
|
// })
|
||||||
|
|
||||||
export class LeaveRequestsModule {}
|
// export class LeaveRequestsModule {}
|
||||||
|
|
@ -1,78 +1,78 @@
|
||||||
import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto';
|
// import { UpsertLeaveRequestDto, UpsertResult } from '../dtos/upsert-leave-request.dto';
|
||||||
import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
|
// import { LeaveRequestViewDto } from '../dtos/leave-request-view.dto';
|
||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
// import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
|
// import { LeaveApprovalStatus, LeaveTypes } from '@prisma/client';
|
||||||
import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
|
// import { HolidayService } from 'src/modules/business-logics/services/holiday.service';
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
// import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
import { mapRowToView } from '../mappers/leave-requests.mapper';
|
// import { mapRowToView } from '../mappers/leave-requests.mapper';
|
||||||
import { leaveRequestsSelect } from '../utils/leave-requests.select';
|
// import { leaveRequestsSelect } from '../utils/leave-requests.select';
|
||||||
import { LeaveRequestsUtils} from '../utils/leave-request.util';
|
// import { LeaveRequestsUtils} from '../utils/leave-request.util';
|
||||||
import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers';
|
// import { normalizeDates, toDateOnly } from 'src/modules/shared/helpers/date-time.helpers';
|
||||||
import { BankCodesResolver } from 'src/modules/shared/utils/resolve-bank-type-id.utils';
|
// import { BankCodesResolver } from 'src/modules/shared/utils/resolve-bank-type-id.utils';
|
||||||
import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
|
// import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
// @Injectable()
|
||||||
export class HolidayLeaveRequestsService {
|
// export class HolidayLeaveRequestsService {
|
||||||
constructor(
|
// constructor(
|
||||||
private readonly prisma: PrismaService,
|
// private readonly prisma: PrismaService,
|
||||||
private readonly holidayService: HolidayService,
|
// private readonly holidayService: HolidayService,
|
||||||
private readonly leaveUtils: LeaveRequestsUtils,
|
// private readonly leaveUtils: LeaveRequestsUtils,
|
||||||
private readonly emailResolver: EmailToIdResolver,
|
// private readonly emailResolver: EmailToIdResolver,
|
||||||
private readonly typeResolver: BankCodesResolver,
|
// private readonly typeResolver: BankCodesResolver,
|
||||||
) {}
|
// ) {}
|
||||||
|
|
||||||
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
// async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
const email = dto.email.trim();
|
// const email = dto.email.trim();
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
// const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
const bank_code = await this.typeResolver.findByType(LeaveTypes.HOLIDAY);
|
// const bank_code = await this.typeResolver.findByType(LeaveTypes.HOLIDAY);
|
||||||
if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
// if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
||||||
const dates = normalizeDates(dto.dates);
|
// const dates = normalizeDates(dto.dates);
|
||||||
if (!dates.length) throw new BadRequestException('Dates array must not be empty');
|
// if (!dates.length) throw new BadRequestException('Dates array must not be empty');
|
||||||
|
|
||||||
const created: LeaveRequestViewDto[] = [];
|
// const created: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
for (const iso_date of dates) {
|
// for (const iso_date of dates) {
|
||||||
const date = toDateOnly(iso_date);
|
// const date = toDateOnly(iso_date);
|
||||||
|
|
||||||
const existing = await this.prisma.leaveRequests.findUnique({
|
// const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
where: {
|
// where: {
|
||||||
leave_per_employee_date: {
|
// leave_per_employee_date: {
|
||||||
employee_id: employee_id,
|
// employee_id: employee_id,
|
||||||
leave_type: LeaveTypes.HOLIDAY,
|
// leave_type: LeaveTypes.HOLIDAY,
|
||||||
date,
|
// date,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
select: { id: true },
|
// select: { id: true },
|
||||||
});
|
// });
|
||||||
if (existing) {
|
// if (existing) {
|
||||||
throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
|
// throw new BadRequestException(`Holiday request already exists for ${iso_date}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
|
// const payable = await this.holidayService.calculateHolidayPay(email, date, bank_code.modifier);
|
||||||
const row = await this.prisma.leaveRequests.create({
|
// const row = await this.prisma.leaveRequests.create({
|
||||||
data: {
|
// data: {
|
||||||
employee_id: employee_id,
|
// employee_id: employee_id,
|
||||||
bank_code_id: bank_code.id,
|
// bank_code_id: bank_code.id,
|
||||||
leave_type: LeaveTypes.HOLIDAY,
|
// leave_type: LeaveTypes.HOLIDAY,
|
||||||
date,
|
// date,
|
||||||
comment: dto.comment ?? '',
|
// comment: dto.comment ?? '',
|
||||||
requested_hours: dto.requested_hours ?? 8,
|
// requested_hours: dto.requested_hours ?? 8,
|
||||||
payable_hours: payable,
|
// payable_hours: payable,
|
||||||
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
// approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
||||||
},
|
// },
|
||||||
select: leaveRequestsSelect,
|
// select: leaveRequestsSelect,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
// if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
|
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours,LeaveTypes.HOLIDAY, row.comment);
|
||||||
}
|
// }
|
||||||
|
|
||||||
created.push({ ...mapRowToView(row), action: 'create' });
|
// created.push({ ...mapRowToView(row), action: 'create' });
|
||||||
}
|
// }
|
||||||
|
|
||||||
return { action: 'create', leave_requests: created };
|
// return { action: 'create', leave_requests: created };
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,248 +1,248 @@
|
||||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
// import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
import { roundToQuarterHour } from "src/common/utils/date-utils";
|
// import { roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
// import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
||||||
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
// import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
// import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||||
import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
// import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
||||||
import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service";
|
// import { HolidayLeaveRequestsService } from "./holiday-leave-requests.service";
|
||||||
import { SickLeaveRequestsService } from "./sick-leave-requests.service";
|
// import { SickLeaveRequestsService } from "./sick-leave-requests.service";
|
||||||
import { VacationLeaveRequestsService } from "./vacation-leave-requests.service";
|
// import { VacationLeaveRequestsService } from "./vacation-leave-requests.service";
|
||||||
import { HolidayService } from "src/modules/business-logics/services/holiday.service";
|
// import { HolidayService } from "src/modules/business-logics/services/holiday.service";
|
||||||
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
|
// import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
|
||||||
import { VacationService } from "src/modules/business-logics/services/vacation.service";
|
// import { VacationService } from "src/modules/business-logics/services/vacation.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { LeaveRequestsUtils } from "../utils/leave-request.util";
|
// import { LeaveRequestsUtils } from "../utils/leave-request.util";
|
||||||
import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers";
|
// import { normalizeDates, toDateOnly, toISODateKey } from "src/modules/shared/helpers/date-time.helpers";
|
||||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
|
||||||
@Injectable()
|
// @Injectable()
|
||||||
export class LeaveRequestsService {
|
// export class LeaveRequestsService {
|
||||||
constructor(
|
// constructor(
|
||||||
private readonly prisma: PrismaService,
|
// private readonly prisma: PrismaService,
|
||||||
private readonly holidayLeaveService: HolidayLeaveRequestsService,
|
// private readonly holidayLeaveService: HolidayLeaveRequestsService,
|
||||||
private readonly holidayService: HolidayService,
|
// private readonly holidayService: HolidayService,
|
||||||
private readonly sickLogic: SickLeaveService,
|
// private readonly sickLogic: SickLeaveService,
|
||||||
private readonly sickLeaveService: SickLeaveRequestsService,
|
// private readonly sickLeaveService: SickLeaveRequestsService,
|
||||||
private readonly vacationLeaveService: VacationLeaveRequestsService,
|
// private readonly vacationLeaveService: VacationLeaveRequestsService,
|
||||||
private readonly vacationLogic: VacationService,
|
// private readonly vacationLogic: VacationService,
|
||||||
private readonly leaveUtils: LeaveRequestsUtils,
|
// private readonly leaveUtils: LeaveRequestsUtils,
|
||||||
private readonly emailResolver: EmailToIdResolver,
|
// private readonly emailResolver: EmailToIdResolver,
|
||||||
private readonly typeResolver: BankCodesResolver,
|
// private readonly typeResolver: BankCodesResolver,
|
||||||
) {}
|
// ) {}
|
||||||
|
|
||||||
//handle distribution to the right service according to the selected type and action
|
// //handle distribution to the right service according to the selected type and action
|
||||||
async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
// async handle(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
switch (dto.type) {
|
// switch (dto.type) {
|
||||||
case LeaveTypes.HOLIDAY:
|
// case LeaveTypes.HOLIDAY:
|
||||||
if( dto.action === 'create'){
|
// if( dto.action === 'create'){
|
||||||
return this.holidayLeaveService.create(dto);
|
// return this.holidayLeaveService.create(dto);
|
||||||
} else if (dto.action === 'update') {
|
// } else if (dto.action === 'update') {
|
||||||
return this.update(dto, LeaveTypes.HOLIDAY);
|
// return this.update(dto, LeaveTypes.HOLIDAY);
|
||||||
} else if (dto.action === 'delete'){
|
// } else if (dto.action === 'delete'){
|
||||||
return this.delete(dto, LeaveTypes.HOLIDAY);
|
// return this.delete(dto, LeaveTypes.HOLIDAY);
|
||||||
}
|
// }
|
||||||
case LeaveTypes.VACATION:
|
// case LeaveTypes.VACATION:
|
||||||
if( dto.action === 'create'){
|
// if( dto.action === 'create'){
|
||||||
return this.vacationLeaveService.create(dto);
|
// return this.vacationLeaveService.create(dto);
|
||||||
} else if (dto.action === 'update') {
|
// } else if (dto.action === 'update') {
|
||||||
return this.update(dto, LeaveTypes.VACATION);
|
// return this.update(dto, LeaveTypes.VACATION);
|
||||||
} else if (dto.action === 'delete'){
|
// } else if (dto.action === 'delete'){
|
||||||
return this.delete(dto, LeaveTypes.VACATION);
|
// return this.delete(dto, LeaveTypes.VACATION);
|
||||||
}
|
// }
|
||||||
case LeaveTypes.SICK:
|
// case LeaveTypes.SICK:
|
||||||
if( dto.action === 'create'){
|
// if( dto.action === 'create'){
|
||||||
return this.sickLeaveService.create(dto);
|
// return this.sickLeaveService.create(dto);
|
||||||
} else if (dto.action === 'update') {
|
// } else if (dto.action === 'update') {
|
||||||
return this.update(dto, LeaveTypes.SICK);
|
// return this.update(dto, LeaveTypes.SICK);
|
||||||
} else if (dto.action === 'delete'){
|
// } else if (dto.action === 'delete'){
|
||||||
return this.delete(dto, LeaveTypes.SICK);
|
// return this.delete(dto, LeaveTypes.SICK);
|
||||||
}
|
// }
|
||||||
default:
|
// default:
|
||||||
throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`);
|
// throw new BadRequestException(`Unsupported leave type: ${dto.type} or action: ${dto.action}`);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
|
// async delete(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
|
||||||
const email = dto.email.trim();
|
// const email = dto.email.trim();
|
||||||
const dates = normalizeDates(dto.dates);
|
// const dates = normalizeDates(dto.dates);
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
// const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
// if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
||||||
|
|
||||||
const rows = await this.prisma.leaveRequests.findMany({
|
// const rows = await this.prisma.leaveRequests.findMany({
|
||||||
where: {
|
// where: {
|
||||||
employee_id: employee_id,
|
// employee_id: employee_id,
|
||||||
leave_type: type,
|
// leave_type: type,
|
||||||
date: { in: dates.map((d) => toDateOnly(d)) },
|
// date: { in: dates.map((d) => toDateOnly(d)) },
|
||||||
},
|
// },
|
||||||
select: leaveRequestsSelect,
|
// select: leaveRequestsSelect,
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (rows.length !== dates.length) {
|
// if (rows.length !== dates.length) {
|
||||||
const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
|
// const missing = dates.filter((isoDate) => !rows.some((row) => toISODateKey(row.date) === isoDate));
|
||||||
throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
|
// throw new NotFoundException(`No Leave request found for: ${missing.join(", ")}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
for (const row of rows) {
|
// for (const row of rows) {
|
||||||
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
// if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
const iso = toISODateKey(row.date);
|
// const iso = toISODateKey(row.date);
|
||||||
await this.leaveUtils.removeShift(email, employee_id, iso, type);
|
// await this.leaveUtils.removeShift(email, employee_id, iso, type);
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
await this.prisma.leaveRequests.deleteMany({
|
// await this.prisma.leaveRequests.deleteMany({
|
||||||
where: { id: { in: rows.map((row) => row.id) } },
|
// where: { id: { in: rows.map((row) => row.id) } },
|
||||||
});
|
// });
|
||||||
|
|
||||||
const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
|
// const deleted = rows.map((row) => ({ ...mapRowToView(row), action: "delete" as const }));
|
||||||
return { action: "delete", leave_requests: deleted };
|
// return { action: "delete", leave_requests: deleted };
|
||||||
}
|
// }
|
||||||
|
|
||||||
async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
|
// async update(dto: UpsertLeaveRequestDto, type: LeaveTypes): Promise<UpsertResult> {
|
||||||
const email = dto.email.trim();
|
// const email = dto.email.trim();
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
// const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
const bank_code = await this.typeResolver.findByType(type);
|
// const bank_code = await this.typeResolver.findByType(type);
|
||||||
if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
// if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
||||||
const modifier = Number(bank_code.modifier ?? 1);
|
// const modifier = Number(bank_code.modifier ?? 1);
|
||||||
const dates = normalizeDates(dto.dates);
|
// const dates = normalizeDates(dto.dates);
|
||||||
if (!dates.length) {
|
// if (!dates.length) {
|
||||||
throw new BadRequestException("Dates array must not be empty");
|
// throw new BadRequestException("Dates array must not be empty");
|
||||||
}
|
// }
|
||||||
|
|
||||||
const entries = await Promise.all(
|
// const entries = await Promise.all(
|
||||||
dates.map(async (iso_date) => {
|
// dates.map(async (iso_date) => {
|
||||||
const date = toDateOnly(iso_date);
|
// const date = toDateOnly(iso_date);
|
||||||
const existing = await this.prisma.leaveRequests.findUnique({
|
// const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
where: {
|
// where: {
|
||||||
leave_per_employee_date: {
|
// leave_per_employee_date: {
|
||||||
employee_id: employee_id,
|
// employee_id: employee_id,
|
||||||
leave_type: type,
|
// leave_type: type,
|
||||||
date,
|
// date,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
select: leaveRequestsSelect,
|
// select: leaveRequestsSelect,
|
||||||
});
|
// });
|
||||||
if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`);
|
// if (!existing) throw new NotFoundException(`No Leave request found for ${iso_date}`);
|
||||||
return { iso_date, date, existing };
|
// return { iso_date, date, existing };
|
||||||
}),
|
// }),
|
||||||
);
|
// );
|
||||||
|
|
||||||
const updated: LeaveRequestViewDto[] = [];
|
// const updated: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
if (type === LeaveTypes.SICK) {
|
// if (type === LeaveTypes.SICK) {
|
||||||
const firstExisting = entries[0].existing;
|
// const firstExisting = entries[0].existing;
|
||||||
const fallbackRequested =
|
// const fallbackRequested =
|
||||||
firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
|
// firstExisting.requested_hours !== null && firstExisting.requested_hours !== undefined
|
||||||
? Number(firstExisting.requested_hours)
|
// ? Number(firstExisting.requested_hours)
|
||||||
: 8;
|
// : 8;
|
||||||
const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
|
// const requested_hours_per_day = dto.requested_hours ?? fallbackRequested;
|
||||||
const reference_date = entries.reduce(
|
// const reference_date = entries.reduce(
|
||||||
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
// (latest, entry) => (entry.date > latest ? entry.date : latest),
|
||||||
entries[0].date,
|
// entries[0].date,
|
||||||
);
|
// );
|
||||||
const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
|
// const total_payable_hours = await this.sickLogic.calculateSickLeavePay(
|
||||||
employee_id,
|
// employee_id,
|
||||||
reference_date,
|
// reference_date,
|
||||||
entries.length,
|
// entries.length,
|
||||||
requested_hours_per_day,
|
// requested_hours_per_day,
|
||||||
modifier,
|
// modifier,
|
||||||
);
|
// );
|
||||||
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
// let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
||||||
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
// const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
||||||
|
|
||||||
for (const { iso_date, existing } of entries) {
|
// for (const { iso_date, existing } of entries) {
|
||||||
const previous_status = existing.approval_status;
|
// const previous_status = existing.approval_status;
|
||||||
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
// const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
||||||
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
// const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
||||||
remaining_payable_hours = roundToQuarterHour(
|
// remaining_payable_hours = roundToQuarterHour(
|
||||||
Math.max(0, remaining_payable_hours - payable_rounded),
|
// Math.max(0, remaining_payable_hours - payable_rounded),
|
||||||
);
|
// );
|
||||||
|
|
||||||
const row = await this.prisma.leaveRequests.update({
|
// const row = await this.prisma.leaveRequests.update({
|
||||||
where: { id: existing.id },
|
// where: { id: existing.id },
|
||||||
data: {
|
// data: {
|
||||||
comment: dto.comment ?? existing.comment,
|
// comment: dto.comment ?? existing.comment,
|
||||||
requested_hours: requested_hours_per_day,
|
// requested_hours: requested_hours_per_day,
|
||||||
payable_hours: payable_rounded,
|
// payable_hours: payable_rounded,
|
||||||
bank_code_id: bank_code.id,
|
// bank_code_id: bank_code.id,
|
||||||
approval_status: dto.approval_status ?? existing.approval_status,
|
// approval_status: dto.approval_status ?? existing.approval_status,
|
||||||
},
|
// },
|
||||||
select: leaveRequestsSelect,
|
// select: leaveRequestsSelect,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
|
// const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
|
||||||
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
|
// const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
|
||||||
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
|
||||||
if (!was_approved && is_approved) {
|
// if (!was_approved && is_approved) {
|
||||||
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
} else if (was_approved && !is_approved) {
|
// } else if (was_approved && !is_approved) {
|
||||||
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
|
// await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
|
||||||
} else if (was_approved && is_approved) {
|
// } else if (was_approved && is_approved) {
|
||||||
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
}
|
// }
|
||||||
updated.push({ ...mapRowToView(row), action: "update" });
|
// updated.push({ ...mapRowToView(row), action: "update" });
|
||||||
}
|
// }
|
||||||
return { action: "update", leave_requests: updated };
|
// return { action: "update", leave_requests: updated };
|
||||||
}
|
// }
|
||||||
|
|
||||||
for (const { iso_date, date, existing } of entries) {
|
// for (const { iso_date, date, existing } of entries) {
|
||||||
const previous_status = existing.approval_status;
|
// const previous_status = existing.approval_status;
|
||||||
const fallbackRequested =
|
// const fallbackRequested =
|
||||||
existing.requested_hours !== null && existing.requested_hours !== undefined
|
// existing.requested_hours !== null && existing.requested_hours !== undefined
|
||||||
? Number(existing.requested_hours)
|
// ? Number(existing.requested_hours)
|
||||||
: 8;
|
// : 8;
|
||||||
const requested_hours = dto.requested_hours ?? fallbackRequested;
|
// const requested_hours = dto.requested_hours ?? fallbackRequested;
|
||||||
|
|
||||||
let payable: number;
|
// let payable: number;
|
||||||
switch (type) {
|
// switch (type) {
|
||||||
case LeaveTypes.HOLIDAY:
|
// case LeaveTypes.HOLIDAY:
|
||||||
payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
|
// payable = await this.holidayService.calculateHolidayPay(email, date, modifier);
|
||||||
break;
|
// break;
|
||||||
case LeaveTypes.VACATION: {
|
// case LeaveTypes.VACATION: {
|
||||||
const days_requested = requested_hours / 8;
|
// const days_requested = requested_hours / 8;
|
||||||
payable = await this.vacationLogic.calculateVacationPay(
|
// payable = await this.vacationLogic.calculateVacationPay(
|
||||||
employee_id,
|
// employee_id,
|
||||||
date,
|
// date,
|
||||||
Math.max(0, days_requested),
|
// Math.max(0, days_requested),
|
||||||
modifier,
|
// modifier,
|
||||||
);
|
// );
|
||||||
break;
|
// break;
|
||||||
}
|
// }
|
||||||
default:
|
// default:
|
||||||
payable = existing.payable_hours !== null && existing.payable_hours !== undefined
|
// payable = existing.payable_hours !== null && existing.payable_hours !== undefined
|
||||||
? Number(existing.payable_hours)
|
// ? Number(existing.payable_hours)
|
||||||
: requested_hours;
|
// : requested_hours;
|
||||||
}
|
// }
|
||||||
|
|
||||||
const row = await this.prisma.leaveRequests.update({
|
// const row = await this.prisma.leaveRequests.update({
|
||||||
where: { id: existing.id },
|
// where: { id: existing.id },
|
||||||
data: {
|
// data: {
|
||||||
requested_hours,
|
// requested_hours,
|
||||||
comment: dto.comment ?? existing.comment,
|
// comment: dto.comment ?? existing.comment,
|
||||||
payable_hours: payable,
|
// payable_hours: payable,
|
||||||
bank_code_id: bank_code.id,
|
// bank_code_id: bank_code.id,
|
||||||
approval_status: dto.approval_status ?? existing.approval_status,
|
// approval_status: dto.approval_status ?? existing.approval_status,
|
||||||
},
|
// },
|
||||||
select: leaveRequestsSelect,
|
// select: leaveRequestsSelect,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
|
// const was_approved = previous_status === LeaveApprovalStatus.APPROVED;
|
||||||
const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
|
// const is_approved = row.approval_status === LeaveApprovalStatus.APPROVED;
|
||||||
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
|
|
||||||
if (!was_approved && is_approved) {
|
// if (!was_approved && is_approved) {
|
||||||
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
} else if (was_approved && !is_approved) {
|
// } else if (was_approved && !is_approved) {
|
||||||
await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
|
// await this.leaveUtils.removeShift(email, employee_id, iso_date, type);
|
||||||
} else if (was_approved && is_approved) {
|
// } else if (was_approved && is_approved) {
|
||||||
await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
// await this.leaveUtils.syncShift(email, employee_id, iso_date, hours, type, row.comment);
|
||||||
}
|
// }
|
||||||
updated.push({ ...mapRowToView(row), action: "update" });
|
// updated.push({ ...mapRowToView(row), action: "update" });
|
||||||
}
|
// }
|
||||||
return { action: "update", leave_requests: updated };
|
// return { action: "update", leave_requests: updated };
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,98 +1,98 @@
|
||||||
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
// import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
||||||
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
// import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
// import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
// import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
||||||
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
// import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
|
// import { SickLeaveService } from "src/modules/business-logics/services/sick-leave.service";
|
||||||
import { roundToQuarterHour } from "src/common/utils/date-utils";
|
// import { roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
import { LeaveRequestsUtils } from "../utils/leave-request.util";
|
// import { LeaveRequestsUtils } from "../utils/leave-request.util";
|
||||||
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
|
// import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
|
||||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
|
||||||
@Injectable()
|
// @Injectable()
|
||||||
export class SickLeaveRequestsService {
|
// export class SickLeaveRequestsService {
|
||||||
constructor(
|
// constructor(
|
||||||
private readonly prisma: PrismaService,
|
// private readonly prisma: PrismaService,
|
||||||
private readonly sickService: SickLeaveService,
|
// private readonly sickService: SickLeaveService,
|
||||||
private readonly leaveUtils: LeaveRequestsUtils,
|
// private readonly leaveUtils: LeaveRequestsUtils,
|
||||||
private readonly emailResolver: EmailToIdResolver,
|
// private readonly emailResolver: EmailToIdResolver,
|
||||||
private readonly typeResolver: BankCodesResolver,
|
// private readonly typeResolver: BankCodesResolver,
|
||||||
) {}
|
// ) {}
|
||||||
|
|
||||||
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
// async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
const email = dto.email.trim();
|
// const email = dto.email.trim();
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
// const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK);
|
// const bank_code = await this.typeResolver.findByType(LeaveTypes.SICK);
|
||||||
if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
// if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
||||||
|
|
||||||
const modifier = bank_code.modifier ?? 1;
|
// const modifier = bank_code.modifier ?? 1;
|
||||||
const dates = normalizeDates(dto.dates);
|
// const dates = normalizeDates(dto.dates);
|
||||||
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
// if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
||||||
const requested_hours_per_day = dto.requested_hours ?? 8;
|
// const requested_hours_per_day = dto.requested_hours ?? 8;
|
||||||
|
|
||||||
const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
|
// const entries = dates.map((iso) => ({ iso, date: toDateOnly(iso) }));
|
||||||
const reference_date = entries.reduce(
|
// const reference_date = entries.reduce(
|
||||||
(latest, entry) => (entry.date > latest ? entry.date : latest),
|
// (latest, entry) => (entry.date > latest ? entry.date : latest),
|
||||||
entries[0].date,
|
// entries[0].date,
|
||||||
);
|
// );
|
||||||
const total_payable_hours = await this.sickService.calculateSickLeavePay(
|
// const total_payable_hours = await this.sickService.calculateSickLeavePay(
|
||||||
employee_id,
|
// employee_id,
|
||||||
reference_date,
|
// reference_date,
|
||||||
entries.length,
|
// entries.length,
|
||||||
requested_hours_per_day,
|
// requested_hours_per_day,
|
||||||
modifier,
|
// modifier,
|
||||||
);
|
// );
|
||||||
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
// let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
||||||
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
// const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
||||||
|
|
||||||
const created: LeaveRequestViewDto[] = [];
|
// const created: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
for (const { iso, date } of entries) {
|
// for (const { iso, date } of entries) {
|
||||||
const existing = await this.prisma.leaveRequests.findUnique({
|
// const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
where: {
|
// where: {
|
||||||
leave_per_employee_date: {
|
// leave_per_employee_date: {
|
||||||
employee_id: employee_id,
|
// employee_id: employee_id,
|
||||||
leave_type: LeaveTypes.SICK,
|
// leave_type: LeaveTypes.SICK,
|
||||||
date,
|
// date,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
select: { id: true },
|
// select: { id: true },
|
||||||
});
|
// });
|
||||||
if (existing) {
|
// if (existing) {
|
||||||
throw new BadRequestException(`Sick request already exists for ${iso}`);
|
// throw new BadRequestException(`Sick request already exists for ${iso}`);
|
||||||
}
|
// }
|
||||||
|
|
||||||
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
// const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
||||||
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
// const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
||||||
remaining_payable_hours = roundToQuarterHour(
|
// remaining_payable_hours = roundToQuarterHour(
|
||||||
Math.max(0, remaining_payable_hours - payable_rounded),
|
// Math.max(0, remaining_payable_hours - payable_rounded),
|
||||||
);
|
// );
|
||||||
|
|
||||||
const row = await this.prisma.leaveRequests.create({
|
// const row = await this.prisma.leaveRequests.create({
|
||||||
data: {
|
// data: {
|
||||||
employee_id: employee_id,
|
// employee_id: employee_id,
|
||||||
bank_code_id: bank_code.id,
|
// bank_code_id: bank_code.id,
|
||||||
leave_type: LeaveTypes.SICK,
|
// leave_type: LeaveTypes.SICK,
|
||||||
comment: dto.comment ?? "",
|
// comment: dto.comment ?? "",
|
||||||
requested_hours: requested_hours_per_day,
|
// requested_hours: requested_hours_per_day,
|
||||||
payable_hours: payable_rounded,
|
// payable_hours: payable_rounded,
|
||||||
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
// approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
||||||
date,
|
// date,
|
||||||
},
|
// },
|
||||||
select: leaveRequestsSelect,
|
// select: leaveRequestsSelect,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
// if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
|
// await this.leaveUtils.syncShift(email, employee_id, iso, hours,LeaveTypes.SICK, row.comment);
|
||||||
}
|
// }
|
||||||
|
|
||||||
created.push({ ...mapRowToView(row), action: "create" });
|
// created.push({ ...mapRowToView(row), action: "create" });
|
||||||
}
|
// }
|
||||||
|
|
||||||
return { action: "create", leave_requests: created };
|
// return { action: "create", leave_requests: created };
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,93 @@
|
||||||
|
|
||||||
import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
// import { UpsertLeaveRequestDto, UpsertResult } from "../dtos/upsert-leave-request.dto";
|
||||||
import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
// import { LeaveRequestViewDto } from "../dtos/leave-request-view.dto";
|
||||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
// import { LeaveApprovalStatus, LeaveTypes } from "@prisma/client";
|
||||||
import { VacationService } from "src/modules/business-logics/services/vacation.service";
|
// import { VacationService } from "src/modules/business-logics/services/vacation.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { mapRowToView } from "../mappers/leave-requests.mapper";
|
// import { mapRowToView } from "../mappers/leave-requests.mapper";
|
||||||
import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
// import { leaveRequestsSelect } from "../utils/leave-requests.select";
|
||||||
import { roundToQuarterHour } from "src/common/utils/date-utils";
|
// import { roundToQuarterHour } from "src/common/utils/date-utils";
|
||||||
import { LeaveRequestsUtils } from "../utils/leave-request.util";
|
// import { LeaveRequestsUtils } from "../utils/leave-request.util";
|
||||||
import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
|
// import { normalizeDates, toDateOnly } from "src/modules/shared/helpers/date-time.helpers";
|
||||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
|
||||||
@Injectable()
|
// @Injectable()
|
||||||
export class VacationLeaveRequestsService {
|
// export class VacationLeaveRequestsService {
|
||||||
constructor(
|
// constructor(
|
||||||
private readonly prisma: PrismaService,
|
// private readonly prisma: PrismaService,
|
||||||
private readonly vacationService: VacationService,
|
// private readonly vacationService: VacationService,
|
||||||
private readonly leaveUtils: LeaveRequestsUtils,
|
// private readonly leaveUtils: LeaveRequestsUtils,
|
||||||
private readonly emailResolver: EmailToIdResolver,
|
// private readonly emailResolver: EmailToIdResolver,
|
||||||
private readonly typeResolver: BankCodesResolver,
|
// private readonly typeResolver: BankCodesResolver,
|
||||||
) {}
|
// ) {}
|
||||||
|
|
||||||
async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
// async create(dto: UpsertLeaveRequestDto): Promise<UpsertResult> {
|
||||||
const email = dto.email.trim();
|
// const email = dto.email.trim();
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
// const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION);
|
// const bank_code = await this.typeResolver.findByType(LeaveTypes.VACATION);
|
||||||
if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
// if(!bank_code) throw new NotFoundException(`bank_code not found`);
|
||||||
|
|
||||||
const modifier = bank_code.modifier ?? 1;
|
// const modifier = bank_code.modifier ?? 1;
|
||||||
const dates = normalizeDates(dto.dates);
|
// const dates = normalizeDates(dto.dates);
|
||||||
const requested_hours_per_day = dto.requested_hours ?? 8;
|
// const requested_hours_per_day = dto.requested_hours ?? 8;
|
||||||
if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
// if (!dates.length) throw new BadRequestException("Dates array must not be empty");
|
||||||
|
|
||||||
const entries = dates
|
// const entries = dates
|
||||||
.map((iso) => ({ iso, date: toDateOnly(iso) }))
|
// .map((iso) => ({ iso, date: toDateOnly(iso) }))
|
||||||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
// .sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||||
const start_date = entries[0].date;
|
// const start_date = entries[0].date;
|
||||||
const total_payable_hours = await this.vacationService.calculateVacationPay(
|
// const total_payable_hours = await this.vacationService.calculateVacationPay(
|
||||||
employee_id,
|
// employee_id,
|
||||||
start_date,
|
// start_date,
|
||||||
entries.length,
|
// entries.length,
|
||||||
modifier,
|
// modifier,
|
||||||
);
|
// );
|
||||||
let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
// let remaining_payable_hours = roundToQuarterHour(Math.max(0, total_payable_hours));
|
||||||
const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
// const daily_payable_cap = roundToQuarterHour(requested_hours_per_day * modifier);
|
||||||
|
|
||||||
const created: LeaveRequestViewDto[] = [];
|
// const created: LeaveRequestViewDto[] = [];
|
||||||
|
|
||||||
for (const { iso, date } of entries) {
|
// for (const { iso, date } of entries) {
|
||||||
const existing = await this.prisma.leaveRequests.findUnique({
|
// const existing = await this.prisma.leaveRequests.findUnique({
|
||||||
where: {
|
// where: {
|
||||||
leave_per_employee_date: {
|
// leave_per_employee_date: {
|
||||||
employee_id: employee_id,
|
// employee_id: employee_id,
|
||||||
leave_type: LeaveTypes.VACATION,
|
// leave_type: LeaveTypes.VACATION,
|
||||||
date,
|
// date,
|
||||||
},
|
// },
|
||||||
},
|
// },
|
||||||
select: { id: true },
|
// select: { id: true },
|
||||||
});
|
// });
|
||||||
if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
|
// if (existing) throw new BadRequestException(`Vacation request already exists for ${iso}`);
|
||||||
|
|
||||||
const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
// const payable = Math.min(remaining_payable_hours, daily_payable_cap);
|
||||||
const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
// const payable_rounded = roundToQuarterHour(Math.max(0, payable));
|
||||||
remaining_payable_hours = roundToQuarterHour(
|
// remaining_payable_hours = roundToQuarterHour(
|
||||||
Math.max(0, remaining_payable_hours - payable_rounded),
|
// Math.max(0, remaining_payable_hours - payable_rounded),
|
||||||
);
|
// );
|
||||||
|
|
||||||
const row = await this.prisma.leaveRequests.create({
|
// const row = await this.prisma.leaveRequests.create({
|
||||||
data: {
|
// data: {
|
||||||
employee_id: employee_id,
|
// employee_id: employee_id,
|
||||||
bank_code_id: bank_code.id,
|
// bank_code_id: bank_code.id,
|
||||||
payable_hours: payable_rounded,
|
// payable_hours: payable_rounded,
|
||||||
requested_hours: requested_hours_per_day,
|
// requested_hours: requested_hours_per_day,
|
||||||
leave_type: LeaveTypes.VACATION,
|
// leave_type: LeaveTypes.VACATION,
|
||||||
comment: dto.comment ?? "",
|
// comment: dto.comment ?? "",
|
||||||
approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
// approval_status: dto.approval_status ?? LeaveApprovalStatus.PENDING,
|
||||||
date,
|
// date,
|
||||||
},
|
// },
|
||||||
select: leaveRequestsSelect,
|
// select: leaveRequestsSelect,
|
||||||
});
|
// });
|
||||||
|
|
||||||
const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
// const hours = Number(row.payable_hours ?? row.requested_hours ?? 0);
|
||||||
if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
// if (row.approval_status === LeaveApprovalStatus.APPROVED) {
|
||||||
await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
|
// await this.leaveUtils.syncShift(email, employee_id, iso, hours, LeaveTypes.VACATION, row.comment);
|
||||||
}
|
// }
|
||||||
created.push({ ...mapRowToView(row), action: "create" });
|
// created.push({ ...mapRowToView(row), action: "create" });
|
||||||
}
|
// }
|
||||||
return { action: "create", leave_requests: created };
|
// return { action: "create", leave_requests: created };
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,104 @@
|
||||||
import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers";
|
// import { hhmmFromLocal, toDateOnly, toStringFromDate } from "src/modules/shared/helpers/date-time.helpers";
|
||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
// import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
import { ShiftsCommandService } from "src/modules/shifts/services/shifts-command.service";
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
// import { LeaveTypes } from "@prisma/client";
|
||||||
import { LeaveTypes } from "@prisma/client";
|
// import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
||||||
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
|
||||||
|
|
||||||
@Injectable()
|
// @Injectable()
|
||||||
export class LeaveRequestsUtils {
|
// export class LeaveRequestsUtils {
|
||||||
constructor(
|
// constructor(
|
||||||
private readonly prisma: PrismaService,
|
// private readonly prisma: PrismaService,
|
||||||
private readonly shiftsCommand: ShiftsCommandService,
|
// private readonly shiftsCommand: ShiftsCommandService,
|
||||||
){}
|
// ){}
|
||||||
|
|
||||||
async syncShift(
|
// async syncShift(
|
||||||
email: string,
|
// email: string,
|
||||||
employee_id: number,
|
// employee_id: number,
|
||||||
date: string,
|
// date: string,
|
||||||
hours: number,
|
// hours: number,
|
||||||
type: LeaveTypes,
|
// type: LeaveTypes,
|
||||||
comment?: string,
|
// comment?: string,
|
||||||
) {
|
// ) {
|
||||||
if (hours <= 0) return;
|
// if (hours <= 0) return;
|
||||||
|
|
||||||
const duration_minutes = Math.round(hours * 60);
|
// const duration_minutes = Math.round(hours * 60);
|
||||||
if (duration_minutes > 8 * 60) {
|
// if (duration_minutes > 8 * 60) {
|
||||||
throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
|
// throw new BadRequestException("Amount of hours cannot exceed 8 hours per day.");
|
||||||
}
|
// }
|
||||||
const date_only = toDateOnly(date);
|
// const date_only = toDateOnly(date);
|
||||||
const yyyy_mm_dd = toStringFromDate(date_only);
|
// const yyyy_mm_dd = toStringFromDate(date_only);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const start_minutes = 8 * 60;
|
// const start_minutes = 8 * 60;
|
||||||
const end_minutes = start_minutes + duration_minutes;
|
// const end_minutes = start_minutes + duration_minutes;
|
||||||
const toHHmm = (total: number) =>
|
// const toHHmm = (total: number) =>
|
||||||
`${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
|
// `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
|
||||||
|
|
||||||
const existing = await this.prisma.shifts.findFirst({
|
// const existing = await this.prisma.shifts.findFirst({
|
||||||
where: {
|
// where: {
|
||||||
date: date_only,
|
// date: date_only,
|
||||||
bank_code: { type },
|
// bank_code: { type },
|
||||||
timesheet: { employee_id: employee_id },
|
// timesheet: { employee_id: employee_id },
|
||||||
},
|
// },
|
||||||
include: { bank_code: true },
|
// include: { bank_code: true },
|
||||||
});
|
// });
|
||||||
|
|
||||||
const action: UpsertAction = existing ? 'update' : 'create';
|
// const action: UpsertAction = existing ? 'update' : 'create';
|
||||||
|
|
||||||
await this.shiftsCommand.upsertShifts(email, action, {
|
// await this.shiftsCommand.upsertShifts(email, action, {
|
||||||
old_shift: existing
|
// old_shift: existing
|
||||||
? {
|
// ? {
|
||||||
date: yyyy_mm_dd,
|
// date: yyyy_mm_dd,
|
||||||
start_time: existing.start_time.toISOString().slice(11, 16),
|
// start_time: existing.start_time.toISOString().slice(11, 16),
|
||||||
end_time: existing.end_time.toISOString().slice(11, 16),
|
// end_time: existing.end_time.toISOString().slice(11, 16),
|
||||||
type: existing.bank_code?.type ?? type,
|
// type: existing.bank_code?.type ?? type,
|
||||||
is_remote: existing.is_remote,
|
// is_remote: existing.is_remote,
|
||||||
is_approved:existing.is_approved,
|
// is_approved:existing.is_approved,
|
||||||
comment: existing.comment ?? undefined,
|
// comment: existing.comment ?? undefined,
|
||||||
}
|
// }
|
||||||
: undefined,
|
// : undefined,
|
||||||
new_shift: {
|
// new_shift: {
|
||||||
date: yyyy_mm_dd,
|
// date: yyyy_mm_dd,
|
||||||
start_time: toHHmm(start_minutes),
|
// start_time: toHHmm(start_minutes),
|
||||||
end_time: toHHmm(end_minutes),
|
// end_time: toHHmm(end_minutes),
|
||||||
is_remote: existing?.is_remote ?? false,
|
// is_remote: existing?.is_remote ?? false,
|
||||||
is_approved:existing?.is_approved ?? false,
|
// is_approved:existing?.is_approved ?? false,
|
||||||
comment: comment ?? existing?.comment ?? "",
|
// comment: comment ?? existing?.comment ?? "",
|
||||||
type: type,
|
// type: type,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
async removeShift(
|
// async removeShift(
|
||||||
email: string,
|
// email: string,
|
||||||
employee_id: number,
|
// employee_id: number,
|
||||||
iso_date: string,
|
// iso_date: string,
|
||||||
type: LeaveTypes,
|
// type: LeaveTypes,
|
||||||
) {
|
// ) {
|
||||||
const date_only = toDateOnly(iso_date);
|
// const date_only = toDateOnly(iso_date);
|
||||||
const yyyy_mm_dd = toStringFromDate(date_only);
|
// const yyyy_mm_dd = toStringFromDate(date_only);
|
||||||
const existing = await this.prisma.shifts.findFirst({
|
// const existing = await this.prisma.shifts.findFirst({
|
||||||
where: {
|
// where: {
|
||||||
date: date_only,
|
// date: date_only,
|
||||||
bank_code: { type },
|
// bank_code: { type },
|
||||||
timesheet: { employee_id: employee_id },
|
// timesheet: { employee_id: employee_id },
|
||||||
},
|
// },
|
||||||
include: { bank_code: true },
|
// include: { bank_code: true },
|
||||||
});
|
// });
|
||||||
if (!existing) return;
|
// if (!existing) return;
|
||||||
|
|
||||||
await this.shiftsCommand.upsertShifts(email, 'delete', {
|
// await this.shiftsCommand.upsertShifts(email, 'delete', {
|
||||||
old_shift: {
|
// old_shift: {
|
||||||
date: yyyy_mm_dd,
|
// date: yyyy_mm_dd,
|
||||||
start_time: hhmmFromLocal(existing.start_time),
|
// start_time: hhmmFromLocal(existing.start_time),
|
||||||
end_time: hhmmFromLocal(existing.end_time),
|
// end_time: hhmmFromLocal(existing.end_time),
|
||||||
type: existing.bank_code?.type ?? type,
|
// type: existing.bank_code?.type ?? type,
|
||||||
is_remote: existing.is_remote,
|
// is_remote: existing.is_remote,
|
||||||
is_approved:existing.is_approved,
|
// is_approved:existing.is_approved,
|
||||||
comment: existing.comment ?? undefined,
|
// comment: existing.comment ?? undefined,
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
}
|
// }
|
||||||
|
|
||||||
}
|
// }
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,27 @@
|
||||||
import { PrismaModule } from "src/prisma/prisma.module";
|
// import { PrismaModule } from "src/prisma/prisma.module";
|
||||||
import { PayPeriodsController } from "./controllers/pay-periods.controller";
|
// import { PayPeriodsController } from "./controllers/pay-periods.controller";
|
||||||
import { Module } from "@nestjs/common";
|
// import { Module } from "@nestjs/common";
|
||||||
import { PayPeriodsCommandService } from "./services/pay-periods-command.service";
|
// import { PayPeriodsCommandService } from "./services/pay-periods-command.service";
|
||||||
import { PayPeriodsQueryService } from "./services/pay-periods-query.service";
|
// import { PayPeriodsQueryService } from "./services/pay-periods-query.service";
|
||||||
import { TimesheetsModule } from "../timesheets/timesheets.module";
|
// import { TimesheetsModule } from "../timesheets/timesheets.module";
|
||||||
import { TimesheetsCommandService } from "../timesheets/services/timesheets-command.service";
|
// import { ExpensesCommandService } from "../expenses/services/expenses-command.service";
|
||||||
import { ExpensesCommandService } from "../expenses/services/expenses-command.service";
|
// import { SharedModule } from "../shared/shared.module";
|
||||||
import { ShiftsCommandService } from "../shifts/services/shifts-command.service";
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { SharedModule } from "../shared/shared.module";
|
// import { BusinessLogicsModule } from "../business-logics/business-logics.module";
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
|
||||||
import { BusinessLogicsModule } from "../business-logics/business-logics.module";
|
|
||||||
import { ShiftsHelpersService } from "../shifts/helpers/shifts.helpers";
|
|
||||||
|
|
||||||
@Module({
|
// @Module({
|
||||||
imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
|
// imports: [PrismaModule, TimesheetsModule, SharedModule, BusinessLogicsModule],
|
||||||
providers: [
|
// providers: [
|
||||||
PayPeriodsQueryService,
|
// PayPeriodsQueryService,
|
||||||
PayPeriodsCommandService,
|
// PayPeriodsCommandService,
|
||||||
TimesheetsCommandService,
|
// ExpensesCommandService,
|
||||||
ExpensesCommandService,
|
// PrismaService,
|
||||||
ShiftsCommandService,
|
// ],
|
||||||
PrismaService,
|
// controllers: [PayPeriodsController],
|
||||||
ShiftsHelpersService,
|
// exports: [
|
||||||
],
|
// PayPeriodsQueryService,
|
||||||
controllers: [PayPeriodsController],
|
// PayPeriodsCommandService,
|
||||||
exports: [
|
// ]
|
||||||
PayPeriodsQueryService,
|
// })
|
||||||
PayPeriodsCommandService,
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
export class PayperiodsModule {}
|
// export class PayperiodsModule {}
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
import { TimesheetsCommandService } from "src/modules/timesheets/services/timesheets-command.service";
|
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
|
import { BulkCrewApprovalDto } from "../dtos/bulk-crew-approval.dto";
|
||||||
import { PayPeriodsQueryService } from "./pay-periods-query.service";
|
import { PayPeriodsQueryService } from "./pay-periods-query.service";
|
||||||
|
import { TimesheetApprovalService } from "src/modules/timesheets/services/timesheet-approval.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PayPeriodsCommandService {
|
export class PayPeriodsCommandService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly timesheets_approval: TimesheetsCommandService,
|
private readonly timesheets_approval: TimesheetApprovalService,
|
||||||
private readonly query: PayPeriodsQueryService,
|
private readonly query: PayPeriodsQueryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
|
||||||
49
src/modules/shifts/controllers/shift.controller.ts
Normal file
49
src/modules/shifts/controllers/shift.controller.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { BadRequestException, Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query } from "@nestjs/common";
|
||||||
|
import { CreateResult, ShiftsUpsertService, UpdateResult } from "../services/shifts-upsert.service";
|
||||||
|
import { updateShiftDto } from "../dtos/update-shift.dto";
|
||||||
|
import { ShiftDto } from "../dtos/shift.dto";
|
||||||
|
import { ShiftsGetService } from "../services/shifts-get.service";
|
||||||
|
|
||||||
|
|
||||||
|
@Controller('shift')
|
||||||
|
export class ShiftController {
|
||||||
|
constructor(
|
||||||
|
private readonly upsert_service: ShiftsUpsertService,
|
||||||
|
private readonly get_service: ShiftsGetService
|
||||||
|
){}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getShiftsByIds(
|
||||||
|
@Query("shift_ids") shift_ids: string) {
|
||||||
|
const parsed = shift_ids.split(/,\s*/).map(value => Number(value)).filter(Number.isFinite);
|
||||||
|
return this.get_service.getShiftByShiftId(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':timesheet_id')
|
||||||
|
createBatch(
|
||||||
|
@Param('timesheet_id', ParseIntPipe) timesheet_id: number,
|
||||||
|
@Body()dtos: ShiftDto[]): Promise<CreateResult[]> {
|
||||||
|
const list = Array.isArray(dtos) ? dtos : [];
|
||||||
|
if(list.length === 0) throw new BadRequestException('Body is missing or invalid (create shifts)')
|
||||||
|
|
||||||
|
return this.upsert_service.createShifts(timesheet_id, dtos)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
updateBatch(
|
||||||
|
@Body() body: { updates: { id: number; dto: updateShiftDto }[] }): Promise<UpdateResult[]>{
|
||||||
|
const updates = Array.isArray(body?.updates)
|
||||||
|
? body.updates.filter(update => Number.isFinite(update?.id) && typeof update.dto === "object")
|
||||||
|
: [];
|
||||||
|
if(updates.length === 0) {
|
||||||
|
throw new BadRequestException(`Body is missing or invalid (update shifts)`);
|
||||||
|
}
|
||||||
|
return this.upsert_service.updateShifts(updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':shift_id')
|
||||||
|
remove(@Param('shift_id') shift_id: number ) {
|
||||||
|
return this.upsert_service.deleteShift(shift_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common";
|
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
|
||||||
import { Roles as RoleEnum } from '.prisma/client';
|
|
||||||
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
|
||||||
import { ShiftsCommandService } from "../services/shifts-command.service";
|
|
||||||
import { ShiftsQueryService } from "../services/shifts-query.service";
|
|
||||||
import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
|
|
||||||
import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
|
||||||
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
|
|
||||||
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
|
||||||
|
|
||||||
@ApiTags('Shifts')
|
|
||||||
@ApiBearerAuth('access-token')
|
|
||||||
// @UseGuards()
|
|
||||||
@Controller('shifts')
|
|
||||||
export class ShiftsController {
|
|
||||||
constructor(
|
|
||||||
private readonly shiftsService: ShiftsQueryService,
|
|
||||||
private readonly shiftsCommandService: ShiftsCommandService,
|
|
||||||
){}
|
|
||||||
|
|
||||||
@Put('upsert/:email')
|
|
||||||
async upsert_by_date(
|
|
||||||
@Param('email') email_param: string,
|
|
||||||
@Query('action') action: UpsertAction,
|
|
||||||
@Body() payload: UpsertShiftDto,
|
|
||||||
) {
|
|
||||||
return this.shiftsCommandService.upsertShifts(email_param, action, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Delete('delete/:email/:date')
|
|
||||||
async remove(
|
|
||||||
@Param('email') email: string,
|
|
||||||
@Param('date') date: string,
|
|
||||||
@Body() payload: UpsertShiftDto,
|
|
||||||
) {
|
|
||||||
return this.shiftsCommandService.deleteShift(email, date, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch('approval/:id')
|
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
|
||||||
async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
|
||||||
return this.shiftsCommandService.updateApproval(id, isApproved);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('summary')
|
|
||||||
async getSummary( @Query() query: GetShiftsOverviewDto): Promise<OverviewRow[]> {
|
|
||||||
return this.shiftsService.getSummary(query.period_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('export.csv')
|
|
||||||
@Header('Content-Type', 'text/csv; charset=utf-8')
|
|
||||||
@Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"')
|
|
||||||
async exportCsv(@Query() query: GetShiftsOverviewDto): Promise<Buffer>{
|
|
||||||
const rows = await this.shiftsService.getSummary(query.period_id);
|
|
||||||
//CSV Headers
|
|
||||||
const header = [
|
|
||||||
'full_name',
|
|
||||||
'supervisor',
|
|
||||||
'total_regular_hrs',
|
|
||||||
'total_evening_hrs',
|
|
||||||
'total_overtime_hrs',
|
|
||||||
'total_expenses',
|
|
||||||
'total_mileage',
|
|
||||||
'is_validated'
|
|
||||||
].join(',') + '\n';
|
|
||||||
|
|
||||||
//CSV rows
|
|
||||||
const body = rows.map(r => {
|
|
||||||
const esc = (str: string) => `"${str.replace(/"/g, '""')}"`;
|
|
||||||
|
|
||||||
return [
|
|
||||||
esc(r.full_name),
|
|
||||||
esc(r.supervisor),
|
|
||||||
r.total_regular_hrs.toFixed(2),
|
|
||||||
r.total_evening_hrs.toFixed(2),
|
|
||||||
r.total_overtime_hrs.toFixed(2),
|
|
||||||
r.total_expenses.toFixed(2),
|
|
||||||
r.total_mileage.toFixed(2),
|
|
||||||
r.is_approved,
|
|
||||||
].join(',');
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
return Buffer.from('\uFEFF' + header + body, 'utf8');
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Type } from "class-transformer";
|
|
||||||
import { IsInt, Min, Max } from "class-validator";
|
|
||||||
|
|
||||||
export class GetShiftsOverviewDto {
|
|
||||||
@Type(()=> Number)
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(26)
|
|
||||||
period_id: number;
|
|
||||||
}
|
|
||||||
11
src/modules/shifts/dtos/get-shift.dto.ts
Normal file
11
src/modules/shifts/dtos/get-shift.dto.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export class GetShiftDto {
|
||||||
|
timesheet_id: number;
|
||||||
|
bank_code_id: number;
|
||||||
|
date: string;
|
||||||
|
start_time: string;
|
||||||
|
end_time: string;
|
||||||
|
is_remote: boolean;
|
||||||
|
is_approved: boolean;
|
||||||
|
comment?: string;
|
||||||
|
}
|
||||||
|
|
||||||
15
src/modules/shifts/dtos/shift.dto.ts
Normal file
15
src/modules/shifts/dtos/shift.dto.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class ShiftDto {
|
||||||
|
@IsInt() timesheet_id!: number;
|
||||||
|
@IsInt() bank_code_id!: number;
|
||||||
|
|
||||||
|
@IsString() date!: string;
|
||||||
|
@IsString() start_time!: string;
|
||||||
|
@IsString() end_time!: string;
|
||||||
|
|
||||||
|
@IsBoolean() is_approved!: boolean;
|
||||||
|
@IsBoolean() is_remote!: boolean;
|
||||||
|
|
||||||
|
@IsOptional() @IsString() @MaxLength(280) comment?: string;
|
||||||
|
}
|
||||||
7
src/modules/shifts/dtos/update-shift.dto.ts
Normal file
7
src/modules/shifts/dtos/update-shift.dto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { PartialType, OmitType } from "@nestjs/swagger";
|
||||||
|
import { ShiftDto } from "./shift.dto";
|
||||||
|
|
||||||
|
export class updateShiftDto extends PartialType (
|
||||||
|
//allows update using ShiftDto and preventing OmitType variables to be modified
|
||||||
|
OmitType(ShiftDto, [ 'is_approved', 'timesheet_id'] as const)
|
||||||
|
){}
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { Type } from "class-transformer";
|
|
||||||
import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
|
|
||||||
|
|
||||||
export const COMMENT_MAX_LENGTH = 280;
|
|
||||||
|
|
||||||
export class ShiftPayloadDto {
|
|
||||||
|
|
||||||
@Matches(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/)
|
|
||||||
date!: string;
|
|
||||||
|
|
||||||
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
|
|
||||||
start_time!: string;
|
|
||||||
|
|
||||||
@Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
|
|
||||||
end_time!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
type!: string;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
is_remote!: boolean;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
is_approved!: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(COMMENT_MAX_LENGTH)
|
|
||||||
comment?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class UpsertShiftDto {
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@ValidateNested()
|
|
||||||
@Type(()=> ShiftPayloadDto)
|
|
||||||
old_shift?: ShiftPayloadDto;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@ValidateNested()
|
|
||||||
@Type(()=> ShiftPayloadDto)
|
|
||||||
new_shift?: ShiftPayloadDto;
|
|
||||||
};
|
|
||||||
|
|
@ -1,15 +1,3 @@
|
||||||
export function timeFromHHMM(hhmm: string): Date {
|
|
||||||
const [h, m] = hhmm.split(':').map(Number);
|
|
||||||
return new Date(1970, 0, 1, h, m, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toDateOnly(ymd: string): Date {
|
|
||||||
const y = Number(ymd.slice(0, 4));
|
|
||||||
const m = Number(ymd.slice(5, 7)) - 1;
|
|
||||||
const d = Number(ymd.slice(8, 10));
|
|
||||||
return new Date(y, m, d, 0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function weekStartSunday(date_local: Date): Date {
|
export function weekStartSunday(date_local: Date): Date {
|
||||||
const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()));
|
const start = new Date(Date.UTC(date_local.getFullYear(), date_local.getMonth(), date_local.getDate()));
|
||||||
const dow = start.getDay(); // 0 = dimanche
|
const dow = start.getDay(); // 0 = dimanche
|
||||||
|
|
@ -18,8 +6,26 @@ export function weekStartSunday(date_local: Date): Date {
|
||||||
return start;
|
return start;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatHHmm(t: Date): string {
|
//converts string to HHmm format
|
||||||
const hh = String(t.getHours()).padStart(2, '0');
|
export const toStringFromHHmm = (date: Date): string => {
|
||||||
const mm = String(t.getMinutes()).padStart(2, '0');
|
const hh = date.getUTCHours().toString().padStart(2, '0');
|
||||||
|
const mm = date.getUTCMinutes().toString().padStart(2, '0');
|
||||||
return `${hh}:${mm}`;
|
return `${hh}:${mm}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//converts string to Date format
|
||||||
|
export const toStringFromDate = (date: Date) =>
|
||||||
|
date.toISOString().slice(0,10);
|
||||||
|
|
||||||
|
//converts HHmm format to string
|
||||||
|
export const toHHmmFromString = (hhmm: string): Date => {
|
||||||
|
const [hh, mm] = hhmm.split(':').map(Number);
|
||||||
|
const date = new Date('1970-01-01T00:00:00.000Z');
|
||||||
|
date.setUTCHours(hh, mm, 0, 0);
|
||||||
|
return new Date(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
//converts Date format to string
|
||||||
|
export const toDateFromString = (ymd: string): Date => {
|
||||||
|
return new Date(`${ymd}T00:00:00:000Z`);
|
||||||
|
}
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common";
|
|
||||||
import { Prisma, Shifts } from "@prisma/client";
|
|
||||||
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
|
||||||
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
|
|
||||||
import { normalizeShiftPayload, overlaps } from "../utils/shifts.utils";
|
|
||||||
import { weekStartSunday, formatHHmm } from "./shifts-date-time-helpers";
|
|
||||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
|
||||||
import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
|
|
||||||
|
|
||||||
|
|
||||||
export type Tx = Prisma.TransactionClient;
|
|
||||||
export type Normalized = Awaited<ReturnType<typeof normalizeShiftPayload>>;
|
|
||||||
|
|
||||||
export class ShiftsHelpersService {
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly bankTypeResolver: BankCodesResolver,
|
|
||||||
private readonly overtimeService: OvertimeService,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
|
|
||||||
const start_of_week = weekStartSunday(date_only);
|
|
||||||
console.log('start of week: ', start_of_week);
|
|
||||||
return tx.timesheets.findUnique({
|
|
||||||
where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async normalizeRequired(
|
|
||||||
raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null,
|
|
||||||
label: 'old_shift' | 'new_shift' = 'new_shift',
|
|
||||||
): Promise<Normalized> {
|
|
||||||
if (!raw) throw new BadRequestException(`${label} is required`);
|
|
||||||
const norm = await normalizeShiftPayload(raw);
|
|
||||||
if (norm.end_time.getTime() <= norm.start_time.getTime()) {
|
|
||||||
throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`);
|
|
||||||
}
|
|
||||||
return norm;
|
|
||||||
}
|
|
||||||
|
|
||||||
async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise<number> {
|
|
||||||
const found = await this.bankTypeResolver.findByType(type, tx);
|
|
||||||
const id = found?.id;
|
|
||||||
if (typeof id !== 'number') {
|
|
||||||
throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`);
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDayShifts(tx: Tx, timesheet_id: number, date_only: Date) {
|
|
||||||
return tx.shifts.findMany({
|
|
||||||
where: { timesheet_id, date: date_only },
|
|
||||||
include: { bank_code: true },
|
|
||||||
orderBy: { start_time: 'asc' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async assertNoOverlap(
|
|
||||||
day_shifts: Array<Shifts & { bank_code: { type: string } | null }>,
|
|
||||||
new_norm: Normalized | undefined,
|
|
||||||
exclude_id?: number,
|
|
||||||
) {
|
|
||||||
if (!new_norm) return;
|
|
||||||
const conflicts = day_shifts.filter((s) => {
|
|
||||||
if (exclude_id && s.id === exclude_id) return false;
|
|
||||||
return overlaps(
|
|
||||||
new_norm.start_time.getTime(),
|
|
||||||
new_norm.end_time.getTime(),
|
|
||||||
s.start_time.getTime(),
|
|
||||||
s.end_time.getTime(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (conflicts.length) {
|
|
||||||
const payload = conflicts.map((s) => ({
|
|
||||||
start_time: formatHHmm(s.start_time),
|
|
||||||
end_time: formatHHmm(s.end_time),
|
|
||||||
type: s.bank_code?.type ?? 'UNKNOWN',
|
|
||||||
}));
|
|
||||||
throw new ConflictException({
|
|
||||||
error_code: 'SHIFT_OVERLAP',
|
|
||||||
message: 'New shift overlaps with existing shift(s)',
|
|
||||||
conflicts: payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async findExactOldShift(
|
|
||||||
tx: Tx,
|
|
||||||
params: {
|
|
||||||
timesheet_id: number;
|
|
||||||
date_only: Date;
|
|
||||||
norm: Normalized;
|
|
||||||
bank_code_id: number;
|
|
||||||
comment?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const { timesheet_id, date_only, norm, bank_code_id } = params;
|
|
||||||
return tx.shifts.findFirst({
|
|
||||||
where: {
|
|
||||||
timesheet_id,
|
|
||||||
date: date_only,
|
|
||||||
start_time: norm.start_time,
|
|
||||||
end_time: norm.end_time,
|
|
||||||
is_remote: norm.is_remote,
|
|
||||||
is_approved: norm.is_approved,
|
|
||||||
comment: norm.comment ?? null,
|
|
||||||
bank_code_id,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date) {
|
|
||||||
// Switch regular → weekly overtime si > 40h
|
|
||||||
await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
|
|
||||||
const daily = await this.overtimeService.getDailyOvertimeHours(employee_id, date_only);
|
|
||||||
const weekly = await this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only);
|
|
||||||
// const [daily, weekly] = await Promise.all([
|
|
||||||
// this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
|
|
||||||
// this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
|
|
||||||
// ]);
|
|
||||||
return { daily, weekly };
|
|
||||||
}
|
|
||||||
|
|
||||||
async mapDay(
|
|
||||||
fresh: Array<Shifts & { bank_code: { type: string } | null }>,
|
|
||||||
): Promise<DayShiftResponse[]> {
|
|
||||||
return fresh.map((s) => ({
|
|
||||||
start_time: formatHHmm(s.start_time),
|
|
||||||
end_time: formatHHmm(s.end_time),
|
|
||||||
type: s.bank_code?.type ?? 'UNKNOWN',
|
|
||||||
is_remote: s.is_remote,
|
|
||||||
comment: s.comment ?? null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
|
||||||
import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
|
|
||||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
|
||||||
import { Prisma, Shifts } from "@prisma/client";
|
|
||||||
import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
|
||||||
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
|
||||||
import { toDateOnly } from "../helpers/shifts-date-time-helpers";
|
|
||||||
import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
|
||||||
import { ShiftsHelpersService } from "../helpers/shifts.helpers";
|
|
||||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
|
||||||
private readonly logger = new Logger(ShiftsCommandService.name);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
prisma: PrismaService,
|
|
||||||
private readonly emailResolver: EmailToIdResolver,
|
|
||||||
private readonly typeResolver: BankCodesResolver,
|
|
||||||
private readonly helpersService: ShiftsHelpersService,
|
|
||||||
) { super(prisma); }
|
|
||||||
|
|
||||||
//_____________________________________________________________________________________________
|
|
||||||
// APPROVAL AND DELEGATE METHODS
|
|
||||||
//_____________________________________________________________________________________________
|
|
||||||
protected get delegate() {
|
|
||||||
return this.prisma.shifts;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected delegateFor(transaction: Prisma.TransactionClient) {
|
|
||||||
return transaction.shifts;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateApproval(id: number, is_approved: boolean): Promise<Shifts> {
|
|
||||||
return this.prisma.$transaction((transaction) =>
|
|
||||||
this.updateApprovalWithTransaction(transaction, id, is_approved),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
//_____________________________________________________________________________________________
|
|
||||||
// MASTER CRUD METHOD
|
|
||||||
//_____________________________________________________________________________________________
|
|
||||||
async upsertShifts(
|
|
||||||
email: string,
|
|
||||||
action: UpsertAction,
|
|
||||||
dto: UpsertShiftDto,
|
|
||||||
): Promise<{
|
|
||||||
action: UpsertAction;
|
|
||||||
day: DayShiftResponse[];
|
|
||||||
}> {
|
|
||||||
if (!dto.old_shift && !dto.new_shift) throw new BadRequestException('At least one of old or new shift must be provided');
|
|
||||||
|
|
||||||
const date = dto.new_shift?.date ?? dto.old_shift?.date;
|
|
||||||
if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift");
|
|
||||||
if (dto.old_shift?.date && dto.new_shift?.date && dto.old_shift.date !== dto.new_shift.date) {
|
|
||||||
throw new BadRequestException('old_shift.date and new_shift.date must be identical');
|
|
||||||
}
|
|
||||||
|
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);//resolve employee id using email
|
|
||||||
|
|
||||||
if(action === 'create') {
|
|
||||||
if(!dto.new_shift || dto.old_shift) {
|
|
||||||
throw new BadRequestException(`Only new_shift must be provided for create`);
|
|
||||||
}
|
|
||||||
return this.createShift(employee_id, date, dto);
|
|
||||||
}
|
|
||||||
if(action === 'update'){
|
|
||||||
if(!dto.old_shift || !dto.new_shift) {
|
|
||||||
throw new BadRequestException(`Both new_shift and old_shift must be provided for update`);
|
|
||||||
}
|
|
||||||
return this.updateShift(employee_id, date, dto);
|
|
||||||
}
|
|
||||||
throw new BadRequestException(`Unknown action: ${action}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
//_________________________________________________________________
|
|
||||||
// CREATE
|
|
||||||
//_________________________________________________________________
|
|
||||||
private async createShift(
|
|
||||||
employee_id: number,
|
|
||||||
date_iso: string,
|
|
||||||
dto: UpsertShiftDto,
|
|
||||||
): Promise<{action: UpsertAction; day: DayShiftResponse[]}> {
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
|
||||||
const date_only = toDateOnly(date_iso);
|
|
||||||
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
|
|
||||||
if(!timesheet) throw new NotFoundException('Timesheet not found')
|
|
||||||
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift);
|
|
||||||
const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
|
|
||||||
|
|
||||||
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
|
|
||||||
|
|
||||||
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift);
|
|
||||||
|
|
||||||
await tx.shifts.create({
|
|
||||||
data: {
|
|
||||||
timesheet_id: timesheet.id,
|
|
||||||
date: date_only,
|
|
||||||
start_time: new_norm_shift.start_time,
|
|
||||||
end_time: new_norm_shift.end_time,
|
|
||||||
is_remote: new_norm_shift.is_remote,
|
|
||||||
is_approved: new_norm_shift.is_approved,
|
|
||||||
comment: new_norm_shift.comment ?? null,
|
|
||||||
bank_code_id: new_bank_code_id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
|
|
||||||
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
|
|
||||||
return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
//_________________________________________________________________
|
|
||||||
// UPDATE
|
|
||||||
//_________________________________________________________________
|
|
||||||
private async updateShift(
|
|
||||||
employee_id: number,
|
|
||||||
date_iso: string,
|
|
||||||
dto: UpsertShiftDto,
|
|
||||||
): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
|
||||||
const date_only = toDateOnly(date_iso);
|
|
||||||
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
|
|
||||||
if(!timesheet) throw new NotFoundException('Timesheet not found')
|
|
||||||
|
|
||||||
const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
|
|
||||||
const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift');
|
|
||||||
|
|
||||||
const old_bank_code = await this.typeResolver.findByType(old_norm_shift.type);
|
|
||||||
const new_bank_code = await this.typeResolver.findByType(new_norm_shift.type);
|
|
||||||
|
|
||||||
const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
|
|
||||||
const existing = await this.helpersService.findExactOldShift(tx, {
|
|
||||||
timesheet_id: timesheet.id,
|
|
||||||
date_only,
|
|
||||||
norm: old_norm_shift,
|
|
||||||
bank_code_id: old_bank_code.id,
|
|
||||||
});
|
|
||||||
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
|
|
||||||
|
|
||||||
await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift, existing.id);
|
|
||||||
|
|
||||||
await tx.shifts.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: {
|
|
||||||
start_time: new_norm_shift.start_time,
|
|
||||||
end_time: new_norm_shift.end_time,
|
|
||||||
is_remote: new_norm_shift.is_remote,
|
|
||||||
comment: new_norm_shift.comment ?? null,
|
|
||||||
bank_code_id: new_bank_code.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
|
|
||||||
const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
|
|
||||||
return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)};
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
//_________________________________________________________________
|
|
||||||
// DELETE
|
|
||||||
//_________________________________________________________________
|
|
||||||
async deleteShift(
|
|
||||||
email: string,
|
|
||||||
date_iso: string,
|
|
||||||
dto: UpsertShiftDto,
|
|
||||||
){
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
|
||||||
const date_only = toDateOnly(date_iso); //converts to Date format
|
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
|
||||||
|
|
||||||
const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
|
|
||||||
if(!timesheet) throw new NotFoundException('Timesheet not found')
|
|
||||||
const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
|
|
||||||
const bank_code_id = await this.typeResolver.findByType(norm_shift.type);
|
|
||||||
|
|
||||||
console.log('timesheet_id: ', timesheet.id );
|
|
||||||
console.log('date: ', date_only);
|
|
||||||
console.log('bank code id: ', bank_code_id.id);
|
|
||||||
console.log('normalized old shift: ', norm_shift);
|
|
||||||
|
|
||||||
const existing = await this.helpersService.findExactOldShift(tx, {
|
|
||||||
timesheet_id: timesheet.id,
|
|
||||||
date_only,
|
|
||||||
norm: norm_shift,
|
|
||||||
bank_code_id: bank_code_id.id,
|
|
||||||
});
|
|
||||||
if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
|
|
||||||
|
|
||||||
await tx.shifts.delete({ where: { id: existing.id } });
|
|
||||||
|
|
||||||
await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
56
src/modules/shifts/services/shifts-get.service.ts
Normal file
56
src/modules/shifts/services/shifts-get.service.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { GetShiftDto } from "../dtos/get-shift.dto";
|
||||||
|
import { toStringFromDate, toStringFromHHmm } from "../helpers/shifts-date-time-helpers";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShiftsGetService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
){}
|
||||||
|
|
||||||
|
//fetch a shift using shift_id and return all that shift's info
|
||||||
|
async getShiftByShiftId(shift_ids: number[]): Promise<GetShiftDto[]> {
|
||||||
|
if(!Array.isArray(shift_ids) || shift_ids.length === 0) return [];
|
||||||
|
|
||||||
|
const rows = await this.prisma.shifts.findMany({
|
||||||
|
where: { id: { in: shift_ids } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
timesheet_id: true,
|
||||||
|
bank_code_id: true,
|
||||||
|
date: true,
|
||||||
|
start_time: true,
|
||||||
|
end_time: true,
|
||||||
|
is_remote: true,
|
||||||
|
is_approved: true,
|
||||||
|
comment: true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if(rows.length !== shift_ids.length) {
|
||||||
|
const found_ids = new Set(rows.map(row => row.id));
|
||||||
|
const missing_ids = shift_ids.filter(id => !found_ids.has(id));
|
||||||
|
throw new NotFoundException(`Shift(s) not found: ${ missing_ids.join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const row_by_id = new Map(rows.map(row => [row.id, row]));
|
||||||
|
|
||||||
|
return shift_ids.map((id) => {
|
||||||
|
const shift = row_by_id.get(id)!;
|
||||||
|
return {
|
||||||
|
timesheet_id: shift.timesheet_id,
|
||||||
|
bank_code_id: shift.bank_code_id,
|
||||||
|
date: toStringFromDate(shift.date),
|
||||||
|
start_time: toStringFromHHmm(shift.start_time),
|
||||||
|
end_time: toStringFromHHmm(shift.end_time),
|
||||||
|
is_remote: shift.is_remote,
|
||||||
|
is_approved: shift.is_approved,
|
||||||
|
comment: shift.comment ?? undefined,
|
||||||
|
} satisfies GetShiftDto;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,114 +0,0 @@
|
||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
|
||||||
import { NotificationsService } from "src/modules/notifications/services/notifications.service";
|
|
||||||
import { computeHours } from "src/common/utils/date-utils";
|
|
||||||
import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
|
|
||||||
|
|
||||||
// const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ShiftsQueryService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly notifs: NotificationsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async getSummary(period_id: number): Promise<OverviewRow[]> {
|
|
||||||
//fetch pay-period to display
|
|
||||||
const period = await this.prisma.payPeriods.findFirst({
|
|
||||||
where: { pay_period_no: period_id },
|
|
||||||
});
|
|
||||||
if(!period) {
|
|
||||||
throw new NotFoundException(`pay-period ${period_id} not found`);
|
|
||||||
}
|
|
||||||
const { period_start, period_end } = period;
|
|
||||||
|
|
||||||
//prepare shifts and expenses for display
|
|
||||||
const shifts = await this.prisma.shifts.findMany({
|
|
||||||
where: { date: { gte: period_start, lte: period_end } },
|
|
||||||
include: {
|
|
||||||
bank_code: true,
|
|
||||||
timesheet: { include: {
|
|
||||||
employee: { include: {
|
|
||||||
user:true,
|
|
||||||
supervisor: { include: { user: true } },
|
|
||||||
} },
|
|
||||||
} },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const expenses = await this.prisma.expenses.findMany({
|
|
||||||
where: { date: { gte: period_start, lte: period_end } },
|
|
||||||
include: {
|
|
||||||
bank_code: true,
|
|
||||||
timesheet: { include: { employee: {
|
|
||||||
include: { user:true,
|
|
||||||
supervisor: { include: { user:true } },
|
|
||||||
} },
|
|
||||||
} },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const mapRow = new Map<string, OverviewRow>();
|
|
||||||
|
|
||||||
for(const shift of shifts) {
|
|
||||||
const employeeId = shift.timesheet.employee.user_id;
|
|
||||||
const user = shift.timesheet.employee.user;
|
|
||||||
const sup = shift.timesheet.employee.supervisor?.user;
|
|
||||||
|
|
||||||
let row = mapRow.get(employeeId);
|
|
||||||
if(!row) {
|
|
||||||
row = {
|
|
||||||
full_name: `${user.first_name} ${user.last_name}`,
|
|
||||||
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
|
|
||||||
total_regular_hrs: 0,
|
|
||||||
total_evening_hrs: 0,
|
|
||||||
total_overtime_hrs: 0,
|
|
||||||
total_expenses: 0,
|
|
||||||
total_mileage: 0,
|
|
||||||
is_approved: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const hours = computeHours(shift.start_time, shift.end_time);
|
|
||||||
|
|
||||||
switch(shift.bank_code.type) {
|
|
||||||
case 'regular' : row.total_regular_hrs += hours;
|
|
||||||
break;
|
|
||||||
case 'evening' : row.total_evening_hrs += hours;
|
|
||||||
break;
|
|
||||||
case 'overtime' : row.total_overtime_hrs += hours;
|
|
||||||
break;
|
|
||||||
default: row.total_regular_hrs += hours;
|
|
||||||
}
|
|
||||||
mapRow.set(employeeId, row);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const exp of expenses) {
|
|
||||||
const employee_id = exp.timesheet.employee.user_id;
|
|
||||||
const user = exp.timesheet.employee.user;
|
|
||||||
const sup = exp.timesheet.employee.supervisor?.user;
|
|
||||||
|
|
||||||
let row = mapRow.get(employee_id);
|
|
||||||
if(!row) {
|
|
||||||
row = {
|
|
||||||
full_name: `${user.first_name} ${user.last_name}`,
|
|
||||||
supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
|
|
||||||
total_regular_hrs: 0,
|
|
||||||
total_evening_hrs: 0,
|
|
||||||
total_overtime_hrs: 0,
|
|
||||||
total_expenses: 0,
|
|
||||||
total_mileage: 0,
|
|
||||||
is_approved: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const amount = Number(exp.amount);
|
|
||||||
row.total_expenses += amount;
|
|
||||||
if(exp.bank_code.type === 'mileage') {
|
|
||||||
row.total_mileage += amount;
|
|
||||||
}
|
|
||||||
mapRow.set(employee_id, row);
|
|
||||||
}
|
|
||||||
//return by default the list of employee in ascending alphabetical order
|
|
||||||
return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
367
src/modules/shifts/services/shifts-upsert.service.ts
Normal file
367
src/modules/shifts/services/shifts-upsert.service.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
import { toDateFromString, toHHmmFromString, toStringFromDate, toStringFromHHmm } from "../helpers/shifts-date-time-helpers";
|
||||||
|
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { OvertimeService, WeekOvertimeSummary } from "src/modules/business-logics/services/overtime.service";
|
||||||
|
import { updateShiftDto } from "../dtos/update-shift.dto";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { GetShiftDto } from "../dtos/get-shift.dto";
|
||||||
|
import { ShiftDto } from "../dtos/shift.dto";
|
||||||
|
|
||||||
|
type Normalized = { date: Date; start_time: Date; end_time: Date; };
|
||||||
|
|
||||||
|
export type ShiftWithOvertimeDto = {
|
||||||
|
shift: GetShiftDto;
|
||||||
|
overtime: WeekOvertimeSummary;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateResult = { ok: true; data: ShiftWithOvertimeDto } | { ok: false; error: any };
|
||||||
|
export type UpdatePayload = { id: number; dto: updateShiftDto };
|
||||||
|
export type UpdateResult = { ok: true; id: number; data: ShiftWithOvertimeDto } | { ok: false; id: number; error: any };
|
||||||
|
export type DeleteResult = { ok: true; id: number; overtime: WeekOvertimeSummary } | { ok: false; id: number; error: any };
|
||||||
|
|
||||||
|
type NormedOk = { index: number; dto: ShiftDto; normed: Normalized };
|
||||||
|
type NormedErr = { index: number; error: any };
|
||||||
|
|
||||||
|
const overlaps = (a: { start: Date; end: Date }, b: { start: Date; end: Date }) =>
|
||||||
|
!(a.end <= b.start || a.start >= b.end);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShiftsUpsertService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly overtime: OvertimeService,
|
||||||
|
) { }
|
||||||
|
|
||||||
|
//_________________________________________________________________
|
||||||
|
// CREATE
|
||||||
|
//_________________________________________________________________
|
||||||
|
//normalized frontend data to match DB
|
||||||
|
//loads all shifts from a selected day to check for overlaping shifts
|
||||||
|
//checks for overlaping shifts
|
||||||
|
//create new shifts
|
||||||
|
//calculate overtime
|
||||||
|
async createShifts(timesheet_id: number, dtos: ShiftDto[]): Promise<CreateResult[]> {
|
||||||
|
if (!Array.isArray(dtos) || dtos.length === 0) return [];
|
||||||
|
|
||||||
|
const normed_shift: Array<NormedOk | NormedErr> = dtos.map((dto, index) => {
|
||||||
|
try {
|
||||||
|
const normed = this.normalizeShiftDto(dto);
|
||||||
|
if (normed.end_time <= normed.start_time) {
|
||||||
|
return { index, error: new BadRequestException(`end_time must be greater than start_time (index ${index})`) };
|
||||||
|
}
|
||||||
|
return { index, dto, normed };
|
||||||
|
} catch (error) {
|
||||||
|
return { index, error };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const ok_items = normed_shift.filter((x): x is NormedOk => "normed" in x);
|
||||||
|
|
||||||
|
const regroup_by_date = new Map<number, number[]>();
|
||||||
|
|
||||||
|
ok_items.forEach(({ index, normed }) => {
|
||||||
|
const d = normed.date;
|
||||||
|
const key = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
|
if (!regroup_by_date.has(key)) regroup_by_date.set(key, []);
|
||||||
|
regroup_by_date.get(key)!.push(index);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const indices of regroup_by_date.values()) {
|
||||||
|
const ordered = indices
|
||||||
|
.map(index => {
|
||||||
|
const item = normed_shift[index] as NormedOk;
|
||||||
|
return { index: index, start: item.normed.start_time, end: item.normed.end_time };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||||
|
|
||||||
|
for (let j = 1; j < ordered.length; j++) {
|
||||||
|
if (overlaps({ start: ordered[j - 1].start, end: ordered[j - 1].end }, { start: ordered[j].start, end: ordered[j].end })) {
|
||||||
|
const err = new ConflictException({
|
||||||
|
error_code: 'SHIFT_OVERLAP_BATCH',
|
||||||
|
message: 'New shift overlaps with another shift in the same batch (same day).',
|
||||||
|
});
|
||||||
|
return dtos.map((_dto, key) =>
|
||||||
|
indices.includes(key)
|
||||||
|
? ({ ok: false, error: err } as CreateResult)
|
||||||
|
: ({ ok: false, error: new BadRequestException('Batch aborted due to overlaps in another date group') })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const results: CreateResult[] = Array.from({ length: dtos.length }, () => ({ ok: false, error: new Error('uninitialized') }));
|
||||||
|
|
||||||
|
|
||||||
|
normed_shift.forEach((x, i) => {
|
||||||
|
if ("error" in x) results[i] = { ok: false, error: x.error };
|
||||||
|
});
|
||||||
|
|
||||||
|
const unique_dates = Array.from(regroup_by_date.keys()).map(ms => new Date(ms));
|
||||||
|
const existing_date = new Map<number, { start_time: Date; end_time: Date }[]>();
|
||||||
|
for (const d of unique_dates) {
|
||||||
|
const rows = await tx.shifts.findMany({
|
||||||
|
where: { timesheet_id, date: d },
|
||||||
|
select: { start_time: true, end_time: true },
|
||||||
|
});
|
||||||
|
existing_date.set(d.getTime(), rows.map(r => ({ start_time: r.start_time, end_time: r.end_time })));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of ok_items) {
|
||||||
|
const { index, dto, normed } = item;
|
||||||
|
const dayKey = new Date(normed.date.getFullYear(), normed.date.getMonth(), normed.date.getDate()).getTime();
|
||||||
|
const existing = existing_date.get(dayKey) ?? [];
|
||||||
|
|
||||||
|
const hit = existing.find(e => overlaps({ start: e.start_time, end: e.end_time }, { start: normed.start_time, end: normed.end_time }));
|
||||||
|
if (hit) {
|
||||||
|
results[index] = {
|
||||||
|
ok: false,
|
||||||
|
error: new ConflictException({
|
||||||
|
error_code: 'SHIFT_OVERLAP',
|
||||||
|
message: 'New shift overlaps with existing shift(s)',
|
||||||
|
conflicts: [{
|
||||||
|
start_time: toStringFromHHmm(hit.start_time),
|
||||||
|
end_time: toStringFromHHmm(hit.end_time),
|
||||||
|
type: 'UNKNOWN',
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await tx.shifts.create({
|
||||||
|
data: {
|
||||||
|
timesheet_id,
|
||||||
|
bank_code_id: dto.bank_code_id,
|
||||||
|
date: normed.date,
|
||||||
|
start_time: normed.start_time,
|
||||||
|
end_time: normed.end_time,
|
||||||
|
is_remote: dto.is_remote,
|
||||||
|
comment: dto.comment ?? undefined,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
timesheet_id: true, bank_code_id: true, date: true,
|
||||||
|
start_time: true, end_time: true, is_remote: true, is_approved: true, comment: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
existing.push({ start_time: row.start_time, end_time: row.end_time });
|
||||||
|
|
||||||
|
const summary = await this.overtime.getWeekOvertimeSummary(timesheet_id, normed.date, tx);
|
||||||
|
const shift: GetShiftDto = {
|
||||||
|
timesheet_id: row.timesheet_id,
|
||||||
|
bank_code_id: row.bank_code_id,
|
||||||
|
date: toStringFromDate(row.date),
|
||||||
|
start_time: toStringFromHHmm(row.start_time),
|
||||||
|
end_time: toStringFromHHmm(row.end_time),
|
||||||
|
is_remote: row.is_remote,
|
||||||
|
is_approved: false,
|
||||||
|
comment: row.comment ?? undefined,
|
||||||
|
};
|
||||||
|
results[index] = { ok: true, data: { shift, overtime: summary } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//_________________________________________________________________
|
||||||
|
// UPDATE
|
||||||
|
//_________________________________________________________________
|
||||||
|
// finds existing shifts in DB
|
||||||
|
// verify if shifts are already approved
|
||||||
|
// normalized Date and Time format to string
|
||||||
|
// check for valid start and end times
|
||||||
|
// check for overlaping possibility
|
||||||
|
// buil a set of data to manipulate modified data only
|
||||||
|
// update shifts in DB
|
||||||
|
// recalculate overtime after update
|
||||||
|
// return an updated version to display
|
||||||
|
async updateShifts(updates: UpdatePayload[]): Promise<UpdateResult[]> {
|
||||||
|
if (!Array.isArray(updates) || updates.length === 0) return [];
|
||||||
|
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const shift_ids = updates.map(update_shift => update_shift.id);
|
||||||
|
const rows = await tx.shifts.findMany({
|
||||||
|
where: { id: { in: shift_ids } },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
timesheet_id: true,
|
||||||
|
bank_code_id: true,
|
||||||
|
date: true,
|
||||||
|
start_time: true,
|
||||||
|
end_time: true,
|
||||||
|
is_remote: true,
|
||||||
|
is_approved: true,
|
||||||
|
comment: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const regroup_id = new Map(rows.map(r => [r.id, r]));
|
||||||
|
|
||||||
|
for (const update of updates) {
|
||||||
|
const existing = regroup_id.get(update.id);
|
||||||
|
if (!existing) {
|
||||||
|
return updates.map(exist => exist.id === update.id
|
||||||
|
? ({ ok: false, id: update.id, error: new NotFoundException(`Shift with id: ${update.id} not found`) } as UpdateResult)
|
||||||
|
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to missing shift') }));
|
||||||
|
}
|
||||||
|
if (existing.is_approved) {
|
||||||
|
return updates.map(exist => exist.id === update.id
|
||||||
|
? ({ ok: false, id: update.id, error: new BadRequestException('Approved shift cannot be updated') } as UpdateResult)
|
||||||
|
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to approved shift in update set') }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const planned_updates = updates.map(update => {
|
||||||
|
const exist_shift = regroup_id.get(update.id)!;
|
||||||
|
const date_string = update.dto.date ?? toStringFromDate(exist_shift.date);
|
||||||
|
const start_string = update.dto.start_time ?? toStringFromHHmm(exist_shift.start_time);
|
||||||
|
const end_string = update.dto.end_time ?? toStringFromHHmm(exist_shift.end_time);
|
||||||
|
const normed: Normalized = {
|
||||||
|
date: toDateFromString(date_string),
|
||||||
|
start_time: toHHmmFromString(start_string),
|
||||||
|
end_time: toHHmmFromString(end_string),
|
||||||
|
};
|
||||||
|
return { update, exist_shift, normed };
|
||||||
|
});
|
||||||
|
|
||||||
|
const groups = new Map<string, { existing: { start: Date; end: Date; id: number }[], incoming: typeof planned_updates }>();
|
||||||
|
function key(timesheet: number, d: Date) {
|
||||||
|
const day_date = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
return `${timesheet}|${day_date.getTime()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unique_pairs = new Map<string, { timesheet_id: number; date: Date }>();
|
||||||
|
for (const { exist_shift, normed } of planned_updates) {
|
||||||
|
unique_pairs.set(key(exist_shift.timesheet_id, exist_shift.date), { timesheet_id: exist_shift.timesheet_id, date: exist_shift.date });
|
||||||
|
unique_pairs.set(key(exist_shift.timesheet_id, normed.date), { timesheet_id: exist_shift.timesheet_id, date: normed.date });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of unique_pairs.values()) {
|
||||||
|
const day_date = new Date(group.date.getFullYear(), group.date.getMonth(), group.date.getDate());
|
||||||
|
const existing = await tx.shifts.findMany({
|
||||||
|
where: { timesheet_id: group.timesheet_id, date: day_date },
|
||||||
|
select: { id: true, start_time: true, end_time: true },
|
||||||
|
});
|
||||||
|
groups.set(key(group.timesheet_id, day_date), { existing: existing.map(row => ({ id: row.id, start: row.start_time, end: row.end_time })), incoming: planned_updates });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const planned of planned_updates) {
|
||||||
|
const keys = key(planned.exist_shift.timesheet_id, planned.normed.date);
|
||||||
|
const group = groups.get(keys)!;
|
||||||
|
|
||||||
|
const conflict = group.existing.find(row =>
|
||||||
|
row.id !== planned.exist_shift.id && overlaps({ start: row.start, end: row.end }, { start: planned.normed.start_time, end: planned.normed.end_time })
|
||||||
|
);
|
||||||
|
if (conflict) {
|
||||||
|
return updates.map(exist =>
|
||||||
|
exist.id === planned.exist_shift.id
|
||||||
|
? ({
|
||||||
|
ok: false, id: exist.id, error: new ConflictException({
|
||||||
|
error_code: 'SHIFT_OVERLAP',
|
||||||
|
message: 'New shift overlaps with existing shift(s)',
|
||||||
|
conflicts: [{ start_time: toStringFromHHmm(conflict.start), end_time: toStringFromHHmm(conflict.end), type: 'UNKNOWN' }],
|
||||||
|
})
|
||||||
|
} as UpdateResult)
|
||||||
|
: ({ ok: false, id: exist.id, error: new BadRequestException('Batch aborted due to overlap in another update') })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const regoup_by_day = new Map<string, { id: number; start: Date; end: Date }[]>();
|
||||||
|
for (const planned of planned_updates) {
|
||||||
|
const keys = key(planned.exist_shift.timesheet_id, planned.normed.date);
|
||||||
|
if (!regoup_by_day.has(keys)) regoup_by_day.set(keys, []);
|
||||||
|
regoup_by_day.get(keys)!.push({ id: planned.exist_shift.id, start: planned.normed.start_time, end: planned.normed.end_time });
|
||||||
|
}
|
||||||
|
for (const arr of regoup_by_day.values()) {
|
||||||
|
arr.sort((a, b) => a.start.getTime() - b.start.getTime());
|
||||||
|
for (let i = 1; i < arr.length; i++) {
|
||||||
|
if (overlaps({ start: arr[i - 1].start, end: arr[i - 1].end }, { start: arr[i].start, end: arr[i].end })) {
|
||||||
|
const error = new ConflictException({ error_code: 'SHIFT_OVERLAP_BATCH', message: 'Overlaps between updates within the same day.' });
|
||||||
|
return updates.map(exist => ({ ok: false, id: exist.id, error: error }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: UpdateResult[] = [];
|
||||||
|
for (const planned of planned_updates) {
|
||||||
|
const data: any = {};
|
||||||
|
const { dto } = planned.update;
|
||||||
|
if (dto.date !== undefined) data.date = planned.normed.date;
|
||||||
|
if (dto.start_time !== undefined) data.start_time = planned.normed.start_time;
|
||||||
|
if (dto.end_time !== undefined) data.end_time = planned.normed.end_time;
|
||||||
|
if (dto.bank_code_id !== undefined) data.bank_code_id = dto.bank_code_id;
|
||||||
|
if (dto.is_remote !== undefined) data.is_remote = dto.is_remote;
|
||||||
|
if (dto.comment !== undefined) data.comment = dto.comment ?? null;
|
||||||
|
|
||||||
|
const row = await tx.shifts.update({
|
||||||
|
where: { id: planned.exist_shift.id },
|
||||||
|
data,
|
||||||
|
select: {
|
||||||
|
timesheet_id: true,
|
||||||
|
bank_code_id: true,
|
||||||
|
date: true,
|
||||||
|
start_time: true,
|
||||||
|
end_time: true,
|
||||||
|
is_remote: true,
|
||||||
|
is_approved: true,
|
||||||
|
comment: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const summary_new = await this.overtime.getWeekOvertimeSummary(row.timesheet_id, planned.exist_shift.date, tx);
|
||||||
|
if (row.date.getTime() !== planned.exist_shift.date.getTime()) {
|
||||||
|
await this.overtime.getWeekOvertimeSummary(row.timesheet_id, row.date, tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shift: GetShiftDto = {
|
||||||
|
timesheet_id: row.timesheet_id,
|
||||||
|
bank_code_id: row.bank_code_id,
|
||||||
|
date: toStringFromDate(row.date),
|
||||||
|
start_time: toStringFromHHmm(row.start_time),
|
||||||
|
end_time: toStringFromHHmm(row.end_time),
|
||||||
|
is_approved: row.is_approved,
|
||||||
|
is_remote: row.is_remote,
|
||||||
|
comment: row.comment ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push({ ok: true, id: planned.exist_shift.id, data: { shift, overtime: summary_new } });
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
//_________________________________________________________________
|
||||||
|
// DELETE
|
||||||
|
//_________________________________________________________________
|
||||||
|
//finds shift using shit_ids
|
||||||
|
//recalc overtime shifts after delete
|
||||||
|
//blocs deletion if approved
|
||||||
|
async deleteShift(shift_id: number) {
|
||||||
|
return await this.prisma.$transaction(async (tx) =>{
|
||||||
|
const shift = await tx.shifts.findUnique({
|
||||||
|
where: { id: shift_id },
|
||||||
|
select: { id: true, date: true, timesheet_id: true },
|
||||||
|
});
|
||||||
|
if(!shift) throw new NotFoundException(`Shift with id #${shift_id} not found`);
|
||||||
|
|
||||||
|
await tx.shifts.delete({ where: { id: shift_id } });
|
||||||
|
|
||||||
|
const summary = await this.overtime.getWeekOvertimeSummary( shift.timesheet_id, shift.date, tx);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
overtime: summary
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
//_________________________________________________________________
|
||||||
|
// LOCAL HELPERS
|
||||||
|
//_________________________________________________________________
|
||||||
|
//converts all string hours and date to Date and HHmm formats
|
||||||
|
private normalizeShiftDto = (dto: ShiftDto): Normalized => {
|
||||||
|
const date = toDateFromString(dto.date);
|
||||||
|
const start_time = toHHmmFromString(dto.start_time);
|
||||||
|
const end_time = toHHmmFromString(dto.end_time);
|
||||||
|
return { date, start_time, end_time };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,30 +1,24 @@
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { ShiftsController } from './controllers/shifts.controller';
|
|
||||||
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
|
|
||||||
import { ShiftsCommandService } from './services/shifts-command.service';
|
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
|
||||||
import { ShiftsQueryService } from './services/shifts-query.service';
|
|
||||||
import { ShiftsArchivalService } from './services/shifts-archival.service';
|
import { ShiftsArchivalService } from './services/shifts-archival.service';
|
||||||
|
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { ShiftsUpsertService } from './services/shifts-upsert.service';
|
||||||
|
import { ShiftsGetService } from './services/shifts-get.service';
|
||||||
|
import { ShiftController } from './controllers/shift.controller';
|
||||||
import { SharedModule } from '../shared/shared.module';
|
import { SharedModule } from '../shared/shared.module';
|
||||||
import { ShiftsHelpersService } from './helpers/shifts.helpers';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
BusinessLogicsModule,
|
BusinessLogicsModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
],
|
],
|
||||||
controllers: [ShiftsController],
|
controllers: [ShiftController],
|
||||||
providers: [
|
providers: [
|
||||||
ShiftsQueryService,
|
|
||||||
ShiftsCommandService,
|
|
||||||
ShiftsArchivalService,
|
ShiftsArchivalService,
|
||||||
ShiftsHelpersService,
|
ShiftsGetService,
|
||||||
],
|
ShiftsUpsertService,
|
||||||
exports: [
|
|
||||||
ShiftsQueryService,
|
|
||||||
ShiftsCommandService,
|
|
||||||
ShiftsArchivalService,
|
|
||||||
],
|
],
|
||||||
|
exports: [ ShiftsUpsertService, ShiftsGetService ],
|
||||||
})
|
})
|
||||||
export class ShiftsModule {}
|
export class ShiftsModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
export interface OverviewRow {
|
|
||||||
full_name: string;
|
|
||||||
supervisor: string;
|
|
||||||
total_regular_hrs: number;
|
|
||||||
total_evening_hrs: number;
|
|
||||||
total_overtime_hrs: number;
|
|
||||||
total_expenses: number;
|
|
||||||
total_mileage: number;
|
|
||||||
is_approved: boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
export type DayShiftResponse = {
|
|
||||||
start_time: string;
|
|
||||||
end_time: string;
|
|
||||||
type: string;
|
|
||||||
is_remote: boolean;
|
|
||||||
comment: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ShiftPayload = {
|
|
||||||
date: string;
|
|
||||||
start_time: string;
|
|
||||||
end_time: string;
|
|
||||||
type: string;
|
|
||||||
is_remote: boolean;
|
|
||||||
is_approved: boolean;
|
|
||||||
comment?: string | null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { NotFoundException } from "@nestjs/common";
|
|
||||||
|
|
||||||
export function overlaps(
|
|
||||||
a_start_ms: number,
|
|
||||||
a_end_ms: number,
|
|
||||||
b_start_ms: number,
|
|
||||||
b_end_ms: number,
|
|
||||||
): boolean {
|
|
||||||
return a_start_ms < b_end_ms && b_start_ms < a_end_ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveBankCodeByType(type: string): Promise<number> {
|
|
||||||
const bank = this.prisma.bankCodes.findFirst({
|
|
||||||
where: { type },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!bank) {
|
|
||||||
throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` });
|
|
||||||
}
|
|
||||||
return bank.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeShiftPayload(payload: {
|
|
||||||
date: string,
|
|
||||||
start_time: string,
|
|
||||||
end_time: string,
|
|
||||||
type: string,
|
|
||||||
is_remote: boolean,
|
|
||||||
is_approved: boolean,
|
|
||||||
comment?: string | null,
|
|
||||||
}) {
|
|
||||||
//normalize shift's infos
|
|
||||||
const date = payload.date?.trim();
|
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? '');
|
|
||||||
if (!m) throw new Error(`Invalid date format (expected YYYY-MM-DD): "${payload.date}"`);
|
|
||||||
const year = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
|
|
||||||
|
|
||||||
const asLocalDateOn = (input: string): Date => {
|
|
||||||
// HH:mm ?
|
|
||||||
const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim());
|
|
||||||
if (hm) return new Date(Date.UTC(1970, 0, 1, Number(hm[1]), Number(hm[2])));
|
|
||||||
const iso = new Date(input);
|
|
||||||
if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`);
|
|
||||||
return new Date(Date.UTC(1970, 0, 1, iso.getHours(), iso.getMinutes(), iso.getSeconds()));
|
|
||||||
};
|
|
||||||
|
|
||||||
const start_time = asLocalDateOn(payload.start_time);
|
|
||||||
const end_time = asLocalDateOn(payload.end_time);
|
|
||||||
|
|
||||||
const type = (payload.type || '').trim().toUpperCase();
|
|
||||||
const is_remote = payload.is_remote;
|
|
||||||
const is_approved = payload.is_approved;
|
|
||||||
//normalize comment
|
|
||||||
const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null;
|
|
||||||
const comment = trimmed && trimmed.length > 0 ? trimmed : null;
|
|
||||||
|
|
||||||
return { date, start_time, end_time, type, is_remote, is_approved, comment };
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
// import { Type } from "class-transformer";
|
||||||
|
// import { IsInt, Min, Max } from "class-validator";
|
||||||
|
|
||||||
|
// export class GetShiftsOverviewDto {
|
||||||
|
// @Type(()=> Number)
|
||||||
|
// @IsInt()
|
||||||
|
// @Min(1)
|
||||||
|
// @Max(26)
|
||||||
|
// period_id: number;
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
// import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||||
|
// import { DayShiftResponse } from "../types-and-interfaces/shifts-upsert.types";
|
||||||
|
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
|
// import { Prisma, Shifts } from "@prisma/client";
|
||||||
|
// import { UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
||||||
|
// import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||||
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
// import { toDateOnly } from "../helpers/shifts-date-time-helpers";
|
||||||
|
// import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
||||||
|
// import { ShiftsHelpersService } from "../helpers/shifts.helpers";
|
||||||
|
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
|
||||||
|
// @Injectable()
|
||||||
|
// export class ShiftsCommandService extends BaseApprovalService<Shifts> {
|
||||||
|
// private readonly logger = new Logger(ShiftsCommandService.name);
|
||||||
|
|
||||||
|
// constructor(
|
||||||
|
// prisma: PrismaService,
|
||||||
|
// private readonly emailResolver: EmailToIdResolver,
|
||||||
|
// private readonly typeResolver: BankCodesResolver,
|
||||||
|
// private readonly helpersService: ShiftsHelpersService,
|
||||||
|
// ) { super(prisma); }
|
||||||
|
|
||||||
|
// //_____________________________________________________________________________________________
|
||||||
|
// // APPROVAL AND DELEGATE METHODS
|
||||||
|
// //_____________________________________________________________________________________________
|
||||||
|
// protected get delegate() {
|
||||||
|
// return this.prisma.shifts;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// protected delegateFor(transaction: Prisma.TransactionClient) {
|
||||||
|
// return transaction.shifts;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async updateApproval(id: number, is_approved: boolean): Promise<Shifts> {
|
||||||
|
// return this.prisma.$transaction((transaction) =>
|
||||||
|
// this.updateApprovalWithTransaction(transaction, id, is_approved),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
// //TODO: modifier le Master Crud pour recevoir l'ensemble des shifts de la pay-period et trier sur l'action 'create'| 'update' | 'delete'
|
||||||
|
// //_____________________________________________________________________________________________
|
||||||
|
// // MASTER CRUD METHOD
|
||||||
|
// //_____________________________________________________________________________________________
|
||||||
|
// async upsertShifts(
|
||||||
|
// email: string,
|
||||||
|
// action: UpsertAction,
|
||||||
|
// dto: UpsertShiftDto,
|
||||||
|
// ): Promise<{
|
||||||
|
// action: UpsertAction;
|
||||||
|
// day: DayShiftResponse[];
|
||||||
|
// }> {
|
||||||
|
// if (!dto.old_shift && !dto.new_shift) throw new BadRequestException('At least one of old or new shift must be provided');
|
||||||
|
|
||||||
|
// const date = dto.new_shift?.date ?? dto.old_shift?.date;
|
||||||
|
// if (!date) throw new BadRequestException("A date (YYYY-MM-DD) must be provided in old_shift or new_shift");
|
||||||
|
// if (dto.old_shift?.date && dto.new_shift?.date && dto.old_shift.date !== dto.new_shift.date) {
|
||||||
|
// throw new BadRequestException('old_shift.date and new_shift.date must be identical');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const employee_id = await this.emailResolver.findIdByEmail(email);//resolve employee id using email
|
||||||
|
|
||||||
|
// if(action === 'create') {
|
||||||
|
// if(!dto.new_shift || dto.old_shift) {
|
||||||
|
// throw new BadRequestException(`Only new_shift must be provided for create`);
|
||||||
|
// }
|
||||||
|
// return this.createShift(employee_id, date, dto);
|
||||||
|
// }
|
||||||
|
// if(action === 'update'){
|
||||||
|
// if(!dto.old_shift || !dto.new_shift) {
|
||||||
|
// throw new BadRequestException(`Both new_shift and old_shift must be provided for update`);
|
||||||
|
// }
|
||||||
|
// return this.updateShift(employee_id, date, dto);
|
||||||
|
// }
|
||||||
|
// throw new BadRequestException(`Unknown action: ${action}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// //_________________________________________________________________
|
||||||
|
// // CREATE
|
||||||
|
// //_________________________________________________________________
|
||||||
|
// private async createShift(
|
||||||
|
// employee_id: number,
|
||||||
|
// date_iso: string,
|
||||||
|
// dto: UpsertShiftDto,
|
||||||
|
// ): Promise<{action: UpsertAction; day: DayShiftResponse[]}> {
|
||||||
|
// return this.prisma.$transaction(async (tx) => {
|
||||||
|
// const date_only = toDateOnly(date_iso);
|
||||||
|
// const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
|
||||||
|
// if(!timesheet) throw new NotFoundException('Timesheet not found')
|
||||||
|
// const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift);
|
||||||
|
// const new_bank_code_id = await this.helpersService.resolveBankIdRequired(tx, new_norm_shift.type, 'new_shift');
|
||||||
|
|
||||||
|
// const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
|
||||||
|
|
||||||
|
// await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift);
|
||||||
|
|
||||||
|
// await tx.shifts.create({
|
||||||
|
// data: {
|
||||||
|
// timesheet_id: timesheet.id,
|
||||||
|
// date: date_only,
|
||||||
|
// start_time: new_norm_shift.start_time,
|
||||||
|
// end_time: new_norm_shift.end_time,
|
||||||
|
// is_remote: new_norm_shift.is_remote,
|
||||||
|
// is_approved: new_norm_shift.is_approved,
|
||||||
|
// comment: new_norm_shift.comment ?? null,
|
||||||
|
// bank_code_id: new_bank_code_id,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
|
||||||
|
// const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
|
||||||
|
// return { action: 'create', day: await this.helpersService.mapDay(fresh_shift)};
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// //_________________________________________________________________
|
||||||
|
// // UPDATE
|
||||||
|
// //_________________________________________________________________
|
||||||
|
// private async updateShift(
|
||||||
|
// employee_id: number,
|
||||||
|
// date_iso: string,
|
||||||
|
// dto: UpsertShiftDto,
|
||||||
|
// ): Promise<{ action: UpsertAction; day: DayShiftResponse[];}>{
|
||||||
|
// return this.prisma.$transaction(async (tx) => {
|
||||||
|
// const date_only = toDateOnly(date_iso);
|
||||||
|
// const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
|
||||||
|
// if(!timesheet) throw new NotFoundException('Timesheet not found')
|
||||||
|
|
||||||
|
// const old_norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
|
||||||
|
// const new_norm_shift = await this.helpersService.normalizeRequired(dto.new_shift, 'new_shift');
|
||||||
|
|
||||||
|
// const old_bank_code = await this.typeResolver.findByType(old_norm_shift.type);
|
||||||
|
// const new_bank_code = await this.typeResolver.findByType(new_norm_shift.type);
|
||||||
|
|
||||||
|
// const day_shifts = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
|
||||||
|
// const existing = await this.helpersService.findExactOldShift(tx, {
|
||||||
|
// timesheet_id: timesheet.id,
|
||||||
|
// date_only,
|
||||||
|
// norm: old_norm_shift,
|
||||||
|
// bank_code_id: old_bank_code.id,
|
||||||
|
// });
|
||||||
|
// if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
|
||||||
|
|
||||||
|
// await this.helpersService.assertNoOverlap(day_shifts, new_norm_shift, existing.id);
|
||||||
|
|
||||||
|
// await tx.shifts.update({
|
||||||
|
// where: { id: existing.id },
|
||||||
|
// data: {
|
||||||
|
// start_time: new_norm_shift.start_time,
|
||||||
|
// end_time: new_norm_shift.end_time,
|
||||||
|
// is_remote: new_norm_shift.is_remote,
|
||||||
|
// comment: new_norm_shift.comment ?? null,
|
||||||
|
// bank_code_id: new_bank_code.id,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
|
||||||
|
// const fresh_shift = await this.helpersService.getDayShifts(tx, timesheet.id, date_only);
|
||||||
|
// return { action: 'update', day: await this.helpersService.mapDay(fresh_shift)};
|
||||||
|
// });
|
||||||
|
|
||||||
|
// }
|
||||||
|
|
||||||
|
// //_________________________________________________________________
|
||||||
|
// // DELETE
|
||||||
|
// //_________________________________________________________________
|
||||||
|
// async deleteShift(
|
||||||
|
// email: string,
|
||||||
|
// date_iso: string,
|
||||||
|
// dto: UpsertShiftDto,
|
||||||
|
// ){
|
||||||
|
// return this.prisma.$transaction(async (tx) => {
|
||||||
|
// const date_only = toDateOnly(date_iso); //converts to Date format
|
||||||
|
// const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
|
||||||
|
// const timesheet = await this.helpersService.ensureTimesheet(tx, employee_id, date_only);
|
||||||
|
// if(!timesheet) throw new NotFoundException('Timesheet not found')
|
||||||
|
// const norm_shift = await this.helpersService.normalizeRequired(dto.old_shift, 'old_shift');
|
||||||
|
// const bank_code_id = await this.typeResolver.findByType(norm_shift.type);
|
||||||
|
|
||||||
|
// const existing = await this.helpersService.findExactOldShift(tx, {
|
||||||
|
// timesheet_id: timesheet.id,
|
||||||
|
// date_only,
|
||||||
|
// norm: norm_shift,
|
||||||
|
// bank_code_id: bank_code_id.id,
|
||||||
|
// });
|
||||||
|
// if(!existing) throw new NotFoundException('[SHIFT_STALE]- The shift was modified or deleted by someone else');
|
||||||
|
|
||||||
|
// await tx.shifts.delete({ where: { id: existing.id } });
|
||||||
|
|
||||||
|
// await this.helpersService.afterWriteOvertimeAndLog(tx, employee_id, date_only);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
// export interface OverviewRow {
|
||||||
|
// full_name: string;
|
||||||
|
// supervisor: string;
|
||||||
|
// total_regular_hrs: number;
|
||||||
|
// total_evening_hrs: number;
|
||||||
|
// total_overtime_hrs: number;
|
||||||
|
// total_expenses: number;
|
||||||
|
// total_mileage: number;
|
||||||
|
// is_approved: boolean;
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
// import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
// import { NotificationsService } from "src/modules/notifications/services/notifications.service";
|
||||||
|
// import { computeHours } from "src/common/utils/date-utils";
|
||||||
|
// import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
|
||||||
|
|
||||||
|
// // const DAILY_LIMIT_HOURS = Number(process.env.DAILY_LIMIT_HOURS ?? 12);
|
||||||
|
|
||||||
|
// @Injectable()
|
||||||
|
// export class ShiftsQueryService {
|
||||||
|
// constructor(
|
||||||
|
// private readonly prisma: PrismaService,
|
||||||
|
// private readonly notifs: NotificationsService,
|
||||||
|
// ) {}
|
||||||
|
|
||||||
|
// async getSummary(period_id: number): Promise<OverviewRow[]> {
|
||||||
|
// //fetch pay-period to display
|
||||||
|
// const period = await this.prisma.payPeriods.findFirst({
|
||||||
|
// where: { pay_period_no: period_id },
|
||||||
|
// });
|
||||||
|
// if(!period) {
|
||||||
|
// throw new NotFoundException(`pay-period ${period_id} not found`);
|
||||||
|
// }
|
||||||
|
// const { period_start, period_end } = period;
|
||||||
|
|
||||||
|
// //prepare shifts and expenses for display
|
||||||
|
// const shifts = await this.prisma.shifts.findMany({
|
||||||
|
// where: { date: { gte: period_start, lte: period_end } },
|
||||||
|
// include: {
|
||||||
|
// bank_code: true,
|
||||||
|
// timesheet: { include: {
|
||||||
|
// employee: { include: {
|
||||||
|
// user:true,
|
||||||
|
// supervisor: { include: { user: true } },
|
||||||
|
// } },
|
||||||
|
// } },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const expenses = await this.prisma.expenses.findMany({
|
||||||
|
// where: { date: { gte: period_start, lte: period_end } },
|
||||||
|
// include: {
|
||||||
|
// bank_code: true,
|
||||||
|
// timesheet: { include: { employee: {
|
||||||
|
// include: { user:true,
|
||||||
|
// supervisor: { include: { user:true } },
|
||||||
|
// } },
|
||||||
|
// } },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const mapRow = new Map<string, OverviewRow>();
|
||||||
|
|
||||||
|
// for(const shift of shifts) {
|
||||||
|
// const employeeId = shift.timesheet.employee.user_id;
|
||||||
|
// const user = shift.timesheet.employee.user;
|
||||||
|
// const sup = shift.timesheet.employee.supervisor?.user;
|
||||||
|
|
||||||
|
// let row = mapRow.get(employeeId);
|
||||||
|
// if(!row) {
|
||||||
|
// row = {
|
||||||
|
// full_name: `${user.first_name} ${user.last_name}`,
|
||||||
|
// supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
|
||||||
|
// total_regular_hrs: 0,
|
||||||
|
// total_evening_hrs: 0,
|
||||||
|
// total_overtime_hrs: 0,
|
||||||
|
// total_expenses: 0,
|
||||||
|
// total_mileage: 0,
|
||||||
|
// is_approved: false,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// const hours = computeHours(shift.start_time, shift.end_time);
|
||||||
|
|
||||||
|
// switch(shift.bank_code.type) {
|
||||||
|
// case 'regular' : row.total_regular_hrs += hours;
|
||||||
|
// break;
|
||||||
|
// case 'evening' : row.total_evening_hrs += hours;
|
||||||
|
// break;
|
||||||
|
// case 'overtime' : row.total_overtime_hrs += hours;
|
||||||
|
// break;
|
||||||
|
// default: row.total_regular_hrs += hours;
|
||||||
|
// }
|
||||||
|
// mapRow.set(employeeId, row);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for(const exp of expenses) {
|
||||||
|
// const employee_id = exp.timesheet.employee.user_id;
|
||||||
|
// const user = exp.timesheet.employee.user;
|
||||||
|
// const sup = exp.timesheet.employee.supervisor?.user;
|
||||||
|
|
||||||
|
// let row = mapRow.get(employee_id);
|
||||||
|
// if(!row) {
|
||||||
|
// row = {
|
||||||
|
// full_name: `${user.first_name} ${user.last_name}`,
|
||||||
|
// supervisor: sup? `${sup.first_name} ${sup.last_name }` : '',
|
||||||
|
// total_regular_hrs: 0,
|
||||||
|
// total_evening_hrs: 0,
|
||||||
|
// total_overtime_hrs: 0,
|
||||||
|
// total_expenses: 0,
|
||||||
|
// total_mileage: 0,
|
||||||
|
// is_approved: false,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// const amount = Number(exp.amount);
|
||||||
|
// row.total_expenses += amount;
|
||||||
|
// if(exp.bank_code.type === 'mileage') {
|
||||||
|
// row.total_mileage += amount;
|
||||||
|
// }
|
||||||
|
// mapRow.set(employee_id, row);
|
||||||
|
// }
|
||||||
|
// //return by default the list of employee in ascending alphabetical order
|
||||||
|
// return Array.from(mapRow.values()).sort((a,b) => a.full_name.localeCompare(b.full_name));
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
// export type DayShiftResponse = {
|
||||||
|
// start_time: string;
|
||||||
|
// end_time: string;
|
||||||
|
// type: string;
|
||||||
|
// is_remote: boolean;
|
||||||
|
// comment: string | null;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export type ShiftPayload = {
|
||||||
|
// date: string;
|
||||||
|
// start_time: string;
|
||||||
|
// end_time: string;
|
||||||
|
// type: string;
|
||||||
|
// is_remote: boolean;
|
||||||
|
// is_approved: boolean;
|
||||||
|
// comment?: string | null;
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
// import { Body, Controller, Delete, Get, Header, Param, ParseBoolPipe, ParseIntPipe, Patch, Put, Query, } from "@nestjs/common";
|
||||||
|
// import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||||
|
// import { Roles as RoleEnum } from '.prisma/client';
|
||||||
|
// import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
|
||||||
|
// import { ShiftsCommandService } from "../services/shifts-command.service";
|
||||||
|
// import { ShiftsQueryService } from "../services/shifts-query.service";
|
||||||
|
// import { GetShiftsOverviewDto } from "../dtos/get-shift-overview.dto";
|
||||||
|
// import { ShiftPayloadDto, UpsertShiftDto } from "../dtos/upsert-shift.dto";
|
||||||
|
// import { OverviewRow } from "../types-and-interfaces/shifts-overview-row.interface";
|
||||||
|
// import { UpsertAction } from "src/modules/shared/types/upsert-actions.types";
|
||||||
|
|
||||||
|
// @ApiTags('Shifts')
|
||||||
|
// @ApiBearerAuth('access-token')
|
||||||
|
// // @UseGuards()
|
||||||
|
// @Controller('shifts')
|
||||||
|
// export class ShiftsController {
|
||||||
|
// constructor(
|
||||||
|
// private readonly shiftsService: ShiftsQueryService,
|
||||||
|
// private readonly shiftsCommandService: ShiftsCommandService,
|
||||||
|
// ){}
|
||||||
|
|
||||||
|
// @Put('upsert/:email')
|
||||||
|
// async upsert_by_date(
|
||||||
|
// @Param('email') email_param: string,
|
||||||
|
// @Query('action') action: UpsertAction,
|
||||||
|
// @Body() payload: UpsertShiftDto,
|
||||||
|
// ) {
|
||||||
|
// return this.shiftsCommandService.upsertShifts(email_param, action, payload);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Delete('delete/:email/:date')
|
||||||
|
// async remove(
|
||||||
|
// @Param('email') email: string,
|
||||||
|
// @Param('date') date: string,
|
||||||
|
// @Body() payload: UpsertShiftDto,
|
||||||
|
// ) {
|
||||||
|
// return this.shiftsCommandService.deleteShift(email, date, payload);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Patch('approval/:id')
|
||||||
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
|
// async approve(@Param('id', ParseIntPipe) id: number, @Body('is_approved', ParseBoolPipe) isApproved: boolean) {
|
||||||
|
// return this.shiftsCommandService.updateApproval(id, isApproved);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Get('summary')
|
||||||
|
// async getSummary( @Query() query: GetShiftsOverviewDto): Promise<OverviewRow[]> {
|
||||||
|
// return this.shiftsService.getSummary(query.period_id);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Get('export.csv')
|
||||||
|
// @Header('Content-Type', 'text/csv; charset=utf-8')
|
||||||
|
// @Header('Content-Disposition', 'attachment; filename="shifts-validation.csv"')
|
||||||
|
// async exportCsv(@Query() query: GetShiftsOverviewDto): Promise<Buffer>{
|
||||||
|
// const rows = await this.shiftsService.getSummary(query.period_id);
|
||||||
|
// //CSV Headers
|
||||||
|
// const header = [
|
||||||
|
// 'full_name',
|
||||||
|
// 'supervisor',
|
||||||
|
// 'total_regular_hrs',
|
||||||
|
// 'total_evening_hrs',
|
||||||
|
// 'total_overtime_hrs',
|
||||||
|
// 'total_expenses',
|
||||||
|
// 'total_mileage',
|
||||||
|
// 'is_validated'
|
||||||
|
// ].join(',') + '\n';
|
||||||
|
|
||||||
|
// //CSV rows
|
||||||
|
// const body = rows.map(r => {
|
||||||
|
// const esc = (str: string) => `"${str.replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
// return [
|
||||||
|
// esc(r.full_name),
|
||||||
|
// esc(r.supervisor),
|
||||||
|
// r.total_regular_hrs.toFixed(2),
|
||||||
|
// r.total_evening_hrs.toFixed(2),
|
||||||
|
// r.total_overtime_hrs.toFixed(2),
|
||||||
|
// r.total_expenses.toFixed(2),
|
||||||
|
// r.total_mileage.toFixed(2),
|
||||||
|
// r.is_approved,
|
||||||
|
// ].join(',');
|
||||||
|
// }).join('\n');
|
||||||
|
|
||||||
|
// return Buffer.from('\uFEFF' + header + body, 'utf8');
|
||||||
|
// }
|
||||||
|
|
||||||
|
// }
|
||||||
103
src/modules/shifts/~misc_deprecated-files/shifts.helpers.ts
Normal file
103
src/modules/shifts/~misc_deprecated-files/shifts.helpers.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// import { BadRequestException, UnprocessableEntityException, NotFoundException, ConflictException } from "@nestjs/common";
|
||||||
|
// import { Prisma, Shifts } from "@prisma/client";
|
||||||
|
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
// import { OvertimeService } from "src/modules/business-logics/services/overtime.service";
|
||||||
|
|
||||||
|
// export type Tx = Prisma.TransactionClient;
|
||||||
|
// export type Normalized = Awaited<ReturnType<typeof normalizeShiftPayload>>;
|
||||||
|
|
||||||
|
// export class ShiftsHelpersService {
|
||||||
|
|
||||||
|
// constructor(
|
||||||
|
// private readonly bankTypeResolver: BankCodesResolver,
|
||||||
|
// private readonly overtimeService: OvertimeService,
|
||||||
|
// ) { }
|
||||||
|
|
||||||
|
// async ensureTimesheet(tx: Tx, employee_id: number, date_only: Date) {
|
||||||
|
// const start_of_week = weekStartSunday(date_only);
|
||||||
|
// return tx.timesheets.findUnique({
|
||||||
|
// where: { employee_id_start_date: { employee_id, start_date: start_of_week } },
|
||||||
|
// select: { id: true },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async normalizeRequired(
|
||||||
|
// raw: UpsertShiftDto['new_shift'] | UpsertShiftDto['old_shift'] | undefined | null,
|
||||||
|
// label: 'old_shift' | 'new_shift' = 'new_shift',
|
||||||
|
// ): Promise<Normalized> {
|
||||||
|
// if (!raw) throw new BadRequestException(`${label} is required`);
|
||||||
|
// const norm = await normalizeShiftPayload(raw);
|
||||||
|
// if (norm.end_time.getTime() <= norm.start_time.getTime()) {
|
||||||
|
// throw new UnprocessableEntityException(` ${label}.end_time must be > ${label}.start_time`);
|
||||||
|
// }
|
||||||
|
// return norm;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async resolveBankIdRequired(tx: Tx, type: string, label: 'old_shift' | 'new_shift'): Promise<number> {
|
||||||
|
// const found = await this.bankTypeResolver.findByType(type, tx);
|
||||||
|
// const id = found?.id;
|
||||||
|
// if (typeof id !== 'number') {
|
||||||
|
// throw new NotFoundException(`bank code not found for ${label}.type: ${type ?? ''}`);
|
||||||
|
// }
|
||||||
|
// return id;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async getDayShifts(tx: Tx, timesheet_id: number, date_only: Date) {
|
||||||
|
// return tx.shifts.findMany({
|
||||||
|
// where: { timesheet_id, date: date_only },
|
||||||
|
// include: { bank_code: true },
|
||||||
|
// orderBy: { start_time: 'asc' },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async findExactOldShift(
|
||||||
|
// tx: Tx,
|
||||||
|
// params: {
|
||||||
|
// timesheet_id: number;
|
||||||
|
// date_only: Date;
|
||||||
|
// norm: Normalized;
|
||||||
|
// bank_code_id: number;
|
||||||
|
// comment?: string;
|
||||||
|
// },
|
||||||
|
// ) {
|
||||||
|
// const { timesheet_id, date_only, norm, bank_code_id } = params;
|
||||||
|
// return tx.shifts.findFirst({
|
||||||
|
// where: {
|
||||||
|
// timesheet_id,
|
||||||
|
// date: date_only,
|
||||||
|
// start_time: norm.start_time,
|
||||||
|
// end_time: norm.end_time,
|
||||||
|
// is_remote: norm.is_remote,
|
||||||
|
// is_approved: norm.is_approved,
|
||||||
|
// comment: norm.comment ?? null,
|
||||||
|
// bank_code_id,
|
||||||
|
// },
|
||||||
|
// select: { id: true },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async afterWriteOvertimeAndLog(tx: Tx, employee_id: number, date_only: Date) {
|
||||||
|
// // Switch regular → weekly overtime si > 40h
|
||||||
|
// await this.overtimeService.transformRegularHoursToWeeklyOvertime(employee_id, date_only, tx);
|
||||||
|
// const daily = await this.overtimeService.getDailyOvertimeHours(employee_id, date_only);
|
||||||
|
// const weekly = await this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only);
|
||||||
|
// // const [daily, weekly] = await Promise.all([
|
||||||
|
// // this.overtimeService.getDailyOvertimeHoursForDay(employee_id, date_only),
|
||||||
|
// // this.overtimeService.getWeeklyOvertimeHours(employee_id, date_only),
|
||||||
|
// // ]);
|
||||||
|
// return { daily, weekly };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async mapDay(
|
||||||
|
// fresh: Array<Shifts & { bank_code: { type: string } | null }>,
|
||||||
|
// ): Promise<DayShiftResponse[]> {
|
||||||
|
// return fresh.map((s) => ({
|
||||||
|
// start_time: toStringFromHHmm(s.start_time),
|
||||||
|
// end_time: toStringFromHHmm(s.end_time),
|
||||||
|
// type: s.bank_code?.type ?? 'UNKNOWN',
|
||||||
|
// is_remote: s.is_remote,
|
||||||
|
// comment: s.comment ?? null,
|
||||||
|
// }));
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
58
src/modules/shifts/~misc_deprecated-files/shifts.utils.ts
Normal file
58
src/modules/shifts/~misc_deprecated-files/shifts.utils.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// import { NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
|
// export function overlaps(
|
||||||
|
// a_start_ms: number,
|
||||||
|
// a_end_ms: number,
|
||||||
|
// b_start_ms: number,
|
||||||
|
// b_end_ms: number,
|
||||||
|
// ): boolean {
|
||||||
|
// return a_start_ms < b_end_ms && b_start_ms < a_end_ms;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function resolveBankCodeByType(type: string): Promise<number> {
|
||||||
|
// const bank = this.prisma.bankCodes.findFirst({
|
||||||
|
// where: { type },
|
||||||
|
// select: { id: true },
|
||||||
|
// });
|
||||||
|
// if (!bank) {
|
||||||
|
// throw new NotFoundException({ error_code: 'SHIFT_TYPE_UNKNOWN', message: `unknown shift type: ${type}` });
|
||||||
|
// }
|
||||||
|
// return bank.id;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function normalizeShiftPayload(payload: {
|
||||||
|
// date: string,
|
||||||
|
// start_time: string,
|
||||||
|
// end_time: string,
|
||||||
|
// type: string,
|
||||||
|
// is_remote: boolean,
|
||||||
|
// is_approved: boolean,
|
||||||
|
// comment?: string | null,
|
||||||
|
// }) {
|
||||||
|
// //normalize shift's infos
|
||||||
|
// const date = payload.date?.trim();
|
||||||
|
// const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date ?? '');
|
||||||
|
// if (!m) throw new Error(`Invalid date format (expected YYYY-MM-DD): "${payload.date}"`);
|
||||||
|
// const year = Number(m[1]), mo = Number(m[2]), d = Number(m[3]);
|
||||||
|
|
||||||
|
// const asLocalDateOn = (input: string): Date => {
|
||||||
|
// // HH:mm ?
|
||||||
|
// const hm = /^(\d{2}):(\d{2})$/.exec((input ?? '').trim());
|
||||||
|
// if (hm) return new Date(year, mo - 1, d, Number(hm[1]), Number(hm[2]), 0, 0);
|
||||||
|
// const iso = new Date(input);
|
||||||
|
// if (isNaN(iso.getTime())) throw new Error(`Invalid time: "${input}"`);
|
||||||
|
// return new Date(year, mo - 1, d, iso.getHours(), iso.getMinutes(), iso.getSeconds(), iso.getMilliseconds());
|
||||||
|
// };
|
||||||
|
|
||||||
|
// const start_time = asLocalDateOn(payload.start_time);
|
||||||
|
// const end_time = asLocalDateOn(payload.end_time);
|
||||||
|
|
||||||
|
// const type = (payload.type || '').trim().toUpperCase();
|
||||||
|
// const is_remote = payload.is_remote;
|
||||||
|
// const is_approved = payload.is_approved;
|
||||||
|
// //normalize comment
|
||||||
|
// const trimmed = typeof payload.comment === 'string' ? payload.comment.trim() : null;
|
||||||
|
// const comment = trimmed && trimmed.length > 0 ? trimmed : null;
|
||||||
|
|
||||||
|
// return { date, start_time, end_time, type, is_remote, is_approved, comment };
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
// import { Type } from "class-transformer";
|
||||||
|
// import { IsBoolean, IsOptional, IsString, Matches, MaxLength, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
// export const COMMENT_MAX_LENGTH = 280;
|
||||||
|
|
||||||
|
// export class ShiftPayloadDto {
|
||||||
|
|
||||||
|
// @Matches(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/)
|
||||||
|
// date!: string;
|
||||||
|
|
||||||
|
// @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
|
||||||
|
// start_time!: string;
|
||||||
|
|
||||||
|
// @Matches(/^([01]\d|2[0-3]):([0-5]\d)$/)
|
||||||
|
// end_time!: string;
|
||||||
|
|
||||||
|
// @IsString()
|
||||||
|
// type!: string;
|
||||||
|
|
||||||
|
// @IsBoolean()
|
||||||
|
// is_remote!: boolean;
|
||||||
|
|
||||||
|
// @IsBoolean()
|
||||||
|
// is_approved!: boolean;
|
||||||
|
|
||||||
|
// @IsOptional()
|
||||||
|
// @IsString()
|
||||||
|
// @MaxLength(COMMENT_MAX_LENGTH)
|
||||||
|
// comment?: string;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export class UpsertShiftDto {
|
||||||
|
|
||||||
|
// @IsOptional()
|
||||||
|
// @ValidateNested()
|
||||||
|
// @Type(()=> ShiftPayloadDto)
|
||||||
|
// old_shift?: ShiftPayloadDto;
|
||||||
|
|
||||||
|
// @IsOptional()
|
||||||
|
// @ValidateNested()
|
||||||
|
// @Type(()=> ShiftPayloadDto)
|
||||||
|
// new_shift?: ShiftPayloadDto;
|
||||||
|
// };
|
||||||
17
src/modules/timesheets/controllers/timesheet.controller.ts
Normal file
17
src/modules/timesheets/controllers/timesheet.controller.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { GetTimesheetsOverviewService } from "../services/timesheet-get-overview.service";
|
||||||
|
import { Controller, Get, Query} from "@nestjs/common";
|
||||||
|
|
||||||
|
@Controller('timesheets')
|
||||||
|
export class TimesheetController {
|
||||||
|
constructor(private readonly timesheetOverview: GetTimesheetsOverviewService){}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async getTimesheetByIds(
|
||||||
|
@Query('timesheet_ids') timesheet_ids: string ) {
|
||||||
|
const parsed = timesheet_ids.split(/,\s*/).map(value => Number(value)).filter(Number.isFinite);
|
||||||
|
return this.timesheetOverview.getTimesheetsByIds(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
|
|
||||||
import { TimesheetsQueryService } from '../services/timesheets-query.service';
|
|
||||||
import { CreateWeekShiftsDto } from '../dtos/create-timesheet.dto';
|
|
||||||
import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
|
||||||
import { Roles as RoleEnum } from '.prisma/client';
|
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { TimesheetsCommandService } from '../services/timesheets-command.service';
|
|
||||||
import { TimesheetMap } from '../utils-helpers-others/timesheet.types';
|
|
||||||
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
|
||||||
|
|
||||||
|
|
||||||
@ApiTags('Timesheets')
|
|
||||||
@ApiBearerAuth('access-token')
|
|
||||||
// @UseGuards()
|
|
||||||
@Controller('timesheets')
|
|
||||||
export class TimesheetsController {
|
|
||||||
constructor(
|
|
||||||
private readonly timesheetsQuery: TimesheetsQueryService,
|
|
||||||
private readonly timesheetsCommand: TimesheetsCommandService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
//@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
|
||||||
async getPeriodByQuery(
|
|
||||||
@Query('year', ParseIntPipe ) year: number,
|
|
||||||
@Query('period_no', ParseIntPipe ) period_no: number,
|
|
||||||
@Query('email') email?: string
|
|
||||||
): Promise<TimesheetPeriodDto> {
|
|
||||||
if(!email || !(email = email.trim())) throw new BadRequestException('Query param "email" is mandatory for this route.');
|
|
||||||
return this.timesheetsQuery.findAll(year, period_no, email);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/:email')
|
|
||||||
async getByEmail(
|
|
||||||
@Param('email') email: string,
|
|
||||||
@Query('offset') offset?: string,
|
|
||||||
): Promise<TimesheetMap> {
|
|
||||||
const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
|
|
||||||
return this.timesheetsQuery.getTimesheetByEmail(email, week_offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('shifts/:email')
|
|
||||||
async createTimesheetShifts(
|
|
||||||
@Param('email') email: string,
|
|
||||||
@Body() dto: CreateWeekShiftsDto,
|
|
||||||
@Query('offset') offset?: string,
|
|
||||||
): Promise<TimesheetMap> {
|
|
||||||
const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
|
|
||||||
return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { Type } from "class-transformer";
|
|
||||||
import { IsArray, IsOptional, IsString, Length, Matches, ValidateNested } from "class-validator";
|
|
||||||
|
|
||||||
export class CreateTimesheetDto {
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Matches(/^\d{4}-\d{2}-\d{2}$/)
|
|
||||||
date!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Length(1,64)
|
|
||||||
type!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Matches(/^\d{2}:\d{2}$/)
|
|
||||||
start_time!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@Matches(/^\d{2}:\d{2}$/)
|
|
||||||
end_time!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@Length(0,512)
|
|
||||||
comment?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CreateWeekShiftsDto {
|
|
||||||
@IsArray()
|
|
||||||
@ValidateNested({each:true})
|
|
||||||
@Type(()=> CreateTimesheetDto)
|
|
||||||
shifts!: CreateTimesheetDto[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import { Type } from "class-transformer";
|
|
||||||
import { IsBoolean, IsInt, IsOptional } from "class-validator";
|
|
||||||
|
|
||||||
|
|
||||||
export class SearchTimesheetDto {
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt()
|
|
||||||
timesheet_id?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(()=> Number)
|
|
||||||
@IsInt()
|
|
||||||
employee_id?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(()=> Boolean)
|
|
||||||
@IsBoolean()
|
|
||||||
is_approved?: boolean;
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
export class TimesheetDto {
|
|
||||||
start_day: string;
|
|
||||||
end_day: string;
|
|
||||||
label: string;
|
|
||||||
shifts: ShiftDto[];
|
|
||||||
expenses: ExpenseDto[]
|
|
||||||
is_approved: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ShiftDto {
|
|
||||||
date: string;
|
|
||||||
type: string;
|
|
||||||
start_time: string;
|
|
||||||
end_time : string;
|
|
||||||
comment: string;
|
|
||||||
is_approved: boolean;
|
|
||||||
is_remote: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ExpenseDto {
|
|
||||||
type: string;
|
|
||||||
amount: number;
|
|
||||||
mileage: number;
|
|
||||||
comment: string;
|
|
||||||
is_approved: boolean;
|
|
||||||
supervisor_comment: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DayShiftsDto = ShiftDto[];
|
|
||||||
|
|
||||||
export class DetailedShifts {
|
|
||||||
shifts: DayShiftsDto;
|
|
||||||
regular_hours: number;
|
|
||||||
evening_hours: number;
|
|
||||||
overtime_hours: number;
|
|
||||||
emergency_hours: number;
|
|
||||||
comment: string;
|
|
||||||
short_date: string;
|
|
||||||
break_durations?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DayExpensesDto {
|
|
||||||
expenses: ExpenseDto[] = [];
|
|
||||||
total_mileage: number;
|
|
||||||
total_expense: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WeekDto {
|
|
||||||
is_approved: boolean;
|
|
||||||
shifts: {
|
|
||||||
sun: DetailedShifts;
|
|
||||||
mon: DetailedShifts;
|
|
||||||
tue: DetailedShifts;
|
|
||||||
wed: DetailedShifts;
|
|
||||||
thu: DetailedShifts;
|
|
||||||
fri: DetailedShifts;
|
|
||||||
sat: DetailedShifts;
|
|
||||||
}
|
|
||||||
expenses: {
|
|
||||||
sun: DayExpensesDto;
|
|
||||||
mon: DayExpensesDto;
|
|
||||||
tue: DayExpensesDto;
|
|
||||||
wed: DayExpensesDto;
|
|
||||||
thu: DayExpensesDto;
|
|
||||||
fri: DayExpensesDto;
|
|
||||||
sat: DayExpensesDto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TimesheetPeriodDto {
|
|
||||||
weeks: WeekDto[];
|
|
||||||
employee_full_name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
export class Session {
|
export class Timesheets {
|
||||||
user_id: number;
|
employee_fullname: string;
|
||||||
|
timesheets: Timesheet[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class Timesheet {
|
||||||
export class Timesheets {
|
|
||||||
timesheet_id: number;
|
timesheet_id: number;
|
||||||
|
is_approved: boolean;
|
||||||
days: TimesheetDay[];
|
days: TimesheetDay[];
|
||||||
weekly_hours: TotalHours[];
|
weekly_hours: TotalHours[];
|
||||||
weekly_expenses: TotalExpenses[];
|
weekly_expenses: TotalExpenses[];
|
||||||
|
|
@ -30,15 +30,15 @@ export class TotalHours {
|
||||||
}
|
}
|
||||||
export class TotalExpenses {
|
export class TotalExpenses {
|
||||||
expenses: number;
|
expenses: number;
|
||||||
perd_diem: number;
|
per_diem: number;
|
||||||
on_call: number;
|
on_call: number;
|
||||||
mileage: number;
|
mileage: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Shift {
|
export class Shift {
|
||||||
date: Date;
|
date: string;
|
||||||
start_time: Date;
|
start_time: string;
|
||||||
end_time: Date;
|
end_time: string;
|
||||||
type: string;
|
type: string;
|
||||||
is_remote: boolean;
|
is_remote: boolean;
|
||||||
is_approved: boolean;
|
is_approved: boolean;
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
export const toDateFromString = ( date: Date | string):Date => {
|
||||||
|
const d = new Date(date);
|
||||||
|
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sevenDaysFrom = (date: Date | string): Date[] => {
|
||||||
|
return Array.from({length: 7 }, (_,i) => {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setUTCDate(d.getUTCDate() + i );
|
||||||
|
return d;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export const toStringFromDate = (date: Date | string): string => {
|
||||||
|
const d = toDateFromString(date);
|
||||||
|
const year = d.getUTCFullYear();
|
||||||
|
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toHHmmFromDate = (input: Date | string): string => {
|
||||||
|
const date = new Date(input);
|
||||||
|
const hh = String(date.getUTCHours()).padStart(2, '0');
|
||||||
|
const mm = String(date.getUTCMinutes()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { Prisma, Timesheets } from "@prisma/client";
|
||||||
|
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TimesheetApprovalService extends BaseApprovalService<Timesheets>{
|
||||||
|
constructor(prisma: PrismaService){super(prisma)}
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
// APPROVAL AND DELEGATE METHODS
|
||||||
|
//_____________________________________________________________________________________________
|
||||||
|
protected get delegate() {
|
||||||
|
return this.prisma.timesheets;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected delegateFor(transaction: Prisma.TransactionClient) {
|
||||||
|
return transaction.timesheets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateApproval(id: number, isApproved: boolean): Promise<Timesheets> {
|
||||||
|
return this.prisma.$transaction((transaction) =>
|
||||||
|
this.updateApprovalWithTransaction(transaction, id, isApproved),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
|
||||||
|
const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved);
|
||||||
|
await transaction.shifts.updateMany({
|
||||||
|
where: { timesheet_id: timesheetId },
|
||||||
|
data: { is_approved: isApproved },
|
||||||
|
});
|
||||||
|
await transaction.expenses.updateManyAndReturn({
|
||||||
|
where: { timesheet_id: timesheetId },
|
||||||
|
data: { is_approved: isApproved },
|
||||||
|
});
|
||||||
|
return timesheet;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
import { sevenDaysFrom, toDateFromString, toHHmmFromDate, toStringFromDate } from "../helpers/timesheets-date-time-helpers";
|
||||||
|
|
||||||
|
type TotalHours = {
|
||||||
|
regular: number;
|
||||||
|
evening: number;
|
||||||
|
emergency: number;
|
||||||
|
overtime: number;
|
||||||
|
vacation: number;
|
||||||
|
holiday: number;
|
||||||
|
sick: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TotalExpenses = {
|
||||||
|
expenses: number;
|
||||||
|
per_diem: number;
|
||||||
|
on_call: number;
|
||||||
|
mileage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetTimesheetsOverviewService {
|
||||||
|
constructor(private readonly prisma: PrismaService) { }
|
||||||
|
|
||||||
|
async getTimesheetsByIds(timesheet_ids: number[]) {
|
||||||
|
if (!Array.isArray(timesheet_ids) || timesheet_ids.length === 0) throw new NotFoundException(`Timesheet_ids are missing`);
|
||||||
|
|
||||||
|
//fetch all needed data using timesheet ids
|
||||||
|
const rows = await this.prisma.timesheets.findMany({
|
||||||
|
where: { id: { in: timesheet_ids } },
|
||||||
|
include: {
|
||||||
|
employee: { include: { user: true } },
|
||||||
|
shift: { include: { bank_code: true } },
|
||||||
|
expense: { include: { bank_code: true, attachment_record: true } },
|
||||||
|
},
|
||||||
|
orderBy: { start_date: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (rows.length === 0) throw new NotFoundException('Timesheet(s) not found');
|
||||||
|
|
||||||
|
//build full name
|
||||||
|
const user = rows[0].employee.user;
|
||||||
|
const employee_fullname = `${user.first_name} ${user.last_name}`.trim();
|
||||||
|
|
||||||
|
const timesheets = rows.map((timesheet) => this.mapOneTimesheet(timesheet));
|
||||||
|
return { employee_fullname, timesheets };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//-----------------------------------------------------------------------------------
|
||||||
|
// MAPPERS & HELPERS
|
||||||
|
//-----------------------------------------------------------------------------------
|
||||||
|
private mapOneTimesheet(timesheet: any) {
|
||||||
|
//converts string to UTC date format
|
||||||
|
const start = toDateFromString(timesheet.start_date);
|
||||||
|
const day_dates = sevenDaysFrom(start);
|
||||||
|
|
||||||
|
//map of shifts by days
|
||||||
|
const shifts_by_date = new Map<string, any[]>();
|
||||||
|
for (const shift of timesheet.shift) {
|
||||||
|
const date = toStringFromDate(shift.date);
|
||||||
|
const arr = shifts_by_date.get(date) ?? [];
|
||||||
|
arr.push(shift);
|
||||||
|
shifts_by_date.set(date, arr);
|
||||||
|
}
|
||||||
|
//map of expenses by days
|
||||||
|
const expenses_by_date = new Map<string, any[]>();
|
||||||
|
for (const expense of timesheet.expense) {
|
||||||
|
const date = toStringFromDate(expense.date);
|
||||||
|
const arr = expenses_by_date.get(date) ?? [];
|
||||||
|
arr.push(expense);
|
||||||
|
expenses_by_date.set(date, arr);
|
||||||
|
}
|
||||||
|
//weekly totals
|
||||||
|
const weekly_hours: TotalHours[] = [emptyHours()];
|
||||||
|
const weekly_expenses: TotalExpenses[] = [emptyExpenses()];
|
||||||
|
|
||||||
|
//map of days
|
||||||
|
const days = day_dates.map((date) => {
|
||||||
|
const date_iso = toStringFromDate(date);
|
||||||
|
const shifts_source = shifts_by_date.get(date_iso) ?? [];
|
||||||
|
const expenses_source = expenses_by_date.get(date_iso) ?? [];
|
||||||
|
//inner map of shifts
|
||||||
|
const shifts = shifts_source.map((shift) => ({
|
||||||
|
date: toStringFromDate(shift.date),
|
||||||
|
start_time: toHHmmFromDate(shift.start_time),
|
||||||
|
end_time: toHHmmFromDate(shift.end_time),
|
||||||
|
type: shift.bank_code?.type ?? '',
|
||||||
|
is_remote: shift.is_remote ?? false,
|
||||||
|
is_approved: shift.is_approved ?? false,
|
||||||
|
shift_id: shift.id ?? null,
|
||||||
|
comment: shift.comment ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
//inner map of expenses
|
||||||
|
const expenses = expenses_source.map((expense) => ({
|
||||||
|
date: toStringFromDate(expense.date),
|
||||||
|
amount: expense.amount ? Number(expense.amount) : undefined,
|
||||||
|
mileage: expense.mileage ? Number(expense.mileage) : undefined,
|
||||||
|
expense_id: expense.id ?? null,
|
||||||
|
attachment: expense.attachment_record ? String(expense.attachment_record.id) : undefined,
|
||||||
|
is_approved: expense.is_approved ?? false,
|
||||||
|
comment: expense.comment ?? '',
|
||||||
|
supervisor_comment: expense.supervisor_comment,
|
||||||
|
}));
|
||||||
|
|
||||||
|
//daily totals
|
||||||
|
const daily_hours = [emptyHours()];
|
||||||
|
const daily_expenses = [emptyExpenses()];
|
||||||
|
|
||||||
|
//totals by shift types
|
||||||
|
for (const shift of shifts_source) {
|
||||||
|
const hours = diffOfHours(shift.start_time, shift.end_time);
|
||||||
|
const subgroup = hoursSubGroupFromBankCode(shift.bank_code);
|
||||||
|
daily_hours[0][subgroup] += hours;
|
||||||
|
weekly_hours[0][subgroup] += hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
//totals by expense types
|
||||||
|
for (const expense of expenses_source) {
|
||||||
|
const subgroup = expenseSubgroupFromBankCode(expense.bank_code);
|
||||||
|
if (subgroup === 'mileage') {
|
||||||
|
const mileage = num(expense.mileage);
|
||||||
|
daily_expenses[0].mileage += mileage;
|
||||||
|
weekly_expenses[0].mileage += mileage;
|
||||||
|
} else if (subgroup === 'per_diem') {
|
||||||
|
const amount = num(expense.amount);
|
||||||
|
daily_expenses[0].per_diem += amount;
|
||||||
|
weekly_expenses[0].per_diem += amount;
|
||||||
|
} else if (subgroup === 'on_call') {
|
||||||
|
const amount = num(expense.amount);
|
||||||
|
daily_expenses[0].on_call += amount;
|
||||||
|
weekly_expenses[0].on_call += amount;
|
||||||
|
} else {
|
||||||
|
const amount = num(expense.amount);
|
||||||
|
daily_expenses[0].expenses += amount;
|
||||||
|
weekly_expenses[0].expenses += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
date: date_iso,
|
||||||
|
shifts,
|
||||||
|
expenses,
|
||||||
|
daily_hours,
|
||||||
|
daily_expenses,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
timesheet_id: timesheet.id,
|
||||||
|
is_approved: timesheet.is_approved ?? false,
|
||||||
|
days,
|
||||||
|
weekly_hours,
|
||||||
|
weekly_expenses,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyHours = (): TotalHours => {
|
||||||
|
return { regular: 0, evening: 0, emergency: 0, overtime: 0, vacation: 0, holiday: 0, sick: 0 };
|
||||||
|
}
|
||||||
|
const emptyExpenses = (): TotalExpenses => {
|
||||||
|
return { expenses: 0, per_diem: 0, on_call: 0, mileage: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffOfHours = (a: Date, b: Date): number => {
|
||||||
|
const ms = new Date(b).getTime() - new Date(a).getTime();
|
||||||
|
return Math.max(0, Math.round((ms / 36e5) * 1000) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const num = (value: any): number => {
|
||||||
|
return value ? Number(value) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursSubGroupFromBankCode = (bank_code: any): keyof TotalHours => {
|
||||||
|
const type = bank_code.type;
|
||||||
|
if (type.includes('EVENING')) return 'evening';
|
||||||
|
if (type.includes('EMERGENCY')) return 'emergency';
|
||||||
|
if (type.includes('OVERTIME')) return 'overtime';
|
||||||
|
if (type.includes('VACATION')) return 'vacation';
|
||||||
|
if (type.includes('HOLIDAY')) return 'holiday';
|
||||||
|
if (type.includes('SICK')) return 'sick';
|
||||||
|
return 'regular'
|
||||||
|
}
|
||||||
|
|
||||||
|
const expenseSubgroupFromBankCode = (bank_code: any): keyof TotalExpenses => {
|
||||||
|
const type = bank_code.type;
|
||||||
|
if (type.includes('MILEAGE')) return 'mileage';
|
||||||
|
if (type.includes('PER_DIEM')) return 'per_diem';
|
||||||
|
if (type.includes('ON_CALL')) return 'on_call';
|
||||||
|
return 'expenses';
|
||||||
|
}
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
|
||||||
import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
|
|
||||||
import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
|
|
||||||
import { parseISODate, parseHHmm } from "../utils-helpers-others/timesheet.helpers";
|
|
||||||
import { TimesheetsQueryService } from "./timesheets-query.service";
|
|
||||||
import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
|
||||||
import { Prisma, Timesheets } from "@prisma/client";
|
|
||||||
import { CreateTimesheetDto } from "../dtos/create-timesheet.dto";
|
|
||||||
import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
|
||||||
import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
|
||||||
import { TimesheetMap } from "../utils-helpers-others/timesheet.types";
|
|
||||||
import { Shift, Expense } from "src/modules/shared/classes/timesheet.dto";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
|
|
||||||
constructor(
|
|
||||||
prisma: PrismaService,
|
|
||||||
private readonly query: TimesheetsQueryService,
|
|
||||||
private readonly emailResolver: EmailToIdResolver,
|
|
||||||
private readonly timesheetResolver: EmployeeTimesheetResolver,
|
|
||||||
private readonly bankTypeResolver: BankCodesResolver,
|
|
||||||
) {super(prisma);}
|
|
||||||
//_____________________________________________________________________________________________
|
|
||||||
// APPROVAL AND DELEGATE METHODS
|
|
||||||
//_____________________________________________________________________________________________
|
|
||||||
protected get delegate() {
|
|
||||||
return this.prisma.timesheets;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected delegateFor(transaction: Prisma.TransactionClient) {
|
|
||||||
return transaction.timesheets;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateApproval(id: number, isApproved: boolean): Promise<Timesheets> {
|
|
||||||
return this.prisma.$transaction((transaction) =>
|
|
||||||
this.updateApprovalWithTransaction(transaction, id, isApproved),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
|
|
||||||
const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved);
|
|
||||||
await transaction.shifts.updateMany({
|
|
||||||
where: { timesheet_id: timesheetId },
|
|
||||||
data: { is_approved: isApproved },
|
|
||||||
});
|
|
||||||
await transaction.expenses.updateManyAndReturn({
|
|
||||||
where: { timesheet_id: timesheetId },
|
|
||||||
data: { is_approved: isApproved },
|
|
||||||
});
|
|
||||||
return timesheet;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**_____________________________________________________________________________________________
|
|
||||||
create/update/delete shifts and expenses from 1 or many timesheet(s)
|
|
||||||
|
|
||||||
-this function receives an email and an array of timesheets
|
|
||||||
|
|
||||||
-this function will find the timesheets with all shifts and expenses
|
|
||||||
-this function will calculate total hours, total expenses, filtered by types,
|
|
||||||
cumulate in daily and weekly.
|
|
||||||
|
|
||||||
-the timesheet_id will be determined using the employee email
|
|
||||||
-with the timesheet_id, all shifts and expenses will be fetched
|
|
||||||
|
|
||||||
-with shift_id and expense_id, this function will compare both
|
|
||||||
datas from the DB and from the body of the function and then:
|
|
||||||
-it will create a shift if no shift is found in the DB
|
|
||||||
-it will update a shift if a shift is found in the DB
|
|
||||||
-it will delete a shift if a shift is found and no data is received from the frontend
|
|
||||||
|
|
||||||
This function will be used for the Timesheet Page for an employee to enter, modify or delete and entry
|
|
||||||
This function will also be used in the modal of the timesheet validation page to
|
|
||||||
allow a supervisor to enter, modify or delete and entry of a selected employee
|
|
||||||
_____________________________________________________________________________________________*/
|
|
||||||
|
|
||||||
async findTimesheetsByEmailAndPayPeriod(email: string, year: number, period_no: number, timesheets: Timesheets[]): Promise<Timesheets[]> {
|
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
|
||||||
|
|
||||||
|
|
||||||
return timesheets;
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsertOrDeleteShiftsByEmailAndDate(email:string, shift_ids: Shift[]) {}
|
|
||||||
|
|
||||||
async upsertOrDeleteExpensesByEmailAndDate(email:string, expenses_id: Expense[]) {}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//_____________________________________________________________________________________________
|
|
||||||
//
|
|
||||||
//_____________________________________________________________________________________________
|
|
||||||
|
|
||||||
async createWeekShiftsAndReturnOverview(
|
|
||||||
email:string,
|
|
||||||
shifts: CreateTimesheetDto[],
|
|
||||||
week_offset = 0,
|
|
||||||
): Promise<TimesheetMap> {
|
|
||||||
//fetchs employee matchint user's email
|
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email);
|
|
||||||
if(!employee_id) throw new NotFoundException(`employee for ${ email } not found`);
|
|
||||||
|
|
||||||
//insure that the week starts on sunday and finishes on saturday
|
|
||||||
const base = new Date();
|
|
||||||
base.setDate(base.getDate() + week_offset * 7);
|
|
||||||
const start_week = getWeekStart(base, 0);
|
|
||||||
const end_week = getWeekEnd(start_week);
|
|
||||||
|
|
||||||
const timesheet = await this.timesheetResolver.findTimesheetIdByEmail(email, base)
|
|
||||||
if(!timesheet) throw new NotFoundException(`no timesheet found for employe ${employee_id}`);
|
|
||||||
|
|
||||||
//validations and insertions
|
|
||||||
for(const shift of shifts) {
|
|
||||||
const date = parseISODate(shift.date);
|
|
||||||
if (date < start_week || date > end_week) throw new BadRequestException(`date ${shift.date} not in current week`);
|
|
||||||
|
|
||||||
const bank_code = await this.bankTypeResolver.findByType(shift.type)
|
|
||||||
if(!bank_code) throw new BadRequestException(`Invalid bank_code type: ${shift.type}`);
|
|
||||||
|
|
||||||
await this.prisma.shifts.create({
|
|
||||||
data: {
|
|
||||||
timesheet_id: timesheet.id,
|
|
||||||
bank_code_id: bank_code.id,
|
|
||||||
date: date,
|
|
||||||
start_time: parseHHmm(shift.start_time),
|
|
||||||
end_time: parseHHmm(shift.end_time),
|
|
||||||
comment: shift.comment ?? null,
|
|
||||||
is_approved: false,
|
|
||||||
is_remote: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return this.query.getTimesheetByEmail(email, week_offset);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
import { makeEmptyTimesheet, mapExpenseRow, mapShiftRow } from '../utils-helpers-others/timesheet.mappers';
|
|
||||||
import { buildPeriod, computeWeekRange } from '../utils-helpers-others/timesheet.utils';
|
|
||||||
import { TimesheetSelectorsService } from '../utils-helpers-others/timesheet.selectors';
|
|
||||||
import { TimesheetPeriodDto } from '../dtos/timesheet-period.dto';
|
|
||||||
import { toRangeFromPeriod } from '../utils-helpers-others/timesheet.helpers';
|
|
||||||
import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
|
|
||||||
import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils';
|
|
||||||
import { PrismaService } from 'src/prisma/prisma.service';
|
|
||||||
import { TimesheetMap } from '../utils-helpers-others/timesheet.types';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TimesheetsQueryService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly emailResolver: EmailToIdResolver,
|
|
||||||
private readonly fullNameResolver: FullNameResolver,
|
|
||||||
private readonly selectors: TimesheetSelectorsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
|
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
|
|
||||||
const full_name = await this.fullNameResolver.resolveFullName(employee_id); //finds the employee full name using employee_id
|
|
||||||
const period = await this.selectors.getPayPeriod(year, period_no);//finds the pay period using year and period_no
|
|
||||||
const{ from, to } = toRangeFromPeriod(period); //finds start and end dates
|
|
||||||
//finds all shifts from selected period
|
|
||||||
const [raw_shifts, raw_expenses] = await Promise.all([
|
|
||||||
this.selectors.getShifts(employee_id, from, to),
|
|
||||||
this.selectors.getExpenses(employee_id, from, to),
|
|
||||||
]);
|
|
||||||
// data mapping
|
|
||||||
const shifts = raw_shifts.map(mapShiftRow);
|
|
||||||
const expenses = raw_expenses.map(mapExpenseRow);
|
|
||||||
|
|
||||||
return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async getTimesheetByEmail(email: string, week_offset = 0): Promise<TimesheetMap> {
|
|
||||||
const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
|
|
||||||
const { start, start_day, end_day, label } = computeWeekRange(week_offset);
|
|
||||||
const timesheet = await this.selectors.getTimesheetWithShiftsAndExpenses(employee_id, start); //fetch timesheet shifts and expenses
|
|
||||||
if(!timesheet) return makeEmptyTimesheet({ start_day, end_day, label});
|
|
||||||
|
|
||||||
//maps all shifts of selected timesheet
|
|
||||||
const shifts = timesheet.shift.map(mapShiftRow);
|
|
||||||
const expenses = timesheet.expense.map(mapExpenseRow);
|
|
||||||
|
|
||||||
|
|
||||||
return { start_day, end_day, label, shifts, expenses, is_approved: timesheet.is_approved};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +1,24 @@
|
||||||
import { TimesheetsController } from './controllers/timesheets.controller';
|
import { GetTimesheetsOverviewService } from './services/timesheet-get-overview.service';
|
||||||
import { TimesheetsQueryService } from './services/timesheets-query.service';
|
import { TimesheetArchiveService } from './services/timesheet-archive.service';
|
||||||
import { TimesheetArchiveService } from './services/timesheet-archive.service';
|
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
|
||||||
import { TimesheetsCommandService } from './services/timesheets-command.service';
|
import { TimesheetController } from './controllers/timesheet.controller';
|
||||||
import { ShiftsCommandService } from '../shifts/services/shifts-command.service';
|
import { SharedModule } from '../shared/shared.module';
|
||||||
import { ExpensesCommandService } from '../expenses/services/expenses-command.service';
|
import { ShiftsModule } from '../shifts/shifts.module';
|
||||||
import { BusinessLogicsModule } from 'src/modules/business-logics/business-logics.module';
|
import { Module } from '@nestjs/common';
|
||||||
import { SharedModule } from '../shared/shared.module';
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors';
|
|
||||||
import { ShiftsHelpersService } from '../shifts/helpers/shifts.helpers';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
BusinessLogicsModule,
|
BusinessLogicsModule,
|
||||||
SharedModule,
|
SharedModule,
|
||||||
|
ShiftsModule,
|
||||||
],
|
],
|
||||||
controllers: [TimesheetsController],
|
controllers: [TimesheetController],
|
||||||
providers: [
|
providers: [
|
||||||
TimesheetsQueryService,
|
TimesheetArchiveService,
|
||||||
TimesheetsCommandService,
|
GetTimesheetsOverviewService,
|
||||||
ShiftsCommandService,
|
|
||||||
ExpensesCommandService,
|
|
||||||
TimesheetArchiveService,
|
|
||||||
TimesheetSelectorsService,
|
|
||||||
ShiftsHelpersService,
|
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TimesheetsQueryService,
|
|
||||||
TimesheetArchiveService,
|
|
||||||
TimesheetsCommandService
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TimesheetsModule {}
|
export class TimesheetsModule {}
|
||||||
|
|
|
||||||
|
|
@ -1,67 +0,0 @@
|
||||||
import { MS_PER_DAY } from "src/modules/shared/constants/date-time.constant";
|
|
||||||
import { DAY_KEYS, DayKey } from "././timesheet.types";
|
|
||||||
|
|
||||||
export function toUTCDateOnly(date: Date | string): Date {
|
|
||||||
const d = new Date(date);
|
|
||||||
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addDays(date:Date, days: number): Date {
|
|
||||||
return new Date(date.getTime() + days * MS_PER_DAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function endOfDayUTC(date: Date | string): Date {
|
|
||||||
const d = toUTCDateOnly(date);
|
|
||||||
return new Date(d.getTime() + MS_PER_DAY - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): boolean {
|
|
||||||
const time = date.getTime();
|
|
||||||
return time >= start.getTime() && time <= end_inclusive.getTime();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toTimeString(date: Date): string {
|
|
||||||
const hours = String(date.getUTCHours()).padStart(2,'0');
|
|
||||||
const minutes = String(date.getUTCMinutes()).padStart(2,'0');
|
|
||||||
return `${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function round2(num: number) {
|
|
||||||
return Math.round(num * 100) / 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function shortDate(date:Date): string {
|
|
||||||
const mm = String(date.getUTCMonth()+1).padStart(2,'0');
|
|
||||||
const dd = String(date.getUTCDate()).padStart(2,'0');
|
|
||||||
return `${mm}/${dd}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
|
|
||||||
const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday
|
|
||||||
return DAY_KEYS[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toHHmm = (date: Date) => date.toISOString().slice(11, 16);
|
|
||||||
|
|
||||||
export function parseISODate(iso: string): Date {
|
|
||||||
const [ y, m, d ] = iso.split('-').map(Number);
|
|
||||||
return new Date(y, (m ?? 1) - 1, d ?? 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseHHmm(t: string): Date {
|
|
||||||
const [ hh, mm ] = t.split(':').map(Number);
|
|
||||||
return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const toNum = (value: any) =>
|
|
||||||
value && typeof value.toNumber === 'function' ? value.toNumber() :
|
|
||||||
typeof value === 'number' ? value :
|
|
||||||
value ? Number(value) : 0;
|
|
||||||
|
|
||||||
|
|
||||||
export const upper = (s?: string | null) => String(s ?? '').toUpperCase();
|
|
||||||
|
|
||||||
export const toRangeFromPeriod = (period: { period_start: Date; period_end: Date }) => ({
|
|
||||||
from: toUTCDateOnly(period.period_start),
|
|
||||||
to: endOfDayUTC(period.period_end),
|
|
||||||
});
|
|
||||||
|
|
@ -1,111 +0,0 @@
|
||||||
import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto";
|
|
||||||
import { ShiftRow, ExpenseRow, ExpensesAmount, TimesheetMap } from "./timesheet.types";
|
|
||||||
import { addDays, shortDate, toNum, upper } from "./timesheet.helpers";
|
|
||||||
import { Prisma } from "@prisma/client";
|
|
||||||
|
|
||||||
|
|
||||||
//mappers
|
|
||||||
export const mapShiftRow = (shift: {
|
|
||||||
date: Date;
|
|
||||||
start_time: Date;
|
|
||||||
end_time: Date;
|
|
||||||
comment?: string | null;
|
|
||||||
is_approved: boolean;
|
|
||||||
is_remote: boolean;
|
|
||||||
bank_code: { type: string };
|
|
||||||
}): ShiftRow => ({
|
|
||||||
date: shift.date,
|
|
||||||
start_time: shift.start_time,
|
|
||||||
end_time: shift.end_time,
|
|
||||||
comment: shift.comment ?? '',
|
|
||||||
is_approved: shift.is_approved,
|
|
||||||
is_remote: shift.is_remote,
|
|
||||||
type: upper(shift.bank_code.type),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const mapExpenseRow = (expense: {
|
|
||||||
date: Date;
|
|
||||||
amount: Prisma.Decimal | number | null;
|
|
||||||
mileage: Prisma.Decimal | number | null;
|
|
||||||
comment?: string | null;
|
|
||||||
is_approved: boolean;
|
|
||||||
supervisor_comment?: string|null;
|
|
||||||
bank_code: { type: string },
|
|
||||||
}): ExpenseRow => ({
|
|
||||||
date: expense.date,
|
|
||||||
amount: toNum(expense.amount),
|
|
||||||
mileage: toNum(expense.mileage),
|
|
||||||
comment: expense.comment ?? '',
|
|
||||||
is_approved: expense.is_approved,
|
|
||||||
supervisor_comment: expense.supervisor_comment ?? '',
|
|
||||||
type: upper(expense.bank_code.type),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Factories
|
|
||||||
export function makeEmptyDayExpenses(): DayExpensesDto {
|
|
||||||
return {
|
|
||||||
expenses: [],
|
|
||||||
total_expense: -1,
|
|
||||||
total_mileage: -1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeEmptyWeek(week_start: Date): WeekDto {
|
|
||||||
const make_empty_shifts = (offset: number): DetailedShifts => ({
|
|
||||||
shifts: [],
|
|
||||||
regular_hours: 0,
|
|
||||||
evening_hours: 0,
|
|
||||||
emergency_hours: 0,
|
|
||||||
overtime_hours: 0,
|
|
||||||
comment: '',
|
|
||||||
short_date: shortDate(addDays(week_start, offset)),
|
|
||||||
break_durations: 0,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
is_approved: true,
|
|
||||||
shifts: {
|
|
||||||
sun: make_empty_shifts(0),
|
|
||||||
mon: make_empty_shifts(1),
|
|
||||||
tue: make_empty_shifts(2),
|
|
||||||
wed: make_empty_shifts(3),
|
|
||||||
thu: make_empty_shifts(4),
|
|
||||||
fri: make_empty_shifts(5),
|
|
||||||
sat: make_empty_shifts(6),
|
|
||||||
},
|
|
||||||
expenses: {
|
|
||||||
sun: makeEmptyDayExpenses(),
|
|
||||||
mon: makeEmptyDayExpenses(),
|
|
||||||
tue: makeEmptyDayExpenses(),
|
|
||||||
wed: makeEmptyDayExpenses(),
|
|
||||||
thu: makeEmptyDayExpenses(),
|
|
||||||
fri: makeEmptyDayExpenses(),
|
|
||||||
sat: makeEmptyDayExpenses(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeEmptyPeriod(): TimesheetPeriodDto {
|
|
||||||
return { weeks: [makeEmptyWeek(new Date()), makeEmptyWeek(new Date())], employee_full_name: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const makeAmounts = (): ExpensesAmount => ({
|
|
||||||
expense: 0,
|
|
||||||
mileage: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function makeEmptyTimesheet(params: {
|
|
||||||
start_day: string;
|
|
||||||
end_day: string;
|
|
||||||
label: string;
|
|
||||||
is_approved?: boolean;
|
|
||||||
}): TimesheetMap {
|
|
||||||
const { start_day, end_day, label, is_approved = false } = params;
|
|
||||||
return {
|
|
||||||
start_day,
|
|
||||||
end_day,
|
|
||||||
label,
|
|
||||||
shifts: [],
|
|
||||||
expenses: [],
|
|
||||||
is_approved,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../../shared/selects/expenses.select";
|
|
||||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
|
||||||
import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../../shared/selects/shifts.select";
|
|
||||||
import { PAY_PERIOD_SELECT } from "../../shared/selects/pay-periods.select";
|
|
||||||
import { PrismaService } from "src/prisma/prisma.service";
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TimesheetSelectorsService {
|
|
||||||
constructor(readonly prisma: PrismaService){}
|
|
||||||
|
|
||||||
async getPayPeriod(pay_year: number, pay_period_no: number) {
|
|
||||||
const period = await this.prisma.payPeriods.findFirst({
|
|
||||||
where: { pay_year, pay_period_no },
|
|
||||||
select: PAY_PERIOD_SELECT ,
|
|
||||||
});
|
|
||||||
if(!period) throw new NotFoundException(`period ${pay_year}-${pay_period_no} not found`);
|
|
||||||
return period;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getShifts(employee_id: number, from: Date, to: Date) {
|
|
||||||
return this.prisma.shifts.findMany({
|
|
||||||
where: {timesheet: { is: { employee_id } }, date: { gte: from, lte: to } },
|
|
||||||
select: SHIFT_SELECT,
|
|
||||||
orderBy: SHIFT_ASC_ORDER,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getExpenses(employee_id: number, from: Date, to: Date) {
|
|
||||||
return this.prisma.expenses.findMany({
|
|
||||||
where: { timesheet: {is: { employee_id } }, date: { gte: from, lte: to } },
|
|
||||||
select: EXPENSE_SELECT,
|
|
||||||
orderBy: EXPENSE_ASC_ORDER,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTimesheetWithShiftsAndExpenses(employee_id: number, start_date_week: Date) {
|
|
||||||
return this.prisma.timesheets.findUnique({
|
|
||||||
where: { employee_id_start_date: { employee_id, start_date: start_date_week } },
|
|
||||||
select: {
|
|
||||||
is_approved: true,
|
|
||||||
shift: { select: SHIFT_SELECT, orderBy: SHIFT_ASC_ORDER },
|
|
||||||
expense: { select: EXPENSE_SELECT, orderBy: EXPENSE_ASC_ORDER },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
export type ShiftRow = {
|
|
||||||
date: Date;
|
|
||||||
start_time: Date;
|
|
||||||
end_time: Date;
|
|
||||||
comment: string;
|
|
||||||
is_approved?: boolean;
|
|
||||||
is_remote: boolean;
|
|
||||||
type: string
|
|
||||||
};
|
|
||||||
export type ExpenseRow = {
|
|
||||||
date: Date;
|
|
||||||
amount: number;
|
|
||||||
mileage?: number | null;
|
|
||||||
comment: string;
|
|
||||||
type: string;
|
|
||||||
is_approved?: boolean;
|
|
||||||
supervisor_comment: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TimesheetMap = {
|
|
||||||
start_day: string;
|
|
||||||
end_day: string;
|
|
||||||
label: string;
|
|
||||||
shifts: ShiftRow[];
|
|
||||||
expenses: ExpenseRow[]
|
|
||||||
is_approved: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export const SHIFT_TYPES = {
|
|
||||||
REGULAR: 'REGULAR',
|
|
||||||
EVENING: 'EVENING',
|
|
||||||
OVERTIME: 'OVERTIME',
|
|
||||||
EMERGENCY: 'EMERGENCY',
|
|
||||||
HOLIDAY: 'HOLIDAY',
|
|
||||||
VACATION: 'VACATION',
|
|
||||||
SICK: 'SICK',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const EXPENSE_TYPES = {
|
|
||||||
MILEAGE: 'MILEAGE',
|
|
||||||
EXPENSE: 'EXPENSES',
|
|
||||||
PER_DIEM: 'PER_DIEM',
|
|
||||||
ON_CALL: 'ON_CALL',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
//makes the strings indexes for arrays
|
|
||||||
export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
|
|
||||||
export type DayKey = typeof DAY_KEYS[number];
|
|
||||||
|
|
||||||
//shifts's hour by type
|
|
||||||
export type ShiftsHours = {
|
|
||||||
regular: number;
|
|
||||||
evening: number;
|
|
||||||
overtime: number;
|
|
||||||
emergency: number;
|
|
||||||
sick: number;
|
|
||||||
vacation: number;
|
|
||||||
holiday: number;
|
|
||||||
};
|
|
||||||
export const make_hours = (): ShiftsHours => ({
|
|
||||||
regular: 0,
|
|
||||||
evening: 0,
|
|
||||||
overtime: 0,
|
|
||||||
emergency: 0,
|
|
||||||
sick: 0,
|
|
||||||
vacation: 0,
|
|
||||||
holiday: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ExpensesAmount = {
|
|
||||||
expense: number;
|
|
||||||
mileage: number;
|
|
||||||
};
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
import {
|
|
||||||
DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow,
|
|
||||||
SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount
|
|
||||||
} from "./timesheet.types";
|
|
||||||
import {
|
|
||||||
isBetweenUTC, dayKeyFromDate, toTimeString, round2,
|
|
||||||
toUTCDateOnly, endOfDayUTC, addDays
|
|
||||||
} from "./timesheet.helpers";
|
|
||||||
import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "../dtos/timesheet-period.dto";
|
|
||||||
import { getWeekStart, getWeekEnd, formatDateISO } from "src/common/utils/date-utils";
|
|
||||||
import { makeAmounts, makeEmptyWeek } from "./timesheet.mappers";
|
|
||||||
import { toDateString } from "src/modules/pay-periods/utils/pay-year.util";
|
|
||||||
import { MS_PER_HOUR } from "src/modules/shared/constants/date-time.constant";
|
|
||||||
|
|
||||||
export function computeWeekRange(week_offset = 0){
|
|
||||||
//sets current week Sunday -> Saturday
|
|
||||||
const base = new Date();
|
|
||||||
const offset = new Date(base);
|
|
||||||
offset.setDate(offset.getDate() + (week_offset * 7));
|
|
||||||
|
|
||||||
const start = getWeekStart(offset, 0);
|
|
||||||
const end = getWeekEnd(start);
|
|
||||||
const start_day = formatDateISO(start);
|
|
||||||
const end_day = formatDateISO(end);
|
|
||||||
const label = `${(start_day)}.${(end_day)}`;
|
|
||||||
|
|
||||||
return { start, end, start_day, end_day, label }
|
|
||||||
};
|
|
||||||
|
|
||||||
export function buildWeek(
|
|
||||||
week_start: Date,
|
|
||||||
week_end: Date,
|
|
||||||
shifts: ShiftRow[],
|
|
||||||
expenses: ExpenseRow[],
|
|
||||||
): WeekDto {
|
|
||||||
const week = makeEmptyWeek(week_start);
|
|
||||||
let all_approved = true;
|
|
||||||
|
|
||||||
const day_times: Record<DayKey, Array<{ start: Date; end: Date }>> = DAY_KEYS.reduce((acc, key) => {
|
|
||||||
acc[key] = []; return acc;
|
|
||||||
}, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
|
|
||||||
|
|
||||||
const day_hours: Record<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
|
|
||||||
acc[key] = make_hours(); return acc;
|
|
||||||
}, {} as Record<DayKey, ShiftsHours>);
|
|
||||||
|
|
||||||
const day_amounts: Record<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
|
|
||||||
acc[key] = makeAmounts(); return acc;
|
|
||||||
}, {} as Record<DayKey, ExpensesAmount>);
|
|
||||||
|
|
||||||
const day_expense_rows: Record<DayKey, DayExpensesDto> = DAY_KEYS.reduce((acc, key) => {
|
|
||||||
acc[key] = {
|
|
||||||
expenses: [{
|
|
||||||
type: '',
|
|
||||||
amount: -1,
|
|
||||||
mileage: -1,
|
|
||||||
comment: '',
|
|
||||||
is_approved: false,
|
|
||||||
supervisor_comment: '',
|
|
||||||
}],
|
|
||||||
total_expense: -1,
|
|
||||||
total_mileage: -1,
|
|
||||||
};
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<DayKey, DayExpensesDto>);
|
|
||||||
|
|
||||||
//regroup hours per type of shifts
|
|
||||||
const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end));
|
|
||||||
for (const shift of week_shifts) {
|
|
||||||
const key = dayKeyFromDate(shift.date, true);
|
|
||||||
week.shifts[key].shifts.push({
|
|
||||||
date: toDateString(shift.date),
|
|
||||||
type: shift.type,
|
|
||||||
start_time: toTimeString(shift.start_time),
|
|
||||||
end_time: toTimeString(shift.end_time),
|
|
||||||
comment: shift.comment,
|
|
||||||
is_approved: shift.is_approved ?? true,
|
|
||||||
is_remote: shift.is_remote,
|
|
||||||
} as ShiftDto);
|
|
||||||
|
|
||||||
day_times[key].push({ start: shift.start_time, end: shift.end_time});
|
|
||||||
|
|
||||||
const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR);
|
|
||||||
const type = (shift.type || '').toUpperCase();
|
|
||||||
|
|
||||||
if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration;
|
|
||||||
else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration;
|
|
||||||
else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration;
|
|
||||||
else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration;
|
|
||||||
else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration;
|
|
||||||
else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration;
|
|
||||||
else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration;
|
|
||||||
|
|
||||||
all_approved = all_approved && (shift.is_approved ?? true );
|
|
||||||
}
|
|
||||||
|
|
||||||
//regroupe amounts to type of expenses
|
|
||||||
const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end));
|
|
||||||
for (const expense of week_expenses) {
|
|
||||||
const key = dayKeyFromDate(expense.date, true);
|
|
||||||
const type = (expense.type || '').toUpperCase();
|
|
||||||
|
|
||||||
const row: ExpenseDto = {
|
|
||||||
type,
|
|
||||||
amount: round2(expense.amount ?? 0),
|
|
||||||
mileage: round2(expense.mileage ?? 0),
|
|
||||||
comment: expense.comment ?? '',
|
|
||||||
is_approved: expense.is_approved ?? true,
|
|
||||||
supervisor_comment: expense.supervisor_comment ?? '',
|
|
||||||
};
|
|
||||||
|
|
||||||
day_expense_rows[key].expenses.push(row);
|
|
||||||
|
|
||||||
if(type === EXPENSE_TYPES.MILEAGE) {
|
|
||||||
day_amounts[key].mileage += row.mileage ?? 0;
|
|
||||||
} else {
|
|
||||||
day_amounts[key].expense += row.amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
all_approved = all_approved && row.is_approved;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key of DAY_KEYS) {
|
|
||||||
//return exposed dto data
|
|
||||||
week.shifts[key].regular_hours = round2(day_hours[key].regular);
|
|
||||||
week.shifts[key].evening_hours = round2(day_hours[key].evening);
|
|
||||||
week.shifts[key].overtime_hours = round2(day_hours[key].overtime);
|
|
||||||
week.shifts[key].emergency_hours = round2(day_hours[key].emergency);
|
|
||||||
|
|
||||||
//calculate gaps between shifts
|
|
||||||
const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime());
|
|
||||||
let gaps = 0;
|
|
||||||
for (let i = 1; i < times.length; i++) {
|
|
||||||
const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR;
|
|
||||||
if(gap > 0) gaps += gap;
|
|
||||||
}
|
|
||||||
week.shifts[key].break_durations = round2(gaps);
|
|
||||||
|
|
||||||
//daily totals
|
|
||||||
const totals = day_amounts[key];
|
|
||||||
|
|
||||||
day_expense_rows[key].total_mileage = round2(totals.mileage);
|
|
||||||
day_expense_rows[key].total_expense = round2(totals.expense);
|
|
||||||
}
|
|
||||||
|
|
||||||
week.is_approved = all_approved;
|
|
||||||
return week;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildPeriod(
|
|
||||||
period_start: Date,
|
|
||||||
period_end: Date,
|
|
||||||
shifts: ShiftRow[],
|
|
||||||
expenses: ExpenseRow[],
|
|
||||||
employeeFullName = ''
|
|
||||||
): TimesheetPeriodDto {
|
|
||||||
const week1_start = toUTCDateOnly(period_start);
|
|
||||||
const week1_end = endOfDayUTC(addDays(week1_start, 6));
|
|
||||||
const week2_start = toUTCDateOnly(addDays(week1_start, 7));
|
|
||||||
const week2_end = endOfDayUTC(period_end);
|
|
||||||
|
|
||||||
const weeks: WeekDto[] = [
|
|
||||||
buildWeek(week1_start, week1_end, shifts, expenses),
|
|
||||||
buildWeek(week2_start, week2_end, shifts, expenses),
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
weeks,
|
|
||||||
employee_full_name: employeeFullName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
// import { Type } from "class-transformer";
|
||||||
|
// import { IsArray, IsOptional, IsString, Length, Matches, ValidateNested } from "class-validator";
|
||||||
|
|
||||||
|
// export class CreateTimesheetDto {
|
||||||
|
|
||||||
|
// @IsString()
|
||||||
|
// @Matches(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
// date!: string;
|
||||||
|
|
||||||
|
// @IsString()
|
||||||
|
// @Length(1,64)
|
||||||
|
// type!: string;
|
||||||
|
|
||||||
|
// @IsString()
|
||||||
|
// @Matches(/^\d{2}:\d{2}$/)
|
||||||
|
// start_time!: string;
|
||||||
|
|
||||||
|
// @IsString()
|
||||||
|
// @Matches(/^\d{2}:\d{2}$/)
|
||||||
|
// end_time!: string;
|
||||||
|
|
||||||
|
// @IsOptional()
|
||||||
|
// @IsString()
|
||||||
|
// @Length(0,512)
|
||||||
|
// comment?: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export class CreateWeekShiftsDto {
|
||||||
|
// @IsArray()
|
||||||
|
// @ValidateNested({each:true})
|
||||||
|
// @Type(()=> CreateTimesheetDto)
|
||||||
|
// shifts!: CreateTimesheetDto[];
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
// import { Type } from "class-transformer";
|
||||||
|
// import { IsBoolean, IsInt, IsOptional } from "class-validator";
|
||||||
|
|
||||||
|
|
||||||
|
// export class SearchTimesheetDto {
|
||||||
|
// @IsOptional()
|
||||||
|
// @Type(() => Number)
|
||||||
|
// @IsInt()
|
||||||
|
// timesheet_id?: number;
|
||||||
|
|
||||||
|
// @IsOptional()
|
||||||
|
// @Type(()=> Number)
|
||||||
|
// @IsInt()
|
||||||
|
// employee_id?: number;
|
||||||
|
|
||||||
|
// @IsOptional()
|
||||||
|
// @Type(()=> Boolean)
|
||||||
|
// @IsBoolean()
|
||||||
|
// is_approved?: boolean;
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
// export class TimesheetDto {
|
||||||
|
// start_day: string;
|
||||||
|
// end_day: string;
|
||||||
|
// label: string;
|
||||||
|
// shifts: ShiftDto[];
|
||||||
|
// expenses: ExpenseDto[]
|
||||||
|
// is_approved: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export class ShiftDto {
|
||||||
|
// date: string;
|
||||||
|
// type: string;
|
||||||
|
// start_time: string;
|
||||||
|
// end_time : string;
|
||||||
|
// comment: string;
|
||||||
|
// is_approved: boolean;
|
||||||
|
// is_remote: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export class ExpenseDto {
|
||||||
|
// type: string;
|
||||||
|
// amount: number;
|
||||||
|
// mileage: number;
|
||||||
|
// comment: string;
|
||||||
|
// is_approved: boolean;
|
||||||
|
// supervisor_comment: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export type DayShiftsDto = ShiftDto[];
|
||||||
|
|
||||||
|
// export class DetailedShifts {
|
||||||
|
// shifts: DayShiftsDto;
|
||||||
|
// regular_hours: number;
|
||||||
|
// evening_hours: number;
|
||||||
|
// overtime_hours: number;
|
||||||
|
// emergency_hours: number;
|
||||||
|
// comment: string;
|
||||||
|
// short_date: string;
|
||||||
|
// break_durations?: number;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export class DayExpensesDto {
|
||||||
|
// expenses: ExpenseDto[] = [];
|
||||||
|
// total_mileage: number;
|
||||||
|
// total_expense: number;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export class WeekDto {
|
||||||
|
// is_approved: boolean;
|
||||||
|
// shifts: {
|
||||||
|
// sun: DetailedShifts;
|
||||||
|
// mon: DetailedShifts;
|
||||||
|
// tue: DetailedShifts;
|
||||||
|
// wed: DetailedShifts;
|
||||||
|
// thu: DetailedShifts;
|
||||||
|
// fri: DetailedShifts;
|
||||||
|
// sat: DetailedShifts;
|
||||||
|
// }
|
||||||
|
// expenses: {
|
||||||
|
// sun: DayExpensesDto;
|
||||||
|
// mon: DayExpensesDto;
|
||||||
|
// tue: DayExpensesDto;
|
||||||
|
// wed: DayExpensesDto;
|
||||||
|
// thu: DayExpensesDto;
|
||||||
|
// fri: DayExpensesDto;
|
||||||
|
// sat: DayExpensesDto;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export class TimesheetPeriodDto {
|
||||||
|
// weeks: WeekDto[];
|
||||||
|
// employee_full_name: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
// import { MS_PER_DAY } from "src/modules/shared/constants/date-time.constant";
|
||||||
|
// import { DAY_KEYS, DayKey } from "./timesheet.types";
|
||||||
|
|
||||||
|
// export function toUTCDateOnly(date: Date | string): Date {
|
||||||
|
// const d = new Date(date);
|
||||||
|
// return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function addDays(date:Date, days: number): Date {
|
||||||
|
// return new Date(date.getTime() + days * MS_PER_DAY);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function endOfDayUTC(date: Date | string): Date {
|
||||||
|
// const d = toUTCDateOnly(date);
|
||||||
|
// return new Date(d.getTime() + MS_PER_DAY - 1);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function isBetweenUTC(date: Date, start: Date, end_inclusive: Date): boolean {
|
||||||
|
// const time = date.getTime();
|
||||||
|
// return time >= start.getTime() && time <= end_inclusive.getTime();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function toTimeString(date: Date): string {
|
||||||
|
// const hours = String(date.getUTCHours()).padStart(2,'0');
|
||||||
|
// const minutes = String(date.getUTCMinutes()).padStart(2,'0');
|
||||||
|
// return `${hours}:${minutes}`;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function round2(num: number) {
|
||||||
|
// return Math.round(num * 100) / 100;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function shortDate(date:Date): string {
|
||||||
|
// const mm = String(date.getUTCMonth()+1).padStart(2,'0');
|
||||||
|
// const dd = String(date.getUTCDate()).padStart(2,'0');
|
||||||
|
// return `${mm}/${dd}`;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function dayKeyFromDate(date: Date, useUTC = true): DayKey {
|
||||||
|
// const index = useUTC ? date.getUTCDay() : date.getDay(); // 0=Sunday..6=Saturday
|
||||||
|
// return DAY_KEYS[index];
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export const toHHmm = (date: Date) => date.toISOString().slice(11, 16);
|
||||||
|
|
||||||
|
// export function parseISODate(iso: string): Date {
|
||||||
|
// const [ y, m, d ] = iso.split('-').map(Number);
|
||||||
|
// return new Date(y, (m ?? 1) - 1, d ?? 1);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function parseHHmm(t: string): Date {
|
||||||
|
// const [ hh, mm ] = t.split(':').map(Number);
|
||||||
|
// return new Date(1970, 0, 1, hh || 0, mm || 0, 0, 0);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export const toNum = (value: any) =>
|
||||||
|
// value && typeof value.toNumber === 'function' ? value.toNumber() :
|
||||||
|
// typeof value === 'number' ? value :
|
||||||
|
// value ? Number(value) : 0;
|
||||||
|
|
||||||
|
|
||||||
|
// export const upper = (s?: string | null) => String(s ?? '').toUpperCase();
|
||||||
|
|
||||||
|
// export const toRangeFromPeriod = (period: { period_start: Date; period_end: Date }) => ({
|
||||||
|
// from: toUTCDateOnly(period.period_start),
|
||||||
|
// to: endOfDayUTC(period.period_end),
|
||||||
|
// });
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
// import { DayExpensesDto, WeekDto, DetailedShifts, TimesheetPeriodDto } from "../dtos/timesheet-period.dto";
|
||||||
|
// import { ShiftRow, ExpenseRow, ExpensesAmount, TimesheetMap } from "./timesheet.types";
|
||||||
|
// import { addDays, shortDate, toNum, upper } from "./timesheet.helpers";
|
||||||
|
// import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
|
||||||
|
// //mappers
|
||||||
|
// export const mapShiftRow = (shift: {
|
||||||
|
// date: Date;
|
||||||
|
// start_time: Date;
|
||||||
|
// end_time: Date;
|
||||||
|
// comment?: string | null;
|
||||||
|
// is_approved: boolean;
|
||||||
|
// is_remote: boolean;
|
||||||
|
// bank_code: { type: string };
|
||||||
|
// }): ShiftRow => ({
|
||||||
|
// date: shift.date,
|
||||||
|
// start_time: shift.start_time,
|
||||||
|
// end_time: shift.end_time,
|
||||||
|
// comment: shift.comment ?? '',
|
||||||
|
// is_approved: shift.is_approved,
|
||||||
|
// is_remote: shift.is_remote,
|
||||||
|
// type: upper(shift.bank_code.type),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// export const mapExpenseRow = (expense: {
|
||||||
|
// date: Date;
|
||||||
|
// amount: Prisma.Decimal | number | null;
|
||||||
|
// mileage: Prisma.Decimal | number | null;
|
||||||
|
// comment?: string | null;
|
||||||
|
// is_approved: boolean;
|
||||||
|
// supervisor_comment?: string|null;
|
||||||
|
// bank_code: { type: string },
|
||||||
|
// }): ExpenseRow => ({
|
||||||
|
// date: expense.date,
|
||||||
|
// amount: toNum(expense.amount),
|
||||||
|
// mileage: toNum(expense.mileage),
|
||||||
|
// comment: expense.comment ?? '',
|
||||||
|
// is_approved: expense.is_approved,
|
||||||
|
// supervisor_comment: expense.supervisor_comment ?? '',
|
||||||
|
// type: upper(expense.bank_code.type),
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // Factories
|
||||||
|
// export function makeEmptyDayExpenses(): DayExpensesDto {
|
||||||
|
// return {
|
||||||
|
// expenses: [],
|
||||||
|
// total_expense: -1,
|
||||||
|
// total_mileage: -1,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function makeEmptyWeek(week_start: Date): WeekDto {
|
||||||
|
// const make_empty_shifts = (offset: number): DetailedShifts => ({
|
||||||
|
// shifts: [],
|
||||||
|
// regular_hours: 0,
|
||||||
|
// evening_hours: 0,
|
||||||
|
// emergency_hours: 0,
|
||||||
|
// overtime_hours: 0,
|
||||||
|
// comment: '',
|
||||||
|
// short_date: shortDate(addDays(week_start, offset)),
|
||||||
|
// break_durations: 0,
|
||||||
|
// });
|
||||||
|
// return {
|
||||||
|
// is_approved: true,
|
||||||
|
// shifts: {
|
||||||
|
// sun: make_empty_shifts(0),
|
||||||
|
// mon: make_empty_shifts(1),
|
||||||
|
// tue: make_empty_shifts(2),
|
||||||
|
// wed: make_empty_shifts(3),
|
||||||
|
// thu: make_empty_shifts(4),
|
||||||
|
// fri: make_empty_shifts(5),
|
||||||
|
// sat: make_empty_shifts(6),
|
||||||
|
// },
|
||||||
|
// expenses: {
|
||||||
|
// sun: makeEmptyDayExpenses(),
|
||||||
|
// mon: makeEmptyDayExpenses(),
|
||||||
|
// tue: makeEmptyDayExpenses(),
|
||||||
|
// wed: makeEmptyDayExpenses(),
|
||||||
|
// thu: makeEmptyDayExpenses(),
|
||||||
|
// fri: makeEmptyDayExpenses(),
|
||||||
|
// sat: makeEmptyDayExpenses(),
|
||||||
|
// },
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function makeEmptyPeriod(): TimesheetPeriodDto {
|
||||||
|
// return { weeks: [makeEmptyWeek(new Date()), makeEmptyWeek(new Date())], employee_full_name: '' };
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export const makeAmounts = (): ExpensesAmount => ({
|
||||||
|
// expense: 0,
|
||||||
|
// mileage: 0,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// export function makeEmptyTimesheet(params: {
|
||||||
|
// start_day: string;
|
||||||
|
// end_day: string;
|
||||||
|
// label: string;
|
||||||
|
// is_approved?: boolean;
|
||||||
|
// }): TimesheetMap {
|
||||||
|
// const { start_day, end_day, label, is_approved = false } = params;
|
||||||
|
// return {
|
||||||
|
// start_day,
|
||||||
|
// end_day,
|
||||||
|
// label,
|
||||||
|
// shifts: [],
|
||||||
|
// expenses: [],
|
||||||
|
// is_approved,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
// import { EXPENSE_ASC_ORDER, EXPENSE_SELECT } from "../../../shared/selects/expenses.select";
|
||||||
|
// import { Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
// import { SHIFT_ASC_ORDER, SHIFT_SELECT } from "../../../shared/selects/shifts.select";
|
||||||
|
// import { PAY_PERIOD_SELECT } from "../../../shared/selects/pay-periods.select";
|
||||||
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
|
||||||
|
// @Injectable()
|
||||||
|
// export class TimesheetSelectorsService {
|
||||||
|
// constructor(readonly prisma: PrismaService){}
|
||||||
|
|
||||||
|
// async getPayPeriod(pay_year: number, pay_period_no: number) {
|
||||||
|
// const period = await this.prisma.payPeriods.findFirst({
|
||||||
|
// where: { pay_year, pay_period_no },
|
||||||
|
// select: PAY_PERIOD_SELECT ,
|
||||||
|
// });
|
||||||
|
// if(!period) throw new NotFoundException(`period ${pay_year}-${pay_period_no} not found`);
|
||||||
|
// return period;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async getShifts(employee_id: number, from: Date, to: Date) {
|
||||||
|
// return this.prisma.shifts.findMany({
|
||||||
|
// where: {timesheet: { is: { employee_id } }, date: { gte: from, lte: to } },
|
||||||
|
// select: SHIFT_SELECT,
|
||||||
|
// orderBy: SHIFT_ASC_ORDER,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async getExpenses(employee_id: number, from: Date, to: Date) {
|
||||||
|
// return this.prisma.expenses.findMany({
|
||||||
|
// where: { timesheet: {is: { employee_id } }, date: { gte: from, lte: to } },
|
||||||
|
// select: EXPENSE_SELECT,
|
||||||
|
// orderBy: EXPENSE_ASC_ORDER,
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async getTimesheetWithShiftsAndExpenses(employee_id: number, start_date_week: Date) {
|
||||||
|
// return this.prisma.timesheets.findUnique({
|
||||||
|
// where: { employee_id_start_date: { employee_id, start_date: start_date_week } },
|
||||||
|
// select: {
|
||||||
|
// is_approved: true,
|
||||||
|
// shift: { select: SHIFT_SELECT, orderBy: SHIFT_ASC_ORDER },
|
||||||
|
// expense: { select: EXPENSE_SELECT, orderBy: EXPENSE_ASC_ORDER },
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
// export type ShiftRow = {
|
||||||
|
// date: Date;
|
||||||
|
// start_time: Date;
|
||||||
|
// end_time: Date;
|
||||||
|
// comment: string;
|
||||||
|
// is_approved?: boolean;
|
||||||
|
// is_remote: boolean;
|
||||||
|
// type: string
|
||||||
|
// };
|
||||||
|
// export type ExpenseRow = {
|
||||||
|
// date: Date;
|
||||||
|
// amount: number;
|
||||||
|
// mileage?: number | null;
|
||||||
|
// comment: string;
|
||||||
|
// type: string;
|
||||||
|
// is_approved?: boolean;
|
||||||
|
// supervisor_comment: string;
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export type TimesheetMap = {
|
||||||
|
// start_day: string;
|
||||||
|
// end_day: string;
|
||||||
|
// label: string;
|
||||||
|
// shifts: ShiftRow[];
|
||||||
|
// expenses: ExpenseRow[]
|
||||||
|
// is_approved: boolean;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // Types
|
||||||
|
// export const SHIFT_TYPES = {
|
||||||
|
// REGULAR: 'REGULAR',
|
||||||
|
// EVENING: 'EVENING',
|
||||||
|
// OVERTIME: 'OVERTIME',
|
||||||
|
// EMERGENCY: 'EMERGENCY',
|
||||||
|
// HOLIDAY: 'HOLIDAY',
|
||||||
|
// VACATION: 'VACATION',
|
||||||
|
// SICK: 'SICK',
|
||||||
|
// } as const;
|
||||||
|
|
||||||
|
// export const EXPENSE_TYPES = {
|
||||||
|
// MILEAGE: 'MILEAGE',
|
||||||
|
// EXPENSE: 'EXPENSES',
|
||||||
|
// PER_DIEM: 'PER_DIEM',
|
||||||
|
// ON_CALL: 'ON_CALL',
|
||||||
|
// } as const;
|
||||||
|
|
||||||
|
// //makes the strings indexes for arrays
|
||||||
|
// export const DAY_KEYS = ['sun','mon','tue','wed','thu','fri','sat'] as const;
|
||||||
|
// export type DayKey = typeof DAY_KEYS[number];
|
||||||
|
|
||||||
|
// //shifts's hour by type
|
||||||
|
// export type ShiftsHours = {
|
||||||
|
// regular: number;
|
||||||
|
// evening: number;
|
||||||
|
// overtime: number;
|
||||||
|
// emergency: number;
|
||||||
|
// sick: number;
|
||||||
|
// vacation: number;
|
||||||
|
// holiday: number;
|
||||||
|
// };
|
||||||
|
// export const make_hours = (): ShiftsHours => ({
|
||||||
|
// regular: 0,
|
||||||
|
// evening: 0,
|
||||||
|
// overtime: 0,
|
||||||
|
// emergency: 0,
|
||||||
|
// sick: 0,
|
||||||
|
// vacation: 0,
|
||||||
|
// holiday: 0,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// export type ExpensesAmount = {
|
||||||
|
// expense: number;
|
||||||
|
// mileage: number;
|
||||||
|
// };
|
||||||
171
src/modules/timesheets/~misc_deprecated-files/timesheet.utils.ts
Normal file
171
src/modules/timesheets/~misc_deprecated-files/timesheet.utils.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
// import {
|
||||||
|
// DayKey, DAY_KEYS, EXPENSE_TYPES, ExpenseRow,
|
||||||
|
// SHIFT_TYPES, ShiftRow, make_hours, ShiftsHours, ExpensesAmount
|
||||||
|
// } from "./timesheet.types";
|
||||||
|
// import {
|
||||||
|
// isBetweenUTC, dayKeyFromDate, toTimeString, round2,
|
||||||
|
// toUTCDateOnly, endOfDayUTC, addDays
|
||||||
|
// } from "./timesheet.helpers";
|
||||||
|
// import { WeekDto, ShiftDto, TimesheetPeriodDto, DayExpensesDto, ExpenseDto } from "../dtos/timesheet-period.dto";
|
||||||
|
// import { getWeekStart, getWeekEnd, formatDateISO } from "src/common/utils/date-utils";
|
||||||
|
// import { makeAmounts, makeEmptyWeek } from "./timesheet.mappers";
|
||||||
|
// import { toDateString } from "src/modules/pay-periods/utils/pay-year.util";
|
||||||
|
// import { MS_PER_HOUR } from "src/modules/shared/constants/date-time.constant";
|
||||||
|
|
||||||
|
// export function computeWeekRange(week_offset = 0){
|
||||||
|
// //sets current week Sunday -> Saturday
|
||||||
|
// const base = new Date();
|
||||||
|
// const offset = new Date(base);
|
||||||
|
// offset.setDate(offset.getDate() + (week_offset * 7));
|
||||||
|
|
||||||
|
// const start = getWeekStart(offset, 0);
|
||||||
|
// const end = getWeekEnd(start);
|
||||||
|
// const start_day = formatDateISO(start);
|
||||||
|
// const end_day = formatDateISO(end);
|
||||||
|
// const label = `${(start_day)}.${(end_day)}`;
|
||||||
|
|
||||||
|
// return { start, end, start_day, end_day, label }
|
||||||
|
// };
|
||||||
|
|
||||||
|
// export function buildWeek(
|
||||||
|
// week_start: Date,
|
||||||
|
// week_end: Date,
|
||||||
|
// shifts: ShiftRow[],
|
||||||
|
// expenses: ExpenseRow[],
|
||||||
|
// ): WeekDto {
|
||||||
|
// const week = makeEmptyWeek(week_start);
|
||||||
|
// let all_approved = true;
|
||||||
|
|
||||||
|
// const day_times: Record<DayKey, Array<{ start: Date; end: Date }>> = DAY_KEYS.reduce((acc, key) => {
|
||||||
|
// acc[key] = []; return acc;
|
||||||
|
// }, {} as Record<DayKey, Array<{ start: Date; end: Date}>>);
|
||||||
|
|
||||||
|
// const day_hours: Record<DayKey, ShiftsHours> = DAY_KEYS.reduce((acc, key) => {
|
||||||
|
// acc[key] = make_hours(); return acc;
|
||||||
|
// }, {} as Record<DayKey, ShiftsHours>);
|
||||||
|
|
||||||
|
// const day_amounts: Record<DayKey, ExpensesAmount> = DAY_KEYS.reduce((acc, key) => {
|
||||||
|
// acc[key] = makeAmounts(); return acc;
|
||||||
|
// }, {} as Record<DayKey, ExpensesAmount>);
|
||||||
|
|
||||||
|
// const day_expense_rows: Record<DayKey, DayExpensesDto> = DAY_KEYS.reduce((acc, key) => {
|
||||||
|
// acc[key] = {
|
||||||
|
// expenses: [{
|
||||||
|
// type: '',
|
||||||
|
// amount: -1,
|
||||||
|
// mileage: -1,
|
||||||
|
// comment: '',
|
||||||
|
// is_approved: false,
|
||||||
|
// supervisor_comment: '',
|
||||||
|
// }],
|
||||||
|
// total_expense: -1,
|
||||||
|
// total_mileage: -1,
|
||||||
|
// };
|
||||||
|
// return acc;
|
||||||
|
// }, {} as Record<DayKey, DayExpensesDto>);
|
||||||
|
|
||||||
|
// //regroup hours per type of shifts
|
||||||
|
// const week_shifts = shifts.filter(shift => isBetweenUTC(shift.date, week_start, week_end));
|
||||||
|
// for (const shift of week_shifts) {
|
||||||
|
// const key = dayKeyFromDate(shift.date, true);
|
||||||
|
// week.shifts[key].shifts.push({
|
||||||
|
// date: toDateString(shift.date),
|
||||||
|
// type: shift.type,
|
||||||
|
// start_time: toTimeString(shift.start_time),
|
||||||
|
// end_time: toTimeString(shift.end_time),
|
||||||
|
// comment: shift.comment,
|
||||||
|
// is_approved: shift.is_approved ?? true,
|
||||||
|
// is_remote: shift.is_remote,
|
||||||
|
// } as ShiftDto);
|
||||||
|
|
||||||
|
// day_times[key].push({ start: shift.start_time, end: shift.end_time});
|
||||||
|
|
||||||
|
// const duration = Math.max(0, (shift.end_time.getTime() - shift.start_time.getTime())/ MS_PER_HOUR);
|
||||||
|
// const type = (shift.type || '').toUpperCase();
|
||||||
|
|
||||||
|
// if ( type === SHIFT_TYPES.REGULAR) day_hours[key].regular += duration;
|
||||||
|
// else if( type === SHIFT_TYPES.EVENING) day_hours[key].evening += duration;
|
||||||
|
// else if( type === SHIFT_TYPES.EMERGENCY) day_hours[key].emergency += duration;
|
||||||
|
// else if( type === SHIFT_TYPES.OVERTIME) day_hours[key].overtime += duration;
|
||||||
|
// else if( type === SHIFT_TYPES.SICK) day_hours[key].sick += duration;
|
||||||
|
// else if( type === SHIFT_TYPES.VACATION) day_hours[key].vacation += duration;
|
||||||
|
// else if( type === SHIFT_TYPES.HOLIDAY) day_hours[key].holiday += duration;
|
||||||
|
|
||||||
|
// all_approved = all_approved && (shift.is_approved ?? true );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// //regroupe amounts to type of expenses
|
||||||
|
// const week_expenses = expenses.filter(expense => isBetweenUTC(expense.date, week_start, week_end));
|
||||||
|
// for (const expense of week_expenses) {
|
||||||
|
// const key = dayKeyFromDate(expense.date, true);
|
||||||
|
// const type = (expense.type || '').toUpperCase();
|
||||||
|
|
||||||
|
// const row: ExpenseDto = {
|
||||||
|
// type,
|
||||||
|
// amount: round2(expense.amount ?? 0),
|
||||||
|
// mileage: round2(expense.mileage ?? 0),
|
||||||
|
// comment: expense.comment ?? '',
|
||||||
|
// is_approved: expense.is_approved ?? true,
|
||||||
|
// supervisor_comment: expense.supervisor_comment ?? '',
|
||||||
|
// };
|
||||||
|
|
||||||
|
// day_expense_rows[key].expenses.push(row);
|
||||||
|
|
||||||
|
// if(type === EXPENSE_TYPES.MILEAGE) {
|
||||||
|
// day_amounts[key].mileage += row.mileage ?? 0;
|
||||||
|
// } else {
|
||||||
|
// day_amounts[key].expense += row.amount;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// all_approved = all_approved && row.is_approved;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// for (const key of DAY_KEYS) {
|
||||||
|
// //return exposed dto data
|
||||||
|
// week.shifts[key].regular_hours = round2(day_hours[key].regular);
|
||||||
|
// week.shifts[key].evening_hours = round2(day_hours[key].evening);
|
||||||
|
// week.shifts[key].overtime_hours = round2(day_hours[key].overtime);
|
||||||
|
// week.shifts[key].emergency_hours = round2(day_hours[key].emergency);
|
||||||
|
|
||||||
|
// //calculate gaps between shifts
|
||||||
|
// const times = day_times[key].sort((a,b) => a.start.getTime() - b.start.getTime());
|
||||||
|
// let gaps = 0;
|
||||||
|
// for (let i = 1; i < times.length; i++) {
|
||||||
|
// const gap = (times[i].start.getTime() - times[i - 1].end.getTime()) / MS_PER_HOUR;
|
||||||
|
// if(gap > 0) gaps += gap;
|
||||||
|
// }
|
||||||
|
// week.shifts[key].break_durations = round2(gaps);
|
||||||
|
|
||||||
|
// //daily totals
|
||||||
|
// const totals = day_amounts[key];
|
||||||
|
|
||||||
|
// day_expense_rows[key].total_mileage = round2(totals.mileage);
|
||||||
|
// day_expense_rows[key].total_expense = round2(totals.expense);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// week.is_approved = all_approved;
|
||||||
|
// return week;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// export function buildPeriod(
|
||||||
|
// period_start: Date,
|
||||||
|
// period_end: Date,
|
||||||
|
// shifts: ShiftRow[],
|
||||||
|
// expenses: ExpenseRow[],
|
||||||
|
// employeeFullName = ''
|
||||||
|
// ): TimesheetPeriodDto {
|
||||||
|
// const week1_start = toUTCDateOnly(period_start);
|
||||||
|
// const week1_end = endOfDayUTC(addDays(week1_start, 6));
|
||||||
|
// const week2_start = toUTCDateOnly(addDays(week1_start, 7));
|
||||||
|
// const week2_end = endOfDayUTC(period_end);
|
||||||
|
|
||||||
|
// const weeks: WeekDto[] = [
|
||||||
|
// buildWeek(week1_start, week1_end, shifts, expenses),
|
||||||
|
// buildWeek(week2_start, week2_end, shifts, expenses),
|
||||||
|
// ];
|
||||||
|
|
||||||
|
// return {
|
||||||
|
// weeks,
|
||||||
|
// employee_full_name: employeeFullName,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
// import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||||
|
// import { EmployeeTimesheetResolver } from "src/modules/shared/utils/resolve-timesheet.utils";
|
||||||
|
// import { getWeekEnd, getWeekStart } from "src/common/utils/date-utils";
|
||||||
|
// import { parseISODate, parseHHmm } from "./utils-helpers-others/timesheet.helpers";
|
||||||
|
// import { TimesheetsQueryService } from "./timesheets-query.service";
|
||||||
|
// import { BaseApprovalService } from "src/common/shared/base-approval.service";
|
||||||
|
// import { Prisma, Timesheets } from "@prisma/client";
|
||||||
|
// import { CreateTimesheetDto } from "./create-timesheet.dto";
|
||||||
|
// import { EmailToIdResolver } from "src/modules/shared/utils/resolve-email-id.utils";
|
||||||
|
// import { BankCodesResolver } from "src/modules/shared/utils/resolve-bank-type-id.utils";
|
||||||
|
// import { PrismaService } from "src/prisma/prisma.service";
|
||||||
|
// import { TimesheetMap } from "./utils-helpers-others/timesheet.types";
|
||||||
|
// import { Shift, Expense } from "../dtos/timesheet.dto";
|
||||||
|
|
||||||
|
// @Injectable()
|
||||||
|
// export class TimesheetsCommandService extends BaseApprovalService<Timesheets>{
|
||||||
|
// constructor(
|
||||||
|
// prisma: PrismaService,
|
||||||
|
// private readonly query: TimesheetsQueryService,
|
||||||
|
// private readonly emailResolver: EmailToIdResolver,
|
||||||
|
// private readonly timesheetResolver: EmployeeTimesheetResolver,
|
||||||
|
// private readonly bankTypeResolver: BankCodesResolver,
|
||||||
|
// ) {super(prisma);}
|
||||||
|
// //_____________________________________________________________________________________________
|
||||||
|
// // APPROVAL AND DELEGATE METHODS
|
||||||
|
// //_____________________________________________________________________________________________
|
||||||
|
// protected get delegate() {
|
||||||
|
// return this.prisma.timesheets;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// protected delegateFor(transaction: Prisma.TransactionClient) {
|
||||||
|
// return transaction.timesheets;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async updateApproval(id: number, isApproved: boolean): Promise<Timesheets> {
|
||||||
|
// return this.prisma.$transaction((transaction) =>
|
||||||
|
// this.updateApprovalWithTransaction(transaction, id, isApproved),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async cascadeApprovalWithtx(transaction: Prisma.TransactionClient, timesheetId: number, isApproved: boolean): Promise<Timesheets> {
|
||||||
|
// const timesheet = await this.updateApprovalWithTransaction(transaction, timesheetId, isApproved);
|
||||||
|
// await transaction.shifts.updateMany({
|
||||||
|
// where: { timesheet_id: timesheetId },
|
||||||
|
// data: { is_approved: isApproved },
|
||||||
|
// });
|
||||||
|
// await transaction.expenses.updateManyAndReturn({
|
||||||
|
// where: { timesheet_id: timesheetId },
|
||||||
|
// data: { is_approved: isApproved },
|
||||||
|
// });
|
||||||
|
// return timesheet;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// /**_____________________________________________________________________________________________
|
||||||
|
// create/update/delete shifts and expenses from 1 or many timesheet(s)
|
||||||
|
|
||||||
|
// -this function receives an email and an array of timesheets
|
||||||
|
|
||||||
|
// -this function will find the timesheets with all shifts and expenses
|
||||||
|
// -this function will calculate total hours, total expenses, filtered by types,
|
||||||
|
// cumulate in daily and weekly.
|
||||||
|
|
||||||
|
// -the timesheet_id will be determined using the employee email
|
||||||
|
// -with the timesheet_id, all shifts and expenses will be fetched
|
||||||
|
|
||||||
|
// -with shift_id and expense_id, this function will compare both
|
||||||
|
// datas from the DB and from the body of the function and then:
|
||||||
|
// -it will create a shift if no shift is found in the DB
|
||||||
|
// -it will update a shift if a shift is found in the DB
|
||||||
|
// -it will delete a shift if a shift is found and no data is received from the frontend
|
||||||
|
|
||||||
|
// This function will be used for the Timesheet Page for an employee to enter, modify or delete and entry
|
||||||
|
// This function will also be used in the modal of the timesheet validation page to
|
||||||
|
// allow a supervisor to enter, modify or delete and entry of a selected employee
|
||||||
|
// _____________________________________________________________________________________________*/
|
||||||
|
|
||||||
|
// async findTimesheetsByEmailAndPayPeriod(email: string, year: number, period_no: number, timesheets: Timesheets[]): Promise<Timesheets[]> {
|
||||||
|
// const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
|
||||||
|
|
||||||
|
// return timesheets;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// async upsertOrDeleteShiftsByEmailAndDate(email:string, shift_ids: Shift[]) {}
|
||||||
|
|
||||||
|
// async upsertOrDeleteExpensesByEmailAndDate(email:string, expenses_id: Expense[]) {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// //_____________________________________________________________________________________________
|
||||||
|
// //
|
||||||
|
// //_____________________________________________________________________________________________
|
||||||
|
|
||||||
|
// async createWeekShiftsAndReturnOverview(
|
||||||
|
// email:string,
|
||||||
|
// shifts: CreateTimesheetDto[],
|
||||||
|
// week_offset = 0,
|
||||||
|
// ): Promise<TimesheetMap> {
|
||||||
|
// //fetchs employee matchint user's email
|
||||||
|
// const employee_id = await this.emailResolver.findIdByEmail(email);
|
||||||
|
// if(!employee_id) throw new NotFoundException(`employee for ${ email } not found`);
|
||||||
|
|
||||||
|
// //insure that the week starts on sunday and finishes on saturday
|
||||||
|
// const base = new Date();
|
||||||
|
// base.setDate(base.getDate() + week_offset * 7);
|
||||||
|
// const start_week = getWeekStart(base, 0);
|
||||||
|
// const end_week = getWeekEnd(start_week);
|
||||||
|
|
||||||
|
// const timesheet = await this.timesheetResolver.findTimesheetIdByEmail(email, base)
|
||||||
|
// if(!timesheet) throw new NotFoundException(`no timesheet found for employe ${employee_id}`);
|
||||||
|
|
||||||
|
// //validations and insertions
|
||||||
|
// for(const shift of shifts) {
|
||||||
|
// const date = parseISODate(shift.date);
|
||||||
|
// if (date < start_week || date > end_week) throw new BadRequestException(`date ${shift.date} not in current week`);
|
||||||
|
|
||||||
|
// const bank_code = await this.bankTypeResolver.findByType(shift.type)
|
||||||
|
// if(!bank_code) throw new BadRequestException(`Invalid bank_code type: ${shift.type}`);
|
||||||
|
|
||||||
|
// await this.prisma.shifts.create({
|
||||||
|
// data: {
|
||||||
|
// timesheet_id: timesheet.id,
|
||||||
|
// bank_code_id: bank_code.id,
|
||||||
|
// date: date,
|
||||||
|
// start_time: parseHHmm(shift.start_time),
|
||||||
|
// end_time: parseHHmm(shift.end_time),
|
||||||
|
// comment: shift.comment ?? null,
|
||||||
|
// is_approved: false,
|
||||||
|
// is_remote: false,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// return this.query.getTimesheetByEmail(email, week_offset);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
// import { makeEmptyTimesheet, mapExpenseRow, mapShiftRow } from './utils-helpers-others/timesheet.mappers';
|
||||||
|
// import { buildPeriod, computeWeekRange } from './utils-helpers-others/timesheet.utils';
|
||||||
|
// import { TimesheetSelectorsService } from './utils-helpers-others/timesheet.selectors';
|
||||||
|
// import { TimesheetPeriodDto } from './timesheet-period.dto';
|
||||||
|
// import { toRangeFromPeriod } from './utils-helpers-others/timesheet.helpers';
|
||||||
|
// import { EmailToIdResolver } from 'src/modules/shared/utils/resolve-email-id.utils';
|
||||||
|
// import { FullNameResolver } from 'src/modules/shared/utils/resolve-full-name.utils';
|
||||||
|
// import { PrismaService } from 'src/prisma/prisma.service';
|
||||||
|
// import { TimesheetMap } from './utils-helpers-others/timesheet.types';
|
||||||
|
// import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
|
||||||
|
// @Injectable()
|
||||||
|
// export class TimesheetsQueryService {
|
||||||
|
// constructor(
|
||||||
|
// private readonly prisma: PrismaService,
|
||||||
|
// private readonly emailResolver: EmailToIdResolver,
|
||||||
|
// private readonly fullNameResolver: FullNameResolver,
|
||||||
|
// private readonly selectors: TimesheetSelectorsService,
|
||||||
|
// ) {}
|
||||||
|
|
||||||
|
// async findAll(year: number, period_no: number, email: string): Promise<TimesheetPeriodDto> {
|
||||||
|
// const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
|
||||||
|
// const full_name = await this.fullNameResolver.resolveFullName(employee_id); //finds the employee full name using employee_id
|
||||||
|
// const period = await this.selectors.getPayPeriod(year, period_no);//finds the pay period using year and period_no
|
||||||
|
// const{ from, to } = toRangeFromPeriod(period); //finds start and end dates
|
||||||
|
// //finds all shifts from selected period
|
||||||
|
// const [raw_shifts, raw_expenses] = await Promise.all([
|
||||||
|
// this.selectors.getShifts(employee_id, from, to),
|
||||||
|
// this.selectors.getExpenses(employee_id, from, to),
|
||||||
|
// ]);
|
||||||
|
// // data mapping
|
||||||
|
// const shifts = raw_shifts.map(mapShiftRow);
|
||||||
|
// const expenses = raw_expenses.map(mapExpenseRow);
|
||||||
|
|
||||||
|
// return buildPeriod(period.period_start, period.period_end, shifts , expenses, full_name);
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// async getTimesheetByEmail(email: string, week_offset = 0): Promise<TimesheetMap> {
|
||||||
|
// const employee_id = await this.emailResolver.findIdByEmail(email); //finds the employee using email
|
||||||
|
// const { start, start_day, end_day, label } = computeWeekRange(week_offset);
|
||||||
|
// const timesheet = await this.selectors.getTimesheetWithShiftsAndExpenses(employee_id, start); //fetch timesheet shifts and expenses
|
||||||
|
// if(!timesheet) return makeEmptyTimesheet({ start_day, end_day, label});
|
||||||
|
|
||||||
|
// //maps all shifts of selected timesheet
|
||||||
|
// const shifts = timesheet.shift.map(mapShiftRow);
|
||||||
|
// const expenses = timesheet.expense.map(mapExpenseRow);
|
||||||
|
|
||||||
|
|
||||||
|
// return { start_day, end_day, label, shifts, expenses, is_approved: timesheet.is_approved};
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
// import { BadRequestException, Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
|
||||||
|
// import { TimesheetsQueryService } from './timesheets-query.service';
|
||||||
|
// import { CreateWeekShiftsDto } from './create-timesheet.dto';
|
||||||
|
// import { RolesAllowed } from "src/common/decorators/roles.decorators";
|
||||||
|
// import { Roles as RoleEnum } from '.prisma/client';
|
||||||
|
// import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
// import { TimesheetsCommandService } from './timesheets-command.service';
|
||||||
|
// import { TimesheetPeriodDto } from './timesheet-period.dto';
|
||||||
|
// import { TimesheetMap } from './timesheet.types';
|
||||||
|
|
||||||
|
|
||||||
|
// @ApiTags('Timesheets')
|
||||||
|
// @ApiBearerAuth('access-token')
|
||||||
|
// // @UseGuards()
|
||||||
|
// @Controller('timesheets')
|
||||||
|
// export class TimesheetsController {
|
||||||
|
// constructor(
|
||||||
|
// private readonly timesheetsQuery: TimesheetsQueryService,
|
||||||
|
// private readonly timesheetsCommand: TimesheetsCommandService,
|
||||||
|
// ) {}
|
||||||
|
|
||||||
|
// @Get()
|
||||||
|
// //@RolesAllowed(RoleEnum.ACCOUNTING, RoleEnum.ADMIN, RoleEnum.EMPLOYEE, RoleEnum.HR, RoleEnum.SUPERVISOR)
|
||||||
|
// async getPeriodByQuery(
|
||||||
|
// @Query('year', ParseIntPipe ) year: number,
|
||||||
|
// @Query('period_no', ParseIntPipe ) period_no: number,
|
||||||
|
// @Query('email') email?: string
|
||||||
|
// ): Promise<TimesheetPeriodDto> {
|
||||||
|
// if(!email || !(email = email.trim())) throw new BadRequestException('Query param "email" is mandatory for this route.');
|
||||||
|
// return this.timesheetsQuery.findAll(year, period_no, email);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Get('/:email')
|
||||||
|
// async getByEmail(
|
||||||
|
// @Param('email') email: string,
|
||||||
|
// @Query('offset') offset?: string,
|
||||||
|
// ): Promise<TimesheetMap> {
|
||||||
|
// const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
|
||||||
|
// return this.timesheetsQuery.getTimesheetByEmail(email, week_offset);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// @Post('shifts/:email')
|
||||||
|
// async createTimesheetShifts(
|
||||||
|
// @Param('email') email: string,
|
||||||
|
// @Body() dto: CreateWeekShiftsDto,
|
||||||
|
// @Query('offset') offset?: string,
|
||||||
|
// ): Promise<TimesheetMap> {
|
||||||
|
// const week_offset = Number.isFinite(Number(offset)) ? Number(offset) : 0;
|
||||||
|
// return this.timesheetsCommand.createWeekShiftsAndReturnOverview(email, dto.shifts, week_offset);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
Loading…
Reference in New Issue
Block a user