diff --git a/.perm_test_apache b/.perm_test_apache new file mode 100644 index 0000000..e69de29 diff --git a/.perm_test_exec b/.perm_test_exec new file mode 100644 index 0000000..e69de29 diff --git a/assets/pasted-20260314-132921-6765892c.webp b/assets/pasted-20260314-132921-6765892c.webp new file mode 100644 index 0000000..bf459c8 Binary files /dev/null and b/assets/pasted-20260314-132921-6765892c.webp differ diff --git a/assets/pasted-20260413-130727-e67a0305.png b/assets/pasted-20260413-130727-e67a0305.png new file mode 100644 index 0000000..c288ed6 Binary files /dev/null and b/assets/pasted-20260413-130727-e67a0305.png differ diff --git a/assets/pasted-20260413-131715-2c4e92ec.png b/assets/pasted-20260413-131715-2c4e92ec.png new file mode 100644 index 0000000..bb84091 Binary files /dev/null and b/assets/pasted-20260413-131715-2c4e92ec.png differ diff --git a/assets/pasted-20260413-145044-bf0d1263.png b/assets/pasted-20260413-145044-bf0d1263.png new file mode 100644 index 0000000..f6d0529 Binary files /dev/null and b/assets/pasted-20260413-145044-bf0d1263.png differ diff --git a/backend/src/db/api/employee_pay_types.js b/backend/src/db/api/employee_pay_types.js index ad0e660..dcbc60a 100644 --- a/backend/src/db/api/employee_pay_types.js +++ b/backend/src/db/api/employee_pay_types.js @@ -3,6 +3,7 @@ const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); const Utils = require('../utils'); +const { isRestrictedPayrollUser } = require('../../security/payrollAccess'); @@ -205,12 +206,17 @@ module.exports = class Employee_pay_typesDBApi { static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; + const currentUser = options?.currentUser; const employee_pay_types = await db.employee_pay_types.findOne( { where }, { transaction }, ); + if (employee_pay_types && isRestrictedPayrollUser(currentUser) && employee_pay_types.employeeId !== currentUser.id) { + return null; + } + if (!employee_pay_types) { return employee_pay_types; } @@ -249,6 +255,8 @@ module.exports = class Employee_pay_typesDBApi { filter, options ) { + const currentUser = options?.currentUser; + const effectiveEmployeeFilter = isRestrictedPayrollUser(currentUser) ? currentUser.id : filter.employee; const limit = filter.limit || 0; let offset = 0; let where = {}; @@ -270,16 +278,16 @@ module.exports = class Employee_pay_typesDBApi { model: db.users, as: 'employee', - where: filter.employee ? { + where: effectiveEmployeeFilter ? { [Op.or]: [ - { id: { [Op.in]: filter.employee.split('|').map(term => Utils.uuid(term)) } }, + { id: { [Op.in]: effectiveEmployeeFilter.split('|').map(term => Utils.uuid(term)) } }, { firstName: { - [Op.or]: filter.employee.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) + [Op.or]: effectiveEmployeeFilter.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) } }, ] - } : {}, + } : undefined, }, @@ -296,7 +304,7 @@ module.exports = class Employee_pay_typesDBApi { } }, ] - } : {}, + } : undefined, }, @@ -447,7 +455,8 @@ module.exports = class Employee_pay_typesDBApi { } } - static async findAllAutocomplete(query, limit, offset, ) { + static async findAllAutocomplete(query, limit, offset, options) { + const currentUser = options?.currentUser; let where = {}; @@ -465,6 +474,13 @@ module.exports = class Employee_pay_typesDBApi { }; } + if (isRestrictedPayrollUser(currentUser)) { + where = { + ...where, + employeeId: currentUser.id, + }; + } + const records = await db.employee_pay_types.findAll({ attributes: [ 'id', 'active' ], where, diff --git a/backend/src/db/api/job_chemical_usages.js b/backend/src/db/api/job_chemical_usages.js index 15ab2b6..fbf1229 100644 --- a/backend/src/db/api/job_chemical_usages.js +++ b/backend/src/db/api/job_chemical_usages.js @@ -264,7 +264,7 @@ module.exports = class Job_chemical_usagesDBApi { } }, ] - } : {}, + } : undefined, }, @@ -281,7 +281,7 @@ module.exports = class Job_chemical_usagesDBApi { } }, ] - } : {}, + } : undefined, }, diff --git a/backend/src/db/api/job_logs.js b/backend/src/db/api/job_logs.js index 63273c1..0832b01 100644 --- a/backend/src/db/api/job_logs.js +++ b/backend/src/db/api/job_logs.js @@ -3,6 +3,7 @@ const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); const Utils = require('../utils'); +const { isRestrictedPayrollUser } = require('../../security/payrollAccess'); @@ -90,6 +91,10 @@ module.exports = class Job_logsDBApi { transaction, }); + await job_logs.setWorkersCompClass( data.workersCompClass || null, { + transaction, + }); + @@ -248,6 +253,15 @@ module.exports = class Job_logsDBApi { ); } + if (data.workersCompClass !== undefined) { + await job_logs.setWorkersCompClass( + + data.workersCompClass, + + { transaction } + ); + } + @@ -307,12 +321,17 @@ module.exports = class Job_logsDBApi { static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; + const currentUser = options?.currentUser; const job_logs = await db.job_logs.findOne( { where }, { transaction }, ); + if (job_logs && isRestrictedPayrollUser(currentUser) && job_logs.employeeId !== currentUser.id) { + return null; + } + if (!job_logs) { return job_logs; } @@ -356,6 +375,10 @@ module.exports = class Job_logsDBApi { transaction }); + output.workersCompClass = await job_logs.getWorkersCompClass({ + transaction + }); + return output; @@ -365,6 +388,7 @@ module.exports = class Job_logsDBApi { filter, options ) { + const currentUser = options?.currentUser; const limit = filter.limit || 0; let offset = 0; let where = {}; @@ -395,7 +419,7 @@ module.exports = class Job_logsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -412,7 +436,7 @@ module.exports = class Job_logsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -429,7 +453,7 @@ module.exports = class Job_logsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -446,14 +470,36 @@ module.exports = class Job_logsDBApi { } }, ] - } : {}, + } : undefined, }, + + { + model: db.workers_comp_classes, + as: 'workersCompClass', + where: filter.workersCompClass ? { + [Op.or]: [ + { id: { [Op.in]: filter.workersCompClass.split('|').map(term => Utils.uuid(term)) } }, + { + name: { + [Op.or]: filter.workersCompClass.split('|').map(term => ({ [Op.iLike]: `%${term}%` })) + } + }, + ] + } : undefined, + }, ]; + if (isRestrictedPayrollUser(currentUser)) { + where = { + ...where, + employeeId: currentUser.id, + }; + } + if (filter) { if (filter.id) { where = { @@ -720,7 +766,8 @@ module.exports = class Job_logsDBApi { } } - static async findAllAutocomplete(query, limit, offset, ) { + static async findAllAutocomplete(query, limit, offset, options) { + const currentUser = options?.currentUser; let where = {}; @@ -738,6 +785,13 @@ module.exports = class Job_logsDBApi { }; } + if (isRestrictedPayrollUser(currentUser)) { + where = { + ...where, + employeeId: currentUser.id, + }; + } + const records = await db.job_logs.findAll({ attributes: [ 'id', 'job_address' ], where, diff --git a/backend/src/db/api/pay_types.js b/backend/src/db/api/pay_types.js index c5dec32..80d2cae 100644 --- a/backend/src/db/api/pay_types.js +++ b/backend/src/db/api/pay_types.js @@ -3,6 +3,7 @@ const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); const Utils = require('../utils'); +const { isRestrictedPayrollUser } = require('../../security/payrollAccess'); @@ -141,10 +142,10 @@ module.exports = class Pay_typesDBApi { if (data.pay_method !== undefined) updatePayload.pay_method = data.pay_method; - if (data.hourly_rate !== undefined) updatePayload.hourly_rate = data.hourly_rate; + if (data.hourly_rate !== undefined) updatePayload.hourly_rate = data.hourly_rate === "" ? null : data.hourly_rate; - if (data.commission_rate !== undefined) updatePayload.commission_rate = data.commission_rate; + if (data.commission_rate !== undefined) updatePayload.commission_rate = data.commission_rate === "" ? null : data.commission_rate; if (data.active !== undefined) updatePayload.active = data.active; @@ -218,9 +219,20 @@ module.exports = class Pay_typesDBApi { static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; + const currentUser = options?.currentUser; + const accessInclude = isRestrictedPayrollUser(currentUser) ? [{ + model: db.employee_pay_types, + as: 'employee_pay_types_pay_type', + required: true, + attributes: [], + where: { + employeeId: currentUser.id, + active: true, + }, + }] : undefined; const pay_types = await db.pay_types.findOne( - { where }, + { where, include: accessInclude }, { transaction }, ); @@ -238,13 +250,15 @@ module.exports = class Pay_typesDBApi { output.employee_pay_types_pay_type = await pay_types.getEmployee_pay_types_pay_type({ - transaction + transaction, + where: isRestrictedPayrollUser(currentUser) ? { employeeId: currentUser.id, active: true } : undefined, }); output.job_logs_pay_type = await pay_types.getJob_logs_pay_type({ - transaction + transaction, + where: isRestrictedPayrollUser(currentUser) ? { employeeId: currentUser.id } : undefined, }); @@ -260,6 +274,7 @@ module.exports = class Pay_typesDBApi { filter, options ) { + const currentUser = options?.currentUser; const limit = filter.limit || 0; let offset = 0; let where = {}; @@ -281,6 +296,22 @@ module.exports = class Pay_typesDBApi { ]; + if (isRestrictedPayrollUser(currentUser)) { + include = [ + { + model: db.employee_pay_types, + as: 'employee_pay_types_pay_type', + required: true, + attributes: [], + where: { + employeeId: currentUser.id, + active: true, + }, + }, + ...include, + ]; + } + if (filter) { if (filter.id) { where = { @@ -449,7 +480,8 @@ module.exports = class Pay_typesDBApi { } } - static async findAllAutocomplete(query, limit, offset, ) { + static async findAllAutocomplete(query, limit, offset, options) { + const currentUser = options?.currentUser; let where = {}; @@ -470,6 +502,16 @@ module.exports = class Pay_typesDBApi { const records = await db.pay_types.findAll({ attributes: [ 'id', 'name' ], where, + include: isRestrictedPayrollUser(currentUser) ? [{ + model: db.employee_pay_types, + as: 'employee_pay_types_pay_type', + required: true, + attributes: [], + where: { + employeeId: currentUser.id, + active: true, + }, + }] : undefined, limit: limit ? Number(limit) : undefined, offset: offset ? Number(offset) : undefined, orderBy: [['name', 'ASC']], diff --git a/backend/src/db/api/payroll_line_items.js b/backend/src/db/api/payroll_line_items.js index a0b5b58..e580f29 100644 --- a/backend/src/db/api/payroll_line_items.js +++ b/backend/src/db/api/payroll_line_items.js @@ -1,4 +1,3 @@ - const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); @@ -36,6 +35,11 @@ module.exports = class Payroll_line_itemsDBApi { null , + workers_comp_amount: data.workers_comp_amount + || + null + , + total_client_paid: data.total_client_paid || null @@ -92,6 +96,11 @@ module.exports = class Payroll_line_itemsDBApi { total_commission_base: item.total_commission_base || null + , + + workers_comp_amount: item.workers_comp_amount + || + null , total_client_paid: item.total_client_paid @@ -140,6 +149,9 @@ module.exports = class Payroll_line_itemsDBApi { if (data.total_commission_base !== undefined) updatePayload.total_commission_base = data.total_commission_base; + if (data.workers_comp_amount !== undefined) updatePayload.workers_comp_amount = data.workers_comp_amount; + + if (data.total_client_paid !== undefined) updatePayload.total_client_paid = data.total_client_paid; @@ -303,7 +315,7 @@ module.exports = class Payroll_line_itemsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -320,7 +332,7 @@ module.exports = class Payroll_line_itemsDBApi { } }, ] - } : {}, + } : undefined, }, @@ -425,6 +437,30 @@ module.exports = class Payroll_line_itemsDBApi { } } + if (filter.workers_comp_amountRange) { + const [start, end] = filter.workers_comp_amountRange; + + if (start !== undefined && start !== null && start !== '') { + where = { + ...where, + workers_comp_amount: { + ...where.workers_comp_amount, + [Op.gte]: start, + }, + }; + } + + if (end !== undefined && end !== null && end !== '') { + where = { + ...where, + workers_comp_amount: { + ...where.workers_comp_amount, + [Op.lte]: end, + }, + }; + } + } + if (filter.total_client_paidRange) { const [start, end] = filter.total_client_paidRange; @@ -556,5 +592,4 @@ module.exports = class Payroll_line_itemsDBApi { } -}; - +}; \ No newline at end of file diff --git a/backend/src/db/api/payroll_runs.js b/backend/src/db/api/payroll_runs.js index 554b706..c06458c 100644 --- a/backend/src/db/api/payroll_runs.js +++ b/backend/src/db/api/payroll_runs.js @@ -240,9 +240,19 @@ module.exports = class Payroll_runsDBApi { - output.payroll_line_items_payroll_run = await payroll_runs.getPayroll_line_items_payroll_run({ - transaction - }); + output.payroll_line_items_payroll_run = await Promise.all( + (await payroll_runs.getPayroll_line_items_payroll_run({ + transaction + })).map(async (payrollLineItem) => { + const payrollLineItemOutput = payrollLineItem.get({ plain: true }); + + payrollLineItemOutput.employee = await payrollLineItem.getEmployee({ + transaction, + }); + + return payrollLineItemOutput; + }), + ); diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 78ffae8..858f8fc 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -3,6 +3,7 @@ const db = require('../models'); const FileDBApi = require('./file'); const crypto = require('crypto'); const Utils = require('../utils'); +const { isRestrictedPayrollUser } = require('../../security/payrollAccess'); const bcrypt = require('bcrypt'); const config = require('../../config'); @@ -387,9 +388,19 @@ module.exports = class UsersDBApi { static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; + const currentUser = options?.currentUser; + const effectiveWhere = { ...(where || {}) }; + + if (isRestrictedPayrollUser(currentUser)) { + if (effectiveWhere.id && Utils.uuid(effectiveWhere.id) !== currentUser.id) { + return null; + } + + effectiveWhere.id = currentUser.id; + } const users = await db.users.findOne( - { where }, + { where: effectiveWhere }, { transaction }, ); @@ -407,7 +418,14 @@ module.exports = class UsersDBApi { output.employee_pay_types_employee = await users.getEmployee_pay_types_employee({ - transaction + transaction, + where: isRestrictedPayrollUser(currentUser) ? { employeeId: currentUser.id, active: true } : undefined, + include: [ + { + model: db.pay_types, + as: 'pay_type', + }, + ], }); @@ -454,6 +472,7 @@ module.exports = class UsersDBApi { filter, options ) { + const currentUser = options?.currentUser; const limit = filter.limit || 0; let offset = 0; let where = {}; @@ -484,7 +503,7 @@ module.exports = class UsersDBApi { } }, ] - } : {}, + } : undefined, }, @@ -501,8 +520,30 @@ module.exports = class UsersDBApi { as: 'avatar', }, + { + model: db.employee_pay_types, + as: 'employee_pay_types_employee', + required: false, + where: { + active: true, + }, + include: [ + { + model: db.pay_types, + as: 'pay_type', + }, + ], + }, + ]; + if (isRestrictedPayrollUser(currentUser)) { + where = { + ...where, + id: currentUser.id, + }; + } + if (filter) { if (filter.id) { where = { @@ -762,7 +803,8 @@ module.exports = class UsersDBApi { } } - static async findAllAutocomplete(query, limit, offset, ) { + static async findAllAutocomplete(query, limit, offset, options) { + const currentUser = options?.currentUser; let where = {}; @@ -780,6 +822,13 @@ module.exports = class UsersDBApi { }; } + if (isRestrictedPayrollUser(currentUser)) { + where = { + ...where, + id: currentUser.id, + }; + } + const records = await db.users.findAll({ attributes: [ 'id', 'firstName' ], where, diff --git a/backend/src/db/api/workers_comp_classes.js b/backend/src/db/api/workers_comp_classes.js new file mode 100644 index 0000000..2eb148e --- /dev/null +++ b/backend/src/db/api/workers_comp_classes.js @@ -0,0 +1,188 @@ +const db = require('../models'); +const FileDBApi = require('./file'); +const crypto = require('crypto'); +const Utils = require('../utils'); + +const Sequelize = db.Sequelize; +const Op = Sequelize.Op; + +module.exports = class Workers_comp_classesDBApi { + static async create(data, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const workers_comp_classes = await db.workers_comp_classes.create( + { + id: data.id || undefined, + name: data.name || null, + percentage: data.percentage !== undefined ? data.percentage : null, + importHash: data.importHash || null, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + + return workers_comp_classes; + } + + static async update(id, data, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + + const workers_comp_classes = await db.workers_comp_classes.findByPk(id, {}, {transaction}); + + const updatePayload = {}; + + if (data.name !== undefined) updatePayload.name = data.name; + if (data.percentage !== undefined) updatePayload.percentage = data.percentage === "" ? null : data.percentage; + + updatePayload.updatedById = currentUser.id; + + await workers_comp_classes.update(updatePayload, {transaction}); + + return workers_comp_classes; + } + + static async deleteByIds(ids, options) { + const currentUser = (options && options.currentUser) || { id: null }; + const transaction = (options && options.transaction) || undefined; + + const workers_comp_classes = await db.workers_comp_classes.findAll({ + where: { + id: { + [Op.in]: ids, + }, + }, + transaction, + }); + + await db.sequelize.transaction(async (transaction) => { + for (const record of workers_comp_classes) { + await record.update( + {deletedBy: currentUser.id}, + {transaction} + ); + } + for (const record of workers_comp_classes) { + await record.destroy({transaction}); + } + }); + + return workers_comp_classes; + } + + static async remove(id, options) { + const currentUser = (options && options.currentUser) || {id: null}; + const transaction = (options && options.transaction) || undefined; + + const workers_comp_classes = await db.workers_comp_classes.findByPk(id, options); + + await workers_comp_classes.update({ + deletedBy: currentUser.id + }, { + transaction, + }); + + await workers_comp_classes.destroy({ + transaction + }); + + return workers_comp_classes; + } + + static async findBy(where, options) { + const transaction = (options && options.transaction) || undefined; + + const workers_comp_classes = await db.workers_comp_classes.findOne( + { where }, + { transaction }, + ); + + if (!workers_comp_classes) { + return workers_comp_classes; + } + + const output = workers_comp_classes.get({plain: true}); + + return output; + } + + static async findAll(filter, options) { + const limit = filter.limit || 0; + let offset = 0; + let where = {}; + const currentPage = +filter.page; + + offset = currentPage * limit; + + const transaction = (options && options.transaction) || undefined; + + let include = []; + + if (filter) { + if (filter.id) { + where = { ...where, ['id']: Utils.uuid(filter.id) }; + } + if (filter.name) { + where = { + ...where, + [Op.and]: Utils.ilike('workers_comp_classes', 'name', filter.name), + }; + } + } + + const queryOptions = { + where, + include, + distinct: true, + order: filter && filter.field && filter.sort + ? [[filter.field, filter.sort]] + : [['createdAt', 'desc']], + transaction: options && options.transaction, + }; + + if (!(options && options.countOnly)) { + queryOptions.limit = limit ? Number(limit) : undefined; + queryOptions.offset = offset ? Number(offset) : undefined; + } + + try { + const { rows, count } = await db.workers_comp_classes.findAndCountAll(queryOptions); + + return { + rows: (options && options.countOnly) ? [] : rows, + count: count + }; + } catch (error) { + console.error('Error executing query:', error); + throw error; + } + } + + static async findAllAutocomplete(query, limit, offset) { + let where = {}; + + if (query) { + where = { + [Op.or]: [ + { ['id']: Utils.uuid(query) }, + Utils.ilike('workers_comp_classes', 'name', query), + ], + }; + } + + const records = await db.workers_comp_classes.findAll({ + attributes: [ 'id', 'name' ], + where, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + order: [['name', 'ASC']], + }); + + return records.map((record) => ({ + id: record.id, + label: record.name, + })); + } +}; \ No newline at end of file diff --git a/backend/src/db/migrations/1773330455690.js b/backend/src/db/migrations/1773330455690.js new file mode 100644 index 0000000..1935fd8 --- /dev/null +++ b/backend/src/db/migrations/1773330455690.js @@ -0,0 +1,32 @@ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable("usersCustom_permissionsPermissions", { + id: { + type: Sequelize.DataTypes.UUID, + defaultValue: Sequelize.DataTypes.UUIDV4, + primaryKey: true, + }, + users_custom_permissionsId: { + type: Sequelize.DataTypes.UUID, + references: { + model: "users", + key: "id", + }, + onDelete: "CASCADE", + }, + permissionId: { + type: Sequelize.DataTypes.UUID, + references: { + model: "permissions", + key: "id", + }, + onDelete: "CASCADE", + }, + createdAt: { type: Sequelize.DataTypes.DATE }, + updatedAt: { type: Sequelize.DataTypes.DATE }, + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.dropTable("usersCustom_permissionsPermissions"); + } +}; \ No newline at end of file diff --git a/backend/src/db/migrations/1773330455692.js b/backend/src/db/migrations/1773330455692.js new file mode 100644 index 0000000..7ef167b --- /dev/null +++ b/backend/src/db/migrations/1773330455692.js @@ -0,0 +1,12 @@ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('pay_types', 'workers_comp_percentage', { + type: Sequelize.DataTypes.DECIMAL, + allowNull: true, + defaultValue: 0, + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('pay_types', 'workers_comp_percentage'); + } +}; \ No newline at end of file diff --git a/backend/src/db/migrations/1773330455693.js b/backend/src/db/migrations/1773330455693.js new file mode 100644 index 0000000..2cf012f --- /dev/null +++ b/backend/src/db/migrations/1773330455693.js @@ -0,0 +1,12 @@ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn('payroll_line_items', 'workers_comp_amount', { + type: Sequelize.DataTypes.DECIMAL, + allowNull: true, + defaultValue: 0, + }); + }, + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn('payroll_line_items', 'workers_comp_amount'); + } +}; diff --git a/backend/src/db/migrations/1773330455694-create-workers-comp-classes.js b/backend/src/db/migrations/1773330455694-create-workers-comp-classes.js new file mode 100644 index 0000000..7874ed7 --- /dev/null +++ b/backend/src/db/migrations/1773330455694-create-workers-comp-classes.js @@ -0,0 +1,51 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable('workers_comp_classes', { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + }, + name: { + type: Sequelize.TEXT, + }, + percentage: { + type: Sequelize.DECIMAL, + }, + importHash: { + type: Sequelize.STRING(255), + allowNull: true, + unique: true, + }, + createdAt: { + type: Sequelize.DATE, + }, + updatedAt: { + type: Sequelize.DATE, + }, + deletedAt: { + 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('workers_comp_classes'); + }, +}; diff --git a/backend/src/db/migrations/1773330455695-alter-job-logs-workers-comp.js b/backend/src/db/migrations/1773330455695-alter-job-logs-workers-comp.js new file mode 100644 index 0000000..77b891e --- /dev/null +++ b/backend/src/db/migrations/1773330455695-alter-job-logs-workers-comp.js @@ -0,0 +1,24 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn('job_logs', 'workersCompClassId', { + type: Sequelize.UUID, + references: { + model: 'workers_comp_classes', + key: 'id', + }, + }); + // Remove the old enum column + // The enum type might cause issues if we try to drop it directly without dropping dependent views, + // but just dropping the column is usually fine. + await queryInterface.removeColumn('job_logs', 'workers_comp_class'); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.removeColumn('job_logs', 'workersCompClassId'); + await queryInterface.addColumn('job_logs', 'workers_comp_class', { + type: Sequelize.ENUM('roof', 'ladder', 'ground'), + }); + }, +}; diff --git a/backend/src/db/migrations/1773330455696-add-workers-comp-permissions.js b/backend/src/db/migrations/1773330455696-add-workers-comp-permissions.js new file mode 100644 index 0000000..affc565 --- /dev/null +++ b/backend/src/db/migrations/1773330455696-add-workers-comp-permissions.js @@ -0,0 +1,62 @@ +'use strict'; +const { v4: uuid } = require('uuid'); + +module.exports = { + up: async (queryInterface, Sequelize) => { + const createdAt = new Date(); + const updatedAt = new Date(); + const entity = 'workers_comp_classes'; + + const permissions = [ + { id: uuid(), name: `CREATE_${entity.toUpperCase()}`, createdAt, updatedAt }, + { id: uuid(), name: `READ_${entity.toUpperCase()}`, createdAt, updatedAt }, + { id: uuid(), name: `UPDATE_${entity.toUpperCase()}`, createdAt, updatedAt }, + { id: uuid(), name: `DELETE_${entity.toUpperCase()}`, createdAt, updatedAt }, + ]; + + await queryInterface.bulkInsert('permissions', permissions); + + // Get Admin and SystemOwner roles + const roles = await queryInterface.sequelize.query( + `SELECT id, name FROM roles WHERE name IN ('Administrator', 'SystemOwner');`, + { type: Sequelize.QueryTypes.SELECT } + ); + + const rolePermissions = []; + for (const role of roles) { + for (const perm of permissions) { + rolePermissions.push({ + roles_permissionsId: role.id, + permissionId: perm.id, + createdAt, + updatedAt, + }); + } + } + + if (rolePermissions.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', rolePermissions); + } + }, + + down: async (queryInterface, Sequelize) => { + const entity = 'workers_comp_classes'; + const permissionNames = [ + `CREATE_${entity.toUpperCase()}`, + `READ_${entity.toUpperCase()}`, + `UPDATE_${entity.toUpperCase()}`, + `DELETE_${entity.toUpperCase()}`, + ]; + + await queryInterface.sequelize.query( + `DELETE FROM "rolesPermissionsPermissions" WHERE "permissionId" IN (SELECT id FROM permissions WHERE name IN (:names));`, + { replacements: { names: permissionNames } } + ); + + await queryInterface.bulkDelete('permissions', { + name: { + [Sequelize.Op.in]: permissionNames, + }, + }); + }, +}; diff --git a/backend/src/db/migrations/1773330455697-fix-workers-comp-permissions.js b/backend/src/db/migrations/1773330455697-fix-workers-comp-permissions.js new file mode 100644 index 0000000..ad62089 --- /dev/null +++ b/backend/src/db/migrations/1773330455697-fix-workers-comp-permissions.js @@ -0,0 +1,39 @@ +'use strict'; +module.exports = { + up: async (queryInterface, Sequelize) => { + const createdAt = new Date(); + const updatedAt = new Date(); + // Find all roles + const roles = await queryInterface.sequelize.query( + `SELECT id, name FROM roles WHERE name != 'Public';`, + { type: Sequelize.QueryTypes.SELECT } + ); + // Find the read permission + const permissions = await queryInterface.sequelize.query( + `SELECT id, name FROM permissions WHERE name = 'READ_WORKERS_COMP_CLASSES';`, + { type: Sequelize.QueryTypes.SELECT } + ); + if (permissions.length === 0) return; + const readPerm = permissions[0]; + const rolePermissions = []; + const existing = await queryInterface.sequelize.query( + `SELECT "roles_permissionsId", "permissionId" FROM "rolesPermissionsPermissions" WHERE "permissionId" = '${readPerm.id}';`, + { type: Sequelize.QueryTypes.SELECT } + ); + const existingSet = new Set(existing.map(e => e.roles_permissionsId + '-' + e.permissionId)); + for (const role of roles) { + if (!existingSet.has(role.id + '-' + readPerm.id)) { + rolePermissions.push({ + roles_permissionsId: role.id, + permissionId: readPerm.id, + createdAt, + updatedAt, + }); + } + } + if (rolePermissions.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', rolePermissions); + } + }, + down: async (queryInterface, Sequelize) => {} +}; diff --git a/backend/src/db/migrations/1773330455698-fix-employee-pay-types-permissions.js b/backend/src/db/migrations/1773330455698-fix-employee-pay-types-permissions.js new file mode 100644 index 0000000..340d50d --- /dev/null +++ b/backend/src/db/migrations/1773330455698-fix-employee-pay-types-permissions.js @@ -0,0 +1,42 @@ +'use strict'; +module.exports = { + up: async (queryInterface, Sequelize) => { + const createdAt = new Date(); + const updatedAt = new Date(); + + const roles = await queryInterface.sequelize.query( + `SELECT id, name FROM roles WHERE name != 'Public';`, + { type: Sequelize.QueryTypes.SELECT } + ); + + const permissions = await queryInterface.sequelize.query( + `SELECT id, name FROM permissions WHERE name = 'READ_EMPLOYEE_PAY_TYPES';`, + { type: Sequelize.QueryTypes.SELECT } + ); + + if (permissions.length === 0) return; + const readPerm = permissions[0]; + const rolePermissions = []; + + const existing = await queryInterface.sequelize.query( + `SELECT "roles_permissionsId", "permissionId" FROM "rolesPermissionsPermissions" WHERE "permissionId" = '${readPerm.id}';`, + { type: Sequelize.QueryTypes.SELECT } + ); + const existingSet = new Set(existing.map(e => e.roles_permissionsId + '-' + e.permissionId)); + + for (const role of roles) { + if (!existingSet.has(role.id + '-' + readPerm.id)) { + rolePermissions.push({ + roles_permissionsId: role.id, + permissionId: readPerm.id, + createdAt, + updatedAt, + }); + } + } + if (rolePermissions.length > 0) { + await queryInterface.bulkInsert('rolesPermissionsPermissions', rolePermissions); + } + }, + down: async (queryInterface, Sequelize) => {} +}; diff --git a/backend/src/db/models/job_logs.js b/backend/src/db/models/job_logs.js index 9ebb791..37dc8b9 100644 --- a/backend/src/db/models/job_logs.js +++ b/backend/src/db/models/job_logs.js @@ -35,25 +35,6 @@ client_paid: { }, -workers_comp_class: { - type: DataTypes.ENUM, - - - - values: [ - -"roof", - - -"ladder", - - -"ground" - - ], - - }, - odometer_start: { type: DataTypes.INTEGER, @@ -180,6 +161,14 @@ notes_to_admin: { constraints: false, }); + db.job_logs.belongsTo(db.workers_comp_classes, { + as: 'workersCompClass', + foreignKey: { + name: 'workersCompClassId', + }, + constraints: false, + }); + @@ -195,6 +184,4 @@ notes_to_admin: { return job_logs; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/models/pay_types.js b/backend/src/db/models/pay_types.js index a6f504a..a789dce 100644 --- a/backend/src/db/models/pay_types.js +++ b/backend/src/db/models/pay_types.js @@ -49,6 +49,13 @@ commission_rate: { + }, + +workers_comp_percentage: { + type: DataTypes.DECIMAL, + + + }, active: { @@ -135,6 +142,4 @@ description: { return pay_types; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/models/payroll_line_items.js b/backend/src/db/models/payroll_line_items.js index 8d92dda..ec6c5fb 100644 --- a/backend/src/db/models/payroll_line_items.js +++ b/backend/src/db/models/payroll_line_items.js @@ -33,6 +33,13 @@ total_commission_base: { + }, + +workers_comp_amount: { + type: DataTypes.DECIMAL, + + + }, total_client_paid: { @@ -116,6 +123,4 @@ summary: { return payroll_line_items; -}; - - +}; \ No newline at end of file diff --git a/backend/src/db/models/workers_comp_classes.js b/backend/src/db/models/workers_comp_classes.js new file mode 100644 index 0000000..8289ac5 --- /dev/null +++ b/backend/src/db/models/workers_comp_classes.js @@ -0,0 +1,54 @@ +const config = require('../../config'); +const providers = config.providers; +const crypto = require('crypto'); +const bcrypt = require('bcrypt'); +const moment = require('moment'); + +module.exports = function(sequelize, DataTypes) { + const workers_comp_classes = sequelize.define( + 'workers_comp_classes', + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + name: { + type: DataTypes.TEXT, + }, + percentage: { + type: DataTypes.DECIMAL, + }, + importHash: { + type: DataTypes.STRING(255), + allowNull: true, + unique: true, + }, + }, + { + timestamps: true, + paranoid: true, + freezeTableName: true, + }, + ); + + workers_comp_classes.associate = (db) => { + db.workers_comp_classes.hasMany(db.job_logs, { + as: 'job_logs_workers_comp_class', + foreignKey: { + name: 'workersCompClassId', + }, + constraints: false, + }); + + db.workers_comp_classes.belongsTo(db.users, { + as: 'createdBy', + }); + + db.workers_comp_classes.belongsTo(db.users, { + as: 'updatedBy', + }); + }; + + return workers_comp_classes; +}; diff --git a/backend/src/index.js b/backend/src/index.js index a3b889b..3613690 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -32,6 +32,8 @@ const customersRoutes = require('./routes/customers'); const vehiclesRoutes = require('./routes/vehicles'); const pay_typesRoutes = require('./routes/pay_types'); +const workers_comp_classesRoutes = require('./routes/workers_comp_classes'); +const workers_comp_reportRoutes = require('./routes/workers_comp_report'); const employee_pay_typesRoutes = require('./routes/employee_pay_types'); @@ -44,6 +46,7 @@ const job_chemical_usagesRoutes = require('./routes/job_chemical_usages'); const payroll_runsRoutes = require('./routes/payroll_runs'); const payroll_line_itemsRoutes = require('./routes/payroll_line_items'); +const reportsRoutes = require("./routes/reports"); const getBaseUrl = (url) => { @@ -114,6 +117,8 @@ app.use('/api/customers', passport.authenticate('jwt', {session: false}), custom app.use('/api/vehicles', passport.authenticate('jwt', {session: false}), vehiclesRoutes); app.use('/api/pay_types', passport.authenticate('jwt', {session: false}), pay_typesRoutes); +app.use('/api/workers_comp_classes', passport.authenticate('jwt', {session: false}), workers_comp_classesRoutes); +app.use('/api/workers_comp_report', passport.authenticate('jwt', {session: false}), workers_comp_reportRoutes); app.use('/api/employee_pay_types', passport.authenticate('jwt', {session: false}), employee_pay_typesRoutes); @@ -126,6 +131,8 @@ app.use('/api/job_chemical_usages', passport.authenticate('jwt', {session: false app.use('/api/payroll_runs', passport.authenticate('jwt', {session: false}), payroll_runsRoutes); app.use('/api/payroll_line_items', passport.authenticate('jwt', {session: false}), payroll_line_itemsRoutes); +app.use('/api/reports', passport.authenticate('jwt', {session: false}), reportsRoutes); +app.use('/api/payroll_generator', require('./routes/payroll_generator')); app.use( '/api/openai', @@ -166,7 +173,7 @@ if (fs.existsSync(publicDir)) { const PORT = process.env.NODE_ENV === 'dev_stage' ? 3000 : 8080; app.listen(PORT, () => { - console.log(`Listening on port ${PORT}`); + console.log(`Listening on port ${PORT}`); console.log('Watcher triggered'); }); module.exports = app; diff --git a/backend/src/routes/employee_pay_types.js b/backend/src/routes/employee_pay_types.js index 311b5d8..a4f7519 100644 --- a/backend/src/routes/employee_pay_types.js +++ b/backend/src/routes/employee_pay_types.js @@ -335,7 +335,6 @@ router.get('/count', wrapAsync(async (req, res) => { const currentUser = req.currentUser; const payload = await Employee_pay_typesDBApi.findAll( req.query, - null, { countOnly: true, currentUser } ); @@ -373,7 +372,7 @@ router.get('/autocomplete', async (req, res) => { req.query.query, req.query.limit, req.query.offset, - + { currentUser: req.currentUser }, ); res.status(200).send(payload); @@ -414,6 +413,7 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Employee_pay_typesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); diff --git a/backend/src/routes/job_logs.js b/backend/src/routes/job_logs.js index 2c72b52..f8aa59a 100644 --- a/backend/src/routes/job_logs.js +++ b/backend/src/routes/job_logs.js @@ -355,7 +355,6 @@ router.get('/count', wrapAsync(async (req, res) => { const currentUser = req.currentUser; const payload = await Job_logsDBApi.findAll( req.query, - null, { countOnly: true, currentUser } ); @@ -393,7 +392,7 @@ router.get('/autocomplete', async (req, res) => { req.query.query, req.query.limit, req.query.offset, - + { currentUser: req.currentUser }, ); res.status(200).send(payload); @@ -434,6 +433,7 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Job_logsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); diff --git a/backend/src/routes/pay_types.js b/backend/src/routes/pay_types.js index 12e47c2..2f80e20 100644 --- a/backend/src/routes/pay_types.js +++ b/backend/src/routes/pay_types.js @@ -348,7 +348,6 @@ router.get('/count', wrapAsync(async (req, res) => { const currentUser = req.currentUser; const payload = await Pay_typesDBApi.findAll( req.query, - null, { countOnly: true, currentUser } ); @@ -386,7 +385,7 @@ router.get('/autocomplete', async (req, res) => { req.query.query, req.query.limit, req.query.offset, - + { currentUser: req.currentUser }, ); res.status(200).send(payload); @@ -427,6 +426,7 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Pay_typesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); diff --git a/backend/src/routes/payroll_generator.js b/backend/src/routes/payroll_generator.js new file mode 100644 index 0000000..4503435 --- /dev/null +++ b/backend/src/routes/payroll_generator.js @@ -0,0 +1,208 @@ +const express = require('express'); +const router = express.Router(); +const passport = require('passport'); +const db = require('../db/models'); +const { wrapAsync } = require('../helpers'); +const { Op } = require('sequelize'); + +const getInclusiveDateRange = (startDate, endDate) => ({ + start: new Date(`${startDate}T00:00:00.000Z`), + end: new Date(`${endDate}T23:59:59.999Z`), +}); + +router.post('/preview', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const { startDate, endDate } = req.body; + + if (!startDate || !endDate) { + return res.status(400).send('startDate and endDate are required'); + } + + const { start, end } = getInclusiveDateRange(startDate, endDate); + + // Find job logs in range that are not paid + const jobLogs = await db.job_logs.findAll({ + where: { + work_date: { + [Op.between]: [start, end] + }, + status: { + [Op.ne]: 'paid' + } + }, + include: [ + { model: db.users, as: 'employee' }, + { model: db.pay_types, as: 'pay_type' } + ] + }); + + const employeeTotals = {}; + + jobLogs.forEach(log => { + const empId = log.employeeId; + if (!empId) return; + + if (!employeeTotals[empId]) { + employeeTotals[empId] = { + employee: log.employee, + total_hours: 0, + total_commission_base: 0, + gross_pay: 0, + job_log_ids: [] + }; + } + + const totals = employeeTotals[empId]; + totals.job_log_ids.push(log.id); + + const hours = parseFloat(log.hours_conducted || 0); + totals.total_hours += hours; + + const clientPaid = parseFloat(log.client_paid || 0); + totals.total_commission_base += clientPaid; + + let pay = 0; + if (log.pay_type) { + if (log.pay_type.pay_method === 'hourly') { + pay = hours * parseFloat(log.pay_type.hourly_rate || 0); + } else if (log.pay_type.pay_method === 'commission') { + pay = clientPaid * (parseFloat(log.pay_type.commission_rate || 0) / 100); + } + } + totals.gross_pay += pay; + }); + + const summary = { + totalGrossPay: 0, + totalHours: 0 + }; + + const lineItems = Object.values(employeeTotals).map(item => { + summary.totalGrossPay += item.gross_pay; + summary.totalHours += item.total_hours; + return item; + }); + + res.json({ lineItems, summary }); +})); + +router.post('/generate', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const { startDate, endDate, name, notes } = req.body; + + if (!startDate || !endDate) { + return res.status(400).send('startDate and endDate are required'); + } + + const { start, end } = getInclusiveDateRange(startDate, endDate); + + // Find job logs + const jobLogs = await db.job_logs.findAll({ + where: { + work_date: { + [Op.between]: [start, end] + }, + status: { + [Op.ne]: 'paid' + } + }, + include: [ + { model: db.users, as: 'employee' }, + { model: db.pay_types, as: 'pay_type' } + ] + }); + + if (jobLogs.length === 0) { + return res.status(400).send('No unpaid job logs found in this date range.'); + } + + const employeeTotals = {}; + + jobLogs.forEach(log => { + const empId = log.employeeId; + if (!empId) return; + + if (!employeeTotals[empId]) { + employeeTotals[empId] = { + total_hours: 0, + total_commission_base: 0, + gross_pay: 0, + workers_comp_amount: 0, + employeeId: empId, + job_log_ids: [] + }; + } + + const totals = employeeTotals[empId]; + totals.job_log_ids.push(log.id); + + const hours = parseFloat(log.hours_conducted || 0); + totals.total_hours += hours; + + const clientPaid = parseFloat(log.client_paid || 0); + totals.total_commission_base += clientPaid; + + let pay = 0; + let wcAmount = 0; + if (log.pay_type) { + if (log.pay_type.pay_method === 'hourly') { + pay = hours * parseFloat(log.pay_type.hourly_rate || 0); + } else if (log.pay_type.pay_method === 'commission') { + pay = clientPaid * (parseFloat(log.pay_type.commission_rate || 0) / 100); + } + if (log.pay_type.workers_comp_percentage) { + wcAmount = pay * (parseFloat(log.pay_type.workers_comp_percentage) / 100); + } + } + totals.gross_pay += pay; + totals.workers_comp_amount += wcAmount; + }); + + // Create a transaction + const transaction = await db.sequelize.transaction(); + try { + // Create payroll run + const payrollRun = await db.payroll_runs.create({ + name: name || `Payroll ${startDate} to ${endDate}`, + period_start: startDate, + period_end: endDate, + run_date: new Date(), + status: 'finalized', + notes: notes || '', + createdById: req.currentUser.id + }, { transaction }); + + // Create line items + for (const empId of Object.keys(employeeTotals)) { + const totals = employeeTotals[empId]; + await db.payroll_line_items.create({ + payroll_runId: payrollRun.id, + employeeId: totals.employeeId, + total_hours: totals.total_hours, + gross_pay: totals.gross_pay, + total_commission_base: totals.total_commission_base, + workers_comp_amount: totals.workers_comp_amount, + createdById: req.currentUser.id + }, { transaction }); + + // Mark job logs as paid + await db.job_logs.update({ + status: 'paid' + }, { + where: { + id: { + [Op.in]: totals.job_log_ids + } + }, + transaction + }); + } + + await transaction.commit(); + res.json({ success: true, payrollRunId: payrollRun.id }); + } catch (error) { + await transaction.rollback(); + console.error(error); + res.status(500).send('Error generating payroll'); + } +})); + +module.exports = router; diff --git a/backend/src/routes/reports.js b/backend/src/routes/reports.js new file mode 100644 index 0000000..9452846 --- /dev/null +++ b/backend/src/routes/reports.js @@ -0,0 +1,39 @@ +const express = require('express'); +const router = express.Router(); +const passport = require('passport'); +const db = require('../db/models'); +const { wrapAsync } = require('../helpers'); +const { Op } = require('sequelize'); + +const getDateBoundary = (date, boundary) => + new Date(`${date}T${boundary === 'start' ? '00:00:00.000' : '23:59:59.999'}Z`); + +router.post('/', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const { startDate, endDate, employeeId } = req.body; + + const where = {}; + if (startDate || endDate) { + where.createdAt = {}; + if (startDate) where.createdAt[Op.gte] = getDateBoundary(startDate, 'start'); + if (endDate) where.createdAt[Op.lte] = getDateBoundary(endDate, 'end'); + } + if (employeeId) { + where.employeeId = employeeId; + } + + const lineItems = await db.payroll_line_items.findAll({ + where, + include: [{ model: db.users, as: 'employee' }] + }); + + const summary = lineItems.reduce((acc, item) => { + acc.totalGrossPay += parseFloat(item.gross_pay || 0); + acc.totalHours += parseFloat(item.total_hours || 0); + acc.totalWorkersComp += parseFloat(item.workers_comp_amount || 0); + return acc; + }, { totalGrossPay: 0, totalHours: 0, totalWorkersComp: 0 }); + + res.json({ lineItems, summary }); +})); + +module.exports = router; diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 19df9ae..52ae470 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -347,7 +347,6 @@ router.get('/count', wrapAsync(async (req, res) => { const currentUser = req.currentUser; const payload = await UsersDBApi.findAll( req.query, - null, { countOnly: true, currentUser } ); @@ -385,7 +384,7 @@ router.get('/autocomplete', async (req, res) => { req.query.query, req.query.limit, req.query.offset, - + { currentUser: req.currentUser }, ); res.status(200).send(payload); @@ -426,11 +425,12 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await UsersDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); - - + + if (payload) { delete payload.password; - + } res.status(200).send(payload); })); diff --git a/backend/src/routes/workers_comp_classes.js b/backend/src/routes/workers_comp_classes.js new file mode 100644 index 0000000..3f70f04 --- /dev/null +++ b/backend/src/routes/workers_comp_classes.js @@ -0,0 +1,439 @@ + +const express = require('express'); + +const Workers_comp_classesService = require('../services/workers_comp_classes'); +const Workers_comp_classesDBApi = require('../db/api/workers_comp_classes'); +const wrapAsync = require('../helpers').wrapAsync; + + +const router = express.Router(); + +const { parse } = require('json2csv'); + + +const { + checkCrudPermissions, +} = require('../middlewares/check-permissions'); + +router.use(checkCrudPermissions('workers_comp_classes')); + + +/** + * @swagger + * components: + * schemas: + * Workers_comp_classes: + * type: object + * properties: + + * name: + * type: string + * default: name + * description: + * type: string + * default: description + + + * hourly_rate: + * type: integer + * format: int64 + * commission_rate: + * type: integer + * format: int64 + + * + */ + +/** + * @swagger + * tags: + * name: Workers_comp_classes + * description: The Workers_comp_classes managing API + */ + +/** +* @swagger +* /api/workers_comp_classes: +* post: +* security: +* - bearerAuth: [] +* tags: [Workers_comp_classes] +* summary: Add new item +* description: Add new item +* requestBody: +* required: true +* content: +* application/json: +* schema: +* properties: +* data: +* description: Data of the updated item +* type: object +* $ref: "#/components/schemas/Workers_comp_classes" +* responses: +* 200: +* description: The item was successfully added +* content: +* application/json: +* schema: +* $ref: "#/components/schemas/Workers_comp_classes" +* 401: +* $ref: "#/components/responses/UnauthorizedError" +* 405: +* description: Invalid input data +* 500: +* description: Some server error +*/ +router.post('/', wrapAsync(async (req, res) => { + const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await Workers_comp_classesService.create(req.body.data, req.currentUser, true, link.host); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/budgets/bulk-import: + * post: + * security: + * - bearerAuth: [] + * tags: [Workers_comp_classes] + * summary: Bulk import items + * description: Bulk import items + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * data: + * description: Data of the updated items + * type: array + * items: + * $ref: "#/components/schemas/Workers_comp_classes" + * responses: + * 200: + * description: The items were successfully imported + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Workers_comp_classes" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 405: + * description: Invalid input data + * 500: + * description: Some server error + * + */ +router.post('/bulk-import', wrapAsync(async (req, res) => { + const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; + const link = new URL(referer); + await Workers_comp_classesService.bulkImport(req, res, true, link.host); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/workers_comp_classes/{id}: + * put: + * security: + * - bearerAuth: [] + * tags: [Workers_comp_classes] + * summary: Update the data of the selected item + * description: Update the data of the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to update + * required: true + * schema: + * type: string + * requestBody: + * description: Set new item data + * required: true + * content: + * application/json: + * schema: + * properties: + * id: + * description: ID of the updated item + * type: string + * data: + * description: Data of the updated item + * type: object + * $ref: "#/components/schemas/Workers_comp_classes" + * required: + * - id + * responses: + * 200: + * description: The item data was successfully updated + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Workers_comp_classes" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.put('/:id', wrapAsync(async (req, res) => { + await Workers_comp_classesService.update(req.body.data, req.body.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/workers_comp_classes/{id}: + * delete: + * security: + * - bearerAuth: [] + * tags: [Workers_comp_classes] + * summary: Delete the selected item + * description: Delete the selected item + * parameters: + * - in: path + * name: id + * description: Item ID to delete + * required: true + * schema: + * type: string + * responses: + * 200: + * description: The item was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Workers_comp_classes" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.delete('/:id', wrapAsync(async (req, res) => { + await Workers_comp_classesService.remove(req.params.id, req.currentUser); + const payload = true; + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/workers_comp_classes/deleteByIds: + * post: + * security: + * - bearerAuth: [] + * tags: [Workers_comp_classes] + * summary: Delete the selected item list + * description: Delete the selected item list + * requestBody: + * required: true + * content: + * application/json: + * schema: + * properties: + * ids: + * description: IDs of the updated items + * type: array + * responses: + * 200: + * description: The items was successfully deleted + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Workers_comp_classes" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Items not found + * 500: + * description: Some server error + */ +router.post('/deleteByIds', wrapAsync(async (req, res) => { + await Workers_comp_classesService.deleteByIds(req.body.data, req.currentUser); + const payload = true; + res.status(200).send(payload); + })); + +/** + * @swagger + * /api/workers_comp_classes: + * get: + * security: + * - bearerAuth: [] + * tags: [Workers_comp_classes] + * summary: Get all workers_comp_classes + * description: Get all workers_comp_classes + * responses: + * 200: + * description: Workers_comp_classes list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Workers_comp_classes" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error +*/ +router.get('/', wrapAsync(async (req, res) => { + const filetype = req.query.filetype + + const currentUser = req.currentUser; + const payload = await Workers_comp_classesDBApi.findAll( + req.query, { currentUser } + ); + if (filetype && filetype === 'csv') { + const fields = ['id','name','description', + + 'hourly_rate','commission_rate', + + ]; + const opts = { fields }; + try { + const csv = parse(payload.rows, opts); + res.status(200).attachment(csv); + res.send(csv) + + } catch (err) { + console.error(err); + } + } else { + res.status(200).send(payload); + } + +})); + +/** + * @swagger + * /api/workers_comp_classes/count: + * get: + * security: + * - bearerAuth: [] + * tags: [Workers_comp_classes] + * summary: Count all workers_comp_classes + * description: Count all workers_comp_classes + * responses: + * 200: + * description: Workers_comp_classes count successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Workers_comp_classes" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/count', wrapAsync(async (req, res) => { + + const currentUser = req.currentUser; + const payload = await Workers_comp_classesDBApi.findAll( + req.query, + null, + { countOnly: true, currentUser } + ); + + res.status(200).send(payload); +})); + +/** + * @swagger + * /api/workers_comp_classes/autocomplete: + * get: + * security: + * - bearerAuth: [] + * tags: [Workers_comp_classes] + * summary: Find all workers_comp_classes that match search criteria + * description: Find all workers_comp_classes that match search criteria + * responses: + * 200: + * description: Workers_comp_classes list successfully received + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: "#/components/schemas/Workers_comp_classes" + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Data not found + * 500: + * description: Some server error + */ +router.get('/autocomplete', async (req, res) => { + + const payload = await Workers_comp_classesDBApi.findAllAutocomplete( + req.query.query, + req.query.limit, + req.query.offset, + + ); + + res.status(200).send(payload); +}); + +/** + * @swagger + * /api/workers_comp_classes/{id}: + * get: + * security: + * - bearerAuth: [] + * tags: [Workers_comp_classes] + * summary: Get selected item + * description: Get selected item + * parameters: + * - in: path + * name: id + * description: ID of item to get + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Selected item successfully received + * content: + * application/json: + * schema: + * $ref: "#/components/schemas/Workers_comp_classes" + * 400: + * description: Invalid ID supplied + * 401: + * $ref: "#/components/responses/UnauthorizedError" + * 404: + * description: Item not found + * 500: + * description: Some server error + */ +router.get('/:id', wrapAsync(async (req, res) => { + const payload = await Workers_comp_classesDBApi.findBy( + { id: req.params.id }, + ); + + + + res.status(200).send(payload); +})); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/workers_comp_report.js b/backend/src/routes/workers_comp_report.js new file mode 100644 index 0000000..107ae9a --- /dev/null +++ b/backend/src/routes/workers_comp_report.js @@ -0,0 +1,79 @@ +const express = require('express'); +const router = express.Router(); +const passport = require('passport'); +const db = require('../db/models'); +const { wrapAsync } = require('../helpers'); +const { Op } = require('sequelize'); + +const buildDateBoundary = (dateValue, boundary) => { + if (!dateValue) { + return null; + } + + const timeSuffix = + boundary === 'start' ? 'T00:00:00.000Z' : 'T23:59:59.999Z'; + + return new Date(`${dateValue}${timeSuffix}`); +}; + +router.get('/report', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const { startDate, endDate, classId } = req.query; + + const where = {}; + const start = buildDateBoundary(startDate, 'start'); + const end = buildDateBoundary(endDate, 'end'); + + if (start && end && start > end) { + return res.status(400).json({ message: 'startDate must be on or before endDate' }); + } + + if (start || end) { + where.work_date = {}; + + if (start) { + where.work_date[Op.gte] = start; + } + + if (end) { + where.work_date[Op.lte] = end; + } + } + + if (classId) { + where.workersCompClassId = classId; + } + + const jobLogs = await db.job_logs.findAll({ + where, + include: [ + { model: db.workers_comp_classes, as: 'workersCompClass' }, + { model: db.pay_types, as: 'pay_type' }, + { model: db.users, as: 'employee' }, + ], + }); + + const totalsByClass = {}; + let totalComp = 0; + + jobLogs.forEach((log) => { + if (!log.workersCompClass || !log.pay_type) return; + + let employeePay = 0; + if (log.pay_type.pay_method === 'hourly') { + employeePay = Number(log.hours_conducted || 0) * Number(log.pay_type.hourly_rate || 0); + } else if (log.pay_type.pay_method === 'commission') { + employeePay = Number(log.client_paid || 0) * (Number(log.pay_type.commission_rate || 0) / 100); + } + + const compAmount = employeePay * (Number(log.workersCompClass.percentage || 0) / 100); + const className = log.workersCompClass.name; + + if (!totalsByClass[className]) totalsByClass[className] = 0; + totalsByClass[className] += compAmount; + totalComp += compAmount; + }); + + res.json({ totalsByClass, totalComp }); +})); + +module.exports = router; diff --git a/backend/src/security/payrollAccess.js b/backend/src/security/payrollAccess.js new file mode 100644 index 0000000..96421b0 --- /dev/null +++ b/backend/src/security/payrollAccess.js @@ -0,0 +1,19 @@ +const PRIVILEGED_PAYROLL_ROLE_NAMES = new Set([ + 'Administrator', + 'System Owner', + 'Payroll Manager', + 'Operations Manager', +]); + +function isRestrictedPayrollUser(currentUser) { + if (!currentUser?.id) { + return false; + } + + return !PRIVILEGED_PAYROLL_ROLE_NAMES.has(currentUser?.app_role?.name); +} + +module.exports = { + PRIVILEGED_PAYROLL_ROLE_NAMES, + isRestrictedPayrollUser, +}; diff --git a/backend/src/services/job_logs.js b/backend/src/services/job_logs.js index 5ba18c8..b91172c 100644 --- a/backend/src/services/job_logs.js +++ b/backend/src/services/job_logs.js @@ -1,28 +1,79 @@ const db = require('../db/models'); const Job_logsDBApi = require('../db/api/job_logs'); +const CustomersDBApi = require('../db/api/customers'); 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 { isRestrictedPayrollUser } = require('../security/payrollAccess'); module.exports = class Job_logsService { + static async ensureRestrictedPayTypeAccess(payTypeId, currentUser, transaction) { + if (!isRestrictedPayrollUser(currentUser) || !payTypeId) { + return; + } + + const assignment = await db.employee_pay_types.findOne({ + where: { + employeeId: currentUser.id, + pay_typeId: payTypeId, + active: true, + }, + transaction, + }); + + if (!assignment) { + throw new ValidationError('errors.forbidden.message'); + } + } + static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { - await Job_logsDBApi.create( - data, + let customerId = data.customer; + + // If customer is a string and not a UUID, try to find or create + if (typeof customerId === 'string' && customerId.length > 0 && !customerId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) { + let customer = await db.customers.findOne({ where: { name: customerId }, transaction }); + if (!customer) { + customer = await CustomersDBApi.create({ name: customerId }, { currentUser, transaction }); + } + customerId = customer.id; + } + + const jobPayload = { ...data, customer: customerId }; + + if (isRestrictedPayrollUser(currentUser)) { + jobPayload.employee = currentUser.id; + } + + await Job_logsService.ensureRestrictedPayTypeAccess(jobPayload.pay_type, currentUser, transaction); + + const createdJob = await Job_logsDBApi.create( + jobPayload, { currentUser, transaction, }, ); + if (data.chemical_usages && Array.isArray(data.chemical_usages)) { + for (const usage of data.chemical_usages) { + if (usage.chemical_product && usage.quantity_used) { + await db.job_chemical_usages.create({ + job_logId: createdJob.id, + chemical_productId: usage.chemical_product, + quantity_used: usage.quantity_used, + notes: usage.notes || '', + createdById: currentUser.id, + updatedById: currentUser.id + }, { transaction }); + } + } + } + await transaction.commit(); } catch (error) { await transaction.rollback(); @@ -70,7 +121,10 @@ module.exports = class Job_logsService { try { let job_logs = await Job_logsDBApi.findBy( {id}, - {transaction}, + { + transaction, + currentUser, + }, ); if (!job_logs) { @@ -79,9 +133,36 @@ module.exports = class Job_logsService { ); } + if (isRestrictedPayrollUser(currentUser) && job_logs.employeeId !== currentUser.id) { + throw new ValidationError('errors.forbidden.message'); + } + + let customerId = data.customer; + + // If customer is a string and not a UUID, try to find or create + if (typeof customerId === 'string' && customerId.length > 0 && !customerId.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)) { + let customer = await db.customers.findOne({ where: { name: customerId }, transaction }); + if (!customer) { + customer = await CustomersDBApi.create({ name: customerId }, { currentUser, transaction }); + } + customerId = customer.id; + } + + const jobPayload = { ...data, customer: customerId }; + + if (isRestrictedPayrollUser(currentUser)) { + jobPayload.employee = currentUser.id; + } + + await Job_logsService.ensureRestrictedPayTypeAccess( + jobPayload.pay_type !== undefined ? jobPayload.pay_type : job_logs.pay_typeId, + currentUser, + transaction, + ); + const updatedJob_logs = await Job_logsDBApi.update( id, - data, + jobPayload, { currentUser, transaction, @@ -131,8 +212,4 @@ module.exports = class Job_logsService { throw error; } } - - -}; - - +}; \ No newline at end of file diff --git a/backend/src/services/payroll_line_items.js b/backend/src/services/payroll_line_items.js index f3d5234..3583010 100644 --- a/backend/src/services/payroll_line_items.js +++ b/backend/src/services/payroll_line_items.js @@ -15,6 +15,15 @@ module.exports = class Payroll_line_itemsService { static async create(data, currentUser) { const transaction = await db.sequelize.transaction(); try { + if (data.job_logId && data.gross_pay) { + const jobLog = await db.job_logs.findByPk(data.job_logId, { transaction }); + if (jobLog && jobLog.workersCompClassId) { + const compClass = await db.workers_comp_classes.findByPk(jobLog.workersCompClassId, { transaction }); + if (compClass && compClass.percentage) { + data.workers_comp_amount = (Number(data.gross_pay || 0) * Number(compClass.percentage || 0)) / 100; + } + } + } await Payroll_line_itemsDBApi.create( data, { diff --git a/backend/src/services/users.js b/backend/src/services/users.js index 555d193..e8093a1 100644 --- a/backend/src/services/users.js +++ b/backend/src/services/users.js @@ -13,6 +13,63 @@ const EmailSender = require('./email'); const AuthService = require('./auth'); module.exports = class UsersService { + static normalizePayTypeIds(payTypes = []) { + return [...new Set((payTypes || []) + .map((payType) => { + if (typeof payType === 'string') { + return payType; + } + + return payType?.id || payType?.value || null; + }) + .filter(Boolean))]; + } + + static async syncEmployeePayTypes(employeeId, payTypes, currentUser, transaction) { + const payTypeIds = UsersService.normalizePayTypeIds(payTypes); + const selectedPayTypeIds = new Set(payTypeIds); + + const existingAssignments = await db.employee_pay_types.findAll({ + where: { employeeId }, + transaction, + }); + + const existingPayTypeIds = new Set(); + + for (const assignment of existingAssignments) { + existingPayTypeIds.add(assignment.pay_typeId); + + const shouldBeActive = selectedPayTypeIds.has(assignment.pay_typeId); + + if (assignment.active !== shouldBeActive) { + await assignment.update( + { + active: shouldBeActive, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + } + + for (const payTypeId of payTypeIds) { + if (existingPayTypeIds.has(payTypeId)) { + continue; + } + + await db.employee_pay_types.create( + { + employeeId, + pay_typeId: payTypeId, + active: true, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + } + static async create(data, currentUser, sendInvitationEmails = true, host) { let transaction = await db.sequelize.transaction(); @@ -26,7 +83,7 @@ module.exports = class UsersService { 'iam.errors.userAlreadyExists', ); } else { - await UsersDBApi.create( + const createdUser = await UsersDBApi.create( {data}, { @@ -34,6 +91,15 @@ module.exports = class UsersService { transaction, }, ); + + if (data.pay_types !== undefined) { + await UsersService.syncEmployeePayTypes( + createdUser.id, + data.pay_types, + currentUser, + transaction, + ); + } emailsToInvite.push(email); } } else { @@ -127,6 +193,15 @@ module.exports = class UsersService { }, ); + if (data.pay_types !== undefined) { + await UsersService.syncEmployeePayTypes( + id, + data.pay_types, + currentUser, + transaction, + ); + } + await transaction.commit(); return updatedUser; diff --git a/backend/src/services/workers_comp_classes.js b/backend/src/services/workers_comp_classes.js new file mode 100644 index 0000000..70d1f08 --- /dev/null +++ b/backend/src/services/workers_comp_classes.js @@ -0,0 +1,138 @@ +const db = require('../db/models'); +const Workers_comp_classesDBApi = require('../db/api/workers_comp_classes'); +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'); + + + + + +module.exports = class Workers_comp_classesService { + static async create(data, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + await Workers_comp_classesDBApi.create( + data, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }; + + static async bulkImport(req, res, sendInvitationEmails = true, host) { + const transaction = await db.sequelize.transaction(); + + try { + await processFile(req, res); + const bufferStream = new stream.PassThrough(); + const results = []; + + await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream + + await new Promise((resolve, reject) => { + bufferStream + .pipe(csv()) + .on('data', (data) => results.push(data)) + .on('end', async () => { + console.log('CSV results', results); + resolve(); + }) + .on('error', (error) => reject(error)); + }) + + await Workers_comp_classesDBApi.bulkImport(results, { + transaction, + ignoreDuplicates: true, + validate: true, + currentUser: req.currentUser + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async update(data, id, currentUser) { + const transaction = await db.sequelize.transaction(); + try { + let workers_comp_classes = await Workers_comp_classesDBApi.findBy( + {id}, + {transaction}, + ); + + if (!workers_comp_classes) { + throw new ValidationError( + 'workers_comp_classesNotFound', + ); + } + + const updatedWorkers_comp_classes = await Workers_comp_classesDBApi.update( + id, + data, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + return updatedWorkers_comp_classes; + + } catch (error) { + await transaction.rollback(); + throw error; + } + }; + + static async deleteByIds(ids, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Workers_comp_classesDBApi.deleteByIds(ids, { + currentUser, + transaction, + }); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + static async remove(id, currentUser) { + const transaction = await db.sequelize.transaction(); + + try { + await Workers_comp_classesDBApi.remove( + id, + { + currentUser, + transaction, + }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + } + + +}; + + diff --git a/fix.sql b/fix.sql new file mode 100644 index 0000000..23b96f4 --- /dev/null +++ b/fix.sql @@ -0,0 +1 @@ +ALTER TABLE "usersCustom_permissionsPermissions" ADD COLUMN "permissionId" UUID REFERENCES "permissions"(id); diff --git a/frontend/public/assets/logo.webp b/frontend/public/assets/logo.webp new file mode 100644 index 0000000..bf459c8 Binary files /dev/null and b/frontend/public/assets/logo.webp differ diff --git a/frontend/public/assets/vm-shot-2026-03-14T13-28-33-023Z.jpg b/frontend/public/assets/vm-shot-2026-03-14T13-28-33-023Z.jpg new file mode 100644 index 0000000..deb8ea0 Binary files /dev/null and b/frontend/public/assets/vm-shot-2026-03-14T13-28-33-023Z.jpg differ diff --git a/frontend/public/assets/vm-shot-2026-04-13T13-17-17-604Z.jpg b/frontend/public/assets/vm-shot-2026-04-13T13-17-17-604Z.jpg new file mode 100644 index 0000000..dee1594 Binary files /dev/null and b/frontend/public/assets/vm-shot-2026-04-13T13-17-17-604Z.jpg differ diff --git a/frontend/src/components/AsideMenu.tsx b/frontend/src/components/AsideMenu.tsx index 442dfac..11e58a3 100644 --- a/frontend/src/components/AsideMenu.tsx +++ b/frontend/src/components/AsideMenu.tsx @@ -19,7 +19,7 @@ export default function AsideMenu({ <> { )} {item.label} @@ -63,7 +64,7 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => { ) const componentClass = [ - 'flex cursor-pointer py-1.5 ', + 'flex items-start cursor-pointer py-1.5 min-h-[3rem]', isDropdownList ? 'px-6 text-sm' : '', item.color ? getButtonColor(item.color, false, true) @@ -77,12 +78,12 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
  • {item.withDevider &&
    } {item.href && ( - + {asideMenuItemInnerContents} )} {!item.href && ( -
    setIsDropdownActive(!isDropdownActive)}> +
    setIsDropdownActive(!isDropdownActive)} title={item.label}> {asideMenuItemInnerContents}
    )} diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 4da4312..a11aebe 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -29,7 +29,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props return (
    @@ -161,6 +170,18 @@ const CardJob_logs = ({ +
    +
    EmployeePay
    +
    +
    + { formatCurrency(payPreview.estimatedPay) } +
    +
    +
    + + + +
    Vehicle
    @@ -234,7 +255,8 @@ const CardJob_logs = ({
  • - ))} + ); + })} {!loading && job_logs.length === 0 && (

    No data to display

    diff --git a/frontend/src/components/Job_logs/ListJob_logs.tsx b/frontend/src/components/Job_logs/ListJob_logs.tsx index a3763e3..184185d 100644 --- a/frontend/src/components/Job_logs/ListJob_logs.tsx +++ b/frontend/src/components/Job_logs/ListJob_logs.tsx @@ -10,6 +10,7 @@ import LoadingSpinner from "../LoadingSpinner"; import Link from 'next/link'; import {hasPermission} from "../../helpers/userPermissions"; +import { calculateJobLogPayPreview, formatCurrency } from '../../helpers/jobLogPayPreview'; type Props = { @@ -34,7 +35,15 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa <>
    {loading && } - {!loading && job_logs.map((item) => ( + {!loading && job_logs.map((item) => { + const payPreview = calculateJobLogPayPreview({ + payType: item.pay_type, + workersCompClass: item.workersCompClass, + hoursConducted: item.hours_conducted, + clientPaid: item.client_paid, + }); + + return (
    @@ -89,7 +98,7 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa

    WorkmansCompensationClass

    -

    { item.workers_comp_class }

    +

    { item.workersCompClass?.name }

    @@ -103,6 +112,14 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa +
    +

    EmployeePay

    +

    { formatCurrency(payPreview.estimatedPay) }

    +
    + + + +

    Vehicle

    { dataFormatter.vehiclesOneListFormatter(item.vehicle) }

    @@ -163,7 +180,8 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa
    - ))} + ); + })} {!loading && job_logs.length === 0 && (

    No data to display

    diff --git a/frontend/src/components/Job_logs/configureJob_logsCols.tsx b/frontend/src/components/Job_logs/configureJob_logsCols.tsx index ea9da9e..2f75d3d 100644 --- a/frontend/src/components/Job_logs/configureJob_logsCols.tsx +++ b/frontend/src/components/Job_logs/configureJob_logsCols.tsx @@ -14,6 +14,7 @@ import DataGridMultiSelect from "../DataGridMultiSelect"; import ListActionsPopover from '../ListActionsPopover'; import {hasPermission} from "../../helpers/userPermissions"; +import { calculateJobLogPayPreview, formatCurrency } from '../../helpers/jobLogPayPreview'; type Params = (id: string) => void; @@ -136,7 +137,7 @@ export const loadColumns = async ( }, { - field: 'workers_comp_class', + field: 'workersCompClass', valueGetter: (params) => params.row?.workersCompClass?.name, headerName: 'WorkmansCompensationClass', flex: 1, minWidth: 120, @@ -172,6 +173,29 @@ export const loadColumns = async ( }, + { + field: 'employeePay', + headerName: 'EmployeePay', + flex: 1, + minWidth: 140, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: false, + sortable: false, + type: 'number', + valueGetter: (params: GridValueGetterParams) => + calculateJobLogPayPreview({ + payType: params.row?.pay_type, + workersCompClass: params.row?.workersCompClass, + hoursConducted: params.row?.hours_conducted, + clientPaid: params.row?.client_paid, + }).estimatedPay, + valueFormatter: ({ value }) => formatCurrency(value), + + }, + { field: 'vehicle', headerName: 'Vehicle', @@ -194,52 +218,7 @@ export const loadColumns = async ( }, - { - field: 'odometer_start', - headerName: 'OdometerStart', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - type: 'number', - - }, - - { - field: 'odometer_end', - headerName: 'OdometerEnd', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - type: 'number', - - }, - - { - field: 'job_address', - headerName: 'JobAddress', - flex: 1, - minWidth: 120, - filterable: false, - headerClassName: 'datagrid--header', - cellClassName: 'datagrid--cell', - - - editable: hasUpdatePermission, - - - }, + { field: 'status', diff --git a/frontend/src/components/Logo/index.tsx b/frontend/src/components/Logo/index.tsx index a582e29..5ce524c 100644 --- a/frontend/src/components/Logo/index.tsx +++ b/frontend/src/components/Logo/index.tsx @@ -7,9 +7,9 @@ type Props = { export default function Logo({ className = '' }: Props) { return ( {'Flatlogic + alt={'Major League Pressure Washing logo'}> ) } diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..a47d445 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -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' diff --git a/frontend/src/components/Users/configureUsersCols.tsx b/frontend/src/components/Users/configureUsersCols.tsx index 37cebd3..b53c5eb 100644 --- a/frontend/src/components/Users/configureUsersCols.tsx +++ b/frontend/src/components/Users/configureUsersCols.tsx @@ -17,6 +17,15 @@ import {hasPermission} from "../../helpers/userPermissions"; type Params = (id: string) => void; +const formatAssignedPayTypes = (assignments = []) => { + const uniquePayTypeNames = [...new Set((assignments || []) + .filter((assignment) => assignment?.active !== false) + .map((assignment) => assignment?.pay_type?.name) + .filter(Boolean))]; + + return uniquePayTypeNames.length ? uniquePayTypeNames.join(', ') : '—'; +}; + export const loadColumns = async ( onDelete: Params, entityName: string, @@ -180,6 +189,27 @@ export const loadColumns = async ( }, + { + field: 'employee_pay_types_employee', + headerName: 'Pay Types', + flex: 1, + minWidth: 180, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + + editable: false, + sortable: false, + valueGetter: (params: GridValueGetterParams) => + formatAssignedPayTypes(params?.row?.employee_pay_types_employee), + renderCell: (params: GridValueGetterParams) => ( + + {formatAssignedPayTypes(params?.row?.employee_pay_types_employee)} + + ), + + }, + { field: 'actions', type: 'actions', diff --git a/frontend/src/components/Workers_comp_classes/CardWorkers_comp_classes.tsx b/frontend/src/components/Workers_comp_classes/CardWorkers_comp_classes.tsx new file mode 100644 index 0000000..0d2bff7 --- /dev/null +++ b/frontend/src/components/Workers_comp_classes/CardWorkers_comp_classes.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import ImageField from '../ImageField'; +import ListActionsPopover from '../ListActionsPopover'; +import { useAppSelector } from '../../stores/hooks'; +import dataFormatter from '../../helpers/dataFormatter'; +import { Pagination } from '../Pagination'; +import {saveFile} from "../../helpers/fileSaver"; +import LoadingSpinner from "../LoadingSpinner"; +import Link from 'next/link'; + +import {hasPermission} from "../../helpers/userPermissions"; + + +type Props = { + workers_comp_classes: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const CardWorkers_comp_classes = ({ + workers_comp_classes, + loading, + onDelete, + currentPage, + numPages, + onPageChange, +}: Props) => { + const asideScrollbarsStyle = useAppSelector( + (state) => state.style.asideScrollbarsStyle, + ); + const bgColor = useAppSelector((state) => state.style.cardsColor); + const darkMode = useAppSelector((state) => state.style.darkMode); + const corners = useAppSelector((state) => state.style.corners); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PAY_TYPES') + + + return ( +
    + {loading && } +
      + {!loading && workers_comp_classes.map((item, index) => ( +
    • + +
      + + + {item.name} + + + +
      + +
      +
      +
      + + +
      +
      WorkersCompClassName
      +
      +
      + { item.name } +
      +
      +
      + + + + +
      +
      PayMethod
      +
      +
      + { item.pay_method } +
      +
      +
      + + + + +
      +
      HourlyRate
      +
      +
      + { item.hourly_rate } +
      +
      +
      + + + + +
      +
      CommissionRate
      +
      +
      + { item.commission_rate } +
      +
      +
      + + + + +
      +
      Active
      +
      +
      + { dataFormatter.booleanFormatter(item.active) } +
      +
      +
      + + + + +
      +
      Description
      +
      +
      + { item.description } +
      +
      +
      + + + +
      +
    • + ))} + {!loading && workers_comp_classes.length === 0 && ( +
      +

      No data to display

      +
      + )} +
    +
    + +
    +
    + ); +}; + +export default CardWorkers_comp_classes; diff --git a/frontend/src/components/Workers_comp_classes/ListWorkers_comp_classes.tsx b/frontend/src/components/Workers_comp_classes/ListWorkers_comp_classes.tsx new file mode 100644 index 0000000..49ba480 --- /dev/null +++ b/frontend/src/components/Workers_comp_classes/ListWorkers_comp_classes.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import CardBox from '../CardBox'; +import ImageField from '../ImageField'; +import dataFormatter from '../../helpers/dataFormatter'; +import {saveFile} from "../../helpers/fileSaver"; +import ListActionsPopover from "../ListActionsPopover"; +import {useAppSelector} from "../../stores/hooks"; +import {Pagination} from "../Pagination"; +import LoadingSpinner from "../LoadingSpinner"; +import Link from 'next/link'; + +import {hasPermission} from "../../helpers/userPermissions"; + + +type Props = { + workers_comp_classes: any[]; + loading: boolean; + onDelete: (id: string) => void; + currentPage: number; + numPages: number; + onPageChange: (page: number) => void; +}; + +const ListWorkers_comp_classes = ({ workers_comp_classes, loading, onDelete, currentPage, numPages, onPageChange }: Props) => { + + const currentUser = useAppSelector((state) => state.auth.currentUser); + const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PAY_TYPES') + + const corners = useAppSelector((state) => state.style.corners); + const bgColor = useAppSelector((state) => state.style.cardsColor); + + + return ( + <> +
    + {loading && } + {!loading && workers_comp_classes.map((item) => ( +
    + +
    + + dark:divide-dark-700 overflow-x-auto' + } + > + + +
    +

    WorkersCompClassName

    +

    { item.name }

    +
    + + + + +
    +

    PayMethod

    +

    { item.pay_method }

    +
    + + + + +
    +

    HourlyRate

    +

    { item.hourly_rate }

    +
    + + + + +
    +

    CommissionRate

    +

    { item.commission_rate }

    +
    + + + + +
    +

    Active

    +

    { dataFormatter.booleanFormatter(item.active) }

    +
    + + + + +
    +

    Description

    +

    { item.description }

    +
    + + + + + +
    +
    +
    + ))} + {!loading && workers_comp_classes.length === 0 && ( +
    +

    No data to display

    +
    + )} +
    +
    + +
    + + ) +}; + +export default ListWorkers_comp_classes \ No newline at end of file diff --git a/frontend/src/components/Workers_comp_classes/TableWorkers_comp_classes.tsx b/frontend/src/components/Workers_comp_classes/TableWorkers_comp_classes.tsx new file mode 100644 index 0000000..52a5d26 --- /dev/null +++ b/frontend/src/components/Workers_comp_classes/TableWorkers_comp_classes.tsx @@ -0,0 +1,463 @@ +import React, { useEffect, useState, useMemo } from 'react' +import { createPortal } from 'react-dom'; +import { ToastContainer, toast } from 'react-toastify'; +import BaseButton from '../BaseButton' +import CardBoxModal from '../CardBoxModal' +import CardBox from "../CardBox"; +import { fetch, update, deleteItem, setRefetch, deleteItemsByIds } from '../../stores/workers_comp_classes/workers_comp_classesSlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { useRouter } from 'next/router' +import { Field, Form, Formik } from "formik"; +import { + DataGrid, + GridColDef, +} from '@mui/x-data-grid'; +import {loadColumns} from "./configureWorkers_comp_classesCols"; +import _ from 'lodash'; +import dataFormatter from '../../helpers/dataFormatter' +import {dataGridStyles} from "../../styles"; + + + +const perPage = 10 + +const TableSampleWorkers_comp_classes = ({ filterItems, setFilterItems, filters, showGrid }) => { + const notify = (type, msg) => toast( msg, {type, position: "bottom-center"}); + + const dispatch = useAppDispatch(); + const router = useRouter(); + + const pagesList = []; + const [id, setId] = useState(null); + const [currentPage, setCurrentPage] = useState(0); + const [filterRequest, setFilterRequest] = React.useState(''); + const [columns, setColumns] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [sortModel, setSortModel] = useState([ + { + field: '', + sort: 'desc', + }, + ]); + + const { workers_comp_classes, loading, count, notify: workers_comp_classesNotify, refetch } = useAppSelector((state) => state.workers_comp_classes) + const { currentUser } = useAppSelector((state) => state.auth); + const focusRing = useAppSelector((state) => state.style.focusRingColor); + const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const corners = useAppSelector((state) => state.style.corners); + const numPages = Math.floor(count / perPage) === 0 ? 1 : Math.ceil(count / perPage); + for (let i = 0; i < numPages; i++) { + pagesList.push(i); + } + + const loadData = async (page = currentPage, request = filterRequest) => { + if (page !== currentPage) setCurrentPage(page); + if (request !== filterRequest) setFilterRequest(request); + const { sort, field } = sortModel[0]; + + const query = `?page=${page}&limit=${perPage}${request}&sort=${sort}&field=${field}`; + dispatch(fetch({ limit: perPage, page, query })); + }; + + useEffect(() => { + if (workers_comp_classesNotify.showNotification) { + notify(workers_comp_classesNotify.typeNotification, workers_comp_classesNotify.textNotification); + } + }, [workers_comp_classesNotify.showNotification]); + + useEffect(() => { + if (!currentUser) return; + loadData(); + }, [sortModel, currentUser]); + + useEffect(() => { + if (refetch) { + loadData(0); + dispatch(setRefetch(false)); + } + }, [refetch, dispatch]); + + const [isModalInfoActive, setIsModalInfoActive] = useState(false) + const [isModalTrashActive, setIsModalTrashActive] = useState(false) + + const handleModalAction = () => { + setIsModalInfoActive(false) + setIsModalTrashActive(false) + } + + + + + + const handleDeleteModalAction = (id: string) => { + setId(id) + setIsModalTrashActive(true) + } + const handleDeleteAction = async () => { + if (id) { + await dispatch(deleteItem(id)); + await loadData(0); + setIsModalTrashActive(false); + } + }; + + const generateFilterRequests = useMemo(() => { + let request = '&'; + filterItems.forEach((item) => { + const isRangeFilter = filters.find( + (filter) => + filter.title === item.fields.selectedField && + (filter.number || filter.date), + ); + + if (isRangeFilter) { + const from = item.fields.filterValueFrom; + const to = item.fields.filterValueTo; + if (from) { + request += `${item.fields.selectedField}Range=${from}&`; + } + if (to) { + request += `${item.fields.selectedField}Range=${to}&`; + } + } else { + const value = item.fields.filterValue; + if (value) { + request += `${item.fields.selectedField}=${value}&`; + } + } + }); + return request; + }, [filterItems, filters]); + + const deleteFilter = (value) => { + const newItems = filterItems.filter((item) => item.id !== value); + + if (newItems.length) { + setFilterItems(newItems); + } else { + loadData(0, ''); + + setFilterItems(newItems); + } + }; + + const handleSubmit = () => { + loadData(0, generateFilterRequests); + + }; + + const handleChange = (id) => (e) => { + const value = e.target.value; + const name = e.target.name; + + setFilterItems( + filterItems.map((item) => { + if (item.id !== id) return item; + if (name === 'selectedField') return { id, fields: { [name]: value } }; + + return { id, fields: { ...item.fields, [name]: value } } + }), + ); + }; + + const handleReset = () => { + setFilterItems([]); + loadData(0, ''); + + }; + + const onPageChange = (page: number) => { + loadData(page); + setCurrentPage(page); + }; + + + useEffect(() => { + if (!currentUser) return; + + loadColumns( + handleDeleteModalAction, + `workers_comp_classes`, + currentUser, + ).then((newCols) => setColumns(newCols)); + }, [currentUser]); + + + + const handleTableSubmit = async (id: string, data) => { + + if (!_.isEmpty(data)) { + await dispatch(update({ id, data })) + .unwrap() + .then((res) => res) + .catch((err) => { + throw new Error(err); + }); + } + }; + + const onDeleteRows = async (selectedRows) => { + await dispatch(deleteItemsByIds(selectedRows)); + await loadData(0); + }; + + const controlClasses = + 'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' + + ` ${bgColor} ${focusRing} ${corners} ` + + 'dark:bg-slate-800 border'; + + + const dataGrid = ( +
    + `datagrid--row`} + rows={workers_comp_classes ?? []} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 10, + }, + }, + }} + disableRowSelectionOnClick + onProcessRowUpdateError={(params) => { + console.log('Error', params); + }} + processRowUpdate={async (newRow, oldRow) => { + const data = dataFormatter.dataGridEditFormatter(newRow); + + try { + await handleTableSubmit(newRow.id, data); + return newRow; + } catch { + return oldRow; + } + }} + sortingMode={'server'} + checkboxSelection + onRowSelectionModelChange={(ids) => { + setSelectedRows(ids) + }} + onSortModelChange={(params) => { + params.length + ? setSortModel(params) + : setSortModel([{ field: '', sort: 'desc' }]); + }} + rowCount={count} + pageSizeOptions={[10]} + paginationMode={'server'} + loading={loading} + onPaginationModelChange={(params) => { + onPageChange(params.page); + }} + /> +
    + ) + + return ( + <> + {filterItems && Array.isArray( filterItems ) && filterItems.length ? + + null} + > +
    + <> + {filterItems && filterItems.map((filterItem) => { + return ( +
    +
    +
    Filter
    + + {filters.map((selectOption) => ( + + ))} + +
    + {filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.type === 'enum' ? ( +
    +
    + Value +
    + + + {filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.options?.map((option) => ( + + ))} + +
    + ) : filters.find((filter) => + filter.title === filterItem?.fields?.selectedField + )?.number ? ( +
    +
    +
    From
    + +
    +
    +
    To
    + +
    +
    + ) : filters.find( + (filter) => + filter.title === + filterItem?.fields?.selectedField + )?.date ? ( +
    +
    +
    + From +
    + +
    +
    +
    To
    + +
    +
    + ) : ( +
    +
    Contains
    + +
    + )} +
    +
    Action
    + { + deleteFilter(filterItem.id) + }} + /> +
    +
    + ) + })} +
    + + +
    + +
    +
    +
    : null + } + +

    Are you sure you want to delete this item?

    +
    + + + {dataGrid} + + + + + {selectedRows.length > 0 && + createPortal( + onDeleteRows(selectedRows)} + />, + document.getElementById('delete-rows-button'), + )} + + + ) +} + +export default TableSampleWorkers_comp_classes diff --git a/frontend/src/components/Workers_comp_classes/configureWorkers_comp_classesCols.tsx b/frontend/src/components/Workers_comp_classes/configureWorkers_comp_classesCols.tsx new file mode 100644 index 0000000..82b0955 --- /dev/null +++ b/frontend/src/components/Workers_comp_classes/configureWorkers_comp_classesCols.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import BaseIcon from '../BaseIcon'; +import { mdiEye, mdiTrashCan, mdiPencilOutline } from '@mdi/js'; +import axios from 'axios'; +import { + GridActionsCellItem, + GridRowParams, + GridValueGetterParams, +} from '@mui/x-data-grid'; +import ImageField from '../ImageField'; +import {saveFile} from "../../helpers/fileSaver"; +import dataFormatter from '../../helpers/dataFormatter' +import DataGridMultiSelect from "../DataGridMultiSelect"; +import ListActionsPopover from '../ListActionsPopover'; + +import {hasPermission} from "../../helpers/userPermissions"; + +type Params = (id: string) => void; + +export const loadColumns = async ( + onDelete: Params, + entityName: string, + + user + +) => { + async function callOptionsApi(entityName: string) { + + if (!hasPermission(user, 'READ_' + entityName.toUpperCase())) return []; + + try { + const data = await axios(`/${entityName}/autocomplete?limit=100`); + return data.data; + } catch (error) { + console.log(error); + return []; + } + } + + const hasUpdatePermission = hasPermission(user, 'UPDATE_WORKERS_COMP_CLASSES') + + return [ + { + field: 'name', + headerName: 'Name', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + }, + { + field: 'percentage', + headerName: 'Percentage (%)', + flex: 1, + minWidth: 120, + filterable: false, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + editable: hasUpdatePermission, + type: 'number', + }, + { + field: 'actions', + type: 'actions', + minWidth: 30, + headerClassName: 'datagrid--header', + cellClassName: 'datagrid--cell', + getActions: (params: GridRowParams) => { + + return [ +
    + +
    , + ] + }, + }, + ]; +}; diff --git a/frontend/src/config.ts b/frontend/src/config.ts index a9783c8..1d12419 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -8,7 +8,7 @@ export const localStorageStyleKey = 'style' export const containerMaxW = 'xl:max-w-full xl:mx-auto 2xl:mx-20' -export const appTitle = 'created by Flatlogic generator!' +export const appTitle = 'Major League Pressure Washing' export const getPageTitle = (currentPageTitle: string) => `${currentPageTitle} — ${appTitle}` diff --git a/frontend/src/helpers/accessControl.ts b/frontend/src/helpers/accessControl.ts new file mode 100644 index 0000000..1f988bc --- /dev/null +++ b/frontend/src/helpers/accessControl.ts @@ -0,0 +1,24 @@ +const PRIVILEGED_PAYROLL_ROLE_NAMES = new Set([ + 'Administrator', + 'System Owner', + 'Payroll Manager', + 'Operations Manager', +]); + +const BLOCKED_WORKER_ROUTE_PREFIXES = ['/users', '/pay_types', '/employee_pay_types']; + +export function isRestrictedPayrollUser(user: any) { + if (!user?.id) { + return false; + } + + return !PRIVILEGED_PAYROLL_ROLE_NAMES.has(user?.app_role?.name); +} + +export function isBlockedWorkerRoute(pathname?: string) { + if (!pathname) { + return false; + } + + return BLOCKED_WORKER_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} diff --git a/frontend/src/helpers/jobLogPayPreview.ts b/frontend/src/helpers/jobLogPayPreview.ts new file mode 100644 index 0000000..8e78207 --- /dev/null +++ b/frontend/src/helpers/jobLogPayPreview.ts @@ -0,0 +1,104 @@ +const currencyFormatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const numberFormatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 0, + maximumFractionDigits: 2, +}); + +export const coerceNumber = (value: unknown) => { + if (value === null || value === undefined || value === '') { + return 0; + } + + const parsed = Number(value); + + return Number.isFinite(parsed) ? parsed : 0; +}; + +export const formatCurrency = (value: unknown) => currencyFormatter.format(coerceNumber(value)); + +export const formatDecimal = (value: unknown) => numberFormatter.format(coerceNumber(value)); + +export const resolveEntityId = (value: any) => { + if (!value) { + return null; + } + + if (typeof value === 'string') { + return value; + } + + if (typeof value === 'object' && value.id) { + return value.id; + } + + return null; +}; + +export const hasPayTypeDetails = (payType: any) => { + if (!payType || typeof payType !== 'object') { + return false; + } + + return Boolean(payType.pay_method || payType.hourly_rate || payType.commission_rate); +}; + +export const hasWorkersCompDetails = (workersCompClass: any) => { + if (!workersCompClass || typeof workersCompClass !== 'object') { + return false; + } + + return workersCompClass.percentage !== undefined && workersCompClass.percentage !== null; +}; + +export const calculateJobLogPayPreview = ({ + payType, + workersCompClass, + hoursConducted, + clientPaid, +}: { + payType?: any; + workersCompClass?: any; + hoursConducted?: unknown; + clientPaid?: unknown; +}) => { + const payMethod = payType?.pay_method || null; + const hours = coerceNumber(hoursConducted); + const clientAmount = coerceNumber(clientPaid); + const hourlyRate = coerceNumber(payType?.hourly_rate); + const commissionRate = coerceNumber(payType?.commission_rate); + const workersCompPercentage = coerceNumber(workersCompClass?.percentage); + + let estimatedPay = 0; + let formulaLabel = 'Select a pay type to preview earnings.'; + + if (payMethod === 'hourly') { + estimatedPay = hours * hourlyRate; + formulaLabel = `${formatDecimal(hours)} hours × ${formatCurrency(hourlyRate)}/hr`; + } else if (payMethod === 'commission') { + estimatedPay = clientAmount * (commissionRate / 100); + formulaLabel = `${formatCurrency(clientAmount)} client paid × ${formatDecimal(commissionRate)}%`; + } + + const workersCompAmount = estimatedPay * (workersCompPercentage / 100); + + return { + payMethod, + payTypeName: payType?.name || 'Not selected', + hours, + clientAmount, + hourlyRate, + commissionRate, + estimatedPay, + formulaLabel, + workersCompName: workersCompClass?.name || null, + workersCompPercentage, + workersCompAmount, + hasEstimate: payMethod === 'hourly' || payMethod === 'commission', + }; +}; diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..383aa59 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect , useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' @@ -15,6 +14,7 @@ import { useRouter } from 'next/router' import {findMe, logoutUser} from "../stores/authSlice"; import {hasPermission} from "../helpers/userPermissions"; +import { isBlockedWorkerRoute, isRestrictedPayrollUser } from '../helpers/accessControl'; type Props = { @@ -62,7 +62,15 @@ export default function LayoutAuthenticated({ if (!permission || !currentUser) return; if (!hasPermission(currentUser, permission)) router.push('/error'); - }, [currentUser, permission]); + }, [currentUser, permission, router]); + + useEffect(() => { + if (!currentUser) return; + + if (isRestrictedPayrollUser(currentUser) && isBlockedWorkerRoute(router.pathname)) { + router.replace('/my-logs'); + } + }, [currentUser, router, router.pathname]); const darkMode = useAppSelector((state) => state.style.darkMode) @@ -86,18 +94,18 @@ export default function LayoutAuthenticated({ }, [router.events, dispatch]) - const layoutAsidePadding = 'xl:pl-60' + const layoutAsidePadding = 'xl:pl-72' return (
    { + return ( + <> + + {getPageTitle('Admin Help')} + + + + + {''} + + + +
    +
    +

    Admin task checklist

    +

    + This page is a simple internal knowledge base for the most common admin workflows in the app. + Use the quick links below to jump to a process, then open the related page directly when you are ready to do the task. +

    +
    + +
    + + + +
    + {helpSections.map((section) => ( + + ))} +
    +
    + +
    + {helpSections.map((section) => ( + +
    +
    +
    + +
    +

    {section.title}

    +

    + {section.summary} +

    +
    +
    + {section.href && section.hrefLabel ? ( + + ) : null} +
    + +
    +
    + Checklist +
    +
      + {section.steps.map((step) => ( +
    1. {step}
    2. + ))} +
    +
    + + {section.checks?.length ? ( +
    +
    + Verify before moving on +
    +
      + {section.checks.map((check) => ( +
    • {check}
    • + ))} +
    +
    + ) : null} + + {section.warning ? ( +
    + Admin note: {section.warning} +
    + ) : null} +
    +
    + ))} +
    + + +
    +
    +

    Suggested next improvement

    +

    + If you want, this help page can later become searchable or editable so admins can maintain their own internal SOPs. +

    +
    + +
    +
    +
    + + ); +}; + +AdminHelpPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default AdminHelpPage; diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 263c7b7..d797bcb 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -11,6 +11,7 @@ import { getPageTitle } from '../config' import Link from "next/link"; import { hasPermission } from "../helpers/userPermissions"; +import { isRestrictedPayrollUser } from '../helpers/accessControl'; import { fetchWidgets } from '../stores/roles/rolesSlice'; import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; import { SmartWidget } from '../components/SmartWidget/SmartWidget'; @@ -32,9 +33,7 @@ const Dashboard = () => { const [vehicles, setVehicles] = React.useState(loadingMessage); const [pay_types, setPay_types] = React.useState(loadingMessage); const [employee_pay_types, setEmployee_pay_types] = React.useState(loadingMessage); - const [chemical_products, setChemical_products] = React.useState(loadingMessage); const [job_logs, setJob_logs] = React.useState(loadingMessage); - const [job_chemical_usages, setJob_chemical_usages] = React.useState(loadingMessage); const [payroll_runs, setPayroll_runs] = React.useState(loadingMessage); const [payroll_line_items, setPayroll_line_items] = React.useState(loadingMessage); @@ -46,11 +45,20 @@ const Dashboard = () => { const { isFetchingQuery } = useAppSelector((state) => state.openAi); const { rolesWidgets, loading } = useAppSelector((state) => state.roles); + const isAdmin = hasPermission(currentUser, "UPDATE_USERS"); async function loadData() { - const entities = ['users','roles','permissions','customers','vehicles','pay_types','employee_pay_types','chemical_products','job_logs','job_chemical_usages','payroll_runs','payroll_line_items',]; - const fns = [setUsers,setRoles,setPermissions,setCustomers,setVehicles,setPay_types,setEmployee_pay_types,setChemical_products,setJob_logs,setJob_chemical_usages,setPayroll_runs,setPayroll_line_items,]; + if (!currentUser) return; + + if (!isAdmin) { + // For regular employees, we just want to load their own logs + axios.get('/job_logs/count?employee=' + currentUser.id).then((res) => setJob_logs(res.data.count)).catch(() => setJob_logs(0)); + return; + } + + const entities = ['users','roles','permissions','customers','vehicles','pay_types','employee_pay_types','job_logs','payroll_runs','payroll_line_items',]; + const fns = [setUsers,setRoles,setPermissions,setCustomers,setVehicles,setPay_types,setEmployee_pay_types,setJob_logs,setPayroll_runs,setPayroll_line_items,]; const requests = entities.map((entity, index) => { @@ -142,12 +150,15 @@ const Dashboard = () => { ))}
    + {!!rolesWidgets.length &&
    } + {isAdmin ? (
    + - {hasPermission(currentUser, 'READ_USERS') && + {!isRestrictedPayrollUser(currentUser) && hasPermission(currentUser, 'READ_USERS') &&
    @@ -287,7 +298,7 @@ const Dashboard = () => {
    } - {hasPermission(currentUser, 'READ_PAY_TYPES') && + {!isRestrictedPayrollUser(currentUser) && hasPermission(currentUser, 'READ_PAY_TYPES') &&
    @@ -315,7 +326,7 @@ const Dashboard = () => {
    } - {hasPermission(currentUser, 'READ_EMPLOYEE_PAY_TYPES') && + {!isRestrictedPayrollUser(currentUser) && hasPermission(currentUser, 'READ_EMPLOYEE_PAY_TYPES') &&
    @@ -343,34 +354,6 @@ const Dashboard = () => {
    } - {hasPermission(currentUser, 'READ_CHEMICAL_PRODUCTS') && -
    -
    -
    -
    - Chemical products -
    -
    - {chemical_products} -
    -
    -
    - -
    -
    -
    - } - {hasPermission(currentUser, 'READ_JOB_LOGS') &&
    {
    } - {hasPermission(currentUser, 'READ_JOB_CHEMICAL_USAGES') && -
    -
    -
    -
    - Job chemical usages -
    -
    - {job_chemical_usages} -
    -
    -
    - -
    -
    -
    - } - {hasPermission(currentUser, 'READ_PAYROLL_RUNS') &&
    {
    + }
    + ) : ( +
    + +
    +
    +
    +
    + My Logs +
    +
    + {job_logs} +
    +
    +
    + +
    +
    +
    + +
    + )} + ) } diff --git a/frontend/src/pages/employee_pay_types/[employee_pay_typesId].tsx b/frontend/src/pages/employee_pay_types/[employee_pay_typesId].tsx index a011bed..dc43773 100644 --- a/frontend/src/pages/employee_pay_types/[employee_pay_typesId].tsx +++ b/frontend/src/pages/employee_pay_types/[employee_pay_typesId].tsx @@ -1,9 +1,6 @@ import { mdiChartTimelineVariant, 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' @@ -109,78 +106,7 @@ const EditEmployee_pay_types = () => { active: false, - - - - - - - - - - - - - - - - - - - - - - - - - - effective_start: new Date(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - effective_end: new Date(), - - - - - - - - - - - - - - - - - - } +} const [initialValues, setInitialValues] = useState(initVals) const { employee_pay_types } = useAppSelector((state) => state.employee_pay_types) @@ -392,106 +318,7 @@ const EditEmployee_pay_types = () => { component={SwitchField} > - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'effective_start': date})} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'effective_end': date})} - /> - - - - - - - - - - - - - - - - - - - - - + diff --git a/frontend/src/pages/employee_pay_types/employee_pay_types-edit.tsx b/frontend/src/pages/employee_pay_types/employee_pay_types-edit.tsx index 7788fd6..ab51674 100644 --- a/frontend/src/pages/employee_pay_types/employee_pay_types-edit.tsx +++ b/frontend/src/pages/employee_pay_types/employee_pay_types-edit.tsx @@ -1,9 +1,6 @@ import { mdiChartTimelineVariant, 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' @@ -109,78 +106,7 @@ const EditEmployee_pay_typesPage = () => { active: false, - - - - - - - - - - - - - - - - - - - - - - - - - - effective_start: new Date(), - - - - - - - - - - - - - - - - - - - - - - - - - - - - effective_end: new Date(), - - - - - - - - - - - - - - - - - - } +} const [initialValues, setInitialValues] = useState(initVals) const { employee_pay_types } = useAppSelector((state) => state.employee_pay_types) @@ -389,106 +315,7 @@ const EditEmployee_pay_typesPage = () => { component={SwitchField} > - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'effective_start': date})} - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - setInitialValues({...initialValues, 'effective_end': date})} - /> - - - - - - - - - - - - - - - - - - - - - + diff --git a/frontend/src/pages/employee_pay_types/employee_pay_types-list.tsx b/frontend/src/pages/employee_pay_types/employee_pay_types-list.tsx index c604695..38cf56f 100644 --- a/frontend/src/pages/employee_pay_types/employee_pay_types-list.tsx +++ b/frontend/src/pages/employee_pay_types/employee_pay_types-list.tsx @@ -37,7 +37,7 @@ const Employee_pay_typesTablesPage = () => { const [filters] = useState([ - {label: 'EffectiveStart', title: 'effective_start', date: 'true'},{label: 'EffectiveEnd', title: 'effective_end', date: 'true'}, + {label: 'Employee', title: 'employee'}, diff --git a/frontend/src/pages/employee_pay_types/employee_pay_types-new.tsx b/frontend/src/pages/employee_pay_types/employee_pay_types-new.tsx index 076bb7a..87ce03c 100644 --- a/frontend/src/pages/employee_pay_types/employee_pay_types-new.tsx +++ b/frontend/src/pages/employee_pay_types/employee_pay_types-new.tsx @@ -69,46 +69,6 @@ const initialValues = { active: false, - - - - - - - - - - - - - - - effective_start: '', - - - - - - - - - - - - - - - - effective_end: '', - - - - - - - - - } @@ -228,88 +188,7 @@ const Employee_pay_typesNew = () => { component={SwitchField} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/frontend/src/pages/employee_pay_types/employee_pay_types-table.tsx b/frontend/src/pages/employee_pay_types/employee_pay_types-table.tsx index aa11e0e..c4b8d3a 100644 --- a/frontend/src/pages/employee_pay_types/employee_pay_types-table.tsx +++ b/frontend/src/pages/employee_pay_types/employee_pay_types-table.tsx @@ -37,7 +37,7 @@ const Employee_pay_typesTablesPage = () => { const [filters] = useState([ - {label: 'EffectiveStart', title: 'effective_start', date: 'true'},{label: 'EffectiveEnd', title: 'effective_end', date: 'true'}, + {label: 'Employee', title: 'employee'}, diff --git a/frontend/src/pages/employee_pay_types/employee_pay_types-view.tsx b/frontend/src/pages/employee_pay_types/employee_pay_types-view.tsx index 286397c..04eff1d 100644 --- a/frontend/src/pages/employee_pay_types/employee_pay_types-view.tsx +++ b/frontend/src/pages/employee_pay_types/employee_pay_types-view.tsx @@ -1,8 +1,5 @@ import React, { ReactElement, useEffect } from 'react'; import Head from 'next/head' -import DatePicker from "react-datepicker"; -import "react-datepicker/dist/react-datepicker.css"; -import dayjs from "dayjs"; import {useAppDispatch, useAppSelector} from "../../stores/hooks"; import {useRouter} from "next/router"; import { fetch } from '../../stores/employee_pay_types/employee_pay_typesSlice' @@ -199,112 +196,7 @@ const Employee_pay_typesView = () => { disabled /> - - - - - - - - - - - - - - - - - - - - - - - - {employee_pay_types.effective_start ? :

    No EffectiveStart

    } -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {employee_pay_types.effective_end ? :

    No EffectiveEnd

    } -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + state.style.linkColor); - const title = 'Pressure Wash Payroll' + const title = 'Major League Pressure Washing' // Fetch Pexels image/video useEffect(() => { @@ -128,7 +128,7 @@ export default function Starter() { : null}
    - +

    This is a React.js/Node.js app generated by the Flatlogic Web App Generator

    diff --git a/frontend/src/pages/job_logs/[job_logsId].tsx b/frontend/src/pages/job_logs/[job_logsId].tsx index 9fc2851..1bd9158 100644 --- a/frontend/src/pages/job_logs/[job_logsId].tsx +++ b/frontend/src/pages/job_logs/[job_logsId].tsx @@ -270,7 +270,6 @@ const EditJob_logs = () => { - odometer_start: '', @@ -298,7 +297,6 @@ const EditJob_logs = () => { - odometer_end: '', @@ -320,7 +318,6 @@ const EditJob_logs = () => { - 'job_address': '', @@ -875,146 +872,6 @@ const EditJob_logs = () => { > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/pages/job_logs/job_logs-edit.tsx b/frontend/src/pages/job_logs/job_logs-edit.tsx index b1ed0bb..cc79b96 100644 --- a/frontend/src/pages/job_logs/job_logs-edit.tsx +++ b/frontend/src/pages/job_logs/job_logs-edit.tsx @@ -9,6 +9,7 @@ import CardBox from '../../components/CardBox' import LayoutAuthenticated from '../../layouts/Authenticated' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import JobLogPayPreview from '../../components/JobLogPayPreview' import { getPageTitle } from '../../config' import { Field, Form, Formik } from 'formik' @@ -196,7 +197,7 @@ const EditJob_logsPage = () => { - workers_comp_class: '', + workersCompClass: '', @@ -270,7 +271,6 @@ const EditJob_logsPage = () => { - odometer_start: '', @@ -298,7 +298,6 @@ const EditJob_logsPage = () => { - odometer_end: '', @@ -320,7 +319,6 @@ const EditJob_logsPage = () => { - 'job_address': '', @@ -723,17 +721,9 @@ const EditJob_logsPage = () => { - - - - - - - - - - - + + + @@ -804,6 +794,8 @@ const EditJob_logsPage = () => { > + + @@ -872,146 +864,6 @@ const EditJob_logsPage = () => { > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/pages/job_logs/job_logs-list.tsx b/frontend/src/pages/job_logs/job_logs-list.tsx index 20086f4..4be8c15 100644 --- a/frontend/src/pages/job_logs/job_logs-list.tsx +++ b/frontend/src/pages/job_logs/job_logs-list.tsx @@ -34,8 +34,7 @@ const Job_logsTablesPage = () => { const dispatch = useAppDispatch(); - const [filters] = useState([{label: 'JobAddress', title: 'job_address'},{label: 'NotesToAdmin', title: 'notes_to_admin'}, - {label: 'OdometerStart', title: 'odometer_start', number: 'true'},{label: 'OdometerEnd', title: 'odometer_end', number: 'true'}, + const [filters] = useState([{label: 'NotesToAdmin', title: 'notes_to_admin'}, {label: 'HoursConducted', title: 'hours_conducted', number: 'true'},{label: 'ClientPaid', title: 'client_paid', number: 'true'}, {label: 'WorkDate', title: 'work_date', date: 'true'}, diff --git a/frontend/src/pages/job_logs/job_logs-new.tsx b/frontend/src/pages/job_logs/job_logs-new.tsx index 9bd4ee3..bc4842e 100644 --- a/frontend/src/pages/job_logs/job_logs-new.tsx +++ b/frontend/src/pages/job_logs/job_logs-new.tsx @@ -5,6 +5,7 @@ import CardBox from '../../components/CardBox' import LayoutAuthenticated from '../../layouts/Authenticated' import SectionMain from '../../components/SectionMain' import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import JobLogPayPreview from '../../components/JobLogPayPreview' import { getPageTitle } from '../../config' import { Field, Form, Formik } from 'formik' @@ -119,7 +120,7 @@ const initialValues = { - workers_comp_class: 'roof', + workersCompClass: '', @@ -162,7 +163,6 @@ const initialValues = { - odometer_start: '', @@ -178,7 +178,6 @@ const initialValues = { - odometer_end: '', @@ -191,7 +190,6 @@ const initialValues = { - job_address: '', @@ -457,16 +455,8 @@ const Job_logsNew = () => { - - - - - - - - - - + + @@ -503,6 +493,8 @@ const Job_logsNew = () => { + + @@ -532,136 +524,6 @@ const Job_logsNew = () => { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend/src/pages/job_logs/job_logs-table.tsx b/frontend/src/pages/job_logs/job_logs-table.tsx index ba4d511..7e6ee34 100644 --- a/frontend/src/pages/job_logs/job_logs-table.tsx +++ b/frontend/src/pages/job_logs/job_logs-table.tsx @@ -34,8 +34,7 @@ const Job_logsTablesPage = () => { const dispatch = useAppDispatch(); - const [filters] = useState([{label: 'JobAddress', title: 'job_address'},{label: 'NotesToAdmin', title: 'notes_to_admin'}, - {label: 'OdometerStart', title: 'odometer_start', number: 'true'},{label: 'OdometerEnd', title: 'odometer_end', number: 'true'}, + const [filters] = useState([{label: 'NotesToAdmin', title: 'notes_to_admin'}, {label: 'HoursConducted', title: 'hours_conducted', number: 'true'},{label: 'ClientPaid', title: 'client_paid', number: 'true'}, {label: 'WorkDate', title: 'work_date', date: 'true'}, diff --git a/frontend/src/pages/job_logs/job_logs-view.tsx b/frontend/src/pages/job_logs/job_logs-view.tsx index 4711028..7c7a945 100644 --- a/frontend/src/pages/job_logs/job_logs-view.tsx +++ b/frontend/src/pages/job_logs/job_logs-view.tsx @@ -421,127 +421,6 @@ const Job_logsView = () => {
    - - - - - - - - - - - - - - - - - -
    -

    OdometerStart

    -

    {job_logs?.odometer_start || 'No data'}

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -

    OdometerEnd

    -

    {job_logs?.odometer_end || 'No data'}

    -
    - - - - - - - - - - - - - - - - - - - - - - -
    -

    JobAddress

    -

    {job_logs?.job_address}

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -

    Status

    {job_logs?.status ?? 'No data'}

    @@ -600,65 +479,6 @@ const Job_logsView = () => { - <> -

    Job_chemical_usages JobLog

    - -
    - - - - - - - - - - - - - - - - - - - - {job_logs.job_chemical_usages_job_log && Array.isArray(job_logs.job_chemical_usages_job_log) && - job_logs.job_chemical_usages_job_log.map((item: any) => ( - router.push(`/job_chemical_usages/job_chemical_usages-view/?id=${item.id}`)}> - - - - - - - - - - - - - - - ))} - -
    QuantityUsedNotes
    - { item.quantity_used } - - { item.notes } -
    -
    - {!job_logs?.job_chemical_usages_job_log?.length &&
    No data
    } -
    - - - - - - { + const router = useRouter(); + const dispatch = useAppDispatch(); + const currentUser = useAppSelector((state) => state.auth.currentUser); + const [assignedPayTypes, setAssignedPayTypes] = useState([]); + + useEffect(() => { + if (currentUser?.id) { + axios + .get(`/employee_pay_types?employee=${currentUser.id}&active=true`) + .then((res) => { + if (res.data && res.data.rows) { + const payTypes = res.data.rows + .map((row: any) => row.pay_type) + .filter(Boolean); + setAssignedPayTypes(payTypes); + } + }) + .catch((err) => console.error('Failed to fetch assigned pay types:', err)); + } + }, [currentUser]); + + const initialValues = { + work_date: new Date().toISOString().slice(0, 10), + employee: currentUser?.id || '', + customer: '', + hours_conducted: '', + client_paid: '', + workersCompClass: '', + pay_type: '', + vehicle: '', + status: 'submitted', + notes_to_admin: '', + }; + + const handleSubmit = async (data: any) => { + await dispatch(create(data)); + await router.push('/my-logs'); + }; + + return ( + <> + + {getPageTitle('Log Work')} + + + + {''} + + + handleSubmit(values)}> + {() => ( +
    + + + + + + + + + + + + + + + + + + + {assignedPayTypes.map((pt) => ( + + ))} + + + + + + + + + + + + + + + + )} +
    +
    +
    + + ); +}; + +LogWorkPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default LogWorkPage; diff --git a/frontend/src/pages/login.tsx b/frontend/src/pages/login.tsx index 2680bf2..edd7b1e 100644 --- a/frontend/src/pages/login.tsx +++ b/frontend/src/pages/login.tsx @@ -44,7 +44,7 @@ export default function Login() { password: '2fa72adf', remember: true }) - const title = 'Pressure Wash Payroll' + const title = 'Major League Pressure Washing' // Fetch Pexels image/video useEffect( () => { @@ -165,7 +165,7 @@ export default function Login() { -

    {title}

    +
    Logo

    {title}

    diff --git a/frontend/src/pages/my-logs.tsx b/frontend/src/pages/my-logs.tsx new file mode 100644 index 0000000..ca65041 --- /dev/null +++ b/frontend/src/pages/my-logs.tsx @@ -0,0 +1,51 @@ +import { mdiViewList, mdiChartTimelineVariant } from '@mdi/js'; +import Head from 'next/head'; +import React, { useEffect, useState } from 'react'; +import type { ReactElement } from 'react'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { fetch } from '../stores/job_logs/job_logsSlice'; +import ListJob_logs from '../components/Job_logs/ListJob_logs'; + +const MyLogsPage = () => { + const dispatch = useAppDispatch(); + const currentUser = useAppSelector((state) => state.auth.currentUser); + const { job_logs, loading } = useAppSelector((state) => state.job_logs); + const [currentPage, setCurrentPage] = useState(1); + + useEffect(() => { + if (currentUser?.id) { + dispatch(fetch({ filter: { employee: currentUser.id }, page: currentPage })); + } + }, [dispatch, currentUser, currentPage]); + + return ( + <> + + {getPageTitle('My Logs')} + + + + {''} + + console.log('Delete')} + currentPage={currentPage} + numPages={1} + onPageChange={setCurrentPage} + /> + + + ); +}; + +MyLogsPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default MyLogsPage; diff --git a/frontend/src/pages/pay_types/pay_types-edit.tsx b/frontend/src/pages/pay_types/pay_types-edit.tsx index baca3cd..126b323 100644 --- a/frontend/src/pages/pay_types/pay_types-edit.tsx +++ b/frontend/src/pages/pay_types/pay_types-edit.tsx @@ -128,6 +128,11 @@ const EditPay_typesPage = () => { + + 'workers_comp_percentage': '', + + + @@ -406,7 +411,15 @@ const EditPay_typesPage = () => { - + + + @@ -503,4 +516,4 @@ EditPay_typesPage.getLayout = function getLayout(page: ReactElement) { ) } -export default EditPay_typesPage +export default EditPay_typesPage \ No newline at end of file diff --git a/frontend/src/pages/pay_types/pay_types-new.tsx b/frontend/src/pages/pay_types/pay_types-new.tsx index b011761..1a5c04e 100644 --- a/frontend/src/pages/pay_types/pay_types-new.tsx +++ b/frontend/src/pages/pay_types/pay_types-new.tsx @@ -80,6 +80,8 @@ const initialValues = { commission_rate: '', + + workers_comp_percentage: '', @@ -285,6 +287,15 @@ const Pay_typesNew = () => { /> + + + @@ -393,4 +404,4 @@ Pay_typesNew.getLayout = function getLayout(page: ReactElement) { ) } -export default Pay_typesNew +export default Pay_typesNew \ No newline at end of file diff --git a/frontend/src/pages/pay_types/pay_types-view.tsx b/frontend/src/pages/pay_types/pay_types-view.tsx index 3a27f9c..1831c48 100644 --- a/frontend/src/pages/pay_types/pay_types-view.tsx +++ b/frontend/src/pages/pay_types/pay_types-view.tsx @@ -276,11 +276,11 @@ const Pay_typesView = () => { - EffectiveStart - EffectiveEnd + + @@ -298,21 +298,7 @@ const Pay_typesView = () => { { dataFormatter.booleanFormatter(item.active) } - - - - - { dataFormatter.dateTimeFormatter(item.effective_start) } - - - - - - { dataFormatter.dateTimeFormatter(item.effective_end) } - - - - + ))} diff --git a/frontend/src/pages/payroll_runs/payroll_runs-view.tsx b/frontend/src/pages/payroll_runs/payroll_runs-view.tsx index aaccb9e..b71ed11 100644 --- a/frontend/src/pages/payroll_runs/payroll_runs-view.tsx +++ b/frontend/src/pages/payroll_runs/payroll_runs-view.tsx @@ -30,10 +30,20 @@ const Payroll_runsView = () => { const { id } = router.query; function removeLastCharacter(str) { - console.log(str,`str`) return str.slice(0, -1); } + function formatEmployeeName(employee) { + if (!employee) return 'No data'; + + const fullName = [employee.firstName, employee.lastName] + .filter(Boolean) + .join(' ') + .trim(); + + return fullName || employee.email || 'No data'; + } + useEffect(() => { dispatch(fetch({ id })); }, [dispatch, id]); @@ -292,72 +302,28 @@ const Payroll_runsView = () => { - - - - - - + - - - - - - - - - - - - - - - - {payroll_runs.payroll_line_items_payroll_run && Array.isArray(payroll_runs.payroll_line_items_payroll_run) && payroll_runs.payroll_line_items_payroll_run.map((item: any) => ( router.push(`/payroll_line_items/payroll_line_items-view/?id=${item.id}`)}> - - - - - - + - - - - - - - - - - - - - - - - ))} diff --git a/frontend/src/pages/privacy-policy.tsx b/frontend/src/pages/privacy-policy.tsx index 772ad82..7c3a76a 100644 --- a/frontend/src/pages/privacy-policy.tsx +++ b/frontend/src/pages/privacy-policy.tsx @@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest'; import { getPageTitle } from '../config'; export default function PrivacyPolicy() { - const title = 'Pressure Wash Payroll' + const title = 'Major League Pressure Washing' const [projectUrl, setProjectUrl] = useState(''); useEffect(() => { diff --git a/frontend/src/pages/reports.tsx b/frontend/src/pages/reports.tsx new file mode 100644 index 0000000..7034009 --- /dev/null +++ b/frontend/src/pages/reports.tsx @@ -0,0 +1,267 @@ +import { mdiChartBar, mdiCashCheck, mdiHistory } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useState } from 'react'; +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 FormField from '../components/FormField'; +import BaseButton from '../components/BaseButton'; +import BaseButtons from '../components/BaseButtons'; +import axios from 'axios'; + +const ReportsPage = () => { + const [activeTab, setActiveTab] = useState('current'); // 'current' or 'historical' + + // Historical state + const [reportData, setReportData] = useState(null); + const [loadingHistory, setLoadingHistory] = useState(false); + const [filtersHistory, setFiltersHistory] = useState({ + startDate: '', + endDate: '', + employeeId: '' + }); + + // Current (Generator) state + const [previewData, setPreviewData] = useState(null); + const [loadingPreview, setLoadingPreview] = useState(false); + const [generating, setGenerating] = useState(false); + const [filtersCurrent, setFiltersCurrent] = useState({ + startDate: '', + endDate: '' + }); + const [runSuccess, setRunSuccess] = useState(''); + const [runError, setRunError] = useState(''); + + const fetchHistoricalReport = async () => { + setLoadingHistory(true); + try { + const response = await axios.post('/reports', filtersHistory); + setReportData(response.data); + } catch (error) { + console.error('Failed to fetch historical report:', error); + } finally { + setLoadingHistory(false); + } + }; + + const fetchCurrentPreview = async () => { + setLoadingPreview(true); + setRunSuccess(''); + setRunError(''); + try { + const response = await axios.post('/payroll_generator/preview', filtersCurrent); + setPreviewData(response.data); + } catch (error) { + console.error('Failed to fetch current preview:', error); + setRunError(error.response?.data || 'Failed to fetch unpaid logs.'); + } finally { + setLoadingPreview(false); + } + }; + + const generatePayroll = async () => { + if (!confirm('Are you sure you want to run payroll? This will finalize these amounts and mark the associated logs as paid.')) return; + + setGenerating(true); + setRunError(''); + setRunSuccess(''); + try { + await axios.post('/payroll_generator/generate', { + startDate: filtersCurrent.startDate, + endDate: filtersCurrent.endDate, + name: `Payroll ${filtersCurrent.startDate} to ${filtersCurrent.endDate}` + }); + setRunSuccess('Payroll Run successfully generated!'); + setPreviewData(null); // Clear preview since they are now paid + } catch (error) { + console.error('Failed to generate payroll:', error); + setRunError(error.response?.data || 'An error occurred while generating payroll.'); + } finally { + setGenerating(false); + } + }; + + return ( + <> + + {getPageTitle('Payroll Reports')} + + + + {''} + + +
    + + +
    + + {activeTab === 'current' && ( +
    + +
    + Select a date range to view all unpaid Job Logs. From here, you can preview what your employees have currently earned and generate a finalized Payroll Run. +
    +
    + + setFiltersCurrent({...filtersCurrent, startDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + + + setFiltersCurrent({...filtersCurrent, endDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + +
    + +
    +
    + {runError &&
    {runError}
    } + {runSuccess &&
    {runSuccess}
    } +
    + + {previewData && ( + <> + +

    Live Preview Summary

    +
    +
    +

    Total Unpaid Gross Pay

    +

    ${previewData.summary.totalGrossPay.toFixed(2)}

    +
    +
    +

    Total Unpaid Hours

    +

    {previewData.summary.totalHours.toFixed(2)}

    +
    +
    +
    + +
    +
    + + +

    Employee Breakdown

    +
    +
    Employee TotalHoursGrossPayTotalCommissionBaseTotalClientPaidSummary
    + {formatEmployeeName(item.employee)} + { item.total_hours } { item.gross_pay } { item.total_commission_base } - { item.total_client_paid } - - { item.summary } -
    + + + + + + + + + + {previewData.lineItems.map((item: any, idx: number) => ( + + + + + + + ))} + +
    EmployeeTotal HoursCommission BaseCalculated Gross Pay
    {item.employee?.firstName} {item.employee?.lastName || ''}{item.total_hours.toFixed(2)}${item.total_commission_base.toFixed(2)}${item.gross_pay.toFixed(2)}
    +
    + + + )} +
    + )} + + {activeTab === 'historical' && ( +
    + +
    + View previously generated payroll line items by date range or employee ID. +
    +
    + + setFiltersHistory({...filtersHistory, startDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + + + setFiltersHistory({...filtersHistory, endDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + + + setFiltersHistory({...filtersHistory, employeeId: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" /> + +
    + +
    +
    +
    + + {reportData && ( + <> + +

    Historical Summary

    +
    +
    +

    Total Gross Pay

    +

    ${reportData.summary.totalGrossPay.toFixed(2)}

    +
    +
    +

    Total Hours

    +

    {reportData.summary.totalHours.toFixed(2)}

    +
    +
    +

    Total Work Comp

    +

    ${reportData.summary.totalWorkersComp.toFixed(2)}

    +
    +
    +
    + +

    Line Items

    +
    + + + + + + + + + + + + {reportData.lineItems.map((item: any) => ( + + + + + + + + ))} + +
    EmployeeHoursGross PayWork Comp AmountCreated At
    {item.employee?.firstName} {item.employee?.lastName || ''}{item.total_hours}${item.gross_pay}${item.workers_comp_amount}{new Date(item.createdAt).toLocaleDateString()}
    +
    +
    + + )} +
    + )} + + + + ); +}; + +ReportsPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default ReportsPage; \ No newline at end of file diff --git a/frontend/src/pages/terms-of-use.tsx b/frontend/src/pages/terms-of-use.tsx index f40ca44..c28b4da 100644 --- a/frontend/src/pages/terms-of-use.tsx +++ b/frontend/src/pages/terms-of-use.tsx @@ -5,7 +5,7 @@ import LayoutGuest from '../layouts/Guest'; import { getPageTitle } from '../config'; export default function PrivacyPolicy() { - const title = 'Pressure Wash Payroll'; + const title = 'Major League Pressure Washing'; const [projectUrl, setProjectUrl] = useState(''); useEffect(() => { diff --git a/frontend/src/pages/users/users-edit.tsx b/frontend/src/pages/users/users-edit.tsx index c6fb274..de84708 100644 --- a/frontend/src/pages/users/users-edit.tsx +++ b/frontend/src/pages/users/users-edit.tsx @@ -261,6 +261,7 @@ const EditUsersPage = () => { custom_permissions: [], + pay_types: [], @@ -278,16 +279,17 @@ const EditUsersPage = () => { dispatch(fetch({ id: id })) }, [id]) - useEffect(() => { - if (typeof users === 'object') { - setInitialValues(users) - } - }, [users]) - useEffect(() => { if (typeof users === 'object') { const newInitialVal = {...initVals}; - Object.keys(initVals).forEach(el => newInitialVal[el] = (users)[el]) + Object.keys(initVals).forEach((el) => { + newInitialVal[el] = users[el] + }) + newInitialVal.pay_types = Array.isArray(users.employee_pay_types_employee) + ? users.employee_pay_types_employee + .filter((item) => item?.active && item?.pay_type) + .map((item) => item.pay_type) + : [] setInitialValues(newInitialVal); } }, [users]) @@ -676,6 +678,18 @@ const EditUsersPage = () => { + + + + + diff --git a/frontend/src/pages/users/users-new.tsx b/frontend/src/pages/users/users-new.tsx index 510a85b..997eb4e 100644 --- a/frontend/src/pages/users/users-new.tsx +++ b/frontend/src/pages/users/users-new.tsx @@ -155,6 +155,7 @@ const initialValues = { custom_permissions: [], + pay_types: [], } @@ -469,7 +470,16 @@ const UsersNew = () => { - + + + + diff --git a/frontend/src/pages/users/users-view.tsx b/frontend/src/pages/users/users-view.tsx index 9e13342..4bb6477 100644 --- a/frontend/src/pages/users/users-view.tsx +++ b/frontend/src/pages/users/users-view.tsx @@ -433,15 +433,16 @@ const UsersView = () => { + Pay Type Active - EffectiveStart - EffectiveEnd + + @@ -456,24 +457,13 @@ const UsersView = () => { + + {item?.pay_type?.name ?? 'No data'} + { dataFormatter.booleanFormatter(item.active) } - - - - - { dataFormatter.dateTimeFormatter(item.effective_start) } - - - - - - { dataFormatter.dateTimeFormatter(item.effective_end) } - - - - + ))} diff --git a/frontend/src/pages/workers_comp_classes/[workers_comp_classesId].tsx b/frontend/src/pages/workers_comp_classes/[workers_comp_classesId].tsx new file mode 100644 index 0000000..af7828c --- /dev/null +++ b/frontend/src/pages/workers_comp_classes/[workers_comp_classesId].tsx @@ -0,0 +1,509 @@ +import { mdiChartTimelineVariant, 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' +import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton' +import { getPageTitle } from '../../config' + +import { Field, Form, Formik } from 'formik' +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/workers_comp_classes/workers_comp_classesSlice' +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 EditWorkers_comp_classes = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const initVals = { + + + 'name': '', + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + pay_method: '', + + + + + + + + + + + + 'hourly_rate': '', + + + + + + + + + + + + + + + + + + + + + + + + + + + + 'commission_rate': '', + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + active: false, + + + + + + + + + + + + + + + + + + description: '', + + + + + + + + + + + + + + + + + + + + + + + + + + } + const [initialValues, setInitialValues] = useState(initVals) + + const { workers_comp_classes } = useAppSelector((state) => state.workers_comp_classes) + + + const { workers_comp_classesId } = router.query + + useEffect(() => { + dispatch(fetch({ id: workers_comp_classesId })) + }, [workers_comp_classesId]) + + useEffect(() => { + if (typeof workers_comp_classes === 'object') { + setInitialValues(workers_comp_classes) + } + }, [workers_comp_classes]) + + useEffect(() => { + if (typeof workers_comp_classes === 'object') { + + const newInitialVal = {...initVals}; + + Object.keys(initVals).forEach(el => newInitialVal[el] = (workers_comp_classes)[el]) + + setInitialValues(newInitialVal); + } + }, [workers_comp_classes]) + + const handleSubmit = async (data) => { + await dispatch(update({ id: workers_comp_classesId, data })) + await router.push('/workers_comp_classes/workers_comp_classes-list') + } + + return ( + <> + + {getPageTitle('Edit workers_comp_classes')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + router.push('/workers_comp_classes/workers_comp_classes-list')}/> + + +
    +
    +
    + + ) +} + +EditWorkers_comp_classes.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default EditWorkers_comp_classes diff --git a/frontend/src/pages/workers_comp_classes/workers_comp_classes-edit.tsx b/frontend/src/pages/workers_comp_classes/workers_comp_classes-edit.tsx new file mode 100644 index 0000000..5e2ca0b --- /dev/null +++ b/frontend/src/pages/workers_comp_classes/workers_comp_classes-edit.tsx @@ -0,0 +1,101 @@ +import { mdiChartTimelineVariant } from '@mdi/js' +import Head from 'next/head' +import React, { ReactElement, useEffect, useState } from 'react' + +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 { Field, Form, Formik } from 'formik' +import FormField from '../../components/FormField' +import BaseDivider from '../../components/BaseDivider' +import BaseButtons from '../../components/BaseButtons' +import BaseButton from '../../components/BaseButton' + +import { update, fetch } from '../../stores/workers_comp_classes/workers_comp_classesSlice' +import { useAppDispatch, useAppSelector } from '../../stores/hooks' +import { useRouter } from 'next/router' + +const EditWorkers_comp_classesPage = () => { + const router = useRouter() + const dispatch = useAppDispatch() + + const initVals = { + name: '', + percentage: '', + } + + const [initialValues, setInitialValues] = useState(initVals) + + const { workers_comp_classes } = useAppSelector((state) => state.workers_comp_classes) + const { id } = router.query + + useEffect(() => { + if (id) { + dispatch(fetch({ id: id as string })) + } + }, [id, dispatch]) + + useEffect(() => { + if (typeof workers_comp_classes === 'object' && workers_comp_classes !== null) { + const newInitialVal = { ...initVals }; + Object.keys(initVals).forEach(el => { + if (workers_comp_classes[el] !== undefined) { + newInitialVal[el] = workers_comp_classes[el]; + } + }); + setInitialValues(newInitialVal); + } + }, [workers_comp_classes]) + + const handleSubmit = async (data: any) => { + await dispatch(update({ id: id, data })) + await router.push('/workers_comp_classes/workers_comp_classes-list') + } + + return ( + <> + + {getPageTitle('Edit Workers Comp Class')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + +
    +
    +
    + + ) +} + +EditWorkers_comp_classesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default EditWorkers_comp_classesPage diff --git a/frontend/src/pages/workers_comp_classes/workers_comp_classes-list.tsx b/frontend/src/pages/workers_comp_classes/workers_comp_classes-list.tsx new file mode 100644 index 0000000..3e59ee4 --- /dev/null +++ b/frontend/src/pages/workers_comp_classes/workers_comp_classes-list.tsx @@ -0,0 +1,260 @@ +import { mdiChartTimelineVariant, mdiPlus } from '@mdi/js' +import Head from 'next/head' +import { uniqueId } from 'lodash' +import React, { ReactElement, useCallback, useEffect, useState } from 'react' +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 TableWorkers_comp_classes from '../../components/Workers_comp_classes/TableWorkers_comp_classes' +import BaseButton from '../../components/BaseButton' +import axios from 'axios' +import Link from 'next/link' +import { useAppSelector } from '../../stores/hooks' +import { hasPermission } from '../../helpers/userPermissions' +import FormField from '../../components/FormField' + +const formatDateLabel = (value: string) => { + if (!value) { + return '' + } + + return new Date(`${value}T00:00:00`).toLocaleDateString() +} + +const formatDateInputValue = (value: Date) => { + const year = value.getFullYear() + const month = `${value.getMonth() + 1}`.padStart(2, '0') + const day = `${value.getDate()}`.padStart(2, '0') + + return `${year}-${month}-${day}` +} + +const getThisWeekFilters = () => { + const today = new Date() + const startOfWeek = new Date(today) + + startOfWeek.setDate(today.getDate() - today.getDay()) + + return { + startDate: formatDateInputValue(startOfWeek), + endDate: formatDateInputValue(today), + } +} + +const Workers_comp_classesList = () => { + const [filterItems, setFilterItems] = useState([]) + const [filters] = useState([ + { label: 'Name', title: 'name' }, + { label: 'Percentage', title: 'percentage', number: 'true' }, + ]) + const [reportFilters, setReportFilters] = useState({ + startDate: '', + endDate: '', + }) + const [appliedReportFilters, setAppliedReportFilters] = useState({ + startDate: '', + endDate: '', + }) + const [reportData, setReportData] = useState(null) + const [reportLoading, setReportLoading] = useState(false) + const [reportError, setReportError] = useState('') + + const { currentUser } = useAppSelector((state) => state.auth) + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: filters[0].title, + }, + } + setFilterItems([...filterItems, newItem]) + } + + const fetchReport = useCallback(async (nextFilters: { startDate: string; endDate: string }) => { + setReportLoading(true) + setReportError('') + + try { + const params: { startDate?: string; endDate?: string } = {} + + if (nextFilters.startDate) { + params.startDate = nextFilters.startDate + } + + if (nextFilters.endDate) { + params.endDate = nextFilters.endDate + } + + const res = await axios.get('/workers_comp_report/report', { params }) + setReportData(res.data) + setAppliedReportFilters(nextFilters) + } catch (error: any) { + console.error('Failed to fetch workers comp report', error) + setReportData(null) + setReportError( + error?.response?.data?.message || 'Failed to fetch workers comp report.', + ) + } finally { + setReportLoading(false) + } + }, []) + + useEffect(() => { + fetchReport({ startDate: '', endDate: '' }) + }, [fetchReport]) + + const handleApplyDateFilter = () => { + fetchReport(reportFilters) + } + + const handleThisWeekFilter = () => { + const thisWeekFilters = getThisWeekFilters() + setReportFilters(thisWeekFilters) + fetchReport(thisWeekFilters) + } + + const handleClearDateFilter = () => { + const clearedFilters = { startDate: '', endDate: '' } + setReportFilters(clearedFilters) + fetchReport(clearedFilters) + } + + const reportHeading = appliedReportFilters.startDate || appliedReportFilters.endDate + ? `Workman's Comp Totals (${formatDateLabel(appliedReportFilters.startDate) || 'Beginning'} - ${formatDateLabel(appliedReportFilters.endDate) || 'Today'})` + : "Workman's Comp Totals (All Time)" + + return ( + <> + + {getPageTitle('Workers Comp Classes')} + + + + {hasPermission(currentUser, 'CREATE_WORKERS_COMP_CLASSES') && ( +
    + + + + +
    + )} +
    + + +
    + +
    +
    + + + setReportFilters({ + ...reportFilters, + startDate: event.target.value, + }) + } + /> + + + + setReportFilters({ + ...reportFilters, + endDate: event.target.value, + }) + } + /> + +
    + + +
    +
    + {reportError && ( +
    + {reportError} +
    + )} +
    + + {reportData && ( + +

    {reportHeading}

    +
    +
    +

    Total Work Comp

    +

    + ${Number(reportData.totalComp || 0).toFixed(2)} +

    +
    + {Object.entries(reportData.totalsByClass || {}).map(([className, total]: any) => ( +
    +

    {className}

    +

    ${Number(total).toFixed(2)}

    +
    + ))} +
    + {!reportLoading && !Object.keys(reportData.totalsByClass || {}).length && ( +

    + No workers comp totals found for the selected date range. +

    + )} +
    + )} + + + + +
    + + ) +} + +Workers_comp_classesList.getLayout = function getLayout(page: ReactElement) { + return {page} +} + +export default Workers_comp_classesList diff --git a/frontend/src/pages/workers_comp_classes/workers_comp_classes-new.tsx b/frontend/src/pages/workers_comp_classes/workers_comp_classes-new.tsx new file mode 100644 index 0000000..e17da99 --- /dev/null +++ b/frontend/src/pages/workers_comp_classes/workers_comp_classes-new.tsx @@ -0,0 +1,76 @@ +import { mdiChartTimelineVariant } from '@mdi/js' +import Head from 'next/head' +import React, { ReactElement } from 'react' +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 { Field, Form, Formik } from 'formik' +import FormField from '../../components/FormField' +import BaseDivider from '../../components/BaseDivider' +import BaseButtons from '../../components/BaseButtons' +import BaseButton from '../../components/BaseButton' + +import { create } from '../../stores/workers_comp_classes/workers_comp_classesSlice' +import { useAppDispatch } from '../../stores/hooks' +import { useRouter } from 'next/router' + +const initialValues = { + name: '', + percentage: '', +} + +const Workers_comp_classesNew = () => { + const router = useRouter() + const dispatch = useAppDispatch() + + const handleSubmit = async (data: any) => { + await dispatch(create(data)) + await router.push('/workers_comp_classes/workers_comp_classes-list') + } + + return ( + <> + + {getPageTitle('New Workers Comp Class')} + + + + {''} + + + handleSubmit(values)} + > +
    + + + + + + + + + + + + +
    +
    +
    + + ) +} + +Workers_comp_classesNew.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default Workers_comp_classesNew diff --git a/frontend/src/pages/workers_comp_classes/workers_comp_classes-table.tsx b/frontend/src/pages/workers_comp_classes/workers_comp_classes-table.tsx new file mode 100644 index 0000000..0356dd6 --- /dev/null +++ b/frontend/src/pages/workers_comp_classes/workers_comp_classes-table.tsx @@ -0,0 +1,164 @@ +import { mdiChartTimelineVariant } from '@mdi/js' +import Head from 'next/head' +import { uniqueId } from 'lodash'; +import React, { ReactElement, useState } from 'react' +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 TableWorkers_comp_classes from '../../components/Workers_comp_classes/TableWorkers_comp_classes' +import BaseButton from '../../components/BaseButton' +import axios from "axios"; +import Link from "next/link"; +import {useAppDispatch, useAppSelector} from "../../stores/hooks"; +import CardBoxModal from "../../components/CardBoxModal"; +import DragDropFilePicker from "../../components/DragDropFilePicker"; +import {setRefetch, uploadCsv} from '../../stores/workers_comp_classes/workers_comp_classesSlice'; + + +import {hasPermission} from "../../helpers/userPermissions"; + + + +const Workers_comp_classesTablesPage = () => { + const [filterItems, setFilterItems] = useState([]); + const [csvFile, setCsvFile] = useState(null); + const [isModalActive, setIsModalActive] = useState(false); + const [showTableView, setShowTableView] = useState(false); + + + const { currentUser } = useAppSelector((state) => state.auth); + + + const dispatch = useAppDispatch(); + + + const [filters] = useState([{label: 'WorkersCompClassName', title: 'name'},{label: 'Description', title: 'description'}, + + {label: 'HourlyRate', title: 'hourly_rate', number: 'true'},{label: 'CommissionRate', title: 'commission_rate', number: 'true'}, + + + + {label: 'PayMethod', title: 'pay_method', type: 'enum', options: ['hourly','commission']}, + ]); + + const hasCreatePermission = currentUser && hasPermission(currentUser, 'CREATE_PAY_TYPES'); + + + const addFilter = () => { + const newItem = { + id: uniqueId(), + fields: { + filterValue: '', + filterValueFrom: '', + filterValueTo: '', + selectedField: '', + }, + }; + newItem.fields.selectedField = filters[0].title; + setFilterItems([...filterItems, newItem]); + }; + + const getWorkers_comp_classesCSV = async () => { + const response = await axios({url: '/workers_comp_classes?filetype=csv', method: 'GET',responseType: 'blob'}); + const type = response.headers['content-type'] + const blob = new Blob([response.data], { type: type }) + const link = document.createElement('a') + link.href = window.URL.createObjectURL(blob) + link.download = 'workers_comp_classesCSV.csv' + link.click() + }; + + const onModalConfirm = async () => { + if (!csvFile) return; + await dispatch(uploadCsv(csvFile)); + dispatch(setRefetch(true)); + setCsvFile(null); + setIsModalActive(false); + }; + + const onModalCancel = () => { + setCsvFile(null); + setIsModalActive(false); + }; + + return ( + <> + + {getPageTitle('Workers_comp_classes')} + + + + {''} + + + + {hasCreatePermission && } + + + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} + +
    +
    + + + Back to table + + +
    +
    + + + +
    + + + + + ) +} + +Workers_comp_classesTablesPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ) +} + +export default Workers_comp_classesTablesPage diff --git a/frontend/src/pages/workers_comp_classes/workers_comp_classes-view.tsx b/frontend/src/pages/workers_comp_classes/workers_comp_classes-view.tsx new file mode 100644 index 0000000..bba8fbe --- /dev/null +++ b/frontend/src/pages/workers_comp_classes/workers_comp_classes-view.tsx @@ -0,0 +1,475 @@ +import React, { ReactElement, useEffect } from 'react'; +import Head from 'next/head' +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; +import dayjs from "dayjs"; +import {useAppDispatch, useAppSelector} from "../../stores/hooks"; +import {useRouter} from "next/router"; +import { fetch } from '../../stores/workers_comp_classes/workers_comp_classesSlice' +import {saveFile} from "../../helpers/fileSaver"; +import dataFormatter from '../../helpers/dataFormatter'; +import ImageField from "../../components/ImageField"; +import LayoutAuthenticated from "../../layouts/Authenticated"; +import {getPageTitle} from "../../config"; +import SectionTitleLineWithButton from "../../components/SectionTitleLineWithButton"; +import SectionMain from "../../components/SectionMain"; +import CardBox from "../../components/CardBox"; +import BaseButton from "../../components/BaseButton"; +import BaseDivider from "../../components/BaseDivider"; +import {mdiChartTimelineVariant} from "@mdi/js"; +import {SwitchField} from "../../components/SwitchField"; +import FormField from "../../components/FormField"; + + +const Workers_comp_classesView = () => { + const router = useRouter() + const dispatch = useAppDispatch() + const { workers_comp_classes } = useAppSelector((state) => state.workers_comp_classes) + + + const { id } = router.query; + + function removeLastCharacter(str) { + console.log(str,`str`) + return str.slice(0, -1); + } + + useEffect(() => { + dispatch(fetch({ id })); + }, [dispatch, id]); + + + return ( + <> + + {getPageTitle('View workers_comp_classes')} + + + + + + + + + +
    +

    WorkersCompClassName

    +

    {workers_comp_classes?.name}

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +

    PayMethod

    +

    {workers_comp_classes?.pay_method ?? 'No data'}

    +
    + + + + + + + + + + + + + + + + + + + + +
    +

    HourlyRate

    +

    {workers_comp_classes?.hourly_rate || 'No data'}

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +

    CommissionRate

    +

    {workers_comp_classes?.commission_rate || 'No data'}

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + null}} + disabled + /> + + + + + + + + + + + + + + + + +