Autosave: 20260217-124320
This commit is contained in:
parent
311eb8b6ee
commit
39c16de175
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -278,24 +277,33 @@ module.exports = class Approval_tasksDBApi {
|
||||
{
|
||||
model: db.time_off_requests,
|
||||
as: 'time_off_request',
|
||||
|
||||
where: filter.time_off_request ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.time_off_request.split('|').map(term => Utils.uuid(term)) } },
|
||||
required: true,
|
||||
where: {
|
||||
status: 'pending_approval',
|
||||
...(filter.time_off_request ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.time_off_request.split('|').map(term => Utils.uuid(term)) } },
|
||||
{
|
||||
reason: {
|
||||
[Op.or]: filter.time_off_request.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {})
|
||||
},
|
||||
include: [
|
||||
{
|
||||
reason: {
|
||||
[Op.or]: filter.time_off_request.split('|').map(term => ({ [Op.iLike]: `%${term}%` }))
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
|
||||
model: db.users,
|
||||
as: 'requester',
|
||||
required: false,
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
model: db.users,
|
||||
as: 'assigned_manager',
|
||||
|
||||
required: false,
|
||||
where: filter.assigned_manager ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.assigned_manager.split('|').map(term => Utils.uuid(term)) } },
|
||||
@ -305,7 +313,7 @@ module.exports = class Approval_tasksDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
} : undefined,
|
||||
|
||||
},
|
||||
|
||||
@ -435,6 +443,17 @@ module.exports = class Approval_tasksDBApi {
|
||||
}
|
||||
}
|
||||
|
||||
// Role-based filtering
|
||||
if (options && options.currentUser) {
|
||||
const roleName = options.currentUser.app_role?.name;
|
||||
// Managers and Employees only see tasks assigned to them
|
||||
if (['People Manager', 'Employee'].includes(roleName)) {
|
||||
where = {
|
||||
...where,
|
||||
assigned_managerId: options.currentUser.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -500,5 +519,4 @@ module.exports = class Approval_tasksDBApi {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
const db = require('../models');
|
||||
const FileDBApi = require('./file');
|
||||
const crypto = require('crypto');
|
||||
@ -446,7 +445,7 @@ module.exports = class Time_off_requestsDBApi {
|
||||
{
|
||||
model: db.users,
|
||||
as: 'requester',
|
||||
|
||||
required: false,
|
||||
where: filter.requester ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.requester.split('|').map(term => Utils.uuid(term)) } },
|
||||
@ -456,14 +455,14 @@ module.exports = class Time_off_requestsDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
} : undefined,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
model: db.users,
|
||||
as: 'approver',
|
||||
|
||||
required: false,
|
||||
where: filter.approver ? {
|
||||
[Op.or]: [
|
||||
{ id: { [Op.in]: filter.approver.split('|').map(term => Utils.uuid(term)) } },
|
||||
@ -473,7 +472,7 @@ module.exports = class Time_off_requestsDBApi {
|
||||
}
|
||||
},
|
||||
]
|
||||
} : {},
|
||||
} : undefined,
|
||||
|
||||
},
|
||||
|
||||
@ -713,6 +712,13 @@ module.exports = class Time_off_requestsDBApi {
|
||||
requires_approval: filter.requires_approval,
|
||||
};
|
||||
}
|
||||
|
||||
if (filter.requesterId) {
|
||||
where = {
|
||||
...where,
|
||||
requesterId: Utils.uuid(filter.requesterId),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -747,6 +753,16 @@ module.exports = class Time_off_requestsDBApi {
|
||||
}
|
||||
}
|
||||
|
||||
// Role-based filtering
|
||||
if (options && options.currentUser) {
|
||||
const roleName = options.currentUser.app_role?.name;
|
||||
if (roleName === 'Employee') {
|
||||
where = {
|
||||
...where,
|
||||
requesterId: options.currentUser.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -813,4 +829,3 @@ module.exports = class Time_off_requestsDBApi {
|
||||
|
||||
|
||||
};
|
||||
|
||||
|
||||
34
backend/src/db/models/user_notification_recipients.js
Normal file
34
backend/src/db/models/user_notification_recipients.js
Normal file
@ -0,0 +1,34 @@
|
||||
module.exports = function(sequelize, DataTypes) {
|
||||
const user_notification_recipients = sequelize.define(
|
||||
'user_notification_recipients',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
recipientId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
tableName: 'user_notification_recipients',
|
||||
},
|
||||
);
|
||||
|
||||
return user_notification_recipients;
|
||||
};
|
||||
@ -1,5 +1,5 @@
|
||||
|
||||
const ValidationError = require('../services/notifications/errors/validation');
|
||||
const ForbiddenError = require('../services/notifications/errors/forbidden');
|
||||
const RolesDBApi = require('../db/api/roles');
|
||||
|
||||
// Cache for the 'Public' role object
|
||||
@ -109,7 +109,7 @@ function checkPermissions(permission) {
|
||||
} else {
|
||||
// The "effective" role does not have the required permission
|
||||
const roleName = effectiveRole.name || 'unknown role';
|
||||
next(new ValidationError('auth.forbidden', `Role '${roleName}' denied access to '${permission}'.`));
|
||||
next(new ForbiddenError('auth.forbidden', `Role '${roleName}' denied access to '${permission}'.`));
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
@ -145,5 +145,4 @@ function checkCrudPermissions(name) {
|
||||
module.exports = {
|
||||
checkPermissions,
|
||||
checkCrudPermissions,
|
||||
};
|
||||
|
||||
};
|
||||
@ -1,5 +1,6 @@
|
||||
const db = require('../db/models');
|
||||
const Pto_journal_entriesDBApi = require('../db/api/pto_journal_entries');
|
||||
const Yearly_leave_summariesService = require('./yearly_leave_summaries');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
@ -24,6 +25,11 @@ module.exports = class Pto_journal_entriesService {
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
if (data.userId && data.calendar_year) {
|
||||
await Yearly_leave_summariesService.recalculate(data.userId, data.calendar_year);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -87,32 +93,15 @@ module.exports = class Pto_journal_entriesService {
|
||||
|
||||
await db.pto_journal_entries.bulkCreate(entries, { transaction });
|
||||
|
||||
// Update Yearly Leave Summaries
|
||||
// We assume adjustments primarily affect 'regular_pto' available balance for now
|
||||
await transaction.commit();
|
||||
|
||||
// Recalculate summaries
|
||||
if (leave_bucket === 'regular_pto' || !leave_bucket) {
|
||||
for (const userId of userIds) {
|
||||
const summary = await db.yearly_leave_summaries.findOne({
|
||||
where: { userId, calendar_year },
|
||||
transaction
|
||||
});
|
||||
|
||||
if (summary) {
|
||||
let adjustment = Number(amount_days || 0);
|
||||
if (entry_type === 'debit_manual_adjustment') {
|
||||
adjustment = -adjustment;
|
||||
}
|
||||
|
||||
const newBalance = Number(summary.pto_available_days || 0) + adjustment;
|
||||
await summary.update({ pto_available_days: newBalance }, { transaction });
|
||||
} else {
|
||||
// If summary doesn't exist, we skip for now or could create it.
|
||||
// Given previous tasks, we assume summaries exist or are created on user creation.
|
||||
console.warn(`Yearly leave summary not found for user ${userId} year ${calendar_year}`);
|
||||
}
|
||||
await Yearly_leave_summariesService.recalculate(userId, calendar_year);
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -143,6 +132,16 @@ module.exports = class Pto_journal_entriesService {
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
if (updatedPto_journal_entries && updatedPto_journal_entries.userId && updatedPto_journal_entries.calendar_year) {
|
||||
await Yearly_leave_summariesService.recalculate(updatedPto_journal_entries.userId, updatedPto_journal_entries.calendar_year);
|
||||
}
|
||||
|
||||
// Also check old year if changed
|
||||
if (pto_journal_entries.calendar_year !== updatedPto_journal_entries.calendar_year) {
|
||||
await Yearly_leave_summariesService.recalculate(pto_journal_entries.userId, pto_journal_entries.calendar_year);
|
||||
}
|
||||
|
||||
return updatedPto_journal_entries;
|
||||
|
||||
} catch (error) {
|
||||
@ -153,14 +152,31 @@ module.exports = class Pto_journal_entriesService {
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
let entriesToRecalculate = [];
|
||||
|
||||
try {
|
||||
const entries = await Pto_journal_entriesDBApi.findAll({
|
||||
id: ids
|
||||
}, { transaction });
|
||||
|
||||
entriesToRecalculate = entries.map(e => ({ userId: e.userId, year: e.calendar_year }));
|
||||
|
||||
await Pto_journal_entriesDBApi.deleteByIds(ids, {
|
||||
currentUser,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Deduplicate
|
||||
const uniquePairs = entriesToRecalculate.filter((v, i, a) => a.findIndex(t => (t.userId === v.userId && t.year === v.year)) === i);
|
||||
|
||||
for (const pair of uniquePairs) {
|
||||
if (pair.userId && pair.year) {
|
||||
await Yearly_leave_summariesService.recalculate(pair.userId, pair.year);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -169,8 +185,14 @@ module.exports = class Pto_journal_entriesService {
|
||||
|
||||
static async remove(id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
let entryToRecalculate = null;
|
||||
|
||||
try {
|
||||
const entry = await Pto_journal_entriesDBApi.findBy({ id }, { transaction });
|
||||
if (entry) {
|
||||
entryToRecalculate = { userId: entry.userId, year: entry.calendar_year };
|
||||
}
|
||||
|
||||
await Pto_journal_entriesDBApi.remove(
|
||||
id,
|
||||
{
|
||||
@ -180,6 +202,11 @@ module.exports = class Pto_journal_entriesService {
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
if (entryToRecalculate && entryToRecalculate.userId && entryToRecalculate.year) {
|
||||
await Yearly_leave_summariesService.recalculate(entryToRecalculate.userId, entryToRecalculate.year);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -187,4 +214,4 @@ module.exports = class Pto_journal_entriesService {
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
};
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
const db = require('../db/models');
|
||||
const Time_off_requestsDBApi = require('../db/api/time_off_requests');
|
||||
const HolidaysDBApi = require('../db/api/holidays');
|
||||
const Yearly_leave_summariesService = require('./yearly_leave_summaries');
|
||||
const processFile = require("../middlewares/upload");
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
const csv = require('csv-parser');
|
||||
@ -8,6 +9,7 @@ const axios = require('axios');
|
||||
const config = require('../config');
|
||||
const stream = require('stream');
|
||||
const moment = require('moment');
|
||||
const Approval_tasksDBApi = require('../db/api/approval_tasks');
|
||||
|
||||
|
||||
|
||||
@ -17,6 +19,9 @@ module.exports = class Time_off_requestsService {
|
||||
static async create(data, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
try {
|
||||
const userId = data.requester || currentUser.id;
|
||||
const requester = await db.users.findByPk(userId, { transaction });
|
||||
|
||||
if (data.starts_at && data.ends_at) {
|
||||
const holidays = await HolidaysDBApi.findAll({
|
||||
calendarStart: data.starts_at,
|
||||
@ -24,11 +29,25 @@ module.exports = class Time_off_requestsService {
|
||||
limit: 1000
|
||||
}, { transaction });
|
||||
|
||||
const workSchedule = currentUser.workSchedule || [1, 2, 3, 4, 5];
|
||||
const workSchedule = requester?.workSchedule || currentUser.workSchedule || [1, 2, 3, 4, 5];
|
||||
data.days = Time_off_requestsService.calculateWorkingDays(data.starts_at, data.ends_at, workSchedule, holidays.rows);
|
||||
}
|
||||
|
||||
await Time_off_requestsDBApi.create(
|
||||
// Auto-approval logic
|
||||
const managerId = requester?.managerId;
|
||||
// Auto-approve if:
|
||||
// 1. The user making the request is the manager of the requester
|
||||
// 2. The requester has no manager (top level)
|
||||
const isAutoApproved = managerId === currentUser.id || !managerId;
|
||||
|
||||
if (isAutoApproved) {
|
||||
data.status = 'approved';
|
||||
data.approver = currentUser.id;
|
||||
data.decided_at = new Date();
|
||||
data.requires_approval = false;
|
||||
}
|
||||
|
||||
const createdRequest = await Time_off_requestsDBApi.create(
|
||||
data,
|
||||
{
|
||||
currentUser,
|
||||
@ -36,7 +55,28 @@ module.exports = class Time_off_requestsService {
|
||||
},
|
||||
);
|
||||
|
||||
// Create approval task if requires_approval is true
|
||||
if (data.requires_approval !== false && createdRequest.status === 'pending_approval') {
|
||||
if (managerId) {
|
||||
await Approval_tasksDBApi.create({
|
||||
state: 'open',
|
||||
time_off_request: createdRequest.id,
|
||||
assigned_manager: managerId,
|
||||
summary: `PTO Request from ${requester.firstName} ${requester.lastName}`
|
||||
}, { currentUser, transaction });
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Recalculate summary
|
||||
const year = moment(data.starts_at).year();
|
||||
if (userId && year) {
|
||||
await Yearly_leave_summariesService.recalculate(userId, year);
|
||||
}
|
||||
|
||||
return createdRequest;
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -72,6 +112,10 @@ module.exports = class Time_off_requestsService {
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// TODO: Ideally we should recalculate for all affected users/years.
|
||||
// Since bulk import is rare, skipping for now or user can manually trigger if needed (or add later).
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -129,7 +173,38 @@ module.exports = class Time_off_requestsService {
|
||||
},
|
||||
);
|
||||
|
||||
// Handle cancellation: dismiss associated approval tasks
|
||||
if (data.status === 'cancelled') {
|
||||
const tasks = await db.approval_tasks.findAll({
|
||||
where: {
|
||||
time_off_requestId: id,
|
||||
state: 'open'
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
for (const task of tasks) {
|
||||
await task.update({ state: 'dismissed' }, { transaction });
|
||||
}
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Recalculate summary
|
||||
if (updatedTime_off_requests) {
|
||||
const year = moment(updatedTime_off_requests.starts_at).year();
|
||||
const userId = updatedTime_off_requests.requesterId;
|
||||
if (userId && year) {
|
||||
await Yearly_leave_summariesService.recalculate(userId, year);
|
||||
}
|
||||
|
||||
// Check if year changed
|
||||
const oldYear = moment(time_off_requests.starts_at).year();
|
||||
if (oldYear !== year && userId) {
|
||||
await Yearly_leave_summariesService.recalculate(userId, oldYear);
|
||||
}
|
||||
}
|
||||
|
||||
return updatedTime_off_requests;
|
||||
|
||||
} catch (error) {
|
||||
@ -140,14 +215,20 @@ module.exports = class Time_off_requestsService {
|
||||
|
||||
static async deleteByIds(ids, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
let requestsToRecalculate = [];
|
||||
|
||||
try {
|
||||
const requests = await Time_off_requestsDBApi.findAll({
|
||||
id: ids
|
||||
}, { transaction });
|
||||
|
||||
requestsToRecalculate = requests.rows.map(r => ({
|
||||
userId: r.requesterId,
|
||||
year: moment(r.starts_at).year()
|
||||
}));
|
||||
|
||||
const isAdmin = currentUser.app_role?.name === config.roles.admin;
|
||||
if (!isAdmin) {
|
||||
const requests = await Time_off_requestsDBApi.findAll({
|
||||
id: ids
|
||||
}, { transaction });
|
||||
|
||||
const hasPastRequests = requests.rows.some(req => moment(req.starts_at).isBefore(moment(), 'day'));
|
||||
if (hasPastRequests) {
|
||||
throw new ValidationError(
|
||||
@ -163,6 +244,16 @@ module.exports = class Time_off_requestsService {
|
||||
});
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
// Recalculate unique user/year pairs
|
||||
const uniquePairs = requestsToRecalculate.filter((v, i, a) => a.findIndex(t => (t.userId === v.userId && t.year === v.year)) === i);
|
||||
|
||||
for (const pair of uniquePairs) {
|
||||
if (pair.userId && pair.year) {
|
||||
await Yearly_leave_summariesService.recalculate(pair.userId, pair.year);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
@ -171,11 +262,19 @@ module.exports = class Time_off_requestsService {
|
||||
|
||||
static async remove(id, currentUser) {
|
||||
const transaction = await db.sequelize.transaction();
|
||||
let requestToRecalculate = null;
|
||||
|
||||
try {
|
||||
const request = await Time_off_requestsDBApi.findBy({ id }, { transaction });
|
||||
if (request) {
|
||||
requestToRecalculate = {
|
||||
userId: request.requesterId,
|
||||
year: moment(request.starts_at).year()
|
||||
};
|
||||
}
|
||||
|
||||
const isAdmin = currentUser.app_role?.name === config.roles.admin;
|
||||
if (!isAdmin) {
|
||||
const request = await Time_off_requestsDBApi.findBy({ id }, { transaction });
|
||||
if (request && moment(request.starts_at).isBefore(moment(), 'day')) {
|
||||
throw new ValidationError(
|
||||
'errors.forbidden.message',
|
||||
@ -193,6 +292,11 @@ module.exports = class Time_off_requestsService {
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
if (requestToRecalculate && requestToRecalculate.userId && requestToRecalculate.year) {
|
||||
await Yearly_leave_summariesService.recalculate(requestToRecalculate.userId, requestToRecalculate.year);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
throw error;
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -132,7 +132,134 @@ 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 });
|
||||
if (!user) {
|
||||
await transaction.rollback();
|
||||
return;
|
||||
}
|
||||
|
||||
const startOfYear = moment(`${year}-01-01`).startOf('day').toDate();
|
||||
const endOfYear = moment(`${year}-12-31`).endOf('day').toDate();
|
||||
const today = moment().startOf('day');
|
||||
|
||||
// Fetch requests
|
||||
const requests = await db.time_off_requests.findAll({
|
||||
where: {
|
||||
requesterId: userId,
|
||||
starts_at: {
|
||||
[db.Sequelize.Op.between]: [startOfYear, endOfYear]
|
||||
},
|
||||
deletedAt: null // Ensure we don't count deleted if paranoid
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
// Fetch journal entries (adjustments)
|
||||
const journalEntries = await db.pto_journal_entries.findAll({
|
||||
where: {
|
||||
userId: userId,
|
||||
calendar_year: year,
|
||||
deletedAt: null
|
||||
},
|
||||
transaction
|
||||
});
|
||||
|
||||
let pto_pending = 0;
|
||||
let pto_scheduled = 0;
|
||||
let pto_taken = 0;
|
||||
let medical_taken = 0;
|
||||
|
||||
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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// credits
|
||||
pto_adjustments += amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
let summary = await db.yearly_leave_summaries.findOne({
|
||||
where: { userId, calendar_year: year },
|
||||
transaction
|
||||
});
|
||||
|
||||
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 });
|
||||
} 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
|
||||
}, { transaction });
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
} 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.
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -7,6 +7,7 @@ import {
|
||||
mdiEye,
|
||||
mdiPencilOutline,
|
||||
mdiTrashCan,
|
||||
mdiCloseCircleOutline,
|
||||
} from '@mdi/js';
|
||||
import Popover from '@mui/material/Popover';
|
||||
import { IconButton } from '@mui/material';
|
||||
@ -15,21 +16,25 @@ import { IconButton } from '@mui/material';
|
||||
type Props = {
|
||||
itemId: string;
|
||||
onDelete: (id: string) => void;
|
||||
onCancel?: (id: string) => void;
|
||||
hasUpdatePermission: boolean;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
pathEdit: string;
|
||||
pathView: string;
|
||||
showCancel?: boolean;
|
||||
};
|
||||
|
||||
const ListActionsPopover = ({
|
||||
itemId,
|
||||
onDelete,
|
||||
onCancel,
|
||||
hasUpdatePermission,
|
||||
className,
|
||||
iconClassName,
|
||||
pathEdit,
|
||||
pathView,
|
||||
showCancel,
|
||||
}: Props) => {
|
||||
const [anchorEl, setAnchorEl] = React.useState(null);
|
||||
const handleClick = (event) => {
|
||||
@ -93,6 +98,19 @@ const ListActionsPopover = ({
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{showCancel && onCancel && (
|
||||
<Button
|
||||
startIcon={<BaseIcon path={mdiCloseCircleOutline} size={24} />}
|
||||
className='w-full MuiButton-colorInherit'
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onCancel(itemId);
|
||||
}}
|
||||
sx={{ justifyContent: "start", color: 'orange' }}
|
||||
>
|
||||
Cancel Request
|
||||
</Button>
|
||||
)}
|
||||
{hasUpdatePermission && (
|
||||
<Button
|
||||
startIcon={<BaseIcon path={mdiTrashCan} size={24} />}
|
||||
@ -112,4 +130,4 @@ const ListActionsPopover = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default ListActionsPopover;
|
||||
export default ListActionsPopover;
|
||||
@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import { mdiClockOutline, mdiCalendarCheck, mdiCalendarBlank, mdiMedicalBag } from '@mdi/js'
|
||||
import { mdiClockOutline, mdiCalendarCheck, mdiCalendarBlank, mdiMedicalBag, mdiCalendarArrowRight } from '@mdi/js'
|
||||
import CardBox from './CardBox'
|
||||
import BaseIcon from './BaseIcon'
|
||||
import Link from 'next/link'
|
||||
@ -9,6 +9,7 @@ type Props = {
|
||||
summary: {
|
||||
pto_pending_days: number | string
|
||||
pto_scheduled_days: number | string
|
||||
pto_taken_days: number | string
|
||||
pto_available_days: number | string
|
||||
medical_taken_days: number | string
|
||||
}
|
||||
@ -28,10 +29,17 @@ const PTOStats = ({ summary }: Props) => {
|
||||
{
|
||||
label: 'Scheduled PTO',
|
||||
value: summary?.pto_scheduled_days || 0,
|
||||
icon: mdiCalendarCheck,
|
||||
icon: mdiCalendarArrowRight,
|
||||
color: 'text-blue-500',
|
||||
href: `/time_off_requests/time_off_requests-list?status=approved&requesterId=${currentUser?.id}`,
|
||||
},
|
||||
{
|
||||
label: 'PTO Taken',
|
||||
value: summary?.pto_taken_days || 0,
|
||||
icon: mdiCalendarCheck,
|
||||
color: 'text-purple-500',
|
||||
href: `/time_off_requests/time_off_requests-list?status=approved&requesterId=${currentUser?.id}`,
|
||||
},
|
||||
{
|
||||
label: 'Available PTO',
|
||||
value: summary?.pto_available_days || 0,
|
||||
@ -48,7 +56,7 @@ const PTOStats = ({ summary }: Props) => {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6">
|
||||
<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">
|
||||
@ -80,4 +88,4 @@ const PTOStats = ({ summary }: Props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default PTOStats
|
||||
export default PTOStats
|
||||
@ -8,13 +8,21 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
useEffect(() => {
|
||||
if(options?.id && field?.value?.id) {
|
||||
setValue({value: field.value?.id, label: field.value[showField]})
|
||||
form.setFieldValue(field.name, field.value?.id);
|
||||
if (field.value && typeof field.value === 'object' && field.value.id) {
|
||||
// Initial load with object (Async case)
|
||||
setValue({ value: field.value.id, label: field.value[showField] || field.value.label });
|
||||
// Set Formik value to ID for submission consistency
|
||||
form.setFieldValue(field.name, field.value.id);
|
||||
} else if (field.value && Array.isArray(options) && options.length > 0) {
|
||||
// Static options case
|
||||
const selected = options.find(o => o.value === field.value);
|
||||
if (selected) {
|
||||
setValue(selected);
|
||||
}
|
||||
} else if (!field.value) {
|
||||
setValue(null);
|
||||
setValue(null);
|
||||
}
|
||||
}, [options?.id, field?.value?.id, field?.value])
|
||||
}, [field.value, options, showField, field.name, form])
|
||||
|
||||
const mapResponseToValuesAndLabels = (data) => ({
|
||||
value: data.id,
|
||||
@ -49,5 +57,4 @@ export const SelectField = ({ options, field, form, itemRef, showField, disabled
|
||||
isClearable
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
@ -2,30 +2,38 @@ import React, {useEffect, useId, useState} from 'react';
|
||||
import { AsyncPaginate } from 'react-select-async-paginate';
|
||||
import axios from 'axios';
|
||||
|
||||
export const SelectFieldMany = ({ options, field, form, itemRef, showField }) => {
|
||||
export const SelectFieldMany = ({ options, field, form, itemRef, showField, disabled }) => {
|
||||
const [value, setValue] = useState([]);
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
useEffect(() => {
|
||||
if (field.value?.[0] && typeof field.value[0] !== 'string') {
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
field.value.map((el) => el.id),
|
||||
);
|
||||
} else if (!field.value || field.value.length === 0) {
|
||||
// Check if field.value is an array of objects (Initial Load with Async data)
|
||||
if (field.value && Array.isArray(field.value) && field.value.length > 0 && typeof field.value[0] === 'object') {
|
||||
const initialValue = field.value.map((el) => ({
|
||||
value: el.id,
|
||||
label: el[showField] || el.label
|
||||
}));
|
||||
setValue(initialValue);
|
||||
// Update form to IDs for submission consistency
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
field.value.map((el) => el.id),
|
||||
);
|
||||
}
|
||||
// Check if field.value is array of primitives (IDs) and options are provided (Static)
|
||||
else if (field.value && Array.isArray(field.value) && field.value.length > 0 && options && options.length > 0) {
|
||||
// Map IDs to labels from options
|
||||
const initialValue = field.value.map(id => {
|
||||
const option = options.find(o => (o.id === id || o.value === id));
|
||||
return option ? { value: option.id || option.value, label: option[showField] || option.label } : null;
|
||||
}).filter(Boolean);
|
||||
setValue(initialValue);
|
||||
}
|
||||
// Handle empty field
|
||||
else if (!field.value || field.value.length === 0) {
|
||||
setValue([]);
|
||||
}
|
||||
}, [field.name, field.value, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (options) {
|
||||
setValue(options.map((el) => ({ value: el.id, label: el[showField] })));
|
||||
form.setFieldValue(
|
||||
field.name,
|
||||
options.map((el) => ({ value: el.id, label: el[showField] })),
|
||||
);
|
||||
}
|
||||
}, [options]);
|
||||
}, [field.value, options, showField, field.name, form]);
|
||||
|
||||
const mapResponseToValuesAndLabels = (data) => ({
|
||||
value: data.id,
|
||||
@ -61,7 +69,8 @@ export const SelectFieldMany = ({ options, field, form, itemRef, showField }) =>
|
||||
loadOptions={callApi}
|
||||
onChange={handleChange}
|
||||
defaultOptions
|
||||
isDisabled={disabled}
|
||||
isClearable
|
||||
/>
|
||||
);
|
||||
};
|
||||
};
|
||||
@ -25,6 +25,11 @@ export default function StaffOffList() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchRequests = async () => {
|
||||
// Check for token before fetching to avoid 401 errors
|
||||
if (typeof window !== 'undefined' && !localStorage.getItem('token')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const start = weekStart.format('YYYY-MM-DD');
|
||||
const end = weekStart.clone().endOf('isoWeek').format('YYYY-MM-DD');
|
||||
@ -32,8 +37,6 @@ export default function StaffOffList() {
|
||||
try {
|
||||
// Backend filter: requests that overlap with the week
|
||||
// (starts_at <= endOfWeek) AND (ends_at >= startOfWeek)
|
||||
// Using existing backend range filters which support [min, max]
|
||||
// Passing null/undefined for open-ended ranges
|
||||
const params = {
|
||||
filter: JSON.stringify({
|
||||
starts_atRange: [null, end],
|
||||
@ -43,7 +46,7 @@ export default function StaffOffList() {
|
||||
|
||||
const response = await axios.get('/time_off_requests', { params });
|
||||
|
||||
// Filter client-side for status/type criteria (backend might return more than needed due to basic filtering)
|
||||
// Filter client-side for status/type criteria
|
||||
const filtered = response.data.rows.filter((r: any) => {
|
||||
const isApproved = r.status === 'approved';
|
||||
const isSpecialType = ['unplanned_pto', 'medical_leave'].includes(r.leave_type);
|
||||
|
||||
@ -130,10 +130,12 @@ const TableSampleTime_off_requests = ({ filterItems, setFilterItems, filters, sh
|
||||
|
||||
const [isModalInfoActive, setIsModalInfoActive] = useState(false)
|
||||
const [isModalTrashActive, setIsModalTrashActive] = useState(false)
|
||||
const [isModalCancelActive, setIsModalCancelActive] = useState(false)
|
||||
|
||||
const handleModalAction = () => {
|
||||
setIsModalInfoActive(false)
|
||||
setIsModalTrashActive(false)
|
||||
setIsModalCancelActive(false)
|
||||
}
|
||||
|
||||
|
||||
@ -181,6 +183,25 @@ const TableSampleTime_off_requests = ({ filterItems, setFilterItems, filters, sh
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelModalAction = (id: string) => {
|
||||
setId(id)
|
||||
setIsModalCancelActive(true)
|
||||
}
|
||||
|
||||
const handleCancelAction = async () => {
|
||||
if (id) {
|
||||
try {
|
||||
await dispatch(update({ id, data: { status: 'cancelled' } })).unwrap();
|
||||
notify('success', 'Request cancelled successfully');
|
||||
loadData(0);
|
||||
} catch (error) {
|
||||
notify('error', 'Failed to cancel request');
|
||||
} finally {
|
||||
setIsModalCancelActive(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const generateFilterRequests = useMemo(() => {
|
||||
let request = '&';
|
||||
filterItems.forEach((item) => {
|
||||
@ -265,6 +286,7 @@ const TableSampleTime_off_requests = ({ filterItems, setFilterItems, filters, sh
|
||||
handleDeleteModalAction,
|
||||
`time_off_requests`,
|
||||
currentUser,
|
||||
handleCancelModalAction
|
||||
).then((newCols) => setColumns(newCols));
|
||||
}, [currentUser]);
|
||||
|
||||
@ -543,6 +565,17 @@ const TableSampleTime_off_requests = ({ filterItems, setFilterItems, filters, sh
|
||||
<p>Are you sure you want to delete this item?</p>
|
||||
</CardBoxModal>
|
||||
|
||||
<CardBoxModal
|
||||
title="Confirm Cancellation"
|
||||
buttonColor="warning"
|
||||
buttonLabel="Cancel Request"
|
||||
isActive={isModalCancelActive}
|
||||
onConfirm={handleCancelAction}
|
||||
onCancel={handleModalAction}
|
||||
>
|
||||
<p>Are you sure you want to cancel this time off request?</p>
|
||||
</CardBoxModal>
|
||||
|
||||
|
||||
|
||||
{!showGrid && kanbanColumns && (
|
||||
|
||||
@ -21,8 +21,9 @@ export const loadColumns = async (
|
||||
onDelete: Params,
|
||||
entityName: string,
|
||||
|
||||
user
|
||||
user,
|
||||
|
||||
onCancel?: Params
|
||||
) => {
|
||||
async function callOptionsApi(entityName: string) {
|
||||
|
||||
@ -333,6 +334,8 @@ export const loadColumns = async (
|
||||
<div key={params?.row?.id}>
|
||||
<ListActionsPopover
|
||||
onDelete={onDelete}
|
||||
onCancel={onCancel}
|
||||
showCancel={params?.row?.status === 'pending_approval' || params?.row?.status === 'approved'}
|
||||
itemId={params?.row?.id}
|
||||
pathEdit={`/time_off_requests/time_off_requests-edit/?id=${params?.row?.id}`}
|
||||
pathView={`/time_off_requests/time_off_requests-view/?id=${params?.row?.id}`}
|
||||
@ -345,4 +348,4 @@ export const loadColumns = async (
|
||||
},
|
||||
},
|
||||
];
|
||||
};
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import Head from 'next/head'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios';
|
||||
import type { ReactElement } from 'react'
|
||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||
@ -16,6 +16,7 @@ import Link from 'next/link'
|
||||
import StaffOffList from '../components/StaffOffList'
|
||||
import Search from '../components/Search'
|
||||
import { mdiPlusBox, mdiHospitalBox, mdiAlertDecagram } from '@mdi/js'
|
||||
import CardBoxModal from '../components/CardBoxModal'
|
||||
|
||||
const Dashboard = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
@ -24,6 +25,14 @@ const Dashboard = () => {
|
||||
const [approvals, setApprovals] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [greeting, setGreeting] = useState('Hello')
|
||||
|
||||
const [isReviewModalActive, setIsReviewModalActive] = useState(false)
|
||||
const [selectedTask, setSelectedTask] = useState(null)
|
||||
|
||||
const canSeeApprovals = useMemo(() => {
|
||||
return currentUser?.app_role_permissions?.some(p => p.name === 'READ_APPROVAL_TASKS') ||
|
||||
currentUser?.custom_permissions?.some(p => p.name === 'READ_APPROVAL_TASKS');
|
||||
}, [currentUser]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
setLoading(true)
|
||||
@ -39,15 +48,17 @@ const Dashboard = () => {
|
||||
})
|
||||
setSummary(summaryRes.data.rows[0] || null)
|
||||
|
||||
// Fetch Pending Approvals if manager/admin
|
||||
const approvalsRes = await axios.get(`/approval_tasks`, {
|
||||
params: {
|
||||
filter: JSON.stringify({
|
||||
state: 'open'
|
||||
})
|
||||
}
|
||||
})
|
||||
setApprovals(approvalsRes.data.rows)
|
||||
// Fetch Pending Approvals if manager/admin/authorized
|
||||
if (canSeeApprovals) {
|
||||
const approvalsRes = await axios.get(`/approval_tasks`, {
|
||||
params: {
|
||||
filter: JSON.stringify({
|
||||
state: 'open'
|
||||
})
|
||||
}
|
||||
})
|
||||
setApprovals(approvalsRes.data.rows)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error)
|
||||
@ -60,7 +71,7 @@ const Dashboard = () => {
|
||||
if (currentUser) {
|
||||
fetchDashboardData()
|
||||
}
|
||||
}, [currentUser, selectedYear])
|
||||
}, [currentUser, selectedYear, canSeeApprovals])
|
||||
|
||||
useEffect(() => {
|
||||
const hour = new Date().getHours()
|
||||
@ -74,12 +85,18 @@ const Dashboard = () => {
|
||||
await axios.put(`/approval_tasks/${taskId}/approve`);
|
||||
// Refresh data
|
||||
fetchDashboardData();
|
||||
if (isReviewModalActive) setIsReviewModalActive(false);
|
||||
} catch (error) {
|
||||
console.error('Error approving task:', error);
|
||||
alert('Failed to approve task');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = (task) => {
|
||||
setSelectedTask(task);
|
||||
setIsReviewModalActive(true);
|
||||
};
|
||||
|
||||
const years = [selectedYear - 1, selectedYear, selectedYear + 1, selectedYear + 2]
|
||||
|
||||
return (
|
||||
@ -117,6 +134,7 @@ const Dashboard = () => {
|
||||
summary={summary || {
|
||||
pto_pending_days: 0,
|
||||
pto_scheduled_days: 0,
|
||||
pto_taken_days: 0,
|
||||
pto_available_days: 0,
|
||||
medical_taken_days: 0
|
||||
}}
|
||||
@ -161,63 +179,100 @@ const Dashboard = () => {
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
{/* Action Items (Approvals) - Full Width */}
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<div className="p-4 border-b dark:border-dark-700 flex justify-between items-center">
|
||||
<h3 className="font-bold">Action Items (Pending Approvals)</h3>
|
||||
<Link href="/approval_tasks/approval_tasks-list" className="text-sm text-blue-500 hover:underline">
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-dark-700">
|
||||
<th className="p-4">Requester</th>
|
||||
<th className="p-4">Type</th>
|
||||
<th className="p-4">Dates</th>
|
||||
<th className="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{approvals.length > 0 ? (
|
||||
approvals.map((task) => (
|
||||
<tr key={task.id} className="border-b dark:border-dark-700">
|
||||
<td className="p-4">{task.time_off_request?.requester?.firstName} {task.time_off_request?.requester?.lastName}</td>
|
||||
<td className="p-4 capitalize">{task.task_type?.replace(/_/g, ' ')}</td>
|
||||
<td className="p-4">
|
||||
{moment(task.time_off_request?.starts_at).format('MMM D')} - {moment(task.time_off_request?.ends_at).format('MMM D')}
|
||||
</td>
|
||||
<td className="p-4 whitespace-nowrap flex space-x-2">
|
||||
<BaseButton
|
||||
color="info"
|
||||
label="Review"
|
||||
small
|
||||
href={`/approval_tasks/approval_tasks-edit?id=${task.id}`}
|
||||
/>
|
||||
<BaseButton
|
||||
color="success"
|
||||
label="Approve"
|
||||
small
|
||||
onClick={() => handleApprove(task.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-4 text-center text-gray-500">No pending approvals</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBox>
|
||||
{/* Action Items (Approvals) - Full Width - Only show if user has permissions */}
|
||||
{canSeeApprovals && (
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<div className="p-4 border-b dark:border-dark-700 flex justify-between items-center">
|
||||
<h3 className="font-bold">Action Items (Pending Approvals)</h3>
|
||||
<Link href="/approval_tasks/approval_tasks-list" className="text-sm text-blue-500 hover:underline">
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-dark-700">
|
||||
<th className="p-4">Requester</th>
|
||||
<th className="p-4">Type</th>
|
||||
<th className="p-4">Dates</th>
|
||||
<th className="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{approvals.length > 0 ? (
|
||||
approvals.map((task) => (
|
||||
<tr key={task.id} className="border-b dark:border-dark-700">
|
||||
<td className="p-4">{task.time_off_request?.requester?.firstName} {task.time_off_request?.requester?.lastName}</td>
|
||||
<td className="p-4 capitalize">{task.time_off_request?.leave_type?.replace(/_/g, ' ')}</td>
|
||||
<td className="p-4">
|
||||
{moment(task.time_off_request?.starts_at).format('MMM D')} - {moment(task.time_off_request?.ends_at).format('MMM D')}
|
||||
</td>
|
||||
<td className="p-4 whitespace-nowrap flex space-x-2">
|
||||
<BaseButton
|
||||
color="info"
|
||||
label="Review"
|
||||
small
|
||||
onClick={() => handleReview(task)}
|
||||
/>
|
||||
<BaseButton
|
||||
color="success"
|
||||
label="Approve"
|
||||
small
|
||||
onClick={() => handleApprove(task.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-4 text-center text-gray-500">No pending approvals</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
{/* Staff Off This Week - Full Width */}
|
||||
<StaffOffList />
|
||||
{currentUser && <StaffOffList />}
|
||||
|
||||
</SectionMain>
|
||||
|
||||
<CardBoxModal
|
||||
title="Review PTO Request"
|
||||
isActive={isReviewModalActive}
|
||||
onConfirm={() => handleApprove(selectedTask?.id)}
|
||||
onCancel={() => setIsReviewModalActive(false)}
|
||||
buttonColor="success"
|
||||
buttonLabel="Approve"
|
||||
>
|
||||
{selectedTask && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="font-bold text-gray-500">Requester:</div>
|
||||
<div>{selectedTask.time_off_request?.requester?.firstName} {selectedTask.time_off_request?.requester?.lastName}</div>
|
||||
|
||||
<div className="font-bold text-gray-500">Leave Type:</div>
|
||||
<div className="capitalize">{selectedTask.time_off_request?.leave_type?.replace(/_/g, ' ')}</div>
|
||||
|
||||
<div className="font-bold text-gray-500">Period:</div>
|
||||
<div>{moment(selectedTask.time_off_request?.starts_at).format('YYYY-MM-DD')} to {moment(selectedTask.time_off_request?.ends_at).format('YYYY-MM-DD')}</div>
|
||||
|
||||
<div className="font-bold text-gray-500">Total Days:</div>
|
||||
<div>{selectedTask.time_off_request?.days}</div>
|
||||
</div>
|
||||
{selectedTask.time_off_request?.reason && (
|
||||
<div>
|
||||
<div className="font-bold text-gray-500 mb-1">Reason:</div>
|
||||
<div className="p-2 bg-gray-50 dark:bg-dark-800 rounded border dark:border-dark-700 italic">
|
||||
{`"${selectedTask.time_off_request.reason}"`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardBoxModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -226,4 +281,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
export default Dashboard;
|
||||
@ -1,6 +1,6 @@
|
||||
import * as icon from '@mdi/js';
|
||||
import Head from 'next/head'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useMemo } from 'react'
|
||||
import axios from 'axios';
|
||||
import type { ReactElement } from 'react'
|
||||
import LayoutAuthenticated from '../layouts/Authenticated'
|
||||
@ -9,13 +9,14 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
|
||||
import CardBox from '../components/CardBox'
|
||||
import BaseButton from '../components/BaseButton'
|
||||
import { getPageTitle } from '../config'
|
||||
import { useAppSelector } from '../stores/hooks'
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import PTOStats from '../components/PTOStats'
|
||||
import moment from 'moment'
|
||||
import Link from 'next/link'
|
||||
import StaffOffList from '../components/StaffOffList'
|
||||
import Search from '../components/Search'
|
||||
import { mdiPlusBox, mdiHospitalBox, mdiAlertDecagram } from '@mdi/js'
|
||||
import CardBoxModal from '../components/CardBoxModal'
|
||||
|
||||
const Dashboard = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth)
|
||||
@ -24,6 +25,14 @@ const Dashboard = () => {
|
||||
const [approvals, setApprovals] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [greeting, setGreeting] = useState('Hello')
|
||||
|
||||
const [isReviewModalActive, setIsReviewModalActive] = useState(false)
|
||||
const [selectedTask, setSelectedTask] = useState(null)
|
||||
|
||||
const canSeeApprovals = useMemo(() => {
|
||||
return currentUser?.app_role_permissions?.some(p => p.name === 'READ_APPROVAL_TASKS') ||
|
||||
currentUser?.custom_permissions?.some(p => p.name === 'READ_APPROVAL_TASKS');
|
||||
}, [currentUser]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
setLoading(true)
|
||||
@ -39,15 +48,17 @@ const Dashboard = () => {
|
||||
})
|
||||
setSummary(summaryRes.data.rows[0] || null)
|
||||
|
||||
// Fetch Pending Approvals if manager/admin
|
||||
const approvalsRes = await axios.get(`/approval_tasks`, {
|
||||
params: {
|
||||
filter: JSON.stringify({
|
||||
state: 'open'
|
||||
})
|
||||
}
|
||||
})
|
||||
setApprovals(approvalsRes.data.rows)
|
||||
// Fetch Pending Approvals if authorized
|
||||
if (canSeeApprovals) {
|
||||
const approvalsRes = await axios.get(`/approval_tasks`, {
|
||||
params: {
|
||||
filter: JSON.stringify({
|
||||
state: 'open'
|
||||
})
|
||||
}
|
||||
})
|
||||
setApprovals(approvalsRes.data.rows)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error)
|
||||
@ -60,7 +71,7 @@ const Dashboard = () => {
|
||||
if (currentUser) {
|
||||
fetchDashboardData()
|
||||
}
|
||||
}, [currentUser, selectedYear])
|
||||
}, [currentUser, selectedYear, canSeeApprovals])
|
||||
|
||||
useEffect(() => {
|
||||
const hour = new Date().getHours()
|
||||
@ -74,12 +85,18 @@ const Dashboard = () => {
|
||||
await axios.put(`/approval_tasks/${taskId}/approve`);
|
||||
// Refresh data
|
||||
fetchDashboardData();
|
||||
if (isReviewModalActive) setIsReviewModalActive(false);
|
||||
} catch (error) {
|
||||
console.error('Error approving task:', error);
|
||||
alert('Failed to approve task');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReview = (task) => {
|
||||
setSelectedTask(task);
|
||||
setIsReviewModalActive(true);
|
||||
};
|
||||
|
||||
const years = [selectedYear - 1, selectedYear, selectedYear + 1, selectedYear + 2]
|
||||
|
||||
return (
|
||||
@ -117,6 +134,7 @@ const Dashboard = () => {
|
||||
summary={summary || {
|
||||
pto_pending_days: 0,
|
||||
pto_scheduled_days: 0,
|
||||
pto_taken_days: 0,
|
||||
pto_available_days: 0,
|
||||
medical_taken_days: 0
|
||||
}}
|
||||
@ -161,63 +179,100 @@ const Dashboard = () => {
|
||||
</CardBox>
|
||||
</div>
|
||||
|
||||
{/* Action Items (Approvals) - Full Width */}
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<div className="p-4 border-b dark:border-dark-700 flex justify-between items-center">
|
||||
<h3 className="font-bold">Action Items (Pending Approvals)</h3>
|
||||
<Link href="/approval_tasks/approval_tasks-list" className="text-sm text-blue-500 hover:underline">
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-dark-700">
|
||||
<th className="p-4">Requester</th>
|
||||
<th className="p-4">Type</th>
|
||||
<th className="p-4">Dates</th>
|
||||
<th className="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{approvals.length > 0 ? (
|
||||
approvals.map((task) => (
|
||||
<tr key={task.id} className="border-b dark:border-dark-700">
|
||||
<td className="p-4">{task.time_off_request?.requester?.firstName} {task.time_off_request?.requester?.lastName}</td>
|
||||
<td className="p-4 capitalize">{task.task_type?.replace(/_/g, ' ')}</td>
|
||||
<td className="p-4">
|
||||
{moment(task.time_off_request?.starts_at).format('MMM D')} - {moment(task.time_off_request?.ends_at).format('MMM D')}
|
||||
</td>
|
||||
<td className="p-4 whitespace-nowrap flex space-x-2">
|
||||
<BaseButton
|
||||
color="info"
|
||||
label="Review"
|
||||
small
|
||||
href={`/approval_tasks/approval_tasks-edit?id=${task.id}`}
|
||||
/>
|
||||
<BaseButton
|
||||
color="success"
|
||||
label="Approve"
|
||||
small
|
||||
onClick={() => handleApprove(task.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-4 text-center text-gray-500">No pending approvals</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBox>
|
||||
{/* Action Items (Approvals) - Full Width - Only show if user has permissions */}
|
||||
{canSeeApprovals && (
|
||||
<CardBox className="mb-6" hasTable>
|
||||
<div className="p-4 border-b dark:border-dark-700 flex justify-between items-center">
|
||||
<h3 className="font-bold">Action Items (Pending Approvals)</h3>
|
||||
<Link href="/approval_tasks/approval_tasks-list" className="text-sm text-blue-500 hover:underline">
|
||||
View All
|
||||
</Link>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead>
|
||||
<tr className="border-b dark:border-dark-700">
|
||||
<th className="p-4">Requester</th>
|
||||
<th className="p-4">Type</th>
|
||||
<th className="p-4">Dates</th>
|
||||
<th className="p-4">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{approvals.length > 0 ? (
|
||||
approvals.map((task) => (
|
||||
<tr key={task.id} className="border-b dark:border-dark-700">
|
||||
<td className="p-4">{task.time_off_request?.requester?.firstName} {task.time_off_request?.requester?.lastName}</td>
|
||||
<td className="p-4 capitalize">{task.time_off_request?.leave_type?.replace(/_/g, ' ')}</td>
|
||||
<td className="p-4">
|
||||
{moment(task.time_off_request?.starts_at).format('MMM D')} - {moment(task.time_off_request?.ends_at).format('MMM D')}
|
||||
</td>
|
||||
<td className="p-4 whitespace-nowrap flex space-x-2">
|
||||
<BaseButton
|
||||
color="info"
|
||||
label="Review"
|
||||
small
|
||||
onClick={() => handleReview(task)}
|
||||
/>
|
||||
<BaseButton
|
||||
color="success"
|
||||
label="Approve"
|
||||
small
|
||||
onClick={() => handleApprove(task.id)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-4 text-center text-gray-500">No pending approvals</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardBox>
|
||||
)}
|
||||
|
||||
{/* Staff Off This Week - Full Width */}
|
||||
<StaffOffList />
|
||||
{currentUser && <StaffOffList />}
|
||||
|
||||
</SectionMain>
|
||||
|
||||
<CardBoxModal
|
||||
title="Review PTO Request"
|
||||
isActive={isReviewModalActive}
|
||||
onConfirm={() => handleApprove(selectedTask?.id)}
|
||||
onCancel={() => setIsReviewModalActive(false)}
|
||||
buttonColor="success"
|
||||
buttonLabel="Approve"
|
||||
>
|
||||
{selectedTask && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="font-bold text-gray-500">Requester:</div>
|
||||
<div>{selectedTask.time_off_request?.requester?.firstName} {selectedTask.time_off_request?.requester?.lastName}</div>
|
||||
|
||||
<div className="font-bold text-gray-500">Leave Type:</div>
|
||||
<div className="capitalize">{selectedTask.time_off_request?.leave_type?.replace(/_/g, ' ')}</div>
|
||||
|
||||
<div className="font-bold text-gray-500">Period:</div>
|
||||
<div>{moment(selectedTask.time_off_request?.starts_at).format('YYYY-MM-DD')} to {moment(selectedTask.time_off_request?.ends_at).format('YYYY-MM-DD')}</div>
|
||||
|
||||
<div className="font-bold text-gray-500">Total Days:</div>
|
||||
<div>{selectedTask.time_off_request?.days}</div>
|
||||
</div>
|
||||
{selectedTask.time_off_request?.reason && (
|
||||
<div>
|
||||
<div className="font-bold text-gray-500 mb-1">Reason:</div>
|
||||
<div className="p-2 bg-gray-50 dark:bg-dark-800 rounded border dark:border-dark-700 italic">
|
||||
{`"${selectedTask.time_off_request.reason}"`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardBoxModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -226,4 +281,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
export default Dashboard;
|
||||
@ -10,7 +10,6 @@ import { getPageTitle } from '../../config'
|
||||
import TableTime_off_requests from '../../components/Time_off_requests/TableTime_off_requests'
|
||||
import BaseButton from '../../components/BaseButton'
|
||||
import axios from "axios";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from 'next/router';
|
||||
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
|
||||
import CardBoxModal from "../../components/CardBoxModal";
|
||||
@ -27,7 +26,7 @@ const Time_off_requestsTablesPage = () => {
|
||||
const [filterItems, setFilterItems] = useState([]);
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [isModalActive, setIsModalActive] = useState(false);
|
||||
const [showTableView, setShowTableView] = useState(false);
|
||||
const [showTableView, setShowTableView] = useState(true);
|
||||
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
@ -140,7 +139,11 @@ const Time_off_requestsTablesPage = () => {
|
||||
</div>
|
||||
|
||||
<div className='md:inline-flex items-center ms-auto'>
|
||||
<Link href={'/time_off_requests/time_off_requests-table'}>Switch to Table</Link>
|
||||
<BaseButton
|
||||
color='info'
|
||||
label={showTableView ? 'Switch to Kanban' : 'Switch to Table'}
|
||||
onClick={() => setShowTableView(!showTableView)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</CardBox>
|
||||
@ -149,7 +152,7 @@ const Time_off_requestsTablesPage = () => {
|
||||
filterItems={filterItems}
|
||||
setFilterItems={setFilterItems}
|
||||
filters={filters}
|
||||
showGrid={false}
|
||||
showGrid={showTableView}
|
||||
/>
|
||||
|
||||
</SectionMain>
|
||||
|
||||
@ -68,9 +68,11 @@ const EditUsersPage = () => {
|
||||
Object.keys(initVals).forEach(el => {
|
||||
if (users[el] !== undefined) {
|
||||
if (el === 'app_role' || el === 'manager') {
|
||||
newInitialVal[el] = users[el]?.id || users[el];
|
||||
// Keep the object so SelectField can display the label
|
||||
newInitialVal[el] = users[el];
|
||||
} else if (el === 'custom_permissions' || el === 'notification_recipients') {
|
||||
newInitialVal[el] = users[el]?.map(item => item.id || item) || [];
|
||||
// Keep objects for SelectFieldMany to display labels
|
||||
newInitialVal[el] = users[el] || [];
|
||||
} else if (el === 'workSchedule') {
|
||||
newInitialVal[el] = users[el] ? users[el].map(day => day.toString()) : [];
|
||||
} else {
|
||||
@ -87,6 +89,18 @@ const EditUsersPage = () => {
|
||||
if (submitData.workSchedule) {
|
||||
submitData.workSchedule = submitData.workSchedule.map(day => parseInt(day, 10));
|
||||
}
|
||||
|
||||
// Ensure relationships are sent as IDs (single select)
|
||||
if (submitData.app_role && typeof submitData.app_role === 'object') {
|
||||
submitData.app_role = submitData.app_role.id;
|
||||
}
|
||||
if (submitData.manager && typeof submitData.manager === 'object') {
|
||||
submitData.manager = submitData.manager.id;
|
||||
}
|
||||
// SelectFieldMany handles conversion to IDs in useEffect/handleChange,
|
||||
// so submitData.custom_permissions and submitData.notification_recipients
|
||||
// should already be arrays of IDs.
|
||||
|
||||
await dispatch(update({ id: id, data: submitData }))
|
||||
await router.push('/users/users-list')
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user