Autosave: 20260217-124320

This commit is contained in:
Flatlogic Bot 2026-02-17 12:43:21 +00:00
parent 311eb8b6ee
commit 39c16de175
18 changed files with 763 additions and 231 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 && (

View File

@ -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 (
},
},
];
};
};

View File

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

View File

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

View File

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

View File

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