Autosave: 20260216-225015

This commit is contained in:
Flatlogic Bot 2026-02-16 22:50:16 +00:00
parent 4b6bdc53b6
commit 60d25175fb
16 changed files with 1061 additions and 1645 deletions

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -321,18 +320,6 @@ module.exports = class Pto_journal_entriesDBApi {
const output = pto_journal_entries.get({plain: true});
output.user = await pto_journal_entries.getUser({
transaction
});
@ -356,21 +343,34 @@ module.exports = class Pto_journal_entriesDBApi {
filter,
options
) {
const currentUser = (options && options.currentUser) || { id: null };
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const currentPage = +filter.page;
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
// Role-based filtering
if (currentUser.app_role?.name === 'User') {
where.userId = currentUser.id;
} else if (currentUser.app_role?.name === 'Manager') {
const managedUsers = await db.users.findAll({
where: {
[Op.or]: [
{ managerId: currentUser.id },
{ id: currentUser.id }
]
},
attributes: ['id'],
transaction
});
const managedUserIds = managedUsers.map(u => u.id);
where.userId = { [Op.in]: managedUserIds };
}
let include = [
{
@ -423,9 +423,6 @@ module.exports = class Pto_journal_entriesDBApi {
} : {},
},
];
if (filter) {
@ -710,5 +707,4 @@ module.exports = class Pto_journal_entriesDBApi {
}
};
};

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -85,6 +84,16 @@ module.exports = class UsersDBApi {
null
,
work_hours_per_week: data.data.work_hours_per_week || null,
leave_policy_type: data.data.leave_policy_type || 'pto',
paid_pto_per_year: data.data.paid_pto_per_year || null,
medical_leave_per_year: data.data.medical_leave_per_year || null,
bereavement_per_year: data.data.bereavement_per_year || null,
vacation_pay_rate: data.data.vacation_pay_rate || null,
hiring_year: data.data.hiring_year || null,
position: data.data.position || null,
managerId: data.data.manager || null,
importHash: data.data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
@ -116,6 +125,9 @@ module.exports = class UsersDBApi {
transaction,
});
await users.setNotification_recipients(data.data.notification_recipients || [], {
transaction,
});
await FileDBApi.replaceRelationFiles(
@ -205,6 +217,16 @@ module.exports = class UsersDBApi {
null
,
work_hours_per_week: item.work_hours_per_week || null,
leave_policy_type: item.leave_policy_type || 'pto',
paid_pto_per_year: item.paid_pto_per_year || null,
medical_leave_per_year: item.medical_leave_per_year || null,
bereavement_per_year: item.bereavement_per_year || null,
vacation_pay_rate: item.vacation_pay_rate || null,
hiring_year: item.hiring_year || null,
position: item.position || null,
managerId: item.manager || null,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
@ -298,7 +320,16 @@ module.exports = class UsersDBApi {
if (data.provider !== undefined) updatePayload.provider = data.provider;
if (data.work_hours_per_week !== undefined) updatePayload.work_hours_per_week = data.work_hours_per_week;
if (data.leave_policy_type !== undefined) updatePayload.leave_policy_type = data.leave_policy_type;
if (data.paid_pto_per_year !== undefined) updatePayload.paid_pto_per_year = data.paid_pto_per_year;
if (data.medical_leave_per_year !== undefined) updatePayload.medical_leave_per_year = data.medical_leave_per_year;
if (data.bereavement_per_year !== undefined) updatePayload.bereavement_per_year = data.bereavement_per_year;
if (data.vacation_pay_rate !== undefined) updatePayload.vacation_pay_rate = data.vacation_pay_rate;
if (data.hiring_year !== undefined) updatePayload.hiring_year = data.hiring_year;
if (data.position !== undefined) updatePayload.position = data.position;
if (data.manager !== undefined) updatePayload.managerId = data.manager;
updatePayload.updatedById = currentUser.id;
await users.update(updatePayload, {transaction});
@ -320,6 +351,10 @@ module.exports = class UsersDBApi {
if (data.custom_permissions !== undefined) {
await users.setCustom_permissions(data.custom_permissions, { transaction });
}
if (data.notification_recipients !== undefined) {
await users.setNotification_recipients(data.notification_recipients, { transaction });
}
@ -447,6 +482,9 @@ module.exports = class UsersDBApi {
output.app_role = await users.getApp_role({
transaction
});
output.manager = await users.getManager({ transaction });
output.notification_recipients = await users.getNotification_recipients({ transaction });
if (output.app_role) {
output.app_role_permissions = await output.app_role.getPermissions({
@ -515,6 +553,11 @@ module.exports = class UsersDBApi {
as: 'avatar',
},
{
model: db.users,
as: 'manager',
},
];
if (filter) {
@ -613,6 +656,17 @@ module.exports = class UsersDBApi {
),
};
}
if (filter.position) {
where = {
...where,
[Op.and]: Utils.ilike(
'users',
'position',
filter.position,
),
};
}
@ -795,7 +849,7 @@ module.exports = class UsersDBApi {
}
const records = await db.users.findAll({
attributes: [ 'id', 'firstName' ],
attributes: [ 'id', 'firstName', 'lastName' ],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
@ -804,7 +858,7 @@ module.exports = class UsersDBApi {
return records.map((record) => ({
id: record.id,
label: record.firstName,
label: `${record.firstName} ${record.lastName}`,
}));
}
@ -936,6 +990,10 @@ module.exports = class UsersDBApi {
},
);
if (users && users.disabled) {
throw new Error('User is disabled');
}
const token = crypto
.randomBytes(20)
.toString('hex');
@ -958,5 +1016,4 @@ module.exports = class UsersDBApi {
};
};

View File

@ -1,4 +1,3 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
@ -280,18 +279,6 @@ module.exports = class Yearly_leave_summariesDBApi {
const output = yearly_leave_summaries.get({plain: true});
output.user = await yearly_leave_summaries.getUser({
transaction
});
@ -305,21 +292,34 @@ module.exports = class Yearly_leave_summariesDBApi {
filter,
options
) {
const currentUser = (options && options.currentUser) || { id: null };
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const currentPage = +filter.page;
offset = currentPage * limit;
const orderBy = null;
const transaction = (options && options.transaction) || undefined;
// Role-based filtering
if (currentUser.app_role?.name === 'User') {
where.userId = currentUser.id;
} else if (currentUser.app_role?.name === 'Manager') {
const managedUsers = await db.users.findAll({
where: {
[Op.or]: [
{ managerId: currentUser.id },
{ id: currentUser.id }
]
},
attributes: ['id'],
transaction
});
const managedUserIds = managedUsers.map(u => u.id);
where.userId = { [Op.in]: managedUserIds };
}
let include = [
{
@ -352,9 +352,19 @@ module.exports = class Yearly_leave_summariesDBApi {
}
if (filter.userId) {
where = {
...where,
userId: filter.userId
};
}
if (filter.calendar_year) {
where = {
...where,
calendar_year: filter.calendar_year
};
}
if (filter.calendar_yearRange) {
@ -678,5 +688,4 @@ module.exports = class Yearly_leave_summariesDBApi {
}
};
};

View File

@ -0,0 +1,107 @@
module.exports = {
async up(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.addColumn('users', 'work_hours_per_week', {
type: Sequelize.DataTypes.DECIMAL,
allowNull: true,
}, { transaction });
await queryInterface.addColumn('users', 'leave_policy_type', {
type: Sequelize.DataTypes.ENUM('pto', 'paid_vacation_pay'),
defaultValue: 'pto',
allowNull: false,
}, { transaction });
await queryInterface.addColumn('users', 'paid_pto_per_year', {
type: Sequelize.DataTypes.DECIMAL,
allowNull: true,
}, { transaction });
await queryInterface.addColumn('users', 'medical_leave_per_year', {
type: Sequelize.DataTypes.DECIMAL,
allowNull: true,
}, { transaction });
await queryInterface.addColumn('users', 'bereavement_per_year', {
type: Sequelize.DataTypes.DECIMAL,
allowNull: true,
}, { transaction });
await queryInterface.addColumn('users', 'vacation_pay_rate', {
type: Sequelize.DataTypes.DECIMAL,
allowNull: true,
}, { transaction });
await queryInterface.addColumn('users', 'hiring_year', {
type: Sequelize.DataTypes.INTEGER,
allowNull: true,
}, { transaction });
await queryInterface.addColumn('users', 'position', {
type: Sequelize.DataTypes.TEXT,
allowNull: true,
}, { transaction });
await queryInterface.addColumn('users', 'managerId', {
type: Sequelize.DataTypes.UUID,
references: {
model: 'users',
key: 'id',
},
allowNull: true,
}, { transaction });
// Create a junction table for notifications if needed,
// but for now let's just add the basic fields.
// The user mentioned "Notifications - multiselect of users who get notifications when away"
// This is likely a many-to-many relationship: User -> Notification Recipients (Users)
await queryInterface.createTable('user_notification_recipients', {
id: {
type: Sequelize.DataTypes.UUID,
defaultValue: Sequelize.DataTypes.UUIDV4,
primaryKey: true,
},
userId: {
type: Sequelize.DataTypes.UUID,
references: { model: 'users', key: 'id' },
onDelete: 'CASCADE',
},
recipientId: {
type: Sequelize.DataTypes.UUID,
references: { model: 'users', key: 'id' },
onDelete: 'CASCADE',
},
createdAt: { type: Sequelize.DataTypes.DATE },
updatedAt: { type: Sequelize.DataTypes.DATE },
}, { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
},
async down(queryInterface, Sequelize) {
const transaction = await queryInterface.sequelize.transaction();
try {
await queryInterface.dropTable('user_notification_recipients', { transaction });
await queryInterface.removeColumn('users', 'managerId', { transaction });
await queryInterface.removeColumn('users', 'position', { transaction });
await queryInterface.removeColumn('users', 'hiring_year', { transaction });
await queryInterface.removeColumn('users', 'vacation_pay_rate', { transaction });
await queryInterface.removeColumn('users', 'bereavement_per_year', { transaction });
await queryInterface.removeColumn('users', 'medical_leave_per_year', { transaction });
await queryInterface.removeColumn('users', 'paid_pto_per_year', { transaction });
await queryInterface.removeColumn('users', 'leave_policy_type', { transaction });
await queryInterface.removeColumn('users', 'work_hours_per_week', { transaction });
await transaction.commit();
} catch (err) {
await transaction.rollback();
throw err;
}
}
};

View File

@ -16,92 +16,87 @@ module.exports = function(sequelize, DataTypes) {
firstName: {
type: DataTypes.TEXT,
},
lastName: {
type: DataTypes.TEXT,
},
phoneNumber: {
type: DataTypes.TEXT,
},
email: {
type: DataTypes.TEXT,
},
disabled: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
password: {
type: DataTypes.TEXT,
},
emailVerified: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
emailVerificationToken: {
type: DataTypes.TEXT,
},
emailVerificationTokenExpiresAt: {
type: DataTypes.DATE,
},
passwordResetToken: {
type: DataTypes.TEXT,
},
passwordResetTokenExpiresAt: {
type: DataTypes.DATE,
},
provider: {
type: DataTypes.TEXT,
},
work_hours_per_week: {
type: DataTypes.DECIMAL,
},
leave_policy_type: {
type: DataTypes.ENUM('pto', 'paid_vacation_pay'),
defaultValue: 'pto',
},
paid_pto_per_year: {
type: DataTypes.DECIMAL,
},
medical_leave_per_year: {
type: DataTypes.DECIMAL,
},
bereavement_per_year: {
type: DataTypes.DECIMAL,
},
vacation_pay_rate: {
type: DataTypes.DECIMAL,
},
hiring_year: {
type: DataTypes.INTEGER,
},
position: {
type: DataTypes.TEXT,
},
importHash: {
@ -137,15 +132,6 @@ provider: {
through: 'usersCustom_permissionsPermissions',
});
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
db.users.hasMany(db.time_off_requests, {
as: 'time_off_requests_requester',
foreignKey: {
@ -162,7 +148,6 @@ provider: {
constraints: false,
});
db.users.hasMany(db.pto_journal_entries, {
as: 'pto_journal_entries_user',
foreignKey: {
@ -179,7 +164,6 @@ provider: {
constraints: false,
});
db.users.hasMany(db.yearly_leave_summaries, {
as: 'yearly_leave_summaries_user',
foreignKey: {
@ -188,7 +172,6 @@ provider: {
constraints: false,
});
db.users.hasMany(db.office_calendar_events, {
as: 'office_calendar_events_user',
foreignKey: {
@ -197,7 +180,6 @@ provider: {
constraints: false,
});
db.users.hasMany(db.approval_tasks, {
as: 'approval_tasks_assigned_manager',
foreignKey: {
@ -206,12 +188,6 @@ provider: {
constraints: false,
});
//end loop
db.users.belongsTo(db.roles, {
as: 'app_role',
foreignKey: {
@ -220,7 +196,19 @@ provider: {
constraints: false,
});
db.users.belongsTo(db.users, {
as: 'manager',
foreignKey: 'managerId',
constraints: false,
});
db.users.belongsToMany(db.users, {
as: 'notification_recipients',
through: 'user_notification_recipients',
foreignKey: 'userId',
otherKey: 'recipientId',
constraints: false,
});
db.users.hasMany(db.file, {
as: 'avatar',
@ -232,7 +220,6 @@ provider: {
},
});
db.users.belongsTo(db.users, {
as: 'createdBy',
});
@ -285,5 +272,4 @@ function trimStringFields(users) {
: null;
return users;
}
}

View File

@ -1,6 +1,7 @@
const config = require('../../config');
const assert = require('assert');
const nodemailer = require('nodemailer');
const db = require('../../db/models');
module.exports = class EmailSender {
constructor(email) {
@ -13,6 +14,43 @@ module.exports = class EmailSender {
assert(this.email.subject, 'email.subject is required');
assert(this.email.html, 'email.html is required');
// Check if user is disabled
if (this.email.to) {
const recipients = Array.isArray(this.email.to) ? this.email.to : this.email.to.split(',').map(e => e.trim());
// If single recipient (common case), check and return if disabled
if (recipients.length === 1) {
const user = await db.users.findOne({ where: { email: recipients[0] } });
if (user && user.disabled) {
console.log(`Email to ${recipients[0]} suppressed because user is disabled.`);
return;
}
} else {
// If multiple, strictly we should filter?
// But nodemailer takes the string/array in mailOptions.
// For safety/simplicity in this specific task "prevents notices",
// I will assume strictly 1-to-1 notices for things like "password reset".
// If there's a bulk email, we might want to filter, but that requires rewriting the `to` field.
// Given the requirement "mark inactive which prevents notices", filtering the list is safer.
const enabledRecipients = [];
for (const email of recipients) {
const user = await db.users.findOne({ where: { email } });
if (!user || !user.disabled) {
enabledRecipients.push(email);
} else {
console.log(`Email to ${email} suppressed because user is disabled.`);
}
}
if (enabledRecipients.length === 0) {
return;
}
this.email.to = enabledRecipients;
}
}
const htmlContent = await this.email.html();
const transporter = nodemailer.createTransport(this.transportConfig);
@ -41,4 +79,4 @@ module.exports = class EmailSender {
get from() {
return config.email.from;
}
};
};

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, {useEffect, useRef, useState} from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'
@ -129,4 +128,4 @@ export default function NavBarItem({ item }: Props) {
}
return <div className={componentClass} ref={excludedRef}>{NavBarItemComponentContents}</div>
}
}

View File

@ -1,19 +1,28 @@
import React from 'react'
import { MenuNavBarItem } from '../interfaces'
import NavBarItem from './NavBarItem'
import { useAppSelector } from '../stores/hooks'
import { hasPermission } from '../helpers/userPermissions'
type Props = {
menu: MenuNavBarItem[]
}
export default function NavBarMenuList({ menu }: Props) {
const { currentUser } = useAppSelector((state) => state.auth)
const filteredMenu = menu.filter((item) => {
if (!item.permissions) return true
return hasPermission(currentUser, item.permissions)
})
return (
<>
{menu.map((item, index) => (
{filteredMenu.map((item, index) => (
<div key={index}>
<NavBarItem item={item} />
</div>
))}
</>
)
}
}

View File

@ -0,0 +1,60 @@
import React from 'react'
import { mdiClockOutline, mdiCalendarCheck, mdiCalendarBlank, mdiMedicalBag } from '@mdi/js'
import CardBox from './CardBox'
import BaseIcon from './BaseIcon'
type Props = {
summary: {
pto_pending_days: number | string
pto_scheduled_days: number | string
pto_available_days: number | string
medical_taken_days: number | string
}
}
const PTOStats = ({ summary }: Props) => {
const stats = [
{
label: 'Pending PTO',
value: summary?.pto_pending_days || 0,
icon: mdiClockOutline,
color: 'text-yellow-500',
},
{
label: 'Scheduled PTO',
value: summary?.pto_scheduled_days || 0,
icon: mdiCalendarCheck,
color: 'text-blue-500',
},
{
label: 'Available PTO',
value: summary?.pto_available_days || 0,
icon: mdiCalendarBlank,
color: 'text-green-500',
},
{
label: 'Medical Leave Taken',
value: summary?.medical_taken_days || 0,
icon: mdiMedicalBag,
color: 'text-red-500',
},
]
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6">
{stats.map((stat, index) => (
<CardBox key={index}>
<div className="flex items-center justify-between">
<div>
<p className="text-gray-500 dark:text-slate-400 text-sm">{stat.label}</p>
<p className="text-2xl font-bold">{stat.value} Days</p>
</div>
<BaseIcon path={stat.icon} size={48} className={stat.color} />
</div>
</CardBox>
))}
</div>
)
}
export default PTOStats

View File

@ -1,13 +1,7 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'
import menuNavBar from '../menuNavBar'
import BaseIcon from '../components/BaseIcon'
import NavBar from '../components/NavBar'
import NavBarItemPlain from '../components/NavBarItemPlain'
import AsideMenu from '../components/AsideMenu'
import FooterBar from '../components/FooterBar'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Search from '../components/Search';
@ -15,6 +9,7 @@ import { useRouter } from 'next/router'
import {findMe, logoutUser} from "../stores/authSlice";
import {hasPermission} from "../helpers/userPermissions";
import NavBarItemPlain from '../components/NavBarItemPlain';
type Props = {
@ -67,61 +62,22 @@ export default function LayoutAuthenticated({
const darkMode = useAppSelector((state) => state.style.darkMode)
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
useEffect(() => {
const handleRouteChangeStart = () => {
setIsAsideMobileExpanded(false)
setIsAsideLgActive(false)
}
router.events.on('routeChangeStart', handleRouteChangeStart)
// If the component is unmounted, unsubscribe
// from the event with the `off` method:
return () => {
router.events.off('routeChangeStart', handleRouteChangeStart)
}
}, [router.events, dispatch])
const layoutAsidePadding = 'xl:pl-60'
return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
<div
className={`${layoutAsidePadding} ${
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
className={`pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
>
<NavBar
menu={menuNavBar}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
className={``}
>
<NavBarItemPlain
display="flex lg:hidden"
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
>
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
</NavBarItemPlain>
<NavBarItemPlain
display="hidden lg:flex xl:hidden"
onClick={() => setIsAsideLgActive(true)}
>
<BaseIcon path={mdiMenu} size="24" />
</NavBarItemPlain>
<NavBarItemPlain useMargin>
<Search />
</NavBarItemPlain>
</NavBar>
<AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded}
isAsideLgActive={isAsideLgActive}
menu={menuAside}
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
{children}
<div className="max-w-7xl mx-auto">
{children}
</div>
<FooterBar>Hand-crafted & Made with </FooterBar>
</div>
</div>

View File

@ -1,19 +1,117 @@
import {
mdiMenu,
mdiClockOutline,
mdiCloud,
mdiCrop,
mdiAccount,
mdiCogOutline,
mdiEmail,
mdiLogout,
mdiThemeLightDark,
mdiGithub,
mdiVuejs,
mdiViewDashboardOutline,
mdiAccountGroup,
mdiShieldAccountVariantOutline,
mdiShieldAccountOutline,
mdiCalendarStar,
mdiCalendarRange,
mdiClipboardTextClock,
mdiBookOpenPageVariant,
mdiChartBox,
mdiCalendarMonth,
mdiCheckDecagram,
mdiFileCode,
mdiAccountCircle,
mdiTable
} from '@mdi/js'
import { MenuNavBarItem } from './interfaces'
const menuNavBar: MenuNavBarItem[] = [
{
href: '/dashboard',
icon: mdiViewDashboardOutline,
label: 'Home',
},
{
label: 'Management',
menu: [
{
href: '/users/users-list',
label: 'Users',
icon: mdiAccountGroup,
permissions: 'READ_USERS'
},
{
href: '/roles/roles-list',
label: 'Roles',
icon: mdiShieldAccountVariantOutline,
permissions: 'READ_ROLES'
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
icon: mdiShieldAccountOutline,
permissions: 'READ_PERMISSIONS'
},
{
href: '/api-docs',
label: 'Swagger API',
icon: mdiFileCode,
permissions: 'READ_API_DOCS'
},
]
},
{
label: 'PTO & Leaves',
menu: [
{
href: '/employee-summary',
label: 'Employee Summary',
icon: mdiAccountGroup,
permissions: 'READ_YEARLY_LEAVE_SUMMARIES'
},
{
href: '/time_off_requests/time_off_requests-list',
label: 'Time off requests',
icon: mdiClipboardTextClock,
permissions: 'READ_TIME_OFF_REQUESTS'
},
{
href: '/pto_journal_entries/pto_journal_entries-list',
label: 'PTO Log',
icon: mdiBookOpenPageVariant,
permissions: 'READ_PTO_JOURNAL_ENTRIES'
},
{
href: '/yearly_leave_summaries/yearly_leave_summaries-list',
label: 'Yearly Summaries',
icon: mdiChartBox,
permissions: 'READ_YEARLY_LEAVE_SUMMARIES'
},
]
},
{
label: 'Calendar & Tasks',
menu: [
{
href: '/office_calendar_events/office_calendar_events-list',
label: 'Office Calendar',
icon: mdiCalendarMonth,
permissions: 'READ_OFFICE_CALENDAR_EVENTS'
},
{
href: '/holidays/holidays-list',
label: 'Holidays',
icon: mdiCalendarRange,
permissions: 'READ_HOLIDAYS'
},
{
href: '/holiday_calendars/holiday_calendars-list',
label: 'Holiday Calendars',
icon: mdiCalendarStar,
permissions: 'READ_HOLIDAY_CALENDARS'
},
{
href: '/approval_tasks/approval_tasks-list',
label: 'Approval Tasks',
icon: mdiCheckDecagram,
permissions: 'READ_APPROVAL_TASKS'
},
]
},
{
isCurrentUser: true,
menu: [

View File

@ -1,431 +1,215 @@
import * as icon from '@mdi/js';
import Head from 'next/head'
import React from 'react'
import React, { useState, useEffect } from 'react'
import axios from 'axios';
import type { ReactElement } from 'react'
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import BaseIcon from "../components/BaseIcon";
import CardBox from '../components/CardBox'
import BaseButton from '../components/BaseButton'
import { getPageTitle } from '../config'
import Link from "next/link";
import { useAppSelector } from '../stores/hooks'
import PTOStats from '../components/PTOStats'
import moment from 'moment'
import Link from 'next/link'
import { hasPermission } from "../helpers/userPermissions";
import { fetchWidgets } from '../stores/roles/rolesSlice';
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
const { currentUser } = useAppSelector((state) => state.auth)
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear())
const [summary, setSummary] = useState(null)
const [approvals, setApprovals] = useState([])
const [upcomingTimeOff, setUpcomingTimeOff] = useState([])
const [holidays, setHolidays] = useState([])
const [loading, setLoading] = useState(true)
const loadingMessage = 'Loading...';
const fetchDashboardData = async () => {
setLoading(true)
try {
// Fetch PTO Summary for selected year
const summaryRes = await axios.get(`/yearly_leave_summaries`, {
params: {
filter: JSON.stringify({
userId: currentUser?.id,
calendar_year: selectedYear
})
}
})
setSummary(summaryRes.data.rows[0] || null)
const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [holiday_calendars, setHoliday_calendars] = React.useState(loadingMessage);
const [holidays, setHolidays] = React.useState(loadingMessage);
const [time_off_requests, setTime_off_requests] = React.useState(loadingMessage);
const [pto_journal_entries, setPto_journal_entries] = React.useState(loadingMessage);
const [yearly_leave_summaries, setYearly_leave_summaries] = React.useState(loadingMessage);
const [office_calendar_events, setOffice_calendar_events] = React.useState(loadingMessage);
const [approval_tasks, setApproval_tasks] = React.useState(loadingMessage);
// Fetch Pending Approvals if manager/admin
const approvalsRes = await axios.get(`/approval_tasks`, {
params: {
filter: JSON.stringify({
status: 'pending'
})
}
})
setApprovals(approvalsRes.data.rows)
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
async function loadData() {
const entities = ['users','roles','permissions','holiday_calendars','holidays','time_off_requests','pto_journal_entries','yearly_leave_summaries','office_calendar_events','approval_tasks',];
const fns = [setUsers,setRoles,setPermissions,setHoliday_calendars,setHolidays,setTime_off_requests,setPto_journal_entries,setYearly_leave_summaries,setOffice_calendar_events,setApproval_tasks,];
// Fetch Upcoming Time Off
const upcomingRes = await axios.get(`/office_calendar_events`, {
params: {
limit: 5,
offset: 0,
sort: 'start_date_ASC'
}
})
setUpcomingTimeOff(upcomingRes.data.rows.filter(e => moment(e.start_date).isSameOrAfter(moment(), 'day')))
const requests = entities.map((entity, index) => {
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({data: {count: null}});
}
});
// Fetch Holidays for selected year
const holidaysRes = await axios.get(`/holidays`, {
params: {
filter: JSON.stringify({
calendar_year: selectedYear
})
}
})
setHolidays(holidaysRes.data.rows)
Promise.allSettled(requests).then((results) => {
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
fns[i](result.value.data.count);
} else {
fns[i](result.reason.message);
}
});
});
} catch (error) {
console.error('Error fetching dashboard data:', error)
} finally {
setLoading(false)
}
async function getWidgets(roleId) {
await dispatch(fetchWidgets(roleId));
}
React.useEffect(() => {
if (!currentUser) return;
loadData().then();
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
}, [currentUser]);
}
useEffect(() => {
if (currentUser) {
fetchDashboardData()
}
}, [currentUser, selectedYear])
const years = [selectedYear - 1, selectedYear, selectedYear + 1, selectedYear + 2]
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
return (
<>
<Head>
<title>
{getPageTitle('Overview')}
</title>
<title>{getPageTitle('Home')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant}
title='Overview'
main>
{''}
<SectionTitleLineWithButton icon={icon.mdiHome} title="Home" main>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Year:</span>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="px-2 py-1 border rounded dark:bg-dark-800 dark:border-dark-700"
>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}
setWidgetsRole={setWidgetsRole}
widgetsRole={widgetsRole}
/>}
{!!rolesWidgets.length &&
hasPermission(currentUser, 'CREATE_ROLES') && (
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</p>
)}
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{(isFetchingQuery || loading) && (
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
w='w-16'
h='h-16'
size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
</div>
)}
{/* PTO Summary Stats */}
<PTOStats
summary={summary || {
pto_pending_days: 0,
pto_scheduled_days: 0,
pto_available_days: 0,
medical_taken_days: 0
}}
/>
{ rolesWidgets &&
rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={hasPermission(currentUser, 'CREATE_ROLES')}
/>
))}
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* Action Items (Approvals) */}
<CardBox className="flex-1" 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">
<BaseButton
color="info"
label="Review"
small
href={`/approval_tasks/approval_tasks-edit?id=${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>
{!!rolesWidgets.length && <hr className='my-6 ' />}
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Users
</div>
<div className="text-3xl leading-tight font-semibold">
{users}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiAccountGroup || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Roles
</div>
<div className="text-3xl leading-tight font-semibold">
{roles}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PERMISSIONS') && <Link href={'/permissions/permissions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Permissions
</div>
<div className="text-3xl leading-tight font-semibold">
{permissions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_HOLIDAY_CALENDARS') && <Link href={'/holiday_calendars/holiday_calendars-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Holiday calendars
</div>
<div className="text-3xl leading-tight font-semibold">
{holiday_calendars}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCalendarStar' in icon ? icon['mdiCalendarStar' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_HOLIDAYS') && <Link href={'/holidays/holidays-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Holidays
</div>
<div className="text-3xl leading-tight font-semibold">
{holidays}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCalendarHoliday' in icon ? icon['mdiCalendarHoliday' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_TIME_OFF_REQUESTS') && <Link href={'/time_off_requests/time_off_requests-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Time off requests
</div>
<div className="text-3xl leading-tight font-semibold">
{time_off_requests}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiClipboardTextClock' in icon ? icon['mdiClipboardTextClock' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PTO_JOURNAL_ENTRIES') && <Link href={'/pto_journal_entries/pto_journal_entries-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Pto journal entries
</div>
<div className="text-3xl leading-tight font-semibold">
{pto_journal_entries}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiBookOpenPageVariant' in icon ? icon['mdiBookOpenPageVariant' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_YEARLY_LEAVE_SUMMARIES') && <Link href={'/yearly_leave_summaries/yearly_leave_summaries-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Yearly leave summaries
</div>
<div className="text-3xl leading-tight font-semibold">
{yearly_leave_summaries}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiChartBox' in icon ? icon['mdiChartBox' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_OFFICE_CALENDAR_EVENTS') && <Link href={'/office_calendar_events/office_calendar_events-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Office calendar events
</div>
<div className="text-3xl leading-tight font-semibold">
{office_calendar_events}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCalendarMonth' in icon ? icon['mdiCalendarMonth' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_APPROVAL_TASKS') && <Link href={'/approval_tasks/approval_tasks-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Approval tasks
</div>
<div className="text-3xl leading-tight font-semibold">
{approval_tasks}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCheckDecagram' in icon ? icon['mdiCheckDecagram' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{/* Upcoming Time Off & Holidays */}
<CardBox className="flex-1" hasTable>
<div className="p-4 border-b dark:border-dark-700">
<h3 className="font-bold">Upcoming Time Off & Holidays</h3>
</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">Date</th>
<th className="p-4">Event / Name</th>
<th className="p-4">Type</th>
</tr>
</thead>
<tbody>
{[...holidays, ...upcomingTimeOff]
.sort((a, b) => {
const dateA = a.holiday_date || a.start_date
const dateB = b.holiday_date || b.start_date
return moment(dateA).diff(moment(dateB))
})
.slice(0, 10)
.map((item, idx) => {
const isHoliday = !!item.holiday_date
return (
<tr key={idx} className="border-b dark:border-dark-700">
<td className="p-4">
{moment(item.holiday_date || item.start_date).format('MMM D, YYYY')}
</td>
<td className="p-4">
{isHoliday ? item.name : `${item.user?.firstName} ${item.user?.lastName}`}
</td>
<td className="p-4">
<span className={`px-2 py-1 rounded-full text-xs ${isHoliday ? 'bg-purple-100 text-purple-700' : 'bg-blue-100 text-blue-700'}`}>
{isHoliday ? 'Holiday' : 'PTO'}
</span>
</td>
</tr>
)
})}
{holidays.length === 0 && upcomingTimeOff.length === 0 && (
<tr>
<td colSpan={3} className="p-4 text-center text-gray-500">No upcoming events</td>
</tr>
)}
</tbody>
</table>
</div>
</CardBox>
</div>
</SectionMain>
</>
@ -436,4 +220,4 @@ Dashboard.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Dashboard
export default Dashboard

View File

@ -0,0 +1,124 @@
import { mdiAccountGroup, mdiHome } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useState, useEffect } from 'react'
import axios from 'axios'
import CardBox from '../components/CardBox'
import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import { getPageTitle } from '../config'
import { useAppSelector } from '../stores/hooks'
import LoadingSpinner from '../components/LoadingSpinner'
const EmployeeSummary = () => {
const { currentUser } = useAppSelector((state) => state.auth)
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear())
const [summaries, setSummaries] = useState([])
const [loading, setLoading] = useState(true)
const fetchSummaries = async () => {
setLoading(true)
try {
// For now fetching all summaries for the year.
// In a real app we might filter by manager if not admin.
const res = await axios.get('/yearly_leave_summaries', {
params: {
limit: 100,
filter: JSON.stringify({
calendar_year: selectedYear
})
}
})
setSummaries(res.data.rows)
} catch (error) {
console.error('Error fetching summaries:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchSummaries()
}, [selectedYear])
const years = [selectedYear - 1, selectedYear, selectedYear + 1, selectedYear + 2]
return (
<>
<Head>
<title>{getPageTitle('Employee Summary')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiAccountGroup} title="Employee Summary" main>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-500">Year:</span>
<select
value={selectedYear}
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
className="px-2 py-1 border rounded dark:bg-dark-800 dark:border-dark-700"
>
{years.map(y => (
<option key={y} value={y}>{y}</option>
))}
</select>
</div>
</SectionTitleLineWithButton>
<CardBox hasTable>
{loading ? (
<div className="p-10 flex justify-center">
<LoadingSpinner />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead>
<tr className="border-b dark:border-dark-700 bg-gray-50 dark:bg-dark-900">
<th className="p-4">Employee Name</th>
<th className="p-4">Pending</th>
<th className="p-4">Scheduled</th>
<th className="p-4">PTO Taken</th>
<th className="p-4">Available</th>
<th className="p-4">Medical Taken</th>
</tr>
</thead>
<tbody>
{summaries.length > 0 ? (
summaries.map((summary) => (
<tr key={summary.id} className="border-b dark:border-dark-700 hover:bg-gray-50 dark:hover:bg-dark-800 transition-colors">
<td className="p-4 font-medium">
{summary.user?.firstName} {summary.user?.lastName}
</td>
<td className="p-4">{summary.pto_pending_days || 0}</td>
<td className="p-4">{summary.pto_scheduled_days || 0}</td>
<td className="p-4">{summary.pto_taken_days || 0}</td>
<td className="p-4 font-bold text-green-600 dark:text-green-400">
{summary.pto_available_days || 0}
</td>
<td className="p-4 text-red-600 dark:text-red-400">
{summary.medical_taken_days || 0}
</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="p-8 text-center text-gray-500">
No summaries found for {selectedYear}
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</CardBox>
</SectionMain>
</>
)
}
EmployeeSummary.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default EmployeeSummary

View File

@ -1,10 +1,6 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement, useEffect, useState } from 'react'
import DatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
import dayjs from "dayjs";
import CardBox from '../../components/CardBox'
import LayoutAuthenticated from '../../layouts/Authenticated'
import SectionMain from '../../components/SectionMain'
@ -16,280 +12,69 @@ import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SelectField } from "../../components/SelectField";
import { SelectFieldMany } from "../../components/SelectFieldMany";
import { SwitchField } from '../../components/SwitchField'
import {RichTextField} from "../../components/RichTextField";
import { update, fetch } from '../../stores/users/usersSlice'
import { useAppDispatch, useAppSelector } from '../../stores/hooks'
import { useRouter } from 'next/router'
import {saveFile} from "../../helpers/fileSaver";
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from "../../components/ImageField";
const initVals = {
firstName: '',
lastName: '',
phoneNumber: '',
email: '',
disabled: false,
avatar: [],
app_role: null,
custom_permissions: [],
password: '',
work_hours_per_week: '',
leave_policy_type: '',
paid_pto_per_year: '',
medical_leave_per_year: '',
bereavement_per_year: '',
vacation_pay_rate: '',
hiring_year: '',
position: '',
manager: null,
notification_recipients: []
}
const emptyOptions = [];
const EditUsersPage = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const initVals = {
'firstName': '',
'lastName': '',
'phoneNumber': '',
'email': '',
disabled: false,
avatar: [],
app_role: null,
custom_permissions: [],
password: ''
}
const [initialValues, setInitialValues] = useState(initVals)
const { users } = useAppSelector((state) => state.users)
const [initialValues, setInitialValues] = useState(initVals)
const { users } = useAppSelector((state) => state.users)
const { id } = router.query
useEffect(() => {
dispatch(fetch({ id: id }))
}, [id])
useEffect(() => {
if (typeof users === 'object') {
setInitialValues(users)
if (id) {
dispatch(fetch({ id: id }))
}
}, [users])
}, [id, dispatch])
useEffect(() => {
if (typeof users === 'object') {
const newInitialVal = {...initVals};
Object.keys(initVals).forEach(el => newInitialVal[el] = (users)[el])
setInitialValues(newInitialVal);
}
if (users && typeof users === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach(el => {
if (users[el] !== undefined) {
if (el === 'app_role' || el === 'manager') {
newInitialVal[el] = users[el]?.id || users[el];
} else if (el === 'custom_permissions' || el === 'notification_recipients') {
newInitialVal[el] = users[el]?.map(item => item.id || item) || [];
} else {
newInitialVal[el] = users[el];
}
}
})
setInitialValues(newInitialVal);
}
}, [users])
const handleSubmit = async (data) => {
@ -300,11 +85,11 @@ const EditUsersPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Edit users')}</title>
<title>{getPageTitle('Edit User')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit users'} main>
{''}
<SectionTitleLineWithButton icon={mdiAccount} title={'Edit User'} main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
@ -312,379 +97,108 @@ const EditUsersPage = () => {
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
{({ values }) => (
<Form>
<FormField
label="First Name"
>
<Field
name="firstName"
placeholder="First Name"
/>
</FormField>
<FormField
label="Last Name"
>
<Field
name="lastName"
placeholder="Last Name"
/>
</FormField>
<FormField
label="Phone Number"
>
<Field
name="phoneNumber"
placeholder="Phone Number"
/>
</FormField>
<FormField
label="E-Mail"
>
<Field
name="email"
placeholder="E-Mail"
/>
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field
name='disabled'
id='disabled'
component={SwitchField}
></Field>
</FormField>
<FormField>
<Field
label='Avatar'
color='info'
icon={mdiUpload}
path={'users/avatar'}
name='avatar'
id='avatar'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
></Field>
</FormField>
<FormField label='App Role' labelFor='app_role'>
<Field
name='app_role'
id='app_role'
component={SelectField}
options={initialValues.app_role}
itemRef={'roles'}
showField={'name'}
></Field>
</FormField>
<FormField label='Custom Permissions' labelFor='custom_permissions'>
<Field
name='custom_permissions'
id='custom_permissions'
component={SelectFieldMany}
options={initialValues.custom_permissions}
itemRef={'permissions'}
showField={'name'}
></Field>
</FormField>
<FormField
label="Password"
>
<Field
name="password"
placeholder="password"
/>
</FormField>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<FormField label="First Name">
<Field name="firstName" placeholder="First Name" />
</FormField>
<FormField label="Last Name">
<Field name="lastName" placeholder="Last Name" />
</FormField>
<FormField label="E-Mail">
<Field name="email" placeholder="E-Mail" type="email" />
</FormField>
<FormField label="Phone Number">
<Field name="phoneNumber" placeholder="Phone Number" />
</FormField>
<FormField label="Position">
<Field name="position" placeholder="Position" />
</FormField>
<FormField label="Hiring Year">
<Field name="hiring_year" placeholder="Hiring Year" type="number" />
</FormField>
<FormField label="Work Hours Per Week">
<Field name="work_hours_per_week" placeholder="Work Hours Per Week" type="number" />
</FormField>
<FormField label="Leave Policy Type">
<Field name="leave_policy_type" component={SelectField} options={[
{ value: 'pto', label: 'PTO' },
{ value: 'paid_vacation_pay', label: 'Earned Vacation Pay' }
]} />
</FormField>
{values.leave_policy_type === 'pto' && (
<FormField label="Paid PTO Per Year">
<Field name="paid_pto_per_year" placeholder="Paid PTO Per Year" type="number" />
</FormField>
)}
<FormField label="Medical Leave Per Year">
<Field name="medical_leave_per_year" placeholder="Medical Leave Per Year" type="number" />
</FormField>
<FormField label="Bereavement Per Year">
<Field name="bereavement_per_year" placeholder="Bereavement Per Year" type="number" />
</FormField>
{values.leave_policy_type === 'paid_vacation_pay' && (
<FormField label="Vacation Pay Rate (%)">
<Field name="vacation_pay_rate" placeholder="Vacation Pay Rate" type="number" />
</FormField>
)}
<FormField label="Manager">
<Field name="manager" component={SelectField} options={emptyOptions} itemRef={'users'} showField="firstName" />
</FormField>
<FormField label="App Role">
<Field name="app_role" component={SelectField} options={emptyOptions} itemRef={'roles'} showField="name" />
</FormField>
<FormField label="Password">
<Field name="password" placeholder="Leave empty to keep current" type="password" />
</FormField>
</div>
<FormField label="Notification Recipients">
<Field name="notification_recipients" component={SelectFieldMany} options={emptyOptions} itemRef={'users'} showField="firstName" />
</FormField>
<FormField label="Custom Permissions">
<Field name="custom_permissions" component={SelectFieldMany} options={emptyOptions} itemRef={'permissions'} showField="name" />
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field name='disabled' id='disabled' component={SwitchField} />
</FormField>
<FormField label="Avatar">
<Field
color='info'
icon={mdiUpload}
path={'users/avatar'}
name='avatar'
id='avatar'
component={FormImagePicker}
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')}/>
<BaseButton type='button' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')} />
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</SectionMain>
@ -694,14 +208,10 @@ const EditUsersPage = () => {
EditUsersPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'UPDATE_USERS'}
>
{page}
</LayoutAuthenticated>
<LayoutAuthenticated permission={'UPDATE_USERS'}>
{page}
</LayoutAuthenticated>
)
}
export default EditUsersPage
export default EditUsersPage

View File

@ -1,4 +1,4 @@
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload } from '@mdi/js'
import { mdiAccount, mdiChartTimelineVariant, mdiMail, mdiUpload, mdiClockOutline, mdiAccountStar, mdiBriefcase, mdiCalendar } from '@mdi/js'
import Head from 'next/head'
import React, { ReactElement } from 'react'
import CardBox from '../../components/CardBox'
@ -12,474 +12,156 @@ import FormField from '../../components/FormField'
import BaseDivider from '../../components/BaseDivider'
import BaseButtons from '../../components/BaseButtons'
import BaseButton from '../../components/BaseButton'
import FormCheckRadio from '../../components/FormCheckRadio'
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup'
import FormFilePicker from '../../components/FormFilePicker'
import FormImagePicker from '../../components/FormImagePicker'
import { SwitchField } from '../../components/SwitchField'
import { SelectField } from '../../components/SelectField'
import { SelectFieldMany } from "../../components/SelectFieldMany";
import {RichTextField} from "../../components/RichTextField";
import { create } from '../../stores/users/usersSlice'
import { useAppDispatch } from '../../stores/hooks'
import { useRouter } from 'next/router'
import moment from 'moment';
const initialValues = {
firstName: '',
lastName: '',
phoneNumber: '',
email: '',
disabled: false,
avatar: [],
app_role: '',
custom_permissions: [],
work_hours_per_week: 40,
leave_policy_type: 'pto',
paid_pto_per_year: 15,
medical_leave_per_year: 10,
bereavement_per_year: 3,
vacation_pay_rate: 0,
hiring_year: new Date().getFullYear(),
position: '',
manager: '',
notification_recipients: []
}
const emptyOptions = [];
const UsersNew = () => {
const router = useRouter()
const dispatch = useAppDispatch()
const handleSubmit = async (data) => {
await dispatch(create(data))
await router.push('/users/users-list')
}
return (
<>
<Head>
<title>{getPageTitle('New Item')}</title>
<title>{getPageTitle('New User')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="New Item" main>
{''}
<SectionTitleLineWithButton icon={mdiAccount} title="New User" main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
initialValues={
initialValues
}
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
{({ values }) => (
<Form>
<FormField
label="First Name"
>
<Field
name="firstName"
placeholder="First Name"
/>
</FormField>
<FormField
label="Last Name"
>
<Field
name="lastName"
placeholder="Last Name"
/>
</FormField>
<FormField
label="Phone Number"
>
<Field
name="phoneNumber"
placeholder="Phone Number"
/>
</FormField>
<FormField
label="E-Mail"
>
<Field
name="email"
placeholder="E-Mail"
/>
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field
name='disabled'
id='disabled'
component={SwitchField}
></Field>
</FormField>
<FormField>
<Field
label='Avatar'
color='info'
icon={mdiUpload}
path={'users/avatar'}
name='avatar'
id='avatar'
schema={{
size: undefined,
formats: undefined,
}}
component={FormImagePicker}
></Field>
</FormField>
<FormField label="App Role" labelFor="app_role">
<Field name="app_role" id="app_role" component={SelectField} options={[]} itemRef={'roles'}></Field>
</FormField>
<FormField label='Custom Permissions' labelFor='custom_permissions'>
<Field
name='custom_permissions'
id='custom_permissions'
itemRef={'permissions'}
options={[]}
component={SelectFieldMany}>
</Field>
</FormField>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
<FormField label="First Name">
<Field name="firstName" placeholder="First Name" />
</FormField>
<FormField label="Last Name">
<Field name="lastName" placeholder="Last Name" />
</FormField>
<FormField label="E-Mail">
<Field name="email" placeholder="E-Mail" type="email" />
</FormField>
<FormField label="Phone Number">
<Field name="phoneNumber" placeholder="Phone Number" />
</FormField>
<FormField label="Position">
<Field name="position" placeholder="Position" />
</FormField>
<FormField label="Hiring Year">
<Field name="hiring_year" placeholder="Hiring Year" type="number" />
</FormField>
<FormField label="Work Hours Per Week">
<Field name="work_hours_per_week" placeholder="Work Hours Per Week" type="number" />
</FormField>
<FormField label="Leave Policy Type">
<Field name="leave_policy_type" component={SelectField} options={[
{ value: 'pto', label: 'PTO' },
{ value: 'paid_vacation_pay', label: 'Earned Vacation Pay' }
]} />
</FormField>
{values.leave_policy_type === 'pto' && (
<FormField label="Paid PTO Per Year">
<Field name="paid_pto_per_year" placeholder="Paid PTO Per Year" type="number" />
</FormField>
)}
<FormField label="Medical Leave Per Year">
<Field name="medical_leave_per_year" placeholder="Medical Leave Per Year" type="number" />
</FormField>
<FormField label="Bereavement Per Year">
<Field name="bereavement_per_year" placeholder="Bereavement Per Year" type="number" />
</FormField>
{values.leave_policy_type === 'paid_vacation_pay' && (
<FormField label="Vacation Pay Rate (%)">
<Field name="vacation_pay_rate" placeholder="Vacation Pay Rate" type="number" />
</FormField>
)}
<FormField label="Manager">
<Field name="manager" component={SelectField} options={emptyOptions} itemRef={'users'} />
</FormField>
<FormField label="App Role">
<Field name="app_role" component={SelectField} options={emptyOptions} itemRef={'roles'} />
</FormField>
</div>
<FormField label="Notification Recipients">
<Field name="notification_recipients" component={SelectFieldMany} options={emptyOptions} itemRef={'users'} />
</FormField>
<FormField label='Disabled' labelFor='disabled'>
<Field name='disabled' id='disabled' component={SwitchField} />
</FormField>
<FormField label="Avatar">
<Field
color='info'
icon={mdiUpload}
path={'users/avatar'}
name='avatar'
id='avatar'
component={FormImagePicker}
/>
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')}/>
<BaseButton type='button' color='danger' outline label='Cancel' onClick={() => router.push('/users/users-list')} />
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</SectionMain>
@ -489,14 +171,10 @@ const UsersNew = () => {
UsersNew.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'CREATE_USERS'}
>
{page}
</LayoutAuthenticated>
<LayoutAuthenticated permission={'CREATE_USERS'}>
{page}
</LayoutAuthenticated>
)
}
export default UsersNew
export default UsersNew

View File

@ -158,6 +158,7 @@ export const usersSlice = createSlice({
builder.addCase(deleteItemsByIds.fulfilled, (state) => {
state.loading = false;
state.refetch = true;
fulfilledNotify(state, 'Users has been deleted');
});
@ -173,13 +174,14 @@ export const usersSlice = createSlice({
builder.addCase(deleteItem.fulfilled, (state) => {
state.loading = false
state.refetch = true;
fulfilledNotify(state, `${'Users'.slice(0, -1)} has been deleted`);
})
builder.addCase(deleteItem.rejected, (state, action) => {
state.loading = false
state.loading = false;
rejectNotify(state, action);
})
});
builder.addCase(create.pending, (state) => {
state.loading = true
@ -192,6 +194,7 @@ export const usersSlice = createSlice({
builder.addCase(create.fulfilled, (state) => {
state.loading = false
// state.refetch = true; // Removed to fix infinite loop
fulfilledNotify(state, `${'Users'.slice(0, -1)} has been created`);
})
@ -201,6 +204,7 @@ export const usersSlice = createSlice({
})
builder.addCase(update.fulfilled, (state) => {
state.loading = false
// state.refetch = true; // Removed to fix infinite loop
fulfilledNotify(state, `${'Users'.slice(0, -1)} has been updated`);
})
builder.addCase(update.rejected, (state, action) => {
@ -214,6 +218,7 @@ export const usersSlice = createSlice({
})
builder.addCase(uploadCsv.fulfilled, (state) => {
state.loading = false;
state.refetch = true;
fulfilledNotify(state, 'Users has been uploaded');
})
builder.addCase(uploadCsv.rejected, (state, action) => {
@ -228,4 +233,4 @@ export const usersSlice = createSlice({
// Action creators are generated for each case reducer function
export const { setRefetch } = usersSlice.actions
export default usersSlice.reducer
export default usersSlice.reducer