Forced merge: merge ai-dev into master

This commit is contained in:
Flatlogic Bot 2026-05-05 23:20:17 +00:00
commit 51e8da98b8
112 changed files with 6202 additions and 1652 deletions

0
.perm_test_apache Normal file
View File

0
.perm_test_exec Normal file
View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

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

View File

@ -264,7 +264,7 @@ module.exports = class Job_chemical_usagesDBApi {
}
},
]
} : {},
} : undefined,
},
@ -281,7 +281,7 @@ module.exports = class Job_chemical_usagesDBApi {
}
},
]
} : {},
} : undefined,
},

View File

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

View File

@ -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']],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -49,6 +49,13 @@ commission_rate: {
},
workers_comp_percentage: {
type: DataTypes.DECIMAL,
},
active: {
@ -135,6 +142,4 @@ description: {
return pay_types;
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
fix.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE "usersCustom_permissionsPermissions" ADD COLUMN "permissionId" UUID REFERENCES "permissions"(id);

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -19,7 +19,7 @@ export default function AsideMenu({
<>
<AsideMenuLayer
menu={props.menu}
className={`${isAsideMobileExpanded ? 'left-0' : '-left-60 lg:left-0'} ${
className={`${isAsideMobileExpanded ? 'left-0' : '-left-72 lg:left-0'} ${
!isAsideLgActive ? 'lg:hidden xl:flex' : ''
}`}
onAsideLgCloseClick={props.onAsideLgClose}

View File

@ -46,9 +46,10 @@ const AsideMenuItem = ({ item, isDropdownList = false }: Props) => {
<BaseIcon path={item.icon} className={`flex-none mx-3 ${activeClassAddon}`} size="18" />
)}
<span
className={`grow text-ellipsis line-clamp-1 ${
className={`min-w-0 grow break-words whitespace-normal leading-5 line-clamp-2 ${
item.menu ? '' : 'pr-12'
} ${activeClassAddon}`}
title={item.label}
>
{item.label}
</span>
@ -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) => {
<li className={'px-3 py-1.5'}>
{item.withDevider && <hr className={`${borders} mb-3`} />}
{item.href && (
<Link href={item.href} target={item.target} className={componentClass}>
<Link href={item.href} target={item.target} className={componentClass} title={item.label}>
{asideMenuItemInnerContents}
</Link>
)}
{!item.href && (
<div className={componentClass} onClick={() => setIsDropdownActive(!isDropdownActive)}>
<div className={componentClass} onClick={() => setIsDropdownActive(!isDropdownActive)} title={item.label}>
{asideMenuItemInnerContents}
</div>
)}

View File

@ -29,7 +29,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
return (
<aside
id='asideMenu'
className={`${className} zzz lg:py-2 lg:pl-2 w-60 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
className={`${className} zzz lg:py-2 lg:pl-2 w-72 fixed flex z-40 top-0 h-screen transition-position overflow-hidden`}
>
<div
className={`flex-1 flex flex-col overflow-hidden dark:bg-dark-900 ${asideStyle} ${corners}`}
@ -39,7 +39,7 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props
>
<div className="text-center flex-1 lg:text-left lg:pl-6 xl:text-center xl:pl-0">
<b className="font-black">Pressure Wash Payroll</b>
<div className="flex items-center justify-center lg:justify-start gap-2"><img src="/assets/logo.webp" alt="Logo" className="h-8" /><b className="font-black text-xs">Major League Pressure Washing</b></div>
</div>

View File

@ -3,6 +3,7 @@ import { MenuAsideItem } from '../interfaces'
import AsideMenuItem from './AsideMenuItem'
import {useAppSelector} from "../stores/hooks";
import {hasPermission} from "../helpers/userPermissions";
import { isBlockedWorkerRoute, isRestrictedPayrollUser } from '../helpers/accessControl';
type Props = {
menu: MenuAsideItem[]
@ -18,9 +19,10 @@ export default function AsideMenuList({ menu, isDropdownList = false, className
return (
<ul className={className}>
{menu.map((item, index) => {
if (!hasPermission(currentUser, item.permissions)) return null;
if (!hasPermission(currentUser, item.permissions)) return null;
if (isRestrictedPayrollUser(currentUser) && isBlockedWorkerRoute(item.href)) return null;
return (
<div key={index}>
<AsideMenuItem

View File

@ -109,34 +109,7 @@ const CardEmployee_pay_types = ({
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>EffectiveStart</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.dateTimeFormatter(item.effective_start) }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>EffectiveEnd</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.dateTimeFormatter(item.effective_end) }
</div>
</dd>
</div>
</dl>
</dl>
</li>
))}
{!loading && employee_pay_types.length === 0 && (

View File

@ -67,26 +67,7 @@ const ListEmployee_pay_types = ({ employee_pay_types, loading, onDelete, current
<p className={'text-xs text-gray-500 '}>Active</p>
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.active) }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>EffectiveStart</p>
<p className={'line-clamp-2'}>{ dataFormatter.dateTimeFormatter(item.effective_start) }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>EffectiveEnd</p>
<p className={'line-clamp-2'}>{ dataFormatter.dateTimeFormatter(item.effective_end) }</p>
</div>
</Link>
</Link>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}

View File

@ -100,44 +100,7 @@ export const loadColumns = async (
type: 'boolean',
},
{
field: 'effective_start',
headerName: 'EffectiveStart',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime',
valueGetter: (params: GridValueGetterParams) =>
new Date(params.row.effective_start),
},
{
field: 'effective_end',
headerName: 'EffectiveEnd',
flex: 1,
minWidth: 120,
filterable: false,
headerClassName: 'datagrid--header',
cellClassName: 'datagrid--cell',
editable: hasUpdatePermission,
type: 'dateTime',
valueGetter: (params: GridValueGetterParams) =>
new Date(params.row.effective_end),
},
{
{
field: 'actions',
type: 'actions',
minWidth: 30,

View File

@ -15,8 +15,8 @@ export default function FooterBar({ children }: Props) {
<div className="text-center md:text-left mb-6 md:mb-0">
<b>
&copy;{year},{` `}
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
Flatlogic
<a href="#" rel="noreferrer" target="_blank">
Major League Pressure Washing
</a>
.
</b>
@ -25,7 +25,7 @@ export default function FooterBar({ children }: Props) {
</div>
<div className="flex item-center md:py-2 gap-4">
<a href="https://flatlogic.com/" rel="noreferrer" target="_blank">
<a href="#" rel="noreferrer" target="_blank">
<Logo className="w-auto h-8 md:h-6 mx-auto" />
</a>
</div>

View File

@ -0,0 +1,196 @@
import React, { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { useFormikContext } from 'formik';
import CardBox from './CardBox';
import {
calculateJobLogPayPreview,
formatCurrency,
formatDecimal,
hasPayTypeDetails,
hasWorkersCompDetails,
resolveEntityId,
} from '../helpers/jobLogPayPreview';
type Props = {
payTypeOptions?: any[];
};
const findById = (items: any[] = [], id: string | null) => items.find((item) => item?.id === id) || null;
export default function JobLogPayPreview({ payTypeOptions = [] }: Props) {
const { values } = useFormikContext<any>();
const [payTypeDetails, setPayTypeDetails] = useState<any>(null);
const [workersCompClassDetails, setWorkersCompClassDetails] = useState<any>(null);
const selectedPayTypeId = resolveEntityId(values?.pay_type);
const selectedWorkersCompClassId = resolveEntityId(values?.workersCompClass);
const selectedPayTypeFromOptions = useMemo(
() => findById(payTypeOptions, selectedPayTypeId),
[payTypeOptions, selectedPayTypeId],
);
useEffect(() => {
let active = true;
if (hasPayTypeDetails(values?.pay_type)) {
setPayTypeDetails(values.pay_type);
return () => {
active = false;
};
}
if (selectedPayTypeFromOptions) {
setPayTypeDetails(selectedPayTypeFromOptions);
return () => {
active = false;
};
}
if (!selectedPayTypeId) {
setPayTypeDetails(null);
return () => {
active = false;
};
}
axios
.get(`/pay_types/${selectedPayTypeId}`)
.then(({ data }) => {
if (active) {
setPayTypeDetails(data);
}
})
.catch((error) => {
console.error('Failed to fetch pay type for job log preview:', error);
if (active) {
setPayTypeDetails(null);
}
});
return () => {
active = false;
};
}, [selectedPayTypeFromOptions, selectedPayTypeId, values?.pay_type]);
useEffect(() => {
let active = true;
if (hasWorkersCompDetails(values?.workersCompClass)) {
setWorkersCompClassDetails(values.workersCompClass);
return () => {
active = false;
};
}
if (!selectedWorkersCompClassId) {
setWorkersCompClassDetails(null);
return () => {
active = false;
};
}
axios
.get(`/workers_comp_classes/${selectedWorkersCompClassId}`)
.then(({ data }) => {
if (active) {
setWorkersCompClassDetails(data);
}
})
.catch((error) => {
console.error('Failed to fetch workers comp class for job log preview:', error);
if (active) {
setWorkersCompClassDetails(null);
}
});
return () => {
active = false;
};
}, [selectedWorkersCompClassId, values?.workersCompClass]);
const preview = useMemo(
() =>
calculateJobLogPayPreview({
payType: payTypeDetails,
workersCompClass: workersCompClassDetails,
hoursConducted: values?.hours_conducted,
clientPaid: values?.client_paid,
}),
[payTypeDetails, workersCompClassDetails, values?.client_paid, values?.hours_conducted],
);
return (
<CardBox className="mb-6 border border-blue-100 bg-blue-50/70 dark:border-dark-700 dark:bg-dark-800">
<div className="space-y-4">
<div className="flex flex-col gap-1 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-blue-700 dark:text-blue-300">
Earnings Preview
</p>
<h3 className="text-lg font-bold">Live estimate for this job</h3>
</div>
<div className="text-left md:text-right">
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Estimated Job Pay</p>
<p className="text-2xl font-bold text-green-700 dark:text-green-400">
{formatCurrency(preview.estimatedPay)}
</p>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Pay Type</p>
<p className="font-semibold capitalize">{preview.payTypeName}</p>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Pay Method</p>
<p className="font-semibold capitalize">{preview.payMethod || 'Not selected'}</p>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">
{preview.payMethod === 'commission' ? 'Client Paid' : 'Hours'}
</p>
<p className="font-semibold">
{preview.payMethod === 'commission'
? formatCurrency(preview.clientAmount)
: formatDecimal(preview.hours)}
</p>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Rate</p>
<p className="font-semibold">
{preview.payMethod === 'commission'
? `${formatDecimal(preview.commissionRate)}%`
: formatCurrency(preview.hourlyRate)}
</p>
</div>
</div>
<div className="rounded-lg border border-dashed border-blue-200 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Calculation</p>
<p className="font-medium">
{preview.formulaLabel}
{preview.hasEstimate ? ` = ${formatCurrency(preview.estimatedPay)}` : ''}
</p>
</div>
{preview.workersCompName && (
<div className="rounded-lg border border-white/80 bg-white/80 p-3 dark:border-dark-700 dark:bg-dark-900">
<p className="text-xs uppercase text-gray-500 dark:text-gray-400">Workers Comp Estimate</p>
<p className="font-semibold">
{preview.workersCompName} · {formatDecimal(preview.workersCompPercentage)}% ·{' '}
{formatCurrency(preview.workersCompAmount)}
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
This is shown for visibility only and does not reduce the employee pay preview.
</p>
</div>
)}
</div>
</CardBox>
);
}

View File

@ -9,6 +9,7 @@ import LoadingSpinner from "../LoadingSpinner";
import Link from 'next/link';
import {hasPermission} from "../../helpers/userPermissions";
import { calculateJobLogPayPreview, formatCurrency } from '../../helpers/jobLogPayPreview';
type Props = {
@ -47,7 +48,15 @@ const CardJob_logs = ({
role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
>
{!loading && job_logs.map((item, index) => (
{!loading && job_logs.map((item, index) => {
const payPreview = calculateJobLogPayPreview({
payType: item.pay_type,
workersCompClass: item.workersCompClass,
hoursConducted: item.hours_conducted,
clientPaid: item.client_paid,
});
return (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
@ -141,7 +150,7 @@ const CardJob_logs = ({
<dt className=' text-gray-500 dark:text-dark-600'>WorkmansCompensationClass</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.workers_comp_class }
{ item.workersCompClass?.name }
</div>
</dd>
</div>
@ -161,6 +170,18 @@ const CardJob_logs = ({
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>EmployeePay</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ formatCurrency(payPreview.estimatedPay) }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Vehicle</dt>
<dd className='flex items-start gap-x-2'>
@ -234,7 +255,8 @@ const CardJob_logs = ({
</dl>
</li>
))}
);
})}
{!loading && job_logs.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>

View File

@ -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
<>
<div className='relative overflow-x-auto p-4 space-y-4'>
{loading && <LoadingSpinner />}
{!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 (
<div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}>
<div className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}>
@ -89,7 +98,7 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>WorkmansCompensationClass</p>
<p className={'line-clamp-2'}>{ item.workers_comp_class }</p>
<p className={'line-clamp-2'}>{ item.workersCompClass?.name }</p>
</div>
@ -103,6 +112,14 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>EmployeePay</p>
<p className={'line-clamp-2 font-semibold'}>{ formatCurrency(payPreview.estimatedPay) }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Vehicle</p>
<p className={'line-clamp-2'}>{ dataFormatter.vehiclesOneListFormatter(item.vehicle) }</p>
@ -163,7 +180,8 @@ const ListJob_logs = ({ job_logs, loading, onDelete, currentPage, numPages, onPa
</div>
</CardBox>
</div>
))}
);
})}
{!loading && job_logs.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>

View File

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

View File

@ -7,9 +7,9 @@ type Props = {
export default function Logo({ className = '' }: Props) {
return (
<img
src={"https://flatlogic.com/logo.svg"}
src={"/assets/logo.webp"}
className={className}
alt={'Flatlogic logo'}>
alt={'Major League Pressure Washing logo'}>
</img>
)
}

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, {useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -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) => (
<span className='whitespace-normal leading-5 py-2'>
{formatAssignedPayTypes(params?.row?.employee_pay_types_employee)}
</span>
),
},
{
field: 'actions',
type: 'actions',

View File

@ -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 (
<div className={'p-4'}>
{loading && <LoadingSpinner />}
<ul
role='list'
className='grid grid-cols-1 gap-x-6 gap-y-8 lg:grid-cols-3 2xl:grid-cols-4 xl:gap-x-8'
>
{!loading && workers_comp_classes.map((item, index) => (
<li
key={item.id}
className={`overflow-hidden ${corners !== 'rounded-full'? corners : 'rounded-3xl'} border ${focusRing} border-gray-200 dark:border-dark-700 ${
darkMode ? 'aside-scrollbars-[slate]' : asideScrollbarsStyle
}`}
>
<div className={`flex items-center ${bgColor} p-6 gap-x-4 border-b border-gray-900/5 bg-gray-50 dark:bg-dark-800 relative`}>
<Link href={`/workers_comp_classes/workers_comp_classes-view/?id=${item.id}`} className='text-lg font-bold leading-6 line-clamp-1'>
{item.name}
</Link>
<div className='ml-auto '>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/workers_comp_classes/workers_comp_classes-edit/?id=${item.id}`}
pathView={`/workers_comp_classes/workers_comp_classes-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</div>
<dl className='divide-y divide-stone-300 dark:divide-dark-700 px-6 py-4 text-sm leading-6 h-64 overflow-y-auto'>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>WorkersCompClassName</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.name }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>PayMethod</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.pay_method }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>HourlyRate</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.hourly_rate }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>CommissionRate</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.commission_rate }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Active</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ dataFormatter.booleanFormatter(item.active) }
</div>
</dd>
</div>
<div className='flex justify-between gap-x-4 py-3'>
<dt className=' text-gray-500 dark:text-dark-600'>Description</dt>
<dd className='flex items-start gap-x-2'>
<div className='font-medium line-clamp-4'>
{ item.description }
</div>
</dd>
</div>
</dl>
</li>
))}
{!loading && workers_comp_classes.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</ul>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</div>
);
};
export default CardWorkers_comp_classes;

View File

@ -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 (
<>
<div className='relative overflow-x-auto p-4 space-y-4'>
{loading && <LoadingSpinner />}
{!loading && workers_comp_classes.map((item) => (
<div key={item.id}>
<CardBox hasTable isList className={'rounded shadow-none'}>
<div className={`flex rounded dark:bg-dark-900 border border-stone-300 items-center overflow-hidden`}>
<Link
href={`/workers_comp_classes/workers_comp_classes-view/?id=${item.id}`}
className={
'flex-1 px-4 py-6 h-24 flex divide-x-2 divide-stone-300 items-center overflow-hidden`}> dark:divide-dark-700 overflow-x-auto'
}
>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>WorkersCompClassName</p>
<p className={'line-clamp-2'}>{ item.name }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>PayMethod</p>
<p className={'line-clamp-2'}>{ item.pay_method }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>HourlyRate</p>
<p className={'line-clamp-2'}>{ item.hourly_rate }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>CommissionRate</p>
<p className={'line-clamp-2'}>{ item.commission_rate }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Active</p>
<p className={'line-clamp-2'}>{ dataFormatter.booleanFormatter(item.active) }</p>
</div>
<div className={'flex-1 px-3'}>
<p className={'text-xs text-gray-500 '}>Description</p>
<p className={'line-clamp-2'}>{ item.description }</p>
</div>
</Link>
<ListActionsPopover
onDelete={onDelete}
itemId={item.id}
pathEdit={`/workers_comp_classes/workers_comp_classes-edit/?id=${item.id}`}
pathView={`/workers_comp_classes/workers_comp_classes-view/?id=${item.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>
</CardBox>
</div>
))}
{!loading && workers_comp_classes.length === 0 && (
<div className='col-span-full flex items-center justify-center h-40'>
<p className=''>No data to display</p>
</div>
)}
</div>
<div className={'flex items-center justify-center my-6'}>
<Pagination
currentPage={currentPage}
numPages={numPages}
setCurrentPage={onPageChange}
/>
</div>
</>
)
};
export default ListWorkers_comp_classes

View File

@ -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<GridColDef[]>([]);
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 = (
<div className='relative overflow-x-auto'>
<DataGrid
autoHeight
rowHeight={64}
sx={dataGridStyles}
className={'datagrid--table'}
getRowClassName={() => `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);
}}
/>
</div>
)
return (
<>
{filterItems && Array.isArray( filterItems ) && filterItems.length ?
<CardBox>
<Formik
initialValues={{
checkboxes: ['lorem'],
switches: ['lorem'],
radio: 'lorem',
}}
onSubmit={() => null}
>
<Form>
<>
{filterItems && filterItems.map((filterItem) => {
return (
<div key={filterItem.id} className="flex mb-4">
<div className="flex flex-col w-full mr-3">
<div className=" text-gray-500 font-bold">Filter</div>
<Field
className={controlClasses}
name='selectedField'
id='selectedField'
component='select'
value={filterItem?.fields?.selectedField || ''}
onChange={handleChange(filterItem.id)}
>
{filters.map((selectOption) => (
<option
key={selectOption.title}
value={`${selectOption.title}`}
>
{selectOption.label}
</option>
))}
</Field>
</div>
{filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField
)?.type === 'enum' ? (
<div className="flex flex-col w-full mr-3">
<div className="text-gray-500 font-bold">
Value
</div>
<Field
className={controlClasses}
name="filterValue"
id='filterValue'
component="select"
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
>
<option value="">Select Value</option>
{filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField
)?.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</Field>
</div>
) : filters.find((filter) =>
filter.title === filterItem?.fields?.selectedField
)?.number ? (
<div className="flex flex-row w-full mr-3">
<div className="flex flex-col w-full mr-3">
<div className=" text-gray-500 font-bold">From</div>
<Field
className={controlClasses}
name='filterValueFrom'
placeholder='From'
id='filterValueFrom'
value={filterItem?.fields?.filterValueFrom || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
<div className="flex flex-col w-full">
<div className=" text-gray-500 font-bold">To</div>
<Field
className={controlClasses}
name='filterValueTo'
placeholder='to'
id='filterValueTo'
value={filterItem?.fields?.filterValueTo || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
</div>
) : filters.find(
(filter) =>
filter.title ===
filterItem?.fields?.selectedField
)?.date ? (
<div className='flex flex-row w-full mr-3'>
<div className='flex flex-col w-full mr-3'>
<div className=' text-gray-500 font-bold'>
From
</div>
<Field
className={controlClasses}
name='filterValueFrom'
placeholder='From'
id='filterValueFrom'
type='datetime-local'
value={filterItem?.fields?.filterValueFrom || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
<div className='flex flex-col w-full'>
<div className=' text-gray-500 font-bold'>To</div>
<Field
className={controlClasses}
name='filterValueTo'
placeholder='to'
id='filterValueTo'
type='datetime-local'
value={filterItem?.fields?.filterValueTo || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
</div>
) : (
<div className="flex flex-col w-full mr-3">
<div className=" text-gray-500 font-bold">Contains</div>
<Field
className={controlClasses}
name='filterValue'
placeholder='Contained'
id='filterValue'
value={filterItem?.fields?.filterValue || ''}
onChange={handleChange(filterItem.id)}
/>
</div>
)}
<div className="flex flex-col">
<div className=" text-gray-500 font-bold">Action</div>
<BaseButton
className="my-2"
type='reset'
color='danger'
label='Delete'
onClick={() => {
deleteFilter(filterItem.id)
}}
/>
</div>
</div>
)
})}
<div className="flex">
<BaseButton
className="my-2 mr-3"
color="success"
label='Apply'
onClick={handleSubmit}
/>
<BaseButton
className="my-2"
color='info'
label='Cancel'
onClick={handleReset}
/>
</div>
</>
</Form>
</Formik>
</CardBox> : null
}
<CardBoxModal
title="Please confirm"
buttonColor="info"
buttonLabel={loading ? 'Deleting...' : 'Confirm'}
isActive={isModalTrashActive}
onConfirm={handleDeleteAction}
onCancel={handleModalAction}
>
<p>Are you sure you want to delete this item?</p>
</CardBoxModal>
{dataGrid}
{selectedRows.length > 0 &&
createPortal(
<BaseButton
className='me-4'
color='danger'
label={`Delete ${selectedRows.length === 1 ? 'Row' : 'Rows'}`}
onClick={() => onDeleteRows(selectedRows)}
/>,
document.getElementById('delete-rows-button'),
)}
<ToastContainer />
</>
)
}
export default TableSampleWorkers_comp_classes

View File

@ -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 [
<div key={params?.row?.id}>
<ListActionsPopover
onDelete={onDelete}
itemId={params?.row?.id}
pathEdit={`/workers_comp_classes/workers_comp_classes-edit/?id=${params?.row?.id}`}
pathView={`/workers_comp_classes/workers_comp_classes-view/?id=${params?.row?.id}`}
hasUpdatePermission={hasUpdatePermission}
/>
</div>,
]
},
},
];
};

View File

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

View File

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

View File

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

View File

@ -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 (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
<div
className={`${layoutAsidePadding} ${
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
isAsideMobileExpanded ? 'ml-72 lg:ml-0' : ''
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
>
<NavBar
menu={menuNavBar}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-72 lg:ml-0' : ''}`}
>
<NavBarItemPlain
display="flex lg:hidden"

View File

@ -6,6 +6,20 @@ const menuAside: MenuAsideItem[] = [
href: '/dashboard',
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
permissions: 'UPDATE_USERS',
},
{
href: '/log-work',
label: 'Log Work',
icon: icon.mdiPencil,
permissions: 'CREATE_JOB_LOGS'
},
{
href: '/my-logs',
label: 'My Logs',
icon: icon.mdiViewList,
permissions: 'READ_JOB_LOGS'
},
{
@ -14,7 +28,7 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS'
permissions: 'UPDATE_USERS'
},
{
href: '/roles/roles-list',
@ -22,7 +36,7 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES'
permissions: 'UPDATE_USERS'
},
{
href: '/permissions/permissions-list',
@ -30,7 +44,7 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
permissions: 'UPDATE_USERS'
},
{
href: '/customers/customers-list',
@ -38,7 +52,7 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAccountMultiple' in icon ? icon['mdiAccountMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CUSTOMERS'
permissions: 'UPDATE_USERS'
},
{
href: '/vehicles/vehicles-list',
@ -46,7 +60,13 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiTruck' in icon ? icon['mdiTruck' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_VEHICLES'
permissions: 'UPDATE_USERS'
},
{
href: '/workers_comp_classes/workers_comp_classes-list',
label: 'Workmans Comp',
icon: icon.mdiTable,
permissions: 'UPDATE_USERS'
},
{
href: '/pay_types/pay_types-list',
@ -54,7 +74,7 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCashMultiple' in icon ? icon['mdiCashMultiple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PAY_TYPES'
permissions: 'UPDATE_USERS'
},
{
href: '/employee_pay_types/employee_pay_types-list',
@ -62,31 +82,13 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_EMPLOYEE_PAY_TYPES'
},
{
href: '/chemical_products/chemical_products-list',
label: 'Chemical products',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFlask' in icon ? icon['mdiFlask' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_CHEMICAL_PRODUCTS'
permissions: 'UPDATE_USERS'
},
{
href: '/job_logs/job_logs-list',
label: 'Job logs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
label: 'All Job Logs',
icon: 'mdiClipboardTextClock' in icon ? icon['mdiClipboardTextClock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_JOB_LOGS'
},
{
href: '/job_chemical_usages/job_chemical_usages-list',
label: 'Job chemical usages',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFlaskOutline' in icon ? icon['mdiFlaskOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_JOB_CHEMICAL_USAGES'
permissions: 'UPDATE_USERS'
},
{
href: '/payroll_runs/payroll_runs-list',
@ -94,7 +96,7 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCalendarClock' in icon ? icon['mdiCalendarClock' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PAYROLL_RUNS'
permissions: 'UPDATE_USERS'
},
{
href: '/payroll_line_items/payroll_line_items-list',
@ -102,7 +104,19 @@ const menuAside: MenuAsideItem[] = [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFileDocumentOutline' in icon ? icon['mdiFileDocumentOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PAYROLL_LINE_ITEMS'
permissions: 'UPDATE_USERS'
},
{
href: '/reports',
label: 'Payroll Dashboard',
icon: icon.mdiChartBar,
permissions: 'UPDATE_USERS'
},
{
href: '/admin-help',
label: 'Admin Help',
icon: icon.mdiHelpCircleOutline,
permissions: 'UPDATE_USERS'
},
{
href: '/profile',
@ -116,8 +130,8 @@ const menuAside: MenuAsideItem[] = [
target: '_blank',
label: 'Swagger API',
icon: icon.mdiFileCode,
permissions: 'READ_API_DOCS'
permissions: 'UPDATE_USERS'
},
]
export default menuAside
export default menuAside

View File

@ -149,7 +149,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
setStepsEnabled(false);
};
const title = 'Pressure Wash Payroll'
const title = 'Major League Pressure Washing'
const description = "Internal payroll app for pressure washing techs to log jobs, track mileage and chemicals, and run payroll reports."
const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/39157/app-hero-20260312-154649.png"

View File

@ -0,0 +1,304 @@
import {
mdiAccountCashOutline,
mdiCashRegister,
mdiClipboardCheckOutline,
mdiClipboardListOutline,
mdiCogOutline,
mdiHelpCircleOutline,
mdiOpenInNew,
mdiPlayCircleOutline,
mdiShieldCheckOutline,
} from '@mdi/js';
import Head from 'next/head';
import React, { ReactElement } from 'react';
import BaseButton from '../components/BaseButton';
import BaseDivider from '../components/BaseDivider';
import BaseIcon from '../components/BaseIcon';
import CardBox from '../components/CardBox';
import SectionMain from '../components/SectionMain';
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
type HelpSection = {
id: string;
title: string;
icon: string;
summary: string;
steps: string[];
checks?: string[];
warning?: string;
href?: string;
hrefLabel?: string;
};
const helpSections: HelpSection[] = [
{
id: 'getting-started',
title: 'Getting started',
icon: mdiPlayCircleOutline,
summary:
'Use this as the admins quick-start routine before you begin assigning pay or creating payroll runs.',
steps: [
'Confirm your users, customers, vehicles, pay types, and workers comp classes are up to date.',
'Make sure employees know whether they should use Log Work or whether an admin is entering logs on their behalf.',
'Decide how often payroll should be run and which date range each payroll run should cover.',
'Review this help page the first few times so your workflow stays consistent.',
],
checks: [
'Users have the correct role and active account status.',
'Employees have at least one pay setup assigned before they start logging jobs.',
'Admins know which menu pages they will use most often: Users, Pay types, Employee pay types, All Job Logs, and Payroll runs.',
],
href: '/dashboard',
hrefLabel: 'Open Dashboard',
},
{
id: 'assign-employee-pay',
title: 'Assign employee pay',
icon: mdiAccountCashOutline,
summary:
'This links an employee to the pay option they are allowed to use on jobs.',
steps: [
'Open Employee pay types.',
'Create a new record and select the employee.',
'Choose the pay type you want that employee to use, such as hourly or commission.',
'Save the record and verify the employee can now select that pay option while logging work.',
'If an employee should have multiple valid pay options, add each allowed pay type as its own assignment.',
],
checks: [
'Use clear pay type names so employees can easily choose the right option while logging work.',
'Remove outdated assignments if an employee should no longer use an old rate or method.',
],
href: '/employee_pay_types/employee_pay_types-list',
hrefLabel: 'Open Employee Pay Types',
},
{
id: 'set-up-pay-types',
title: 'Set up pay types',
icon: mdiCogOutline,
summary:
'Pay types control how job pay is calculated. Keep these clean and intentional so payroll stays predictable.',
steps: [
'Open Pay types and review the existing list before adding a new one.',
'For hourly work, confirm the hourly rate is correct.',
'For commission-based work, confirm the commission percentage is correct.',
'Use consistent naming so admins and employees can tell similar pay types apart.',
'Save changes, then test a sample job log if the pay logic is new or changed.',
],
checks: [
'Avoid creating duplicate pay types with only slightly different names.',
'When changing a rate, double-check whether you should edit an existing pay type or create a new one for future use.',
],
href: '/pay_types/pay_types-list',
hrefLabel: 'Open Pay Types',
},
{
id: 'review-job-logs',
title: 'Review job logs before payroll',
icon: mdiClipboardCheckOutline,
summary:
'A quick review before payroll helps catch missing hours, wrong pay types, or incomplete client-paid amounts.',
steps: [
'Open All Job Logs and filter by the payroll period you are about to run.',
'Scan Employee Pay, hours, client paid, and pay type selections for anything that looks incorrect.',
'Open any questionable record and verify the employee, customer, vehicle, and work details.',
'Fix missing or incorrect values before creating the payroll run.',
],
checks: [
'Hourly jobs should have hours entered.',
'Commission jobs should have the client paid amount entered.',
'Employees should not have duplicate or accidental logs for the same work.',
],
href: '/job_logs/job_logs-list',
hrefLabel: 'Open All Job Logs',
},
{
id: 'run-payroll',
title: 'Run payroll',
icon: mdiCashRegister,
summary:
'Create a payroll run only after the logs in that date range look clean.',
steps: [
'Open Payroll runs and create a new run.',
'Set the payroll dates carefully so the run only includes the jobs you intend to pay.',
'Save the payroll run and review the generated results.',
'Open the payroll line items if you need job-by-job detail for what was included.',
'If totals look wrong, go back to the source job logs, correct them, and then rerun or recreate payroll as needed.',
],
checks: [
'Watch for an incorrect date range first; that is one of the most common causes of missing or extra pay.',
'If one employee total looks off, inspect that employees individual job logs before changing anything else.',
],
href: '/payroll_runs/payroll_runs-list',
hrefLabel: 'Open Payroll Runs',
},
{
id: 'check-results',
title: 'Check payroll results',
icon: mdiClipboardListOutline,
summary:
'After a run is created, verify the output before treating it as final.',
steps: [
'Review the payroll run totals and compare them with the expected workload for that period.',
'Open Payroll line items to confirm each employee has the right combination of job entries.',
'Spot-check a few records manually using the job log Employee Pay values and the live preview logic on the log form.',
'Use the Payroll Dashboard if you want a broader visual review of payroll activity.',
],
checks: [
'Investigate unusual spikes or drops before approving the numbers internally.',
'Keep a repeatable review routine so payroll checks stay consistent every pay period.',
],
href: '/reports',
hrefLabel: 'Open Payroll Dashboard',
},
{
id: 'troubleshooting',
title: 'Troubleshooting and common issues',
icon: mdiShieldCheckOutline,
summary:
'Use this checklist whenever the numbers do not look right or an employee cannot complete a job log correctly.',
steps: [
'If an employee cannot choose the right pay option, confirm the Employee pay types assignment exists.',
'If live pay looks wrong on a job log, verify the pay type values and the job fields used for that method.',
'If payroll totals look off, compare the payroll date range with the dates on the job logs included in the run.',
'If a commission total looks too low or too high, check the client paid amount on the job log first.',
'If an hourly total looks wrong, check hours entered and the selected hourly pay type.',
],
warning:
'Best practice: fix the source job log or pay setup first, then regenerate or recreate the payroll output instead of trying to explain away a mismatch later.',
href: '/payroll_line_items/payroll_line_items-list',
hrefLabel: 'Open Payroll Line Items',
},
];
const AdminHelpPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Admin Help')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiHelpCircleOutline}
title='Admin Help'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6'>
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div className='max-w-3xl'>
<h2 className='text-xl font-semibold mb-2'>Admin task checklist</h2>
<p className='text-gray-600 dark:text-gray-300'>
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.
</p>
</div>
<BaseButton
href='/payroll_runs/payroll_runs-list'
color='info'
icon={mdiOpenInNew}
label='Go to Payroll Runs'
/>
</div>
<BaseDivider />
<div className='flex flex-wrap gap-3'>
{helpSections.map((section) => (
<BaseButton
key={section.id}
asAnchor
href={`#${section.id}`}
color='whiteDark'
small
label={section.title}
/>
))}
</div>
</CardBox>
<div className='grid grid-cols-1 gap-6 xl:grid-cols-2'>
{helpSections.map((section) => (
<CardBox key={section.id} className='h-full'>
<div id={section.id} className='scroll-mt-20'>
<div className='flex items-start justify-between gap-4 mb-4'>
<div className='flex items-start gap-3'>
<BaseIcon path={section.icon} size={28} className='text-blue-600 dark:text-blue-400 mt-1' />
<div>
<h2 className='text-xl font-semibold'>{section.title}</h2>
<p className='text-sm text-gray-600 dark:text-gray-300 mt-1'>
{section.summary}
</p>
</div>
</div>
{section.href && section.hrefLabel ? (
<BaseButton
href={section.href}
color='info'
small
icon={mdiOpenInNew}
label={section.hrefLabel}
/>
) : null}
</div>
<div className='mb-4'>
<div className='text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2'>
Checklist
</div>
<ol className='list-decimal pl-5 space-y-2 text-sm leading-6'>
{section.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
</div>
{section.checks?.length ? (
<div className='mb-4'>
<div className='text-sm font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400 mb-2'>
Verify before moving on
</div>
<ul className='list-disc pl-5 space-y-2 text-sm leading-6'>
{section.checks.map((check) => (
<li key={check}>{check}</li>
))}
</ul>
</div>
) : null}
{section.warning ? (
<div className='rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100'>
<span className='font-semibold'>Admin note:</span> {section.warning}
</div>
) : null}
</div>
</CardBox>
))}
</div>
<CardBox className='mt-6'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between'>
<div>
<h2 className='text-lg font-semibold'>Suggested next improvement</h2>
<p className='text-sm text-gray-600 dark:text-gray-300 mt-1'>
If you want, this help page can later become searchable or editable so admins can maintain their own internal SOPs.
</p>
</div>
<BaseButton href='/dashboard' color='whiteDark' label='Back to Dashboard' />
</div>
</CardBox>
</SectionMain>
</>
);
};
AdminHelpPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='UPDATE_USERS'>{page}</LayoutAuthenticated>;
};
export default AdminHelpPage;

View File

@ -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 = () => {
))}
</div>
{!!rolesWidgets.length && <hr className='my-6 ' />}
{isAdmin ? (
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
{!isRestrictedPayrollUser(currentUser) && hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
@ -287,7 +298,7 @@ const Dashboard = () => {
</div>
</Link>}
{hasPermission(currentUser, 'READ_PAY_TYPES') && <Link href={'/pay_types/pay_types-list'}>
{!isRestrictedPayrollUser(currentUser) && hasPermission(currentUser, 'READ_PAY_TYPES') && <Link href={'/pay_types/pay_types-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
@ -315,7 +326,7 @@ const Dashboard = () => {
</div>
</Link>}
{hasPermission(currentUser, 'READ_EMPLOYEE_PAY_TYPES') && <Link href={'/employee_pay_types/employee_pay_types-list'}>
{!isRestrictedPayrollUser(currentUser) && hasPermission(currentUser, 'READ_EMPLOYEE_PAY_TYPES') && <Link href={'/employee_pay_types/employee_pay_types-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
@ -343,34 +354,6 @@ const Dashboard = () => {
</div>
</Link>}
{hasPermission(currentUser, 'READ_CHEMICAL_PRODUCTS') && <Link href={'/chemical_products/chemical_products-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Chemical products
</div>
<div className="text-3xl leading-tight font-semibold">
{chemical_products}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiFlask' in icon ? icon['mdiFlask' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_JOB_LOGS') && <Link href={'/job_logs/job_logs-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
@ -399,34 +382,6 @@ const Dashboard = () => {
</div>
</Link>}
{hasPermission(currentUser, 'READ_JOB_CHEMICAL_USAGES') && <Link href={'/job_chemical_usages/job_chemical_usages-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Job chemical usages
</div>
<div className="text-3xl leading-tight font-semibold">
{job_chemical_usages}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiFlaskOutline' in icon ? icon['mdiFlaskOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PAYROLL_RUNS') && <Link href={'/payroll_runs/payroll_runs-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
@ -481,11 +436,34 @@ const Dashboard = () => {
</div>
</div>
</div>
</Link>}
</div>
) : (
<div id="dashboard" className="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6">
<Link href={'/my-logs'}>
<div className={"dark:bg-dark-900 dark:border-dark-700 p-6 " + (corners !== 'rounded-full' ? corners : 'rounded-3xl') + " " + cardsStyle}>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
My Logs
</div>
<div className="text-3xl leading-tight font-semibold">
{job_logs}
</div>
</div>
<div>
<BaseIcon className={iconsColor} w="w-16" h="h-16" size={48} path={icon.mdiClipboardTextClock} />
</div>
</div>
</div>
</Link>
</div>
)}
</SectionMain>
</>
)
}

View File

@ -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}
></Field>
</FormField>
<FormField
label="EffectiveStart"
>
<DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={initialValues.effective_start ?
new Date(
dayjs(initialValues.effective_start).format('YYYY-MM-DD hh:mm'),
) : null
}
onChange={(date) => setInitialValues({...initialValues, 'effective_start': date})}
/>
</FormField>
<FormField
label="EffectiveEnd"
>
<DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={initialValues.effective_end ?
new Date(
dayjs(initialValues.effective_end).format('YYYY-MM-DD hh:mm'),
) : null
}
onChange={(date) => setInitialValues({...initialValues, 'effective_end': date})}
/>
</FormField>
<BaseDivider />
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />

View File

@ -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}
></Field>
</FormField>
<FormField
label="EffectiveStart"
>
<DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={initialValues.effective_start ?
new Date(
dayjs(initialValues.effective_start).format('YYYY-MM-DD hh:mm'),
) : null
}
onChange={(date) => setInitialValues({...initialValues, 'effective_start': date})}
/>
</FormField>
<FormField
label="EffectiveEnd"
>
<DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={initialValues.effective_end ?
new Date(
dayjs(initialValues.effective_end).format('YYYY-MM-DD hh:mm'),
) : null
}
onChange={(date) => setInitialValues({...initialValues, 'effective_end': date})}
/>
</FormField>
<BaseDivider />
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />

View File

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

View File

@ -69,46 +69,6 @@ const initialValues = {
active: false,
effective_start: '',
effective_end: '',
}
@ -228,88 +188,7 @@ const Employee_pay_typesNew = () => {
component={SwitchField}
></Field>
</FormField>
<FormField
label="EffectiveStart"
>
<Field
type="datetime-local"
name="effective_start"
placeholder="EffectiveStart"
/>
</FormField>
<FormField
label="EffectiveEnd"
>
<Field
type="datetime-local"
name="effective_end"
placeholder="EffectiveEnd"
/>
</FormField>
<BaseDivider />
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />

View File

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

View File

@ -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
/>
</FormField>
<FormField label='EffectiveStart'>
{employee_pay_types.effective_start ? <DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={employee_pay_types.effective_start ?
new Date(
dayjs(employee_pay_types.effective_start).format('YYYY-MM-DD hh:mm'),
) : null
}
disabled
/> : <p>No EffectiveStart</p>}
</FormField>
<FormField label='EffectiveEnd'>
{employee_pay_types.effective_end ? <DatePicker
dateFormat="yyyy-MM-dd hh:mm"
showTimeSelect
selected={employee_pay_types.effective_end ?
new Date(
dayjs(employee_pay_types.effective_end).format('YYYY-MM-DD hh:mm'),
) : null
}
disabled
/> : <p>No EffectiveEnd</p>}
</FormField>
<BaseDivider />
<BaseDivider />
<BaseButton
color='info'

View File

@ -26,7 +26,7 @@ export default function Starter() {
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => 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}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Pressure Wash Payroll app!"/>
<CardBoxComponentTitle title="Welcome to your Major League Pressure Washing app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>

View File

@ -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 = () => {
></Field>
</FormField>
<FormField
label="OdometerStart"
>
<Field
type="number"
name="odometer_start"
placeholder="OdometerStart"
/>
</FormField>
<FormField
label="OdometerEnd"
>
<Field
type="number"
name="odometer_end"
placeholder="OdometerEnd"
/>
</FormField>
<FormField
label="JobAddress"
>
<Field
name="job_address"
placeholder="JobAddress"
/>
</FormField>
<FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select">

View File

@ -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 = () => {
<FormField label="WorkmansCompensationClass" labelFor="workers_comp_class">
<Field name="workers_comp_class" id="workers_comp_class" component="select">
<option value="roof">roof</option>
<option value="ladder">ladder</option>
<option value="ground">ground</option>
</Field>
</FormField>
<FormField label="Workmans Comp Class" labelFor="workersCompClassId">
<Field name="workersCompClass" id="workersCompClass" component={SelectField} options={initialValues.workersCompClass} itemRef={'workers_comp_classes'} showField={'name'}></Field>
</FormField>
@ -804,6 +794,8 @@ const EditJob_logsPage = () => {
></Field>
</FormField>
<JobLogPayPreview />
@ -872,146 +864,6 @@ const EditJob_logsPage = () => {
></Field>
</FormField>
<FormField
label="OdometerStart"
>
<Field
type="number"
name="odometer_start"
placeholder="OdometerStart"
/>
</FormField>
<FormField
label="OdometerEnd"
>
<Field
type="number"
name="odometer_end"
placeholder="OdometerEnd"
/>
</FormField>
<FormField
label="JobAddress"
>
<Field
name="job_address"
placeholder="JobAddress"
/>
</FormField>
<FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select">

View File

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

View File

@ -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 = () => {
<FormField label="WorkmansCompensationClass" labelFor="workers_comp_class">
<Field name="workers_comp_class" id="workers_comp_class" component="select">
<option value="roof">roof</option>
<option value="ladder">ladder</option>
<option value="ground">ground</option>
</Field>
<FormField label="Workmans Comp Class" labelFor="workersCompClassId">
<Field name="workersCompClass" id="workersCompClass" component={SelectField} options={[]} itemRef={'workers_comp_classes'} showField={'name'}></Field>
</FormField>
@ -503,6 +493,8 @@ const Job_logsNew = () => {
<Field name="pay_type" id="pay_type" component={SelectField} options={[]} itemRef={'pay_types'}></Field>
</FormField>
<JobLogPayPreview />
@ -532,136 +524,6 @@ const Job_logsNew = () => {
<FormField label="Vehicle" labelFor="vehicle">
<Field name="vehicle" id="vehicle" component={SelectField} options={[]} itemRef={'vehicles'}></Field>
</FormField>
<FormField
label="OdometerStart"
>
<Field
type="number"
name="odometer_start"
placeholder="OdometerStart"
/>
</FormField>
<FormField
label="OdometerEnd"
>
<Field
type="number"
name="odometer_end"
placeholder="OdometerEnd"
/>
</FormField>
<FormField
label="JobAddress"
>
<Field
name="job_address"
placeholder="JobAddress"
/>
</FormField>
<FormField label="Status" labelFor="status">
<Field name="status" id="status" component="select">

View File

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

View File

@ -421,127 +421,6 @@ const Job_logsView = () => {
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>OdometerStart</p>
<p>{job_logs?.odometer_start || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>OdometerEnd</p>
<p>{job_logs?.odometer_end || 'No data'}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>JobAddress</p>
<p>{job_logs?.job_address}</p>
</div>
<div className={'mb-4'}>
<p className={'block font-bold mb-2'}>Status</p>
<p>{job_logs?.status ?? 'No data'}</p>
@ -600,65 +479,6 @@ const Job_logsView = () => {
<>
<p className={'block font-bold mb-2'}>Job_chemical_usages JobLog</p>
<CardBox
className='mb-6 border border-gray-300 rounded overflow-hidden'
hasTable
>
<div className='overflow-x-auto'>
<table>
<thead>
<tr>
<th>QuantityUsed</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{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) => (
<tr key={item.id} onClick={() => router.push(`/job_chemical_usages/job_chemical_usages-view/?id=${item.id}`)}>
<td data-label="quantity_used">
{ item.quantity_used }
</td>
<td data-label="notes">
{ item.notes }
</td>
</tr>
))}
</tbody>
</table>
</div>
{!job_logs?.job_chemical_usages_job_log?.length && <div className={'text-center py-4'}>No data</div>}
</CardBox>
</>
<BaseDivider />
<BaseButton

View File

@ -0,0 +1,138 @@
import { mdiPencil } 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 JobLogPayPreview from '../components/JobLogPayPreview';
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 { SelectField } from '../components/SelectField';
import { useRouter } from 'next/router';
import { create } from '../stores/job_logs/job_logsSlice';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import axios from 'axios';
const LogWorkPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const currentUser = useAppSelector((state) => state.auth.currentUser);
const [assignedPayTypes, setAssignedPayTypes] = useState<any[]>([]);
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 (
<>
<Head>
<title>{getPageTitle('Log Work')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiPencil} title="Log Work" main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik initialValues={initialValues} onSubmit={(values) => handleSubmit(values)}>
{() => (
<Form>
<FormField label="Work Date">
<Field type="date" name="work_date" />
</FormField>
<FormField label="Customer" labelFor="customer">
<Field name="customer" id="customer" placeholder="Enter customer name" />
</FormField>
<FormField label="Hours Conducted">
<Field type="number" name="hours_conducted" placeholder="Hours" />
</FormField>
<FormField label="Client Paid">
<Field type="number" name="client_paid" placeholder="Amount" />
</FormField>
<FormField label="Worker's Comp Class" labelFor="workersCompClass">
<Field
name="workersCompClass"
id="workersCompClass"
component={SelectField}
options={[]}
itemRef={'workers_comp_classes'}
showField={'name'}
/>
</FormField>
<FormField label="Pay Type" labelFor="pay_type">
<Field name="pay_type" id="pay_type" as="select">
<option value="">Select an assigned pay type</option>
{assignedPayTypes.map((pt) => (
<option key={pt.id} value={pt.id}>
{pt.name || pt.pay_method}
</option>
))}
</Field>
</FormField>
<JobLogPayPreview payTypeOptions={assignedPayTypes} />
<FormField label="Vehicle" labelFor="vehicle">
<Field
name="vehicle"
id="vehicle"
component={SelectField}
options={[]}
itemRef={'vehicles'}
/>
</FormField>
<FormField label="Notes to Admin" hasTextareaHeight>
<Field name="notes_to_admin" as="textarea" placeholder="Notes..." />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit Work Log" />
</BaseButtons>
</Form>
)}
</Formik>
</CardBox>
</SectionMain>
</>
);
};
LogWorkPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default LogWorkPage;

View File

@ -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() {
<CardBox id="loginRoles" className='w-full md:w-3/5 lg:w-2/3'>
<h2 className="text-4xl font-semibold my-4">{title}</h2>
<div className="flex flex-col items-center"><img src="/assets/logo.webp" alt="Logo" className="h-24 mb-4" /><h2 className="text-4xl font-semibold my-4 text-center">{title}</h2></div>
<div className='flex flex-row text-gray-500 justify-between'>
<div>

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('My Logs')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiViewList} title="My Logs" main>
{''}
</SectionTitleLineWithButton>
<ListJob_logs
job_logs={job_logs}
loading={loading}
onDelete={() => console.log('Delete')}
currentPage={currentPage}
numPages={1}
onPageChange={setCurrentPage}
/>
</SectionMain>
</>
);
};
MyLogsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default MyLogsPage;

View File

@ -128,6 +128,11 @@ const EditPay_typesPage = () => {
'workers_comp_percentage': '',
@ -406,7 +411,15 @@ const EditPay_typesPage = () => {
<FormField
label="WorkersCompPercentage"
>
<Field
type="number"
name="workers_comp_percentage"
placeholder="WorkersCompPercentage"
/>
</FormField>
@ -503,4 +516,4 @@ EditPay_typesPage.getLayout = function getLayout(page: ReactElement) {
)
}
export default EditPay_typesPage
export default EditPay_typesPage

View File

@ -80,6 +80,8 @@ const initialValues = {
commission_rate: '',
workers_comp_percentage: '',
@ -285,6 +287,15 @@ const Pay_typesNew = () => {
/>
</FormField>
<FormField
label="WorkersCompPercentage"
>
<Field
type="number"
name="workers_comp_percentage"
placeholder="WorkersCompPercentage"
/>
</FormField>
@ -393,4 +404,4 @@ Pay_typesNew.getLayout = function getLayout(page: ReactElement) {
)
}
export default Pay_typesNew
export default Pay_typesNew

View File

@ -276,11 +276,11 @@ const Pay_typesView = () => {
<th>EffectiveStart</th>
<th>EffectiveEnd</th>
</tr>
@ -298,21 +298,7 @@ const Pay_typesView = () => {
<td data-label="active">
{ dataFormatter.booleanFormatter(item.active) }
</td>
<td data-label="effective_start">
{ dataFormatter.dateTimeFormatter(item.effective_start) }
</td>
<td data-label="effective_end">
{ dataFormatter.dateTimeFormatter(item.effective_end) }
</td>
</tr>
</tr>
))}
</tbody>
</table>

View File

@ -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 = () => {
<table>
<thead>
<tr>
<th>Employee</th>
<th>TotalHours</th>
<th>GrossPay</th>
<th>TotalCommissionBase</th>
<th>TotalClientPaid</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{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) => (
<tr key={item.id} onClick={() => router.push(`/payroll_line_items/payroll_line_items-view/?id=${item.id}`)}>
<td data-label="employee">
{formatEmployeeName(item.employee)}
</td>
<td data-label="total_hours">
{ item.total_hours }
</td>
<td data-label="gross_pay">
{ item.gross_pay }
</td>
<td data-label="total_commission_base">
{ item.total_commission_base }
</td>
<td data-label="total_client_paid">
{ item.total_client_paid }
</td>
<td data-label="summary">
{ item.summary }
</td>
</tr>
))}
</tbody>

View File

@ -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(() => {

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Payroll Reports')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartBar} title='Payroll Dashboard' main>
{''}
</SectionTitleLineWithButton>
<div className="flex mb-6 space-x-4 border-b pb-2">
<button
onClick={() => setActiveTab('current')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors flex items-center ${
activeTab === 'current' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Current Unpaid Logs
</button>
<button
onClick={() => setActiveTab('historical')}
className={`px-4 py-2 font-semibold rounded-t-lg transition-colors flex items-center ${
activeTab === 'historical' ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
Historical Reports
</button>
</div>
{activeTab === 'current' && (
<div>
<CardBox className="mb-6">
<div className="mb-4 text-gray-600">
Select a date range to view all <strong>unpaid</strong> Job Logs. From here, you can preview what your employees have currently earned and generate a finalized Payroll Run.
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<FormField label="Start Date">
<input type="date" value={filtersCurrent.startDate} onChange={e => setFiltersCurrent({...filtersCurrent, startDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" />
</FormField>
<FormField label="End Date">
<input type="date" value={filtersCurrent.endDate} onChange={e => setFiltersCurrent({...filtersCurrent, endDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" />
</FormField>
<div className="flex items-end">
<BaseButton label="Preview Current Payroll" color="info" onClick={fetchCurrentPreview} disabled={loadingPreview} className="w-full" />
</div>
</div>
{runError && <div className="mt-4 p-3 bg-red-100 text-red-700 rounded">{runError}</div>}
{runSuccess && <div className="mt-4 p-3 bg-green-100 text-green-700 rounded">{runSuccess}</div>}
</CardBox>
{previewData && (
<>
<CardBox className="mb-6">
<h2 className="text-xl font-bold mb-4">Live Preview Summary</h2>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-gray-100 rounded">
<p className="text-sm text-gray-500">Total Unpaid Gross Pay</p>
<p className="text-2xl font-semibold">${previewData.summary.totalGrossPay.toFixed(2)}</p>
</div>
<div className="p-4 bg-gray-100 rounded">
<p className="text-sm text-gray-500">Total Unpaid Hours</p>
<p className="text-2xl font-semibold">{previewData.summary.totalHours.toFixed(2)}</p>
</div>
</div>
<div className="mt-6">
<BaseButton label="Run & Finalize Payroll" color="success" onClick={generatePayroll} disabled={generating} />
</div>
</CardBox>
<CardBox>
<h2 className="text-xl font-bold mb-4">Employee Breakdown</h2>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr>
<th className="p-2 border-b">Employee</th>
<th className="p-2 border-b">Total Hours</th>
<th className="p-2 border-b">Commission Base</th>
<th className="p-2 border-b">Calculated Gross Pay</th>
</tr>
</thead>
<tbody>
{previewData.lineItems.map((item: any, idx: number) => (
<tr key={idx}>
<td className="p-2 border-b">{item.employee?.firstName} {item.employee?.lastName || ''}</td>
<td className="p-2 border-b">{item.total_hours.toFixed(2)}</td>
<td className="p-2 border-b">${item.total_commission_base.toFixed(2)}</td>
<td className="p-2 border-b font-semibold text-green-600">${item.gross_pay.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardBox>
</>
)}
</div>
)}
{activeTab === 'historical' && (
<div>
<CardBox className="mb-6">
<div className="mb-4 text-gray-600">
View previously generated payroll line items by date range or employee ID.
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<FormField label="Start Date">
<input type="date" value={filtersHistory.startDate} onChange={e => setFiltersHistory({...filtersHistory, startDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" />
</FormField>
<FormField label="End Date">
<input type="date" value={filtersHistory.endDate} onChange={e => setFiltersHistory({...filtersHistory, endDate: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" />
</FormField>
<FormField label="Employee ID">
<input type="text" value={filtersHistory.employeeId} onChange={e => setFiltersHistory({...filtersHistory, employeeId: e.target.value})} className="px-3 py-2 border border-gray-300 rounded w-full" />
</FormField>
<div className="flex items-end">
<BaseButton label="View Historical Report" color="info" onClick={fetchHistoricalReport} disabled={loadingHistory} className="w-full" />
</div>
</div>
</CardBox>
{reportData && (
<>
<CardBox className="mb-6">
<h2 className="text-xl font-bold mb-4">Historical Summary</h2>
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-gray-100 rounded">
<p className="text-sm text-gray-500">Total Gross Pay</p>
<p className="text-2xl font-semibold">${reportData.summary.totalGrossPay.toFixed(2)}</p>
</div>
<div className="p-4 bg-gray-100 rounded">
<p className="text-sm text-gray-500">Total Hours</p>
<p className="text-2xl font-semibold">{reportData.summary.totalHours.toFixed(2)}</p>
</div>
<div className="p-4 bg-gray-100 rounded">
<p className="text-sm text-gray-500">Total Work Comp</p>
<p className="text-2xl font-semibold">${reportData.summary.totalWorkersComp.toFixed(2)}</p>
</div>
</div>
</CardBox>
<CardBox>
<h2 className="text-xl font-bold mb-4">Line Items</h2>
<div className="overflow-x-auto">
<table className="w-full text-left">
<thead>
<tr>
<th className="p-2 border-b">Employee</th>
<th className="p-2 border-b">Hours</th>
<th className="p-2 border-b">Gross Pay</th>
<th className="p-2 border-b">Work Comp Amount</th>
<th className="p-2 border-b">Created At</th>
</tr>
</thead>
<tbody>
{reportData.lineItems.map((item: any) => (
<tr key={item.id}>
<td className="p-2 border-b">{item.employee?.firstName} {item.employee?.lastName || ''}</td>
<td className="p-2 border-b">{item.total_hours}</td>
<td className="p-2 border-b">${item.gross_pay}</td>
<td className="p-2 border-b">${item.workers_comp_amount}</td>
<td className="p-2 border-b">{new Date(item.createdAt).toLocaleDateString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardBox>
</>
)}
</div>
)}
</SectionMain>
</>
);
};
ReportsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default ReportsPage;

View File

@ -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(() => {

View File

@ -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 = () => {
<FormField label='Pay Types' labelFor='pay_types'>
<Field
name='pay_types'
id='pay_types'
component={SelectFieldMany}
options={initialValues.pay_types}
itemRef={'pay_types'}
showField={'name'}
></Field>
</FormField>
<FormField
label="Password"
>

View File

@ -155,6 +155,7 @@ const initialValues = {
custom_permissions: [],
pay_types: [],
}
@ -469,7 +470,16 @@ const UsersNew = () => {
</Field>
</FormField>
<FormField label='Pay Types' labelFor='pay_types'>
<Field
name='pay_types'
id='pay_types'
itemRef={'pay_types'}
options={[]}
component={SelectFieldMany}
showField={'name'}>
</Field>
</FormField>

View File

@ -433,15 +433,16 @@ const UsersView = () => {
<th>Pay Type</th>
<th>Active</th>
<th>EffectiveStart</th>
<th>EffectiveEnd</th>
</tr>
@ -456,24 +457,13 @@ const UsersView = () => {
<td data-label="pay_type">
{item?.pay_type?.name ?? 'No data'}
</td>
<td data-label="active">
{ dataFormatter.booleanFormatter(item.active) }
</td>
<td data-label="effective_start">
{ dataFormatter.dateTimeFormatter(item.effective_start) }
</td>
<td data-label="effective_end">
{ dataFormatter.dateTimeFormatter(item.effective_end) }
</td>
</tr>
</tr>
))}
</tbody>
</table>

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Edit workers_comp_classes')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit workers_comp_classes'} main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField
label="WorkersCompClassName"
>
<Field
name="name"
placeholder="WorkersCompClassName"
/>
</FormField>
<FormField label="PayMethod" labelFor="pay_method">
<Field name="pay_method" id="pay_method" component="select">
<option value="hourly">hourly</option>
<option value="commission">commission</option>
</Field>
</FormField>
<FormField
label="HourlyRate"
>
<Field
type="number"
name="hourly_rate"
placeholder="HourlyRate"
/>
</FormField>
<FormField
label="CommissionRate"
>
<Field
type="number"
name="commission_rate"
placeholder="CommissionRate"
/>
</FormField>
<FormField label='Active' labelFor='active'>
<Field
name='active'
id='active'
component={SwitchField}
></Field>
</FormField>
<FormField label="Description" hasTextareaHeight>
<Field name="description" as="textarea" placeholder="Description" />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
<BaseButton type='reset' color='danger' outline label='Cancel' onClick={() => router.push('/workers_comp_classes/workers_comp_classes-list')}/>
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
)
}
EditWorkers_comp_classes.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated
permission={'UPDATE_PAY_TYPES'}
>
{page}
</LayoutAuthenticated>
)
}
export default EditWorkers_comp_classes

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Edit Workers Comp Class')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={'Edit Workers Comp Class'} main>
{''}
</SectionTitleLineWithButton>
<CardBox>
<Formik
enableReinitialize
initialValues={initialValues}
onSubmit={(values) => handleSubmit(values)}
>
<Form>
<FormField label="Name" labelFor="name">
<Field name="name" id="name" type="text" />
</FormField>
<FormField label="Percentage" labelFor="percentage">
<Field name="percentage" id="percentage" type="number" step="0.01" />
</FormField>
<BaseDivider />
<BaseButtons>
<BaseButton type="submit" color="info" label="Submit" />
<BaseButton type="reset" color="info" outline label="Reset" />
</BaseButtons>
</Form>
</Formik>
</CardBox>
</SectionMain>
</>
)
}
EditWorkers_comp_classesPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'UPDATE_WORKERS_COMP_CLASSES'}>
{page}
</LayoutAuthenticated>
)
}
export default EditWorkers_comp_classesPage

View File

@ -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<any[]>([])
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<any>(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 (
<>
<Head>
<title>{getPageTitle('Workers Comp Classes')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title={'Workers Comp Classes'}
main
>
{hasPermission(currentUser, 'CREATE_WORKERS_COMP_CLASSES') && (
<div className="flex items-center gap-2">
<BaseButton color='info' label='Filter' onClick={addFilter} />
<Link href={'/workers_comp_classes/workers_comp_classes-new'}>
<BaseButton
color="info"
icon={mdiPlus}
label="New Workers Comp Class"
/>
</Link>
</div>
)}
</SectionTitleLineWithButton>
<CardBox className="mb-6">
<div className="mb-4 flex flex-wrap gap-3">
<BaseButton
color="info"
outline
label="This Week"
onClick={handleThisWeekFilter}
disabled={reportLoading}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<FormField label="Start Date" labelFor="workers-comp-start-date">
<input
id="workers-comp-start-date"
type="date"
value={reportFilters.startDate}
onChange={(event) =>
setReportFilters({
...reportFilters,
startDate: event.target.value,
})
}
/>
</FormField>
<FormField label="End Date" labelFor="workers-comp-end-date">
<input
id="workers-comp-end-date"
type="date"
value={reportFilters.endDate}
onChange={(event) =>
setReportFilters({
...reportFilters,
endDate: event.target.value,
})
}
/>
</FormField>
<div className="flex items-end gap-3 pb-6">
<BaseButton
color="info"
label={reportLoading ? 'Applying...' : 'Apply Date Filter'}
onClick={handleApplyDateFilter}
disabled={reportLoading}
className="w-full"
/>
<BaseButton
color="info"
outline
label="Clear"
onClick={handleClearDateFilter}
disabled={reportLoading}
/>
</div>
</div>
{reportError && (
<div className="mt-2 rounded bg-red-100 p-3 text-red-700">
{reportError}
</div>
)}
</CardBox>
{reportData && (
<CardBox className="mb-6">
<h3 className="text-xl font-semibold mb-4">{reportHeading}</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-blue-50 rounded shadow">
<p className="text-sm text-blue-600 font-bold">Total Work Comp</p>
<p className="text-2xl font-semibold">
${Number(reportData.totalComp || 0).toFixed(2)}
</p>
</div>
{Object.entries(reportData.totalsByClass || {}).map(([className, total]: any) => (
<div key={className} className="p-4 bg-gray-50 rounded shadow">
<p className="text-sm text-gray-500 font-bold">{className}</p>
<p className="text-xl font-semibold">${Number(total).toFixed(2)}</p>
</div>
))}
</div>
{!reportLoading && !Object.keys(reportData.totalsByClass || {}).length && (
<p className="mt-4 text-sm text-gray-500">
No workers comp totals found for the selected date range.
</p>
)}
</CardBox>
)}
<CardBox className="mb-6" hasTable>
<TableWorkers_comp_classes
filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters}
showGrid={false}
/>
</CardBox>
</SectionMain>
</>
)
}
Workers_comp_classesList.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated>{page}</LayoutAuthenticated>
}
export default Workers_comp_classesList

Some files were not shown because too many files have changed in this diff Show More