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 ( -
+
( +
{title} - +
+ +
); }; diff --git a/frontend/src/components/CardBoxModal.tsx b/frontend/src/components/CardBoxModal.tsx index 27d5676..c441b5b 100644 --- a/frontend/src/components/CardBoxModal.tsx +++ b/frontend/src/components/CardBoxModal.tsx @@ -15,6 +15,9 @@ type Props = { children?: ReactNode onConfirm: () => void onCancel?: () => void + onDecline?: () => void + declineButtonLabel?: string + declineButtonColor?: ColorButtonKey } const CardBoxModal = ({ @@ -25,6 +28,9 @@ const CardBoxModal = ({ children, onConfirm, onCancel, + onDecline, + declineButtonLabel, + declineButtonColor, }: Props) => { if (!isActive) { return null @@ -33,6 +39,13 @@ const CardBoxModal = ({ const footer = ( + {onDecline && ( + + )} {!!onCancel && } ) @@ -56,4 +69,4 @@ const CardBoxModal = ({ ) } -export default CardBoxModal +export default CardBoxModal \ No newline at end of file diff --git a/frontend/src/components/PTOStats.tsx b/frontend/src/components/PTOStats.tsx index fecbab3..61f5804 100644 --- a/frontend/src/components/PTOStats.tsx +++ b/frontend/src/components/PTOStats.tsx @@ -1,5 +1,13 @@ import React from 'react' -import { mdiClockOutline, mdiCalendarCheck, mdiCalendarBlank, mdiMedicalBag, mdiCalendarArrowRight } from '@mdi/js' +import { + mdiClockOutline, + mdiCalendarCheck, + mdiCalendarBlank, + mdiMedicalBag, + mdiCalendarArrowRight, + mdiDoctor, + mdiHeart +} from '@mdi/js' import CardBox from './CardBox' import BaseIcon from './BaseIcon' import Link from 'next/link' @@ -12,13 +20,15 @@ type Props = { pto_taken_days: number | string pto_available_days: number | string medical_taken_days: number | string + medical_scheduled_days?: number | string + bereavement_taken_days?: number | string } } const PTOStats = ({ summary }: Props) => { const { currentUser } = useAppSelector((state) => state.auth) - const stats = [ + const line1 = [ { label: 'Pending PTO', value: summary?.pto_pending_days || 0, @@ -46,46 +56,70 @@ const PTOStats = ({ summary }: Props) => { icon: mdiCalendarBlank, color: 'text-green-500', }, + ] + + const line2 = [ { - label: 'Medical Leave Taken', + label: 'Medical Taken', value: summary?.medical_taken_days || 0, icon: mdiMedicalBag, color: 'text-red-500', href: `/time_off_requests/time_off_requests-list?leave_type=medical_leave&status=approved&requesterId=${currentUser?.id}`, }, + { + label: 'Medical Scheduled', + value: summary?.medical_scheduled_days || 0, + icon: mdiDoctor, + color: 'text-orange-500', + href: `/time_off_requests/time_off_requests-list?leave_type=medical_leave&status=approved&requesterId=${currentUser?.id}`, + }, + { + label: 'Bereavement', + value: summary?.bereavement_taken_days || 0, + icon: mdiHeart, + color: 'text-gray-500', + href: `/time_off_requests/time_off_requests-list?leave_type=bereavement&status=approved&requesterId=${currentUser?.id}`, + }, ] - return ( -
- {stats.map((stat, index) => { - const content = ( -
-
-

{stat.label}

-

{stat.value} Days

-
- + const renderStat = (stat, index) => { + const content = ( +
+
+

{stat.label}

+

{stat.value} Days

- ); - - if (stat.href) { - return ( - - - {content} - - - ) - } + +
+ ); + if (stat.href) { return ( - - {content} - + + + {content} + + ) - })} -
+ } + + return ( + + {content} + + ) + } + + return ( + <> +
+ {line1.map(renderStat)} +
+
+ {line2.map(renderStat)} +
+ ) } -export default PTOStats \ No newline at end of file +export default PTOStats diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 2eae058..bcc56c3 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -81,6 +81,7 @@ const Dashboard = () => { }, []) const handleApprove = async (taskId) => { + if (!taskId) return; try { await axios.put(`/approval_tasks/${taskId}/approve`); // Refresh data @@ -92,12 +93,25 @@ const Dashboard = () => { } }; + const handleReject = async (taskId) => { + if (!taskId) return; + try { + await axios.put(`/approval_tasks/${taskId}/reject`); + // Refresh data + fetchDashboardData(); + if (isReviewModalActive) setIsReviewModalActive(false); + } catch (error) { + console.error('Error rejecting task:', error); + alert('Failed to reject task'); + } + }; + const handleReview = (task) => { setSelectedTask(task); setIsReviewModalActive(true); }; - const years = [selectedYear - 1, selectedYear, selectedYear + 1, selectedYear + 2] + const years = [selectedYear - 1, selectedYear, selectedYear + 2] return ( <> @@ -220,6 +234,12 @@ const Dashboard = () => { small onClick={() => handleApprove(task.id)} /> + handleReject(task.id)} + /> )) @@ -243,9 +263,12 @@ const Dashboard = () => { title="Review PTO Request" isActive={isReviewModalActive} onConfirm={() => handleApprove(selectedTask?.id)} + onDecline={() => handleReject(selectedTask?.id)} onCancel={() => setIsReviewModalActive(false)} buttonColor="success" buttonLabel="Approve" + declineButtonLabel="Decline" + declineButtonColor="danger" > {selectedTask && (
diff --git a/frontend/src/pages/employee-summary.tsx b/frontend/src/pages/employee-summary.tsx index 820b676..83e2967 100644 --- a/frontend/src/pages/employee-summary.tsx +++ b/frontend/src/pages/employee-summary.tsx @@ -19,8 +19,6 @@ const EmployeeSummary = () => { const fetchSummaries = async () => { setLoading(true) try { - // For now fetching all summaries for the year. - // In a real app we might filter by manager if not admin. const res = await axios.get('/yearly_leave_summaries', { params: { limit: 100, @@ -75,18 +73,20 @@ const EmployeeSummary = () => { Employee Name - Pending - Scheduled + Pending PTO + Scheduled PTO PTO Taken - Available + Available PTO Medical Taken + Medical Scheduled + Bereavement {summaries.length > 0 ? ( summaries.map((summary) => ( - - + + {summary.user?.firstName} {summary.user?.lastName} {summary.pto_pending_days || 0} @@ -98,11 +98,17 @@ const EmployeeSummary = () => { {summary.medical_taken_days || 0} + + {summary.medical_scheduled_days || 0} + + + {summary.bereavement_taken_days || 0} + )) ) : ( - + No summaries found for {selectedYear} diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 88779f1..de1230b 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -81,6 +81,7 @@ const Dashboard = () => { }, []) const handleApprove = async (taskId) => { + if (!taskId) return; try { await axios.put(`/approval_tasks/${taskId}/approve`); // Refresh data @@ -92,6 +93,19 @@ const Dashboard = () => { } }; + const handleReject = async (taskId) => { + if (!taskId) return; + try { + await axios.put(`/approval_tasks/${taskId}/reject`); + // Refresh data + fetchDashboardData(); + if (isReviewModalActive) setIsReviewModalActive(false); + } catch (error) { + console.error('Error rejecting task:', error); + alert('Failed to reject task'); + } + }; + const handleReview = (task) => { setSelectedTask(task); setIsReviewModalActive(true); @@ -220,6 +234,12 @@ const Dashboard = () => { small onClick={() => handleApprove(task.id)} /> + handleReject(task.id)} + /> )) @@ -243,9 +263,12 @@ const Dashboard = () => { title="Review PTO Request" isActive={isReviewModalActive} onConfirm={() => handleApprove(selectedTask?.id)} + onDecline={() => handleReject(selectedTask?.id)} onCancel={() => setIsReviewModalActive(false)} buttonColor="success" buttonLabel="Approve" + declineButtonLabel="Decline" + declineButtonColor="danger" > {selectedTask && (
diff --git a/frontend/src/pages/time_off_requests/time_off_requests-new.tsx b/frontend/src/pages/time_off_requests/time_off_requests-new.tsx index 2e103f3..b467bf5 100644 --- a/frontend/src/pages/time_off_requests/time_off_requests-new.tsx +++ b/frontend/src/pages/time_off_requests/time_off_requests-new.tsx @@ -1,4 +1,4 @@ -import { mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js' +import { mdiChartTimelineVariant, mdiMail, mdiUpload, mdiInformation } from '@mdi/js' import Head from 'next/head' import React, { ReactElement, useEffect, useState } from 'react' import CardBox from '../../components/CardBox' @@ -26,6 +26,7 @@ import { create } from '../../stores/time_off_requests/time_off_requestsSlice' import { useAppDispatch, useAppSelector } from '../../stores/hooks' import { useRouter } from 'next/router' import moment from 'moment'; +import BaseIcon from '../../components/BaseIcon' const initialValues = { requester: '', @@ -46,18 +47,36 @@ const initialValues = { external_reference: '', // Custom fields for UI date_requested: '', - duration_type: 'all_day' + starts_at_date: '', + ends_at_date: '', + duration_type: 'all_day', + is_multiple: 'single' } const DateDurationLogic = () => { const { values, setFieldValue } = useFormikContext(); + // Sync is_multiple with duration_type useEffect(() => { - if (values.date_requested && values.duration_type) { + if (values.is_multiple === 'multiple' && values.duration_type !== 'multiple_days') { + setFieldValue('duration_type', 'multiple_days'); + } else if (values.is_multiple === 'single' && values.duration_type === 'multiple_days') { + setFieldValue('duration_type', 'all_day'); + } + }, [values.is_multiple, values.duration_type, setFieldValue]); + + useEffect(() => { + let start = ''; + let end = ''; + let days = 0; + + if (values.duration_type === 'multiple_days') { + if (values.starts_at_date && values.ends_at_date) { + start = `${values.starts_at_date}T09:00`; + end = `${values.ends_at_date}T17:00`; + } + } else if (values.date_requested && values.duration_type) { const date = values.date_requested; - let start = ''; - let end = ''; - let days = 0; if (values.duration_type === 'all_day') { start = `${date}T09:00`; @@ -72,12 +91,13 @@ const DateDurationLogic = () => { end = `${date}T17:00`; days = 0.5; } - - if (start !== values.starts_at) setFieldValue('starts_at', start); - if (end !== values.ends_at) setFieldValue('ends_at', end); - if (days !== values.days) setFieldValue('days', days); } - }, [values.date_requested, values.duration_type, setFieldValue, values.starts_at, values.ends_at, values.days]); + + if (start && start !== values.starts_at) setFieldValue('starts_at', start); + if (end && end !== values.ends_at) setFieldValue('ends_at', end); + if (days && days !== values.days) setFieldValue('days', days); + + }, [values.date_requested, values.starts_at_date, values.ends_at_date, values.duration_type, setFieldValue, values.starts_at, values.ends_at, values.days]); return null; }; @@ -96,33 +116,35 @@ const Time_off_requestsNew = () => { requester: currentUser, submitted_at: moment().format('YYYY-MM-DDTHH:mm'), date_requested: moment().format('YYYY-MM-DD'), + starts_at_date: moment().format('YYYY-MM-DD'), + ends_at_date: moment().add(1, 'day').format('YYYY-MM-DD'), leave_type: (router.query.leave_type as string) || prev.leave_type })); } }, [currentUser, router.query]); const handleSubmit = async (data) => { - // Ensure hidden fields are set correctly if form didn't touch them - // Note: DateDurationLogic handles starts_at/ends_at/days inside Formik, - // so `data` should have them if they were updated. - - // Fallback if date_requested is set but Logic didn't run (unlikely if rendered) const payload = { ...data }; - if (!payload.starts_at && payload.date_requested) { - const date = payload.date_requested; - if (payload.duration_type === 'all_day') { - payload.starts_at = `${date}T09:00`; - payload.ends_at = `${date}T17:00`; - payload.days = 1.0; - } else if (payload.duration_type === 'am') { - payload.starts_at = `${date}T09:00`; - payload.ends_at = `${date}T13:00`; - payload.days = 0.5; - } else if (payload.duration_type === 'pm') { - payload.starts_at = `${date}T13:00`; - payload.ends_at = `${date}T17:00`; - payload.days = 0.5; + if (!payload.starts_at) { + if (payload.duration_type === 'multiple_days' && payload.starts_at_date && payload.ends_at_date) { + payload.starts_at = `${payload.starts_at_date}T09:00`; + payload.ends_at = `${payload.ends_at_date}T17:00`; + } else if (payload.date_requested) { + const date = payload.date_requested; + if (payload.duration_type === 'all_day') { + payload.starts_at = `${date}T09:00`; + payload.ends_at = `${date}T17:00`; + payload.days = 1.0; + } else if (payload.duration_type === 'am') { + payload.starts_at = `${date}T09:00`; + payload.ends_at = `${date}T13:00`; + payload.days = 0.5; + } else if (payload.duration_type === 'pm') { + payload.starts_at = `${date}T13:00`; + payload.ends_at = `${date}T17:00`; + payload.days = 0.5; + } } } @@ -157,70 +179,109 @@ const Time_off_requestsNew = () => { initialValues={formInitialValues} onSubmit={(values) => handleSubmit(values)} > -
- + {({ values, errors, touched }) => ( + + - {/* Requester - Only visible to Admin */} - {isAdmin && ( - - + {/* Requester - Only visible to Admin */} + {isAdmin && ( + + + + )} + + + + + + + + - )} - - - - - - - - + {/* Duration Type Radio */} + + + + + + + + + + - {/* Date Requested */} - - - + {/* Conditional Fields based on is_multiple */} + {values.is_multiple === 'multiple' ? ( +
+
+ + + + + + +
+
+ + Working days will be calculated automatically based on your schedule and holidays. +
+
+ ) : ( +
+
+ + + + + + + + + + +
+
+ )} - {/* Duration Select */} - - - - - - - + {/* Hidden Fields for Backend */} +
+ + + +
- {/* Hidden Fields for Backend */} -
- - - -
+ + + - - - - - - - - - router.push('/time_off_requests/time_off_requests-list')}/> - - + + + + + router.push('/time_off_requests/time_off_requests-list')}/> + + + )}