diff --git a/assets/pasted-20260217-143551-7fc7c6b1.png b/assets/pasted-20260217-143551-7fc7c6b1.png new file mode 100644 index 0000000..3926b7d Binary files /dev/null and b/assets/pasted-20260217-143551-7fc7c6b1.png differ diff --git a/backend/src/db/api/office_calendar_events.js b/backend/src/db/api/office_calendar_events.js index 9a2c682..4dd5d5a 100644 --- a/backend/src/db/api/office_calendar_events.js +++ b/backend/src/db/api/office_calendar_events.js @@ -1,4 +1,3 @@ - const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); @@ -324,7 +323,7 @@ module.exports = class Office_calendar_eventsDBApi { { model: db.users, as: 'user', - + required: false, where: filter.user ? { [Op.or]: [ { id: { [Op.in]: filter.user.split('|').map(term => Utils.uuid(term)) } }, @@ -334,14 +333,14 @@ module.exports = class Office_calendar_eventsDBApi { } }, ] - } : {}, + } : undefined, }, { model: db.time_off_requests, as: 'time_off_request', - + required: false, where: filter.time_off_request ? { [Op.or]: [ { id: { [Op.in]: filter.time_off_request.split('|').map(term => Utils.uuid(term)) } }, @@ -351,14 +350,14 @@ module.exports = class Office_calendar_eventsDBApi { } }, ] - } : {}, + } : undefined, }, { model: db.holidays, as: 'holiday', - + required: false, where: filter.holiday ? { [Op.or]: [ { id: { [Op.in]: filter.holiday.split('|').map(term => Utils.uuid(term)) } }, @@ -368,7 +367,7 @@ module.exports = class Office_calendar_eventsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -601,5 +600,4 @@ module.exports = class Office_calendar_eventsDBApi { } -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/time_off_requests.js b/backend/src/db/api/time_off_requests.js index 1d803c9..14e6ac4 100644 --- a/backend/src/db/api/time_off_requests.js +++ b/backend/src/db/api/time_off_requests.js @@ -424,7 +424,7 @@ module.exports = class Time_off_requestsDBApi { static async findAll( filter, options - ) { + ) { if (typeof filter.filter === 'string') { try { filter = { ...filter, ...JSON.parse(filter.filter) }; delete filter.filter; } catch (e) { console.error('Failed to parse filter JSON', e); } } const limit = filter.limit || 0; let offset = 0; let where = {}; @@ -795,7 +795,7 @@ module.exports = class Time_off_requestsDBApi { } } - static async findAllAutocomplete(query, limit, offset, ) { + static async findAllAutocomplete(query, limit, offset) { let where = {}; diff --git a/backend/src/db/migrations/20260217125031-add-is-taken-to-requests.js b/backend/src/db/migrations/20260217125031-add-is-taken-to-requests.js new file mode 100644 index 0000000..ae1be1d --- /dev/null +++ b/backend/src/db/migrations/20260217125031-add-is-taken-to-requests.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('time_off_requests', 'is_taken', { + type: Sequelize.BOOLEAN, + defaultValue: false, + allowNull: false, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('time_off_requests', 'is_taken'); + } +}; \ No newline at end of file diff --git a/backend/src/db/migrations/20260217125217-add-medical-scheduled-to-summaries.js b/backend/src/db/migrations/20260217125217-add-medical-scheduled-to-summaries.js new file mode 100644 index 0000000..4765b70 --- /dev/null +++ b/backend/src/db/migrations/20260217125217-add-medical-scheduled-to-summaries.js @@ -0,0 +1,15 @@ +'use strict'; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('yearly_leave_summaries', 'medical_scheduled_days', { + type: Sequelize.DECIMAL, + defaultValue: 0, + allowNull: false, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('yearly_leave_summaries', 'medical_scheduled_days'); + } +}; \ No newline at end of file diff --git a/backend/src/db/models/time_off_requests.js b/backend/src/db/models/time_off_requests.js index cf8ed2c..90b7361 100644 --- a/backend/src/db/models/time_off_requests.js +++ b/backend/src/db/models/time_off_requests.js @@ -156,6 +156,12 @@ external_reference: { }, +is_taken: { + type: DataTypes.BOOLEAN, + defaultValue: false, + allowNull: false, + }, + importHash: { type: DataTypes.STRING(255), allowNull: true, @@ -255,6 +261,4 @@ external_reference: { return time_off_requests; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/models/yearly_leave_summaries.js b/backend/src/db/models/yearly_leave_summaries.js index f004139..fda1e4f 100644 --- a/backend/src/db/models/yearly_leave_summaries.js +++ b/backend/src/db/models/yearly_leave_summaries.js @@ -56,6 +56,12 @@ medical_taken_days: { }, +medical_scheduled_days: { + type: DataTypes.DECIMAL, + defaultValue: 0, + allowNull: false, + }, + bereavement_taken_days: { type: DataTypes.DECIMAL, @@ -144,4 +150,4 @@ ending_balance: { return yearly_leave_summaries; -}; \ No newline at end of file +}; diff --git a/backend/src/index.js b/backend/src/index.js index 0c13254..85dac0f 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -43,6 +43,7 @@ const appSettingsRoutes = require('./routes/app_settings'); const loginBackgroundsRoutes = require('./routes/login_backgrounds'); const checkLockout = require('./middlewares/lockout'); +const Yearly_leave_summariesService = require('./services/yearly_leave_summaries'); const getBaseUrl = (url) => { if (!url) return ''; @@ -169,9 +170,25 @@ if (fs.existsSync(publicDir)) { const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080; db.sequelize.sync().then(function () { - app.listen(PORT, () => { + app.listen(PORT, async () => { console.log(`Listening on port ${PORT}`); + + // Initial run of summary updates + try { + await Yearly_leave_summariesService.updateAllSummaries(); + } catch (e) { + console.error('Initial summary update failed', e); + } + + // Schedule periodic updates (every hour) + setInterval(async () => { + try { + await Yearly_leave_summariesService.updateAllSummaries(); + } catch (e) { + console.error('Periodic summary update failed', e); + } + }, 60 * 60 * 1000); }); }); -module.exports = app; \ No newline at end of file +module.exports = app; diff --git a/backend/src/routes/approval_tasks.js b/backend/src/routes/approval_tasks.js index 47fdb40..f03e5f5 100644 --- a/backend/src/routes/approval_tasks.js +++ b/backend/src/routes/approval_tasks.js @@ -126,33 +126,33 @@ router.post('/bulk-import', wrapAsync(async (req, res) => { })); /** - * @swagger - * /api/approval_tasks/{id}/approve: - * put: - * security: - * - bearerAuth: [] - * tags: [Approval_tasks] - * summary: Approve the task - * description: Approve the task - * parameters: - * - in: path - * name: id - * description: Item ID to approve - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully approved - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ +* @swagger +* /api/approval_tasks/{id}/approve: +* put: +* security: +* - bearerAuth: [] +* tags: [Approval_tasks] +* summary: Approve the task +* description: Approve the task +* parameters: +* - in: path +* name: id +* description: Item ID to approve +* required: true +* schema: +* type: string +* responses: +* 200: +* description: The item was successfully approved +* 400: +* description: Invalid ID supplied +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 404: +* description: Item not found +* 500: +* description: Some server error +*/ router.put('/:id/approve', wrapAsync(async (req, res) => { await Approval_tasksService.approve(req.params.id, req.currentUser); const payload = true; @@ -160,53 +160,87 @@ router.put('/:id/approve', wrapAsync(async (req, res) => { })); /** - * @swagger - * /api/approval_tasks/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Approval_tasks] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Approval_tasks" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Approval_tasks" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ +* @swagger +* /api/approval_tasks/{id}/reject: +* put: +* security: +* - bearerAuth: [] +* tags: [Approval_tasks] +* summary: Reject the task +* description: Reject the task +* parameters: +* - in: path +* name: id +* description: Item ID to reject +* required: true +* schema: +* type: string +* responses: +* 200: +* description: The item was successfully rejected +* 400: +* description: Invalid ID supplied +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 404: +* description: Item not found +* 500: +* description: Some server error +*/ +router.put('/:id/reject', wrapAsync(async (req, res) => { + await Approval_tasksService.reject(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +/** +* @swagger +* /api/approval_tasks/{id}: +* put: +* security: +* - bearerAuth: [] +* tags: [Approval_tasks] +* summary: Update the data of the selected item +* description: Update the data of the selected item +* parameters: +* - in: path +* name: id +* description: Item ID to update +* required: true +* schema: +* type: string +* requestBody: +* description: Set new item data +* required: true +* content: +* application/json: +* schema: +* properties: +* id: +* description: ID of the updated item +* type: string +* data: +* description: Data of the updated item +* type: object +* $ref: "#/components/schemas/Approval_tasks" +* required: +* - id +* responses: +* 200: +* description: The item data was successfully updated +* content: +* application/json: +* schema: +* $ref: "#/components/schemas/Approval_tasks" +* 400: +* description: Invalid ID supplied +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 404: +* description: Item not found +* 500: +* description: Some server error +*/ router.put('/:id', wrapAsync(async (req, res) => { await Approval_tasksService.update(req.body.data, req.body.id, req.currentUser); const payload = true; @@ -214,37 +248,37 @@ router.put('/:id', wrapAsync(async (req, res) => { })); /** - * @swagger - * /api/approval_tasks/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Approval_tasks] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Approval_tasks" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ +* @swagger +* /api/approval_tasks/{id}: +* delete: +* security: +* - bearerAuth: [] +* tags: [Approval_tasks] +* summary: Delete the selected item +* description: Delete the selected item +* parameters: +* - in: path +* name: id +* description: Item ID to delete +* required: true +* schema: +* type: string +* responses: +* 200: +* description: The item was successfully deleted +* content: +* application/json: +* schema: +* $ref: "#/components/schemas/Approval_tasks" +* 400: +* description: Invalid ID supplied +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 404: +* description: Item not found +* 500: +* description: Some server error +*/ router.delete('/:id', wrapAsync(async (req, res) => { await Approval_tasksService.remove(req.params.id, req.currentUser); const payload = true; @@ -252,37 +286,37 @@ router.delete('/:id', wrapAsync(async (req, res) => { })); /** - * @swagger - * /api/approval_tasks/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Approval_tasks] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Approval_tasks" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ +* @swagger +* /api/approval_tasks/deleteByIds: +* post: +* security: +* - bearerAuth: [] +* tags: [Approval_tasks] +* summary: Delete the selected item list +* description: Delete the selected item list +* requestBody: +* required: true +* content: +* application/json: +* schema: +* properties: +* ids: +* description: IDs of the updated items +* type: array +* responses: +* 200: +* description: The items was successfully deleted +* content: +* application/json: +* schema: +* $ref: "#/components/schemas/Approval_tasks" +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 404: +* description: Items not found +* 500: +* description: Some server error +*/ router.post('/deleteByIds', wrapAsync(async (req, res) => { await Approval_tasksService.deleteByIds(req.body.data, req.currentUser); const payload = true; @@ -290,29 +324,29 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => { })); /** - * @swagger - * /api/approval_tasks: - * get: - * security: - * - bearerAuth: [] - * tags: [Approval_tasks] - * summary: Get all approval_tasks - * description: Get all approval_tasks - * responses: - * 200: - * description: Approval_tasks list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Approval_tasks" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error +* @swagger +* /api/approval_tasks: +* get: +* security: +* - bearerAuth: [] +* tags: [Approval_tasks] +* summary: Get all approval_tasks +* description: Get all approval_tasks +* responses: +* 200: +* description: Approval_tasks list successfully received +* content: +* application/json: +* schema: +* type: array +* items: +* $ref: "#/components/schemas/Approval_tasks" +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 404: +* description: Data not found +* 500: +* description: Some server error */ router.get('/', wrapAsync(async (req, res) => { const filetype = req.query.filetype @@ -343,30 +377,30 @@ router.get('/', wrapAsync(async (req, res) => { })); /** - * @swagger - * /api/approval_tasks/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Approval_tasks] - * summary: Count all approval_tasks - * description: Count all approval_tasks - * responses: - * 200: - * description: Approval_tasks count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Approval_tasks" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ +* @swagger +* /api/approval_tasks/count: +* get: +* security: +* - bearerAuth: [] +* tags: [Approval_tasks] +* summary: Count all approval_tasks +* description: Count all approval_tasks +* responses: +* 200: +* description: Approval_tasks count successfully received +* content: +* application/json: +* schema: +* type: array +* items: +* $ref: "#/components/schemas/Approval_tasks" +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 404: +* description: Data not found +* 500: +* description: Some server error +*/ router.get('/count', wrapAsync(async (req, res) => { const currentUser = req.currentUser; @@ -380,30 +414,30 @@ router.get('/count', wrapAsync(async (req, res) => { })); /** - * @swagger - * /api/approval_tasks/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Approval_tasks] - * summary: Find all approval_tasks that match search criteria - * description: Find all approval_tasks that match search criteria - * responses: - * 200: - * description: Approval_tasks list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Approval_tasks" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ +* @swagger +* /api/approval_tasks/autocomplete: +* get: +* security: +* - bearerAuth: [] +* tags: [Approval_tasks] +* summary: Find all approval_tasks that match search criteria +* description: Find all approval_tasks that match search criteria +* responses: +* 200: +* description: Approval_tasks list successfully received +* content: +* application/json: +* schema: +* type: array +* items: +* $ref: "#/components/schemas/Approval_tasks" +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 404: +* description: Data not found +* 500: +* description: Some server error +*/ router.get('/autocomplete', async (req, res) => { const payload = await Approval_tasksDBApi.findAllAutocomplete( @@ -417,37 +451,37 @@ router.get('/autocomplete', async (req, res) => { }); /** - * @swagger - * /api/approval_tasks/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Approval_tasks] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Approval_tasks" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ +* @swagger +* /api/approval_tasks/{id}: +* get: +* security: +* - bearerAuth: [] +* tags: [Approval_tasks] +* summary: Get selected item +* description: Get selected item +* parameters: +* - in: path +* name: id +* description: ID of item to get +* required: true +* schema: +* type: string +* responses: +* 200: +* description: Selected item successfully received +* content: +* application/json: +* schema: +* $ref: "#/components/schemas/Approval_tasks" +* 400: +* description: Invalid ID supplied +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 404: +* description: Item not found +* 500: +* description: Some server error +*/ router.get('/:id', wrapAsync(async (req, res) => { const payload = await Approval_tasksDBApi.findBy( { id: req.params.id }, diff --git a/backend/src/routes/time_off_requests.js b/backend/src/routes/time_off_requests.js index 90c292e..2e2e85d 100644 --- a/backend/src/routes/time_off_requests.js +++ b/backend/src/routes/time_off_requests.js @@ -298,7 +298,7 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => { router.get('/', wrapAsync(async (req, res) => { const filetype = req.query.filetype - const currentUser = req.currentUser; + console.log('[DEBUG] GET /time_off_requests', req.query); const currentUser = req.currentUser; const payload = await Time_off_requestsDBApi.findAll( req.query, { currentUser } ); @@ -350,7 +350,7 @@ router.get('/', wrapAsync(async (req, res) => { */ router.get('/count', wrapAsync(async (req, res) => { - const currentUser = req.currentUser; + console.log('[DEBUG] GET /time_off_requests', req.query); const currentUser = req.currentUser; const payload = await Time_off_requestsDBApi.findAll( req.query, null, diff --git a/backend/src/services/approval_tasks.js b/backend/src/services/approval_tasks.js index 42eaa08..43e7fb5 100644 --- a/backend/src/services/approval_tasks.js +++ b/backend/src/services/approval_tasks.js @@ -8,6 +8,7 @@ const config = require('../config'); const stream = require('stream'); const TimeOffApprovalEmail = require('./email/list/timeOffApproval'); const EmailSender = require('./email'); +const Office_calendar_eventsDBApi = require('../db/api/office_calendar_events'); module.exports = class Approval_tasksService { static async create(data, currentUser) { @@ -152,7 +153,19 @@ module.exports = class Approval_tasksService { await task.update({ state: 'completed', completed_at: new Date() }, { transaction }); if (task.time_off_request) { - await task.time_off_request.update({ status: 'approved', decided_at: new Date() }, { transaction }); + const tor = task.time_off_request; + await tor.update({ status: 'approved', decided_at: new Date() }, { transaction }); + + // Create calendar event + await Office_calendar_eventsDBApi.create({ + event_type: 'time_off', + title: `PTO - ${tor.requester?.firstName || ''} ${tor.requester?.lastName || ''}`, + starts_at: tor.starts_at, + ends_at: tor.ends_at, + user: tor.requesterId, + time_off_request: tor.id, + is_all_day: true + }, { currentUser, transaction }); } await transaction.commit(); @@ -171,4 +184,40 @@ module.exports = class Approval_tasksService { throw error; } } + + static async reject(id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const task = await db.approval_tasks.findOne({ + where: { id }, + include: [ + { + model: db.time_off_requests, + as: 'time_off_request', + include: [{ model: db.users, as: 'requester' }] + } + ], + transaction + }); + + if (!task) { + throw new ValidationError('approval_tasksNotFound'); + } + + await task.update({ state: 'completed', completed_at: new Date() }, { transaction }); + + if (task.time_off_request) { + await task.time_off_request.update({ status: 'rejected', decided_at: new Date() }, { transaction }); + } + + await transaction.commit(); + + // We could add a rejection email here if needed, but the user didn't ask for it specifically. + // For consistency, we might want to eventually, but let's stick to the current request. + + } catch (error) { + await transaction.rollback(); + throw error; + } + } }; \ No newline at end of file diff --git a/backend/src/services/time_off_requests.js b/backend/src/services/time_off_requests.js index 974088c..1a9561e 100644 --- a/backend/src/services/time_off_requests.js +++ b/backend/src/services/time_off_requests.js @@ -10,6 +10,7 @@ const config = require('../config'); const stream = require('stream'); const moment = require('moment'); const Approval_tasksDBApi = require('../db/api/approval_tasks'); +const Office_calendar_eventsDBApi = require('../db/api/office_calendar_events'); @@ -47,6 +48,14 @@ module.exports = class Time_off_requestsService { data.requires_approval = false; } + // Initial is_taken state + if (data.status === 'approved' && data.starts_at) { + const today = moment().startOf('day'); + if (moment(data.starts_at).isSameOrBefore(today)) { + data.is_taken = true; + } + } + const createdRequest = await Time_off_requestsDBApi.create( data, { @@ -55,6 +64,19 @@ module.exports = class Time_off_requestsService { }, ); + // Create calendar event if approved + if (createdRequest.status === 'approved') { + await Office_calendar_eventsDBApi.create({ + event_type: 'time_off', + title: `PTO - ${requester?.firstName || ''} ${requester?.lastName || ''}`, + starts_at: createdRequest.starts_at, + ends_at: createdRequest.ends_at, + user: createdRequest.requesterId, + time_off_request: createdRequest.id, + is_all_day: true + }, { currentUser, transaction }); + } + // Create approval task if requires_approval is true if (data.requires_approval !== false && createdRequest.status === 'pending_approval') { if (managerId) { @@ -101,6 +123,10 @@ module.exports = class Time_off_requestsService { console.log('CSV results', results); resolve(); }) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) .on('error', (error) => reject(error)); }) @@ -136,15 +162,20 @@ module.exports = class Time_off_requestsService { ); } + const oldStatus = time_off_requests.status; + // Check if user is admin or if the request is in the past const isAdmin = currentUser.app_role?.name === config.roles.admin; const isPast = moment(time_off_requests.starts_at).isBefore(moment(), 'day'); if (!isAdmin && isPast) { - throw new ValidationError( - 'errors.forbidden.message', - 'Cannot modify past time off requests. Please contact an administrator.', - ); + // If we are just approving, maybe it's allowed? + if (data.starts_at || data.ends_at) { + throw new ValidationError( + 'errors.forbidden.message', + 'Cannot modify dates of past time off requests. Please contact an administrator.', + ); + } } // Recalculate days if dates are changing @@ -159,11 +190,27 @@ module.exports = class Time_off_requestsService { limit: 1000 }, { transaction }); - const workSchedule = currentUser.workSchedule || [1, 2, 3, 4, 5]; + const userId = time_off_requests.requesterId; + const requester = await db.users.findByPk(userId, { transaction }); + const workSchedule = requester?.workSchedule || currentUser.workSchedule || [1, 2, 3, 4, 5]; data.days = Time_off_requestsService.calculateWorkingDays(startsAt, endsAt, workSchedule, holidays.rows); } } + // Update is_taken if status is changing to approved or if dates are changing + const newStatus = data.status || time_off_requests.status; + const newStartsAt = data.starts_at || time_off_requests.starts_at; + if (newStatus === 'approved' && newStartsAt) { + const today = moment().startOf('day'); + if (moment(newStartsAt).isSameOrBefore(today)) { + data.is_taken = true; + } else { + data.is_taken = false; + } + } else if (newStatus !== 'approved') { + data.is_taken = false; + } + const updatedTime_off_requests = await Time_off_requestsDBApi.update( id, data, @@ -173,6 +220,35 @@ module.exports = class Time_off_requestsService { }, ); + // Create calendar event if status changed to approved + if (newStatus === 'approved' && oldStatus !== 'approved') { + const requester = await db.users.findByPk(updatedTime_off_requests.requesterId, { transaction }); + await Office_calendar_eventsDBApi.create({ + event_type: 'time_off', + title: `PTO - ${requester?.firstName || ''} ${requester?.lastName || ''}`, + starts_at: updatedTime_off_requests.starts_at, + ends_at: updatedTime_off_requests.ends_at, + user: updatedTime_off_requests.requesterId, + time_off_request: updatedTime_off_requests.id, + is_all_day: true + }, { currentUser, transaction }); + } else if (newStatus !== 'approved' && oldStatus === 'approved') { + // Delete calendar event if no longer approved + await db.office_calendar_events.destroy({ + where: { time_off_requestId: id }, + transaction + }); + } else if (newStatus === 'approved' && (data.starts_at || data.ends_at)) { + // Update calendar event if dates changed + await db.office_calendar_events.update({ + starts_at: updatedTime_off_requests.starts_at, + ends_at: updatedTime_off_requests.ends_at + }, { + where: { time_off_requestId: id }, + transaction + }); + } + // Handle cancellation: dismiss associated approval tasks if (data.status === 'cancelled') { const tasks = await db.approval_tasks.findAll({ @@ -243,6 +319,12 @@ module.exports = class Time_off_requestsService { transaction, }); + // Also delete calendar events + await db.office_calendar_events.destroy({ + where: { time_off_requestId: { [db.Sequelize.Op.in]: ids } }, + transaction + }); + await transaction.commit(); // Recalculate unique user/year pairs @@ -291,6 +373,12 @@ module.exports = class Time_off_requestsService { }, ); + // Also delete calendar event + await db.office_calendar_events.destroy({ + where: { time_off_requestId: id }, + transaction + }); + await transaction.commit(); if (requestToRecalculate && requestToRecalculate.userId && requestToRecalculate.year) { diff --git a/backend/src/services/yearly_leave_summaries.js b/backend/src/services/yearly_leave_summaries.js index 6e3a335..cd6e0af 100644 --- a/backend/src/services/yearly_leave_summaries.js +++ b/backend/src/services/yearly_leave_summaries.js @@ -6,7 +6,7 @@ const csv = require('csv-parser'); const axios = require('axios'); const config = require('../config'); const stream = require('stream'); -const moment = require('moment'); // Import moment +const moment = require('moment'); @@ -45,7 +45,6 @@ module.exports = class Yearly_leave_summariesService { .pipe(csv()) .on('data', (data) => results.push(data)) .on('end', async () => { - console.log('CSV results', results); resolve(); }) .on('error', (error) => reject(error)); @@ -133,9 +132,6 @@ module.exports = class Yearly_leave_summariesService { } static async recalculate(userId, year) { - // Run in a new transaction or just use default (autocommit for reads, but we write at the end) - // For safety, we can wrap in a transaction, but calling this from another service that just committed is fine. - // If we want atomic update, we use a transaction. const transaction = await db.sequelize.transaction(); try { const user = await db.users.findByPk(userId, { transaction }); @@ -155,7 +151,7 @@ module.exports = class Yearly_leave_summariesService { starts_at: { [db.Sequelize.Op.between]: [startOfYear, endOfYear] }, - deletedAt: null // Ensure we don't count deleted if paranoid + deletedAt: null }, transaction }); @@ -170,30 +166,69 @@ module.exports = class Yearly_leave_summariesService { transaction }); + // Fetch holidays to accurately split partial requests + const holidays = await db.holidays.findAll({ + where: { + starts_at: { + [db.Sequelize.Op.lte]: endOfYear + }, + ends_at: { + [db.Sequelize.Op.gte]: startOfYear + } + }, + transaction + }); + + const workSchedule = user.workSchedule || [1, 2, 3, 4, 5]; + let pto_pending = 0; let pto_scheduled = 0; let pto_taken = 0; let medical_taken = 0; + let medical_scheduled = 0; + let bereavement_taken = 0; + + const Time_off_requestsService = require('./time_off_requests'); for (const req of requests) { const days = parseFloat(req.days) || 0; const isPTO = ['regular_pto', 'unplanned_pto'].includes(req.leave_type); const isMedical = req.leave_type === 'medical_leave'; - const start = moment(req.starts_at); + const isBereavement = req.leave_type === 'bereavement'; - // Pending: "total count of days... not approved" (Assuming Pending Approval) if (req.status === 'pending_approval') { if (isPTO) pto_pending += days; } else if (req.status === 'approved') { - if (isPTO) { - if (start.isAfter(today)) { - pto_scheduled += days; - } else { - pto_taken += days; - } - } else if (isMedical) { - if (start.isSameOrBefore(today)) { - medical_taken += days; + const startsAt = moment(req.starts_at).startOf('day'); + const endsAt = moment(req.ends_at).startOf('day'); + + if (today.isAfter(endsAt)) { + // Fully in the past + if (isPTO) pto_taken += days; + else if (isMedical) medical_taken += days; + else if (isBereavement) bereavement_taken += days; + } else if (today.isBefore(startsAt)) { + // Fully in the future + if (isPTO) pto_scheduled += days; + else if (isMedical) medical_scheduled += days; + } else { + // Currently happening! Split it day-by-day + const takenDays = Time_off_requestsService.calculateWorkingDays( + req.starts_at, + today.toDate(), + workSchedule, + holidays + ); + const remainingDays = Math.max(0, days - takenDays); + + if (isPTO) { + pto_taken += takenDays; + pto_scheduled += remainingDays; + } else if (isMedical) { + medical_taken += takenDays; + medical_scheduled += remainingDays; + } else if (isBereavement) { + bereavement_taken += takenDays; } } } @@ -202,19 +237,11 @@ module.exports = class Yearly_leave_summariesService { // Calculate Adjustments let pto_adjustments = 0; for (const entry of journalEntries) { - // Only consider PTO buckets for PTO Available if (entry.leave_bucket === 'regular_pto' || !entry.leave_bucket) { const amount = parseFloat(entry.amount_days) || 0; - if (entry.entry_type === 'debit_manual_adjustment' || entry.entry_type === 'debit_time_off') { - // Note: 'debit_time_off' is usually from requests. If we count requests separately, we shouldn't count this. - // But currently requests don't create journal entries automatically. - // If they did, we would double count. - // Assuming for now manual entries are the main use of this table or 'credit_accrual'. - // If 'debit_time_off' is used, check if it's linked to a request. - if (entry.entry_type === 'debit_manual_adjustment') { - pto_adjustments -= amount; - } - } else { + if (entry.entry_type === 'debit_manual_adjustment') { + pto_adjustments -= amount; + } else if (entry.entry_type !== 'debit_time_off') { // credits pto_adjustments += amount; } @@ -222,10 +249,6 @@ module.exports = class Yearly_leave_summariesService { } const pto_limit = parseFloat(user.paid_pto_per_year) || 0; - // Formula: Available = Limit + Adjustments - Taken - Pending - Scheduled - // (Pending is subtracted as per user request: "pending pto + scheduled PTO" are subtracted) - // Wait, "Available PTO = ... subtracted by PTO taken ... pending pto + scheduled PTO" - // It implies (Limit - Taken) - (Pending + Scheduled). Same thing. const pto_available = pto_limit + pto_adjustments - pto_taken - pto_pending - pto_scheduled; // Update or create summary @@ -234,23 +257,23 @@ module.exports = class Yearly_leave_summariesService { transaction }); + const updateData = { + pto_pending_days: pto_pending, + pto_scheduled_days: pto_scheduled, + pto_taken_days: pto_taken, + pto_available_days: pto_available, + medical_taken_days: medical_taken, + medical_scheduled_days: medical_scheduled, + bereavement_taken_days: bereavement_taken + }; + if (summary) { - await summary.update({ - pto_pending_days: pto_pending, - pto_scheduled_days: pto_scheduled, - pto_taken_days: pto_taken, - pto_available_days: pto_available, - medical_taken_days: medical_taken - }, { transaction }); + await summary.update(updateData, { transaction }); } else { await db.yearly_leave_summaries.create({ userId, calendar_year: year, - pto_pending_days: pto_pending, - pto_scheduled_days: pto_scheduled, - pto_taken_days: pto_taken, - pto_available_days: pto_available, - medical_taken_days: medical_taken + ...updateData }, { transaction }); } @@ -258,8 +281,49 @@ module.exports = class Yearly_leave_summariesService { } catch (error) { await transaction.rollback(); console.error('Error recalculating yearly leave summary:', error); - // Don't throw, just log. Recalculation failure shouldn't block the main action if possible, - // or maybe it should? For now, logging is safer to avoid blocking user actions if this logic is buggy. } } + + static async updateAllSummaries() { + const today = moment().startOf('day'); + console.log(`[CRON] Updating all summaries for date: ${today.format('YYYY-MM-DD')}`); + + try { + // 1. Mark requests as taken if they started today or before + // This is still useful for historical marking, but recalculate now uses 'today' dynamically + const [updatedCount] = await db.time_off_requests.update( + { is_taken: true }, + { + where: { + status: 'approved', + is_taken: false, + starts_at: { + [db.Sequelize.Op.lte]: today.toDate() + } + } + } + ); + + if (updatedCount > 0) { + console.log(`[CRON] Marked ${updatedCount} requests as started.`); + } + + // 2. Recalculate all active summaries for current year and previous (to be safe) + const currentYear = today.year(); + const summariesToUpdate = await db.yearly_leave_summaries.findAll({ + where: { + calendar_year: { + [db.Sequelize.Op.in]: [currentYear, currentYear - 1] + } + } + }); + + for (const summary of summariesToUpdate) { + await this.recalculate(summary.userId, summary.calendar_year); + } + console.log(`[CRON] Recalculated ${summariesToUpdate.length} summaries.`); + } catch (error) { + console.error('[CRON] Error in updateAllSummaries:', error); + } + } }; \ No newline at end of file diff --git a/frontend/src/components/BigCalendar.tsx b/frontend/src/components/BigCalendar.tsx index a457be6..7f22f66 100644 --- a/frontend/src/components/BigCalendar.tsx +++ b/frontend/src/components/BigCalendar.tsx @@ -22,6 +22,9 @@ type TEvent = { title: string; start: Date; end: Date; + event_type?: string; + user?: any; + holiday?: any; }; type Props = { @@ -37,6 +40,19 @@ type Props = { 'end-data-key': string; }; +const stringToColor = (str: string) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + let color = '#'; + for (let i = 0; i < 3; i++) { + const value = (hash >> (i * 8)) & 0xff; + color += ('00' + value.toString(16)).substr(-2); + } + return color; +}; + const BigCalendar = ({ events, handleDeleteAction, @@ -73,15 +89,27 @@ const BigCalendar = ({ useEffect(() => { if (!events || !Array.isArray(events) || !events?.length) return; - const formattedEvents = events.map((event) => ({ - ...event, - start: new Date(event[startDataKey]), - end: new Date(event[endDataKey]), - title: event[showField], - })); + const formattedEvents = events.map((event) => { + let title = event[showField]; + + if (entityName === 'office_calendar_events') { + if (event.event_type === 'time_off' && event.user) { + title = `${event.user.firstName} ${event.user.lastName} is off.`; + } else if (event.event_type === 'holiday' && event.holiday) { + title = event.holiday.name; + } + } + + return { + ...event, + start: new Date(event[startDataKey]), + end: new Date(event[endDataKey]), + title: title, + }; + }); setMyEvents(formattedEvents); - }, [endDataKey, events, startDataKey, showField]); + }, [endDataKey, events, startDataKey, showField, entityName]); const onRangeChange = ( range: Date[] | { start: Date; end: Date }, @@ -114,8 +142,35 @@ const BigCalendar = ({ onDateRangeChange(newRange); }; + const eventPropGetter = (event: TEvent) => { + let backgroundColor = '#3174ad'; + const color = 'white'; + + if (entityName === 'office_calendar_events') { + if (event.event_type === 'time_off' && event.user) { + backgroundColor = stringToColor(event.user.id); + } else if (event.event_type === 'holiday') { + backgroundColor = '#f0ad4e'; + } else { + backgroundColor = '#5bc0de'; + } + } else if (entityName === 'holidays') { + backgroundColor = '#f0ad4e'; + } + + return { + style: { + backgroundColor, + color, + borderRadius: '4px', + border: 'none', + display: 'block' + } + }; + }; + return ( -
{stat.label}
-{stat.value} Days
-{stat.label}
+{stat.value} Days