From 39c16de1757d891e30f983f1df04eadaacd6bce0 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Tue, 17 Feb 2026 12:43:21 +0000 Subject: [PATCH] Autosave: 20260217-124320 --- backend/src/db/api/approval_tasks.js | 50 +++-- backend/src/db/api/time_off_requests.js | 27 ++- .../db/models/user_notification_recipients.js | 34 ++++ backend/src/middlewares/check-permissions.js | 7 +- backend/src/services/pto_journal_entries.js | 71 ++++--- backend/src/services/time_off_requests.js | 118 ++++++++++- .../src/services/yearly_leave_summaries.js | 133 ++++++++++++- .../src/components/ListActionsPopover.tsx | 20 +- frontend/src/components/PTOStats.tsx | 16 +- frontend/src/components/SelectField.tsx | 21 +- frontend/src/components/SelectFieldMany.tsx | 47 +++-- frontend/src/components/StaffOffList.tsx | 9 +- .../TableTime_off_requests.tsx | 33 ++++ .../configureTime_off_requestsCols.tsx | 7 +- frontend/src/pages/dashboard.tsx | 185 +++++++++++------ frontend/src/pages/index.tsx | 187 +++++++++++------- .../time_off_requests-list.tsx | 11 +- frontend/src/pages/users/users-edit.tsx | 18 +- 18 files changed, 763 insertions(+), 231 deletions(-) create mode 100644 backend/src/db/models/user_notification_recipients.js diff --git a/backend/src/db/api/approval_tasks.js b/backend/src/db/api/approval_tasks.js index eaddaa7..444fadd 100644 --- a/backend/src/db/api/approval_tasks.js +++ b/backend/src/db/api/approval_tasks.js @@ -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 { } -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/time_off_requests.js b/backend/src/db/api/time_off_requests.js index 1b4f283..1d803c9 100644 --- a/backend/src/db/api/time_off_requests.js +++ b/backend/src/db/api/time_off_requests.js @@ -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 { }; - diff --git a/backend/src/db/models/user_notification_recipients.js b/backend/src/db/models/user_notification_recipients.js new file mode 100644 index 0000000..a07333a --- /dev/null +++ b/backend/src/db/models/user_notification_recipients.js @@ -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; +}; diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js index 77740c7..3c328f6 100644 --- a/backend/src/middlewares/check-permissions.js +++ b/backend/src/middlewares/check-permissions.js @@ -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, -}; - +}; \ No newline at end of file diff --git a/backend/src/services/pto_journal_entries.js b/backend/src/services/pto_journal_entries.js index 1aaa756..66e333e 100644 --- a/backend/src/services/pto_journal_entries.js +++ b/backend/src/services/pto_journal_entries.js @@ -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 { } -}; \ No newline at end of file +}; diff --git a/backend/src/services/time_off_requests.js b/backend/src/services/time_off_requests.js index 5de2749..974088c 100644 --- a/backend/src/services/time_off_requests.js +++ b/backend/src/services/time_off_requests.js @@ -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; diff --git a/backend/src/services/yearly_leave_summaries.js b/backend/src/services/yearly_leave_summaries.js index bee51cb..6e3a335 100644 --- a/backend/src/services/yearly_leave_summaries.js +++ b/backend/src/services/yearly_leave_summaries.js @@ -6,7 +6,7 @@ const csv = require('csv-parser'); const axios = require('axios'); const config = require('../config'); const stream = require('stream'); - +const moment = require('moment'); // Import moment @@ -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. + } + } +}; \ No newline at end of file diff --git a/frontend/src/components/ListActionsPopover.tsx b/frontend/src/components/ListActionsPopover.tsx index 0f93245..f48d5ea 100644 --- a/frontend/src/components/ListActionsPopover.tsx +++ b/frontend/src/components/ListActionsPopover.tsx @@ -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 )} + {showCancel && onCancel && ( + + )} {hasUpdatePermission && (