diff --git a/backend/src/db/api/login_backgrounds.js b/backend/src/db/api/login_backgrounds.js new file mode 100644 index 0000000..302e8f5 --- /dev/null +++ b/backend/src/db/api/login_backgrounds.js @@ -0,0 +1,100 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class LoginBackgroundsDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const record = await db.login_backgrounds.create( + { + month: data.month, + importHash: data.importHash, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction } + ); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: 'login_backgrounds', + belongsToColumn: 'image', + belongsToId: record.id, + }, + data.image, + options + ); + + return record; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const record = await db.login_backgrounds.findByPk(id, { transaction }); + if (!record) return null; + + const payload = { + updatedById: currentUser.id, + }; + if (data.month !== undefined) payload.month = data.month; + + await record.update(payload, { transaction }); + + await FileDBApi.replaceRelationFiles( + { + belongsTo: 'login_backgrounds', + belongsToColumn: 'image', + belongsToId: record.id, + }, + data.image, + options + ); + + return record; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + const record = await db.login_backgrounds.findOne({ where, transaction }); + if (!record) return null; + + const output = record.get({ plain: true }); + output.image = await record.getImage({ transaction }); + return output; + } + + static async findAll(filter, options) { + const transaction = (options && options.transaction) || undefined; + const include = [ + { + model: db.file, + as: 'image', + }, + ]; + + const { rows, count } = await db.login_backgrounds.findAndCountAll({ + where: {}, + include, + order: [['month', 'ASC']], + transaction, + }); + + return { rows, count }; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const record = await db.login_backgrounds.findByPk(id, { transaction }); + if (!record) return null; + + await record.destroy({ transaction }); + return record; + } +}; diff --git a/backend/src/db/migrations/1771300000001-add-balances-to-summary.js b/backend/src/db/migrations/1771300000001-add-balances-to-summary.js new file mode 100644 index 0000000..6813359 --- /dev/null +++ b/backend/src/db/migrations/1771300000001-add-balances-to-summary.js @@ -0,0 +1,36 @@ + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.addColumn('yearly_leave_summaries', 'opening_balance', { + type: Sequelize.DataTypes.DECIMAL, + allowNull: true, + defaultValue: 0, + }, { transaction }); + + await queryInterface.addColumn('yearly_leave_summaries', 'ending_balance', { + type: Sequelize.DataTypes.DECIMAL, + allowNull: true, + defaultValue: 0, + }, { transaction }); + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + try { + await queryInterface.removeColumn('yearly_leave_summaries', 'opening_balance', { transaction }); + await queryInterface.removeColumn('yearly_leave_summaries', 'ending_balance', { transaction }); + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + } +}; diff --git a/backend/src/db/migrations/1771400000000-create-login-backgrounds.js b/backend/src/db/migrations/1771400000000-create-login-backgrounds.js new file mode 100644 index 0000000..883d938 --- /dev/null +++ b/backend/src/db/migrations/1771400000000-create-login-backgrounds.js @@ -0,0 +1,45 @@ +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('login_backgrounds', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + month: { + type: Sequelize.INTEGER, + allowNull: false, + unique: true, + comment: '0: Default, 1-12: Specific Month', + }, + importHash: { + type: Sequelize.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { + type: Sequelize.DATE, + }, + updatedAt: { + type: Sequelize.DATE, + }, + createdById: { + type: Sequelize.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + updatedById: { + type: Sequelize.UUID, + references: { + model: 'users', + key: 'id', + }, + }, + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable('login_backgrounds'); + }, +}; diff --git a/backend/src/db/models/login_backgrounds.js b/backend/src/db/models/login_backgrounds.js new file mode 100644 index 0000000..4a5c252 --- /dev/null +++ b/backend/src/db/models/login_backgrounds.js @@ -0,0 +1,51 @@ +module.exports = function(sequelize, DataTypes) { + const loginBackgrounds = sequelize.define( + 'login_backgrounds', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + month: { + type: DataTypes.INTEGER, + allowNull: false, + unique: true, + validate: { + min: 0, + max: 12, + }, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: false, + tableName: 'login_backgrounds', + }, + ); + + loginBackgrounds.associate = (db) => { + db.login_backgrounds.belongsTo(db.users, { + as: 'createdBy', + }); + db.login_backgrounds.belongsTo(db.users, { + as: 'updatedBy', + }); + db.login_backgrounds.hasMany(db.file, { + as: 'image', + foreignKey: 'belongsToId', + constraints: false, + scope: { + belongsTo: 'login_backgrounds', + belongsToColumn: 'image', + }, + }); + }; + + return loginBackgrounds; +}; diff --git a/backend/src/db/models/yearly_leave_summaries.js b/backend/src/db/models/yearly_leave_summaries.js index b4d3a2c..f004139 100644 --- a/backend/src/db/models/yearly_leave_summaries.js +++ b/backend/src/db/models/yearly_leave_summaries.js @@ -77,6 +77,16 @@ vacation_pay_paid_amount: { }, +opening_balance: { + type: DataTypes.DECIMAL, + allowNull: true, + }, + +ending_balance: { + type: DataTypes.DECIMAL, + allowNull: true, + }, + importHash: { type: DataTypes.STRING(255), allowNull: true, @@ -134,6 +144,4 @@ vacation_pay_paid_amount: { return yearly_leave_summaries; -}; - - +}; \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 260260b..0c13254 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,4 +1,3 @@ - const express = require('express'); const cors = require('cors'); const app = express(); @@ -41,6 +40,7 @@ const office_calendar_eventsRoutes = require('./routes/office_calendar_events'); const approval_tasksRoutes = require('./routes/approval_tasks'); const appSettingsRoutes = require('./routes/app_settings'); +const loginBackgroundsRoutes = require('./routes/login_backgrounds'); const checkLockout = require('./middlewares/lockout'); @@ -128,6 +128,7 @@ app.use('/api/approval_tasks', authAndLockout, approval_tasksRoutes); // App Settings (Admin only basically, but handled in route). // IMPORTANT: Do NOT apply lockout middleware here, otherwise admin can't unlock! app.use('/api/app_settings', auth, appSettingsRoutes); +app.use('/api/login_backgrounds', loginBackgroundsRoutes); app.use( '/api/openai', @@ -173,4 +174,4 @@ db.sequelize.sync().then(function () { }); }); -module.exports = app; +module.exports = app; \ No newline at end of file diff --git a/backend/src/routes/login_backgrounds.js b/backend/src/routes/login_backgrounds.js new file mode 100644 index 0000000..fe4997e --- /dev/null +++ b/backend/src/routes/login_backgrounds.js @@ -0,0 +1,34 @@ +const express = require('express'); +const LoginBackgroundsService = require('../services/login_backgrounds'); +const wrapAsync = require('../helpers').wrapAsync; +const router = express.Router(); +const passport = require('passport'); + +// Public route to get current background +router.get('/current', wrapAsync(async (req, res) => { + const record = await LoginBackgroundsService.findCurrent(); + res.send(record); +})); + +// Admin routes +router.get('/', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const payload = await LoginBackgroundsService.listAll(req.currentUser); + res.send(payload); +})); + +router.post('/', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + await LoginBackgroundsService.create(req.body, req.currentUser); + res.sendStatus(200); +})); + +router.put('/:id', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + await LoginBackgroundsService.update(req.params.id, req.body, req.currentUser); + res.sendStatus(200); +})); + +router.delete('/:id', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + await LoginBackgroundsService.remove(req.params.id, req.currentUser); + res.sendStatus(200); +})); + +module.exports = router; diff --git a/backend/src/routes/yearly_leave_summaries.js b/backend/src/routes/yearly_leave_summaries.js index dc8fe0a..6c71e11 100644 --- a/backend/src/routes/yearly_leave_summaries.js +++ b/backend/src/routes/yearly_leave_summaries.js @@ -308,8 +308,19 @@ router.get('/', wrapAsync(async (req, res) => { const filetype = req.query.filetype const currentUser = req.currentUser; + + let filter = { ...req.query }; + if (req.query.filter && typeof req.query.filter === 'string') { + try { + const parsed = JSON.parse(req.query.filter); + filter = { ...filter, ...parsed }; + } catch (e) { + console.error('Failed to parse filter query param:', e); + } + } + const payload = await Yearly_leave_summariesDBApi.findAll( - req.query, { currentUser } + filter, { currentUser } ); if (filetype && filetype === 'csv') { const fields = ['id', @@ -360,8 +371,19 @@ router.get('/', wrapAsync(async (req, res) => { router.get('/count', wrapAsync(async (req, res) => { const currentUser = req.currentUser; + + let filter = { ...req.query }; + if (req.query.filter && typeof req.query.filter === 'string') { + try { + const parsed = JSON.parse(req.query.filter); + filter = { ...filter, ...parsed }; + } catch (e) { + console.error('Failed to parse filter query param:', e); + } + } + const payload = await Yearly_leave_summariesDBApi.findAll( - req.query, + filter, null, { countOnly: true, currentUser } ); diff --git a/backend/src/services/login_backgrounds.js b/backend/src/services/login_backgrounds.js new file mode 100644 index 0000000..1a437c2 --- /dev/null +++ b/backend/src/services/login_backgrounds.js @@ -0,0 +1,59 @@ +const db = require('../db/models'); +const LoginBackgroundsDBApi = require('../db/api/login_backgrounds'); + +module.exports = class LoginBackgroundsService { + static async findCurrent() { + const currentMonth = new Date().getMonth() + 1; // 1-12 + + let record = await LoginBackgroundsDBApi.findBy({ month: currentMonth }); + if (!record) { + record = await LoginBackgroundsDBApi.findBy({ month: 0 }); // Default + } + + if (record && record.image && record.image.length > 0) { + return { imageUrl: record.image[0].publicUrl }; + } + + return { imageUrl: null }; + } + + static async listAll(currentUser) { + const result = await LoginBackgroundsDBApi.findAll({}, { currentUser }); + return result; + } + + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const record = await LoginBackgroundsDBApi.create(data, { currentUser, transaction }); + await transaction.commit(); + return record; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(id, data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + const record = await LoginBackgroundsDBApi.update(id, data, { currentUser, transaction }); + await transaction.commit(); + return record; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await LoginBackgroundsDBApi.remove(id, { currentUser, transaction }); + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } +}; diff --git a/backend/src/services/pto_journal_entries.js b/backend/src/services/pto_journal_entries.js index 3183eaf..1aaa756 100644 --- a/backend/src/services/pto_journal_entries.js +++ b/backend/src/services/pto_journal_entries.js @@ -67,6 +67,7 @@ module.exports = class Pto_journal_entriesService { static async bulkAdjust(data, currentUser) { const { userIds, entry_type, leave_bucket, amount_hours, amount_days, memo } = data; + const calendar_year = data.calendar_year || new Date().getFullYear(); const transaction = await db.sequelize.transaction(); try { const entries = userIds.map(userId => ({ @@ -79,13 +80,38 @@ module.exports = class Pto_journal_entriesService { entered_byId: currentUser.id, entered_at: new Date(), posting_status: 'posted', - calendar_year: new Date().getFullYear(), + calendar_year, effective_at: new Date(), counts_against_balance: true, // Assuming adjustments usually count })); await db.pto_journal_entries.bulkCreate(entries, { transaction }); + // Update Yearly Leave Summaries + // We assume adjustments primarily affect 'regular_pto' available balance for now + 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 transaction.commit(); } catch (error) { await transaction.rollback(); diff --git a/backend/src/services/users.js b/backend/src/services/users.js index 555d193..9e2fe0e 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -1,11 +1,13 @@ const db = require('../db/models'); const UsersDBApi = require('../db/api/users'); +const YearlyLeaveSummariesDBApi = require('../db/api/yearly_leave_summaries'); const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); const axios = require('axios'); const config = require('../config'); const stream = require('stream'); +const moment = require('moment'); const InvitationEmail = require('./email/list/invitation'); @@ -26,7 +28,7 @@ module.exports = class UsersService { 'iam.errors.userAlreadyExists', ); } else { - await UsersDBApi.create( + const newUser = await UsersDBApi.create( {data}, { @@ -34,6 +36,22 @@ module.exports = class UsersService { transaction, }, ); + + // Create yearly leave summary for the current year + const currentYear = moment().year(); + await YearlyLeaveSummariesDBApi.create({ + calendar_year: currentYear, + user: newUser.id, + pto_available_days: data.paid_pto_per_year || 20, + pto_pending_days: 0, + pto_scheduled_days: 0, + pto_taken_days: 0, + medical_taken_days: 0, + bereavement_taken_days: 0, + time_in_lieu_available_days: 0, + vacation_pay_paid_amount: 0 + }, { transaction, currentUser }); + emailsToInvite.push(email); } } else { @@ -86,6 +104,10 @@ module.exports = class UsersService { currentUser: req.currentUser }); + // TODO: Bulk import likely also needs to create yearly summaries, + // but sticking to the request scope for single user creation first. + // If requested, I can add it here too by iterating over created users. + emailsToInvite = results.map((result) => result.email); await transaction.commit(); @@ -166,6 +188,4 @@ module.exports = class UsersService { throw error; } } -}; - - +}; \ No newline at end of file diff --git a/frontend/src/components/FormField.tsx b/frontend/src/components/FormField.tsx index 988ac39..90fddd7 100644 --- a/frontend/src/components/FormField.tsx +++ b/frontend/src/components/FormField.tsx @@ -1,4 +1,4 @@ -import { Children, cloneElement, ReactElement, ReactNode } from 'react' +import { Children, cloneElement, ReactElement, ReactNode, isValidElement } from 'react' import BaseIcon from './BaseIcon' import { useAppSelector } from '../stores/hooks'; @@ -17,12 +17,24 @@ type Props = { websiteBg?: boolean } +const extractNameFromChildren = (children: ReactNode): string => { + const names: string[] = []; + Children.forEach(children, (child) => { + if (isValidElement(child) && (child.props as any).name) { + names.push((child.props as any).name); + } + }); + return names.join(', '); +} + const FormField = ({ icons = [], ...props }: Props) => { const childrenCount = Children.count(props.children) const bgColor = useAppSelector((state) => state.style.cardsColor); const focusRing = useAppSelector((state) => state.style.focusRingColor); const corners = useAppSelector((state) => state.style.corners); const bgWebsiteColor = useAppSelector((state) => state.style.bgLayoutColor); + const showDevInfo = useAppSelector((state) => state.style.showDevInfo); + let elementWrapperClass = '' switch (childrenCount) { @@ -43,15 +55,24 @@ const FormField = ({ icons = [], ...props }: Props) => { props.borderButtom ? `border-0 border-b ${props.diversity ? "border-gray-400" : " placeholder-white border-gray-300/10 border-white "} rounded-none focus:ring-0` : '', ].join(' '); + const devFieldName = props.labelFor || extractNameFromChildren(props.children); + return (