Autosave: 20260217-150839

This commit is contained in:
Flatlogic Bot 2026-02-17 15:08:39 +00:00
parent 39c16de175
commit c1f17908e1
20 changed files with 963 additions and 455 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -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 {
}
};
};

View File

@ -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 = {};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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;
};
};

View File

@ -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;
};
};

View File

@ -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;
module.exports = app;

View File

@ -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 },

View File

@ -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,

View File

@ -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;
}
}
};

View File

@ -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) {

View File

@ -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);
}
}
};

View File

@ -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 (
<div className='h-[600px] p-4'>
<div className='h-[700px] p-4'>
<Calendar
defaultDate={defaultDate}
defaultView={Views.MONTH}
@ -125,6 +180,7 @@ const BigCalendar = ({
onSelectSlot={handleCreateEventAction}
onRangeChange={onRangeChange}
scrollToTime={scrollToTime}
eventPropGetter={eventPropGetter}
components={{
event: (props) => (
<MyCustomEvent
@ -152,22 +208,24 @@ const MyCustomEvent = (
const { onDelete, hasUpdatePermission, title, event, pathEdit, pathView } = props;
return (
<div className={'flex items-center justify-between relative'}>
<div className={'flex items-center justify-between relative h-full px-1 py-0.5 text-xs'}>
<Link
href={`${pathView}${event.id}`}
className={'text-ellipsis overflow-hidden grow'}
className={'text-ellipsis overflow-hidden grow font-semibold'}
>
{title}
</Link>
<ListActionsPopover
className={'w-2 h-2 text-white'}
iconClassName={'text-white w-5'}
itemId={event.id}
onDelete={onDelete}
pathEdit={`${pathEdit}${event.id}`}
pathView={`${pathView}${event.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
<div className="ml-1 shrink-0">
<ListActionsPopover
className={'w-3 h-3 text-white opacity-70 hover:opacity-100'}
iconClassName={'text-white w-3'}
itemId={event.id}
onDelete={onDelete}
pathEdit={`${pathEdit}${event.id}`}
pathView={`${pathView}${event.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
);
};

View File

@ -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 = (
<BaseButtons>
<BaseButton label={buttonLabel} color={buttonColor} onClick={onConfirm} />
{onDecline && (
<BaseButton
label={declineButtonLabel || 'Decline'}
color={declineButtonColor || 'danger'}
onClick={onDecline}
/>
)}
{!!onCancel && <BaseButton label="Cancel" color={buttonColor} outline onClick={onCancel} />}
</BaseButtons>
)
@ -56,4 +69,4 @@ const CardBoxModal = ({
)
}
export default CardBoxModal
export default CardBoxModal

View File

@ -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 (
<div className="grid grid-cols-1 gap-6 md:grid-cols-3 lg:grid-cols-5 mb-6">
{stats.map((stat, index) => {
const content = (
<div className="flex items-center justify-between h-full">
<div>
<p className="text-gray-500 dark:text-slate-400 text-sm">{stat.label}</p>
<p className="text-2xl font-bold">{stat.value} Days</p>
</div>
<BaseIcon path={stat.icon} size={48} className={stat.color} />
const renderStat = (stat, index) => {
const content = (
<div className="flex items-center justify-between h-full">
<div>
<p className="text-gray-500 dark:text-slate-400 text-sm whitespace-nowrap">{stat.label}</p>
<p className="text-xl font-bold">{stat.value} Days</p>
</div>
);
if (stat.href) {
return (
<Link key={index} href={stat.href} className="block hover:opacity-80 transition-opacity">
<CardBox className="h-full">
{content}
</CardBox>
</Link>
)
}
<BaseIcon path={stat.icon} size={24} className={stat.color} />
</div>
);
if (stat.href) {
return (
<CardBox key={index} className="h-full">
{content}
</CardBox>
<Link key={index} href={stat.href} className="block hover:opacity-80 transition-opacity">
<CardBox className="h-full p-4">
{content}
</CardBox>
</Link>
)
})}
</div>
}
return (
<CardBox key={index} className="h-full p-4">
{content}
</CardBox>
)
}
return (
<>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4 mb-6">
{line1.map(renderStat)}
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3 mb-6">
{line2.map(renderStat)}
</div>
</>
)
}
export default PTOStats
export default PTOStats

View File

@ -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)}
/>
<BaseButton
color="danger"
label="Decline"
small
onClick={() => handleReject(task.id)}
/>
</td>
</tr>
))
@ -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 && (
<div className="space-y-3">

View File

@ -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 = () => {
<thead>
<tr className="border-b dark:border-dark-700 bg-gray-50 dark:bg-dark-900">
<th className="p-4">Employee Name</th>
<th className="p-4">Pending</th>
<th className="p-4">Scheduled</th>
<th className="p-4">Pending PTO</th>
<th className="p-4">Scheduled PTO</th>
<th className="p-4">PTO Taken</th>
<th className="p-4">Available</th>
<th className="p-4">Available PTO</th>
<th className="p-4">Medical Taken</th>
<th className="p-4">Medical Scheduled</th>
<th className="p-4 text-center">Bereavement</th>
</tr>
</thead>
<tbody>
{summaries.length > 0 ? (
summaries.map((summary) => (
<tr key={summary.id} className="border-b dark:border-dark-700 hover:bg-gray-50 dark:hover:bg-dark-800 transition-colors">
<td className="p-4 font-medium">
<tr key={summary.id} className="border-b dark:border-dark-700 hover:bg-gray-50 dark:hover:bg-dark-800 transition-colors text-xs lg:text-sm">
<td className="p-4 font-medium whitespace-nowrap">
{summary.user?.firstName} {summary.user?.lastName}
</td>
<td className="p-4">{summary.pto_pending_days || 0}</td>
@ -98,11 +98,17 @@ const EmployeeSummary = () => {
<td className="p-4 text-red-600 dark:text-red-400">
{summary.medical_taken_days || 0}
</td>
<td className="p-4 text-orange-600 dark:text-orange-400">
{summary.medical_scheduled_days || 0}
</td>
<td className="p-4 text-center text-gray-500">
{summary.bereavement_taken_days || 0}
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-500">
<td colSpan={8} className="p-8 text-center text-gray-500">
No summaries found for {selectedYear}
</td>
</tr>

View File

@ -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)}
/>
<BaseButton
color="danger"
label="Decline"
small
onClick={() => handleReject(task.id)}
/>
</td>
</tr>
))
@ -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 && (
<div className="space-y-3">

View File

@ -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<any>();
// 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)}
>
<Form>
<DateDurationLogic />
{({ values, errors, touched }) => (
<Form>
<DateDurationLogic />
{/* Requester - Only visible to Admin */}
{isAdmin && (
<FormField label="Requester" labelFor="requester">
<Field
name="requester"
id="requester"
component={SelectField}
options={currentUser}
itemRef={'users'}
showField="firstName"
></Field>
{/* Requester - Only visible to Admin */}
{isAdmin && (
<FormField label="Requester" labelFor="requester">
<Field
name="requester"
id="requester"
component={SelectField}
options={currentUser}
itemRef={'users'}
showField="firstName"
></Field>
</FormField>
)}
<FormField label="Leave Type" labelFor="leave_type">
<Field name="leave_type" id="leave_type" component="select">
<option value="regular_pto">Time Off Request / PTO Request</option>
<option value="unplanned_pto">Unplanned PTO</option>
<option value="medical_leave">Medical Leave</option>
<option value="bereavement">Bereavement</option>
</Field>
</FormField>
)}
<FormField label="Leave Type" labelFor="leave_type">
<Field name="leave_type" id="leave_type" component="select">
<option value="regular_pto">Time Off Request / PTO Request</option>
<option value="unplanned_pto">Unplanned PTO</option>
<option value="medical_leave">Medical Leave</option>
<option value="bereavement">Bereavement</option>
</Field>
</FormField>
{/* Duration Type Radio */}
<FormField label="Duration">
<FormCheckRadioGroup>
<FormCheckRadio type="radio" label="Single Day">
<Field type="radio" name="is_multiple" value="single" />
</FormCheckRadio>
<FormCheckRadio type="radio" label="Multiple Days">
<Field type="radio" name="is_multiple" value="multiple" />
</FormCheckRadio>
</FormCheckRadioGroup>
</FormField>
{/* Date Requested */}
<FormField
label="Date Requested"
>
<Field
type="date"
name="date_requested"
placeholder="Date Requested"
/>
</FormField>
{/* Conditional Fields based on is_multiple */}
{values.is_multiple === 'multiple' ? (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField label="Start Date">
<Field
type="date"
name="starts_at_date"
className={errors.starts_at_date && touched.starts_at_date ? 'border-red-500' : ''}
/>
</FormField>
<FormField label="End Date">
<Field
type="date"
name="ends_at_date"
className={errors.ends_at_date && touched.ends_at_date ? 'border-red-500' : ''}
/>
</FormField>
</div>
<div className="flex items-center text-sm text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-slate-800 p-3 rounded-lg">
<BaseIcon path={mdiInformation} size={20} className="mr-2" />
<span>Working days will be calculated automatically based on your schedule and holidays.</span>
</div>
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField label="Date Requested">
<Field
type="date"
name="date_requested"
placeholder="Date Requested"
/>
</FormField>
<FormField label="Day Part">
<Field name="duration_type" component="select">
<option value="all_day">All Day</option>
<option value="am">AM (Half Day)</option>
<option value="pm">PM (Half Day)</option>
</Field>
</FormField>
</div>
</div>
)}
{/* Duration Select */}
<FormField label="Duration" labelFor="duration_type">
<Field name="duration_type" id="duration_type" component="select">
<option value="all_day">All Day</option>
<option value="am">AM</option>
<option value="pm">PM</option>
</Field>
</FormField>
{/* Hidden Fields for Backend */}
<div style={{ display: 'none' }}>
<FormField label="StartsAt"><Field type="datetime-local" name="starts_at" /></FormField>
<FormField label="EndsAt"><Field type="datetime-local" name="ends_at" /></FormField>
<FormField label="Days"><Field type="number" name="days" /></FormField>
</div>
{/* Hidden Fields for Backend */}
<div style={{ display: 'none' }}>
<FormField label="StartsAt"><Field type="datetime-local" name="starts_at" /></FormField>
<FormField label="EndsAt"><Field type="datetime-local" name="ends_at" /></FormField>
<FormField label="Days"><Field type="number" name="days" /></FormField>
</div>
<FormField label="Reason" hasTextareaHeight>
<Field name="reason" as="textarea" placeholder="Reason" />
</FormField>
<FormField label="Reason" hasTextareaHeight>
<Field name="reason" as="textarea" placeholder="Reason" />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/time_off_requests/time_off_requests-list')}/>
</BaseButtons>
</Form>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/time_off_requests/time_off_requests-list')}/>
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</SectionMain>