added employee mapping v1

This commit is contained in:
Flatlogic Bot 2026-03-03 11:46:51 +00:00
parent 8508845f5b
commit c2aee2dcc5
14 changed files with 727 additions and 914 deletions

View File

@ -1,6 +1,3 @@
const os = require('os');
const config = {
@ -37,6 +34,12 @@ const config = {
clientId: process.env.MS_CLIENT_ID || '',
clientSecret: process.env.MS_CLIENT_SECRET || '',
},
xero: {
clientId: process.env.XERO_CLIENT_ID || '',
clientSecret: process.env.XERO_CLIENT_SECRET || '',
scopes: 'offline_access openid profile email payroll.employees',
callbackUrl: (process.env.BACK_API_URL || 'http://localhost:8080/api') + '/xero/callback'
},
uploadDir: os.tmpdir(),
email: {
from: 'Xero Payroll Export Shell <app@flatlogic.app>',
@ -78,4 +81,4 @@ config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`;
config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`;
config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`;
module.exports = config;
module.exports = config;

View File

@ -1,4 +1,3 @@
const express = require('express');
const cors = require('cors');
const app = express();
@ -21,6 +20,7 @@ const organizationForAuthRoutes = require('./routes/organizationLogin');
const openaiRoutes = require('./routes/openai');
const xeroAuthRoutes = require('./routes/xero_auth');
const usersRoutes = require('./routes/users');
@ -120,6 +120,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
app.use('/api/xero', xeroAuthRoutes);
app.enable('trust proxy');
@ -214,4 +215,4 @@ db.sequelize.sync().then(function () {
});
});
module.exports = app;
module.exports = app;

View File

@ -1,4 +1,3 @@
const express = require('express');
const Employee_mappingsService = require('../services/employee_mappings');
@ -28,9 +27,21 @@ router.use(checkCrudPermissions('employee_mappings'));
* type: object
* properties:
* match_method:
* type: string
* default: match_method
* is_ready:
* type: boolean
* default: false
* mapped_at:
* type: string
* default: mapped_at
*
*/
@ -255,6 +266,28 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => {
res.status(200).send(payload);
}));
/**
* @swagger
* /api/employee_mappings/auto-map:
* get:
* security:
* - bearerAuth: []
* tags: [Employee_mappings]
* summary: Auto-map workers to Xero employees
* description: Try to match unmapped source workers with Xero employees by email or name
* responses:
* 200:
* description: The workers were successfully mapped
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 500:
* description: Auto-mapping failed
*/
router.get('/auto-map', wrapAsync(async (req, res) => {
const payload = await Employee_mappingsService.autoMap(req.currentUser);
res.status(200).send(payload);
}));
/**
* @swagger
* /api/employee_mappings:
@ -290,10 +323,12 @@ router.get('/', wrapAsync(async (req, res) => {
req.query, globalAccess, { currentUser }
);
if (filetype && filetype === 'csv') {
const fields = ['id',
const fields = ['id','mapped_at',
'mapped_at',
{label: 'match_method', value: 'match_method'},
'is_ready',
];
const opts = { fields };
try {
@ -435,4 +470,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;
module.exports = router;

View File

@ -0,0 +1,95 @@
const express = require('express');
const axios = require('axios');
const config = require('../config');
const wrapAsync = require('../helpers').wrapAsync;
const Xero_connectionsDBApi = require('../db/api/xero_connections');
const passport = require('passport');
const db = require('../db/models');
const router = express.Router();
router.get('/connect', passport.authenticate('jwt', { session: false }), (req, res) => {
const { clientId, callbackUrl, scopes } = config.xero;
const state = req.currentUser.id; // Passing userId as state to identify user on callback
const xeroAuthUrl = `https://login.xero.com/identity/connect/authorize?` +
`response_type=code&` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent(callbackUrl)}&` +
`scope=${encodeURIComponent(scopes)}&` +
`state=${state}`;
res.redirect(xeroAuthUrl);
});
router.get('/callback', wrapAsync(async (req, res) => {
const { code, state } = req.query;
const userId = state;
const { clientId, clientSecret, callbackUrl } = config.xero;
try {
const tokenResponse = await axios.post('https://identity.xero.com/connect/token',
new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: callbackUrl,
}),
{
headers: {
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
const { access_token, refresh_token, expires_in } = tokenResponse.data;
const expiresAt = new Date(Date.now() + expires_in * 1000);
// Get connections (to get tenant ID)
const connectionsResponse = await axios.get('https://api.xero.com/connections', {
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/json'
}
});
const activeConnection = connectionsResponse.data[0]; // Assuming first connection
if (!activeConnection) {
throw new Error('No Xero connection found after authorization');
}
const user = await db.users.findByPk(userId);
const organisationId = user.organisationId;
// Save/Update Xero connection
const existing = await Xero_connectionsDBApi.findAll({
organisation: organisationId
}, false, { currentUser: user });
const connectionData = {
xero_tenant_identifier: activeConnection.tenantId,
tenant_name: activeConnection.tenantName,
connected_user_name: user.email,
connection_status: 'connected',
encrypted_access_token: access_token,
encrypted_refresh_token: refresh_token,
token_expires_at: expiresAt,
scopes: config.xero.scopes,
organisation: organisationId
};
if (existing.count > 0) {
await Xero_connectionsDBApi.update(existing.rows[0].id, connectionData, { currentUser: user });
} else {
await Xero_connectionsDBApi.create(connectionData, { currentUser: user });
}
res.redirect(`${config.uiUrl}xero_connections/xero_connections-list`);
} catch (error) {
console.error('Xero callback error:', error.response ? error.response.data : error.message);
res.redirect(`${config.uiUrl}dashboard?error=xero_auth_failed`);
}
}));
module.exports = router;

View File

@ -1,4 +1,3 @@
const express = require('express');
const Xero_employeesService = require('../services/xero_employees');
@ -264,6 +263,28 @@ router.post('/deleteByIds', wrapAsync(async (req, res) => {
res.status(200).send(payload);
}));
/**
* @swagger
* /api/xero_employees/sync:
* get:
* security:
* - bearerAuth: []
* tags: [Xero_employees]
* summary: Sync employees from Xero
* description: Fetch employees from Xero Payroll AU and sync with local database
* responses:
* 200:
* description: The employees were successfully synced
* 401:
* $ref: "#/components/responses/UnauthorizedError"
* 500:
* description: Sync failed
*/
router.get('/sync', wrapAsync(async (req, res) => {
const payload = await Xero_employeesService.syncFromXero(req.currentUser);
res.status(200).send(payload);
}));
/**
* @swagger
* /api/xero_employees:
@ -444,4 +465,4 @@ router.get('/:id', wrapAsync(async (req, res) => {
router.use('/', require('../helpers').commonErrorHandler);
module.exports = router;
module.exports = router;

View File

@ -3,14 +3,8 @@ const Employee_mappingsDBApi = require('../db/api/employee_mappings');
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 Employee_mappingsService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
@ -28,9 +22,9 @@ module.exports = class Employee_mappingsService {
await transaction.rollback();
throw error;
}
};
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@ -49,7 +43,7 @@ module.exports = class Employee_mappingsService {
resolve();
})
.on('error', (error) => reject(error));
})
});
await Employee_mappingsDBApi.bulkImport(results, {
transaction,
@ -95,7 +89,7 @@ module.exports = class Employee_mappingsService {
await transaction.rollback();
throw error;
}
};
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
@ -132,7 +126,58 @@ module.exports = class Employee_mappingsService {
}
}
static async autoMap(currentUser) {
const organisationId = currentUser.organisationId;
if (!organisationId) {
throw new ValidationError('organisationRequired');
}
const transaction = await db.sequelize.transaction();
try {
const sourceWorkers = await db.source_workers.findAll({
where: { organisationId },
transaction
});
const xeroEmployees = await db.xero_employees.findAll({
where: { organisationId },
transaction
});
const existingMappings = await db.employee_mappings.findAll({
where: { organisationId },
transaction
});
const mappedSourceWorkerIds = existingMappings.map(m => m.source_workerId);
const unmappedWorkers = sourceWorkers.filter(w => !mappedSourceWorkerIds.includes(w.id));
let count = 0;
for (const worker of unmappedWorkers) {
const match = xeroEmployees.find(xe =>
(xe.email && worker.email && xe.email.toLowerCase() === worker.email.toLowerCase()) ||
(xe.full_name && worker.first_name && worker.last_name && xe.full_name.toLowerCase() === `${worker.first_name} ${worker.last_name}`.toLowerCase())
);
if (match) {
await Employee_mappingsDBApi.create({
source_worker: worker.id,
xero_employee: match.id,
match_method: 'auto_email',
mapped_at: new Date(),
mapped_by_user: currentUser.id,
organisation: organisationId,
is_ready: true
}, { currentUser, transaction });
count++;
}
}
await transaction.commit();
return { count };
} catch (error) {
await transaction.rollback();
throw error;
}
}
};

View File

@ -1,16 +1,12 @@
const db = require('../db/models');
const Xero_employeesDBApi = require('../db/api/xero_employees');
const Xero_connectionsDBApi = require('../db/api/xero_connections');
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 Xero_employeesService {
static async create(data, currentUser) {
const transaction = await db.sequelize.transaction();
@ -28,9 +24,9 @@ module.exports = class Xero_employeesService {
await transaction.rollback();
throw error;
}
};
}
static async bulkImport(req, res, sendInvitationEmails = true, host) {
static async bulkImport(req, res) {
const transaction = await db.sequelize.transaction();
try {
@ -49,7 +45,7 @@ module.exports = class Xero_employeesService {
resolve();
})
.on('error', (error) => reject(error));
})
});
await Xero_employeesDBApi.bulkImport(results, {
transaction,
@ -95,7 +91,7 @@ module.exports = class Xero_employeesService {
await transaction.rollback();
throw error;
}
};
}
static async deleteByIds(ids, currentUser) {
const transaction = await db.sequelize.transaction();
@ -132,7 +128,80 @@ module.exports = class Xero_employeesService {
}
}
static async syncFromXero(currentUser) {
const organisationId = currentUser.organisationId;
if (!organisationId) {
throw new ValidationError('organisationRequired');
}
const xeroConnection = await Xero_connectionsDBApi.findBy({
organisationId,
connection_status: 'connected'
});
if (!xeroConnection) {
throw new ValidationError('xeroConnectionNotFound');
}
const accessToken = xeroConnection.encrypted_access_token;
const xeroTenantId = xeroConnection.xero_tenant_identifier;
if (!accessToken || !xeroTenantId) {
throw new ValidationError('xeroConnectionIncomplete');
}
try {
const response = await axios.get('https://api.xero.com/payroll.xro/1.0/Employees', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Xero-Tenant-Id': xeroTenantId,
'Accept': 'application/json'
}
});
const xeroEmployees = response.data.Employees || [];
const transaction = await db.sequelize.transaction();
try {
for (const emp of xeroEmployees) {
const empData = {
xero_employee_identifier: emp.EmployeeID,
full_name: `${emp.FirstName} ${emp.LastName}`,
email: emp.Email,
synced_at: new Date(),
organisation: organisationId
};
// Check if employee already exists
const existing = await Xero_employeesDBApi.findBy({
xero_employee_identifier: emp.EmployeeID,
organisationId
}, { transaction });
if (existing) {
await Xero_employeesDBApi.update(existing.id, empData, { currentUser, transaction });
} else {
await Xero_employeesDBApi.create(empData, { currentUser, transaction });
}
}
// Update last sync at on connection
await Xero_connectionsDBApi.update(xeroConnection.id, {
last_sync_at: new Date()
}, { currentUser, transaction });
await transaction.commit();
return xeroEmployees;
} catch (error) {
await transaction.rollback();
throw error;
}
} catch (error) {
console.error('Xero sync error:', error.response ? error.response.data : error.message);
if (error.response && error.response.status === 401) {
throw new ValidationError('xeroUnauthorized');
}
throw error;
}
}
};

View File

@ -9,180 +9,179 @@ const menuAside: MenuAsideItem[] = [
},
{
href: '/users/users-list',
label: 'Users',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiAccountGroup ?? icon.mdiTable,
permissions: 'READ_USERS'
label: 'Payroll',
icon: icon.mdiCashMultiple,
menu: [
{
href: '/export_runs/export_runs-list',
label: 'Export Runs',
icon: icon.mdiRunFast,
permissions: 'READ_EXPORT_RUNS'
},
{
href: '/export_lines/export_lines-list',
label: 'Export Lines',
icon: icon.mdiFileTableOutline,
permissions: 'READ_EXPORT_LINES'
},
{
href: '/pay_periods/pay_periods-list',
label: 'Pay Periods',
icon: icon.mdiCalendarRange,
permissions: 'READ_PAY_PERIODS'
},
{
href: '/export_line_results/export_line_results-list',
label: 'Export Results',
icon: icon.mdiClipboardCheckOutline,
permissions: 'READ_EXPORT_LINE_RESULTS'
},
{
href: '/export_fingerprints/export_fingerprints-list',
label: 'Export Fingerprints',
icon: icon.mdiFingerprint,
permissions: 'READ_EXPORT_FINGERPRINTS'
},
]
},
{
href: '/roles/roles-list',
label: 'Roles',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountVariantOutline ?? icon.mdiTable,
permissions: 'READ_ROLES'
label: 'Mappings',
icon: icon.mdiMapMarkerPath,
menu: [
{
href: '/employee_mappings/employee_mappings-list',
label: 'Employee Mappings',
icon: icon.mdiAccountArrowRight,
permissions: 'READ_EMPLOYEE_MAPPINGS'
},
{
href: '/earnings_mappings/earnings_mappings-list',
label: 'Earnings Mappings',
icon: icon.mdiMapMarkerPath,
permissions: 'READ_EARNINGS_MAPPINGS'
},
{
href: '/pay_levels/pay_levels-list',
label: 'Pay Levels',
icon: icon.mdiLayersTriple,
permissions: 'READ_PAY_LEVELS'
},
]
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiShieldAccountOutline ?? icon.mdiTable,
permissions: 'READ_PERMISSIONS'
label: 'Xero Data',
icon: icon.mdiDatabaseSync,
menu: [
{
href: '/xero_employees/xero_employees-list',
label: 'Xero Employees',
icon: icon.mdiBadgeAccount,
permissions: 'READ_XERO_EMPLOYEES'
},
{
href: '/xero_earnings_rates/xero_earnings_rates-list',
label: 'Xero Earnings Rates',
icon: icon.mdiCashRegister,
permissions: 'READ_XERO_EARNINGS_RATES'
},
{
href: '/xero_payroll_calendars/xero_payroll_calendars-list',
label: 'Payroll Calendars',
icon: icon.mdiCalendarSync,
permissions: 'READ_XERO_PAYROLL_CALENDARS'
},
]
},
{
href: '/organisations/organisations-list',
label: 'Organisations',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_ORGANISATIONS'
label: 'Source Data',
icon: icon.mdiDatabase,
menu: [
{
href: '/source_workers/source_workers-list',
label: 'Source Workers',
icon: icon.mdiAccountGroup,
permissions: 'READ_SOURCE_WORKERS'
},
{
href: '/source_import_batches/source_import_batches-list',
label: 'Import Batches',
icon: icon.mdiDatabaseImportOutline,
permissions: 'READ_SOURCE_IMPORT_BATCHES'
},
]
},
{
href: '/xero_connections/xero_connections-list',
label: 'Xero connections',
// 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_XERO_CONNECTIONS'
label: 'Configuration',
icon: icon.mdiCog,
menu: [
{
href: '/payroll_settings/payroll_settings-list',
label: 'Payroll Settings',
icon: icon.mdiCalendarClock,
permissions: 'READ_PAYROLL_SETTINGS'
},
{
href: '/xero_connections/xero_connections-list',
label: 'Xero Connections',
icon: icon.mdiLinkVariant,
permissions: 'READ_XERO_CONNECTIONS'
},
{
href: '/organisations/organisations-list',
label: 'Organisations',
icon: icon.mdiTable,
permissions: 'READ_ORGANISATIONS'
},
{
href: '/api_keys/api_keys-list',
label: 'Api keys',
icon: icon.mdiKeyVariant,
permissions: 'READ_API_KEYS'
},
]
},
{
href: '/payroll_settings/payroll_settings-list',
label: 'Payroll settings',
// 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_SETTINGS'
},
{
href: '/source_workers/source_workers-list',
label: 'Source workers',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAccountGroup' in icon ? icon['mdiAccountGroup' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SOURCE_WORKERS'
},
{
href: '/xero_employees/xero_employees-list',
label: 'Xero employees',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiBadgeAccount' in icon ? icon['mdiBadgeAccount' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_XERO_EMPLOYEES'
},
{
href: '/employee_mappings/employee_mappings-list',
label: 'Employee mappings',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiAccountArrowRight' in icon ? icon['mdiAccountArrowRight' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_EMPLOYEE_MAPPINGS'
},
{
href: '/pay_levels/pay_levels-list',
label: 'Pay levels',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiLayersTriple' in icon ? icon['mdiLayersTriple' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PAY_LEVELS'
},
{
href: '/xero_earnings_rates/xero_earnings_rates-list',
label: 'Xero earnings rates',
// 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_XERO_EARNINGS_RATES'
},
{
href: '/earnings_mappings/earnings_mappings-list',
label: 'Earnings mappings',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiMapMarkerPath' in icon ? icon['mdiMapMarkerPath' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_EARNINGS_MAPPINGS'
},
{
href: '/xero_payroll_calendars/xero_payroll_calendars-list',
label: 'Xero payroll calendars',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCalendarSync' in icon ? icon['mdiCalendarSync' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_XERO_PAYROLL_CALENDARS'
},
{
href: '/pay_periods/pay_periods-list',
label: 'Pay periods',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiCalendarRange' in icon ? icon['mdiCalendarRange' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_PAY_PERIODS'
},
{
href: '/export_lines/export_lines-list',
label: 'Export lines',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFileTableOutline' in icon ? icon['mdiFileTableOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_EXPORT_LINES'
},
{
href: '/export_runs/export_runs-list',
label: 'Export runs',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiRunFast' in icon ? icon['mdiRunFast' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_EXPORT_RUNS'
},
{
href: '/export_line_results/export_line_results-list',
label: 'Export line results',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiClipboardCheckOutline' in icon ? icon['mdiClipboardCheckOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_EXPORT_LINE_RESULTS'
},
{
href: '/export_fingerprints/export_fingerprints-list',
label: 'Export fingerprints',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiFingerprint' in icon ? icon['mdiFingerprint' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_EXPORT_FINGERPRINTS'
},
{
href: '/source_import_batches/source_import_batches-list',
label: 'Source import batches',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiDatabaseImportOutline' in icon ? icon['mdiDatabaseImportOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_SOURCE_IMPORT_BATCHES'
},
{
href: '/audit_entries/audit_entries-list',
label: 'Audit entries',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_AUDIT_ENTRIES'
},
{
href: '/api_keys/api_keys-list',
label: 'Api keys',
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
icon: 'mdiKeyVariant' in icon ? icon['mdiKeyVariant' as keyof typeof icon] : icon.mdiTable ?? icon.mdiTable,
permissions: 'READ_API_KEYS'
label: 'System Admin',
icon: icon.mdiCog,
menu: [
{
href: '/users/users-list',
label: 'Users',
icon: icon.mdiAccountGroup,
permissions: 'READ_USERS'
},
{
href: '/roles/roles-list',
label: 'Roles',
icon: icon.mdiShieldAccountVariantOutline,
permissions: 'READ_ROLES'
},
{
href: '/permissions/permissions-list',
label: 'Permissions',
icon: icon.mdiShieldAccountOutline,
permissions: 'READ_PERMISSIONS'
},
{
href: '/audit_entries/audit_entries-list',
label: 'Audit Entries',
icon: icon.mdiClipboardTextOutline,
permissions: 'READ_AUDIT_ENTRIES'
},
]
},
{
href: '/profile',
label: 'Profile',
icon: icon.mdiAccountCircle,
},
{
href: '/api-docs',
target: '_blank',
@ -192,4 +191,4 @@ const menuAside: MenuAsideItem[] = [
},
]
export default menuAside
export default menuAside

View File

@ -9,745 +9,197 @@ import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton
import BaseIcon from "../components/BaseIcon";
import { getPageTitle } from '../config'
import Link from "next/link";
import CardBox from '../components/CardBox';
import BaseButton from '../components/BaseButton';
import BaseButtons from '../components/BaseButtons';
import { hasPermission } from "../helpers/userPermissions";
import { fetchWidgets } from '../stores/roles/rolesSlice';
import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator';
import { SmartWidget } from '../components/SmartWidget/SmartWidget';
import { useAppSelector } from '../stores/hooks';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
const Dashboard = () => {
const dispatch = useAppDispatch();
const iconsColor = useAppSelector((state) => state.style.iconsColor);
const corners = useAppSelector((state) => state.style.corners);
const cardsStyle = useAppSelector((state) => state.style.cardsStyle);
const loadingMessage = 'Loading...';
const loadingMessage = '...';
const [users, setUsers] = React.useState(loadingMessage);
const [roles, setRoles] = React.useState(loadingMessage);
const [permissions, setPermissions] = React.useState(loadingMessage);
const [organisations, setOrganisations] = React.useState(loadingMessage);
const [xero_connections, setXero_connections] = React.useState(loadingMessage);
const [payroll_settings, setPayroll_settings] = React.useState(loadingMessage);
const [source_workers, setSource_workers] = React.useState(loadingMessage);
const [xero_employees, setXero_employees] = React.useState(loadingMessage);
const [employee_mappings, setEmployee_mappings] = React.useState(loadingMessage);
const [pay_levels, setPay_levels] = React.useState(loadingMessage);
const [xero_earnings_rates, setXero_earnings_rates] = React.useState(loadingMessage);
const [earnings_mappings, setEarnings_mappings] = React.useState(loadingMessage);
const [xero_payroll_calendars, setXero_payroll_calendars] = React.useState(loadingMessage);
const [pay_periods, setPay_periods] = React.useState(loadingMessage);
const [export_lines, setExport_lines] = React.useState(loadingMessage);
const [export_runs, setExport_runs] = React.useState(loadingMessage);
const [export_line_results, setExport_line_results] = React.useState(loadingMessage);
const [export_fingerprints, setExport_fingerprints] = React.useState(loadingMessage);
const [source_import_batches, setSource_import_batches] = React.useState(loadingMessage);
const [audit_entries, setAudit_entries] = React.useState(loadingMessage);
const [api_keys, setApi_keys] = React.useState(loadingMessage);
const [widgetsRole, setWidgetsRole] = React.useState({
role: { value: '', label: '' },
});
const [counts, setCounts] = React.useState<Record<string, any>>({});
const { currentUser } = useAppSelector((state) => state.auth);
const { isFetchingQuery } = useAppSelector((state) => state.openAi);
const { rolesWidgets, loading } = useAppSelector((state) => state.roles);
const organizationId = currentUser?.organisations?.id;
async function loadData() {
const entities = ['users','roles','permissions','organisations','xero_connections','payroll_settings','source_workers','xero_employees','employee_mappings','pay_levels','xero_earnings_rates','earnings_mappings','xero_payroll_calendars','pay_periods','export_lines','export_runs','export_line_results','export_fingerprints','source_import_batches','audit_entries','api_keys',];
const fns = [setUsers,setRoles,setPermissions,setOrganisations,setXero_connections,setPayroll_settings,setSource_workers,setXero_employees,setEmployee_mappings,setPay_levels,setXero_earnings_rates,setEarnings_mappings,setXero_payroll_calendars,setPay_periods,setExport_lines,setExport_runs,setExport_line_results,setExport_fingerprints,setSource_import_batches,setAudit_entries,setApi_keys,];
const requests = entities.map((entity, index) => {
const entities = [
'xero_connections',
'payroll_settings',
'employee_mappings',
'earnings_mappings',
'export_runs',
'source_workers'
];
const requests = entities.map((entity) => {
if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) {
return axios.get(`/${entity.toLowerCase()}/count`);
} else {
fns[index](null);
return Promise.resolve({data: {count: null}});
}
return Promise.resolve({data: {count: null}});
});
Promise.allSettled(requests).then((results) => {
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
fns[i](result.value.data.count);
} else {
fns[i](result.reason.message);
}
});
const results = await Promise.allSettled(requests);
const newCounts: Record<string, any> = {};
results.forEach((result, i) => {
if (result.status === 'fulfilled') {
newCounts[entities[i]] = result.value.data.count;
}
});
setCounts(newCounts);
}
async function getWidgets(roleId) {
await dispatch(fetchWidgets(roleId));
}
React.useEffect(() => {
if (!currentUser) return;
loadData().then();
setWidgetsRole({ role: { value: currentUser?.app_role?.id, label: currentUser?.app_role?.name } });
}, [currentUser]);
React.useEffect(() => {
if (!currentUser || !widgetsRole?.role?.value) return;
getWidgets(widgetsRole?.role?.value || '').then();
}, [widgetsRole?.role?.value]);
const handleConnectXero = () => {
window.location.href = `/api/xero/connect`;
};
return (
<>
<Head>
<title>
{getPageTitle('Overview')}
</title>
<title>{getPageTitle('Payroll Command Center')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant}
title='Overview'
icon={icon.mdiViewDashboard}
title='Payroll Command Center'
main>
{''}
<BaseButton
href="/export_runs/export_runs-new"
label="New Export"
color="info"
icon={icon.mdiPlus}
/>
</SectionTitleLineWithButton>
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}
setWidgetsRole={setWidgetsRole}
widgetsRole={widgetsRole}
/>}
{!!rolesWidgets.length &&
hasPermission(currentUser, 'CREATE_ROLES') && (
<p className=' text-gray-500 dark:text-gray-400 mb-4'>
{`${widgetsRole?.role?.label || 'Users'}'s widgets`}
</p>
)}
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{(isFetchingQuery || loading) && (
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
w='w-16'
h='h-16'
size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
{/* Integration Status Bar */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6">
<CardBox isList className="bg-emerald-50 dark:bg-emerald-900/40 border border-emerald-200 dark:border-emerald-800">
<div className="flex items-center justify-between w-full">
<div>
<p className="text-sm text-emerald-600 dark:text-emerald-400 font-semibold uppercase tracking-wider">Xero Connection</p>
<h3 className="text-xl font-bold">{counts.xero_connections > 0 ? 'Connected' : 'Disconnected'}</h3>
</div>
<BaseIcon path={icon.mdiLinkVariant} size={48} className="text-emerald-500" />
</div>
)}
<div className="mt-4">
{counts.xero_connections > 0 ? (
<BaseButton
href="/xero_connections/xero_connections-list"
label='Manage Connection'
color="whiteDark"
small
/>
) : (
<BaseButton
onClick={handleConnectXero}
label='Connect Now'
color="whiteDark"
small
/>
)}
</div>
</CardBox>
{ rolesWidgets &&
rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={hasPermission(currentUser, 'CREATE_ROLES')}
<CardBox isList className="bg-indigo-50 dark:bg-indigo-900/40 border border-indigo-200 dark:border-indigo-800">
<div className="flex items-center justify-between w-full">
<div>
<p className="text-sm text-indigo-600 dark:text-indigo-400 font-semibold uppercase tracking-wider">Payroll Settings</p>
<h3 className="text-xl font-bold">{counts.payroll_settings > 0 ? 'Configured' : 'Needs Setup'}</h3>
</div>
<BaseIcon path={icon.mdiCog} size={48} className="text-indigo-500" />
</div>
<div className="mt-4">
<BaseButton
href="/payroll_settings/payroll_settings-list"
label="Configure"
color="whiteDark"
small
/>
))}
</div>
</CardBox>
<CardBox isList className="bg-amber-50 dark:bg-amber-900/40 border border-amber-200 dark:border-amber-800">
<div className="flex items-center justify-between w-full">
<div>
<p className="text-sm text-amber-600 dark:text-amber-400 font-semibold uppercase tracking-wider">Mappings</p>
<h3 className="text-xl font-bold">{counts.employee_mappings || 0} Employees</h3>
</div>
<BaseIcon path={icon.mdiAccountArrowRight} size={48} className="text-amber-500" />
</div>
<div className="mt-4 flex space-x-2">
<BaseButton
href="/employee_mappings/employee_mappings-list"
label="Map Workers"
color="whiteDark"
small
/>
<BaseButton
href="/earnings_mappings/earnings_mappings-list"
label="Map Rates"
color="whiteDark"
small
/>
</div>
</CardBox>
</div>
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6'>
{/* Quick Stats */}
<Link href={'/export_runs/export_runs-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">Export Runs</div>
<div className="text-3xl leading-tight font-semibold">{counts.export_runs ?? loadingMessage}</div>
</div>
<BaseIcon className={iconsColor} w="w-12" h="h-12" size={32} path={icon.mdiRunFast} />
</div>
</div>
</Link>
<Link href={'/source_workers/source_workers-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">Source Workers</div>
<div className="text-3xl leading-tight font-semibold">{counts.source_workers ?? loadingMessage}</div>
</div>
<BaseIcon className={iconsColor} w="w-12" h="h-12" size={32} path={icon.mdiAccountGroup} />
</div>
</div>
</Link>
{/* Placeholder for "Ready to Export" */}
<div className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} bg-blue-600 text-white p-6 lg:col-span-2 flex items-center justify-between`}>
<div>
<h4 className="text-xl font-bold">Ready to Export?</h4>
<p className="opacity-80">Next pay period starts in 3 days.</p>
</div>
<BaseButton
href="/pay_periods/pay_periods-list"
label="Review Timesheets"
color="white"
className="text-blue-600"
/>
</div>
</div>
{!!rolesWidgets.length && <hr className='my-6 ' />}
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{hasPermission(currentUser, 'READ_USERS') && <Link href={'/users/users-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Users
</div>
<div className="text-3xl leading-tight font-semibold">
{users}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiAccountGroup || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ROLES') && <Link href={'/roles/roles-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Roles
</div>
<div className="text-3xl leading-tight font-semibold">
{roles}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountVariantOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PERMISSIONS') && <Link href={'/permissions/permissions-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Permissions
</div>
<div className="text-3xl leading-tight font-semibold">
{permissions}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiShieldAccountOutline || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_ORGANISATIONS') && <Link href={'/organisations/organisations-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">
Organisations
</div>
<div className="text-3xl leading-tight font-semibold">
{organisations}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_XERO_CONNECTIONS') && <Link href={'/xero_connections/xero_connections-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">
Xero connections
</div>
<div className="text-3xl leading-tight font-semibold">
{xero_connections}
</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={'mdiLinkVariant' in icon ? icon['mdiLinkVariant' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PAYROLL_SETTINGS') && <Link href={'/payroll_settings/payroll_settings-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">
Payroll settings
</div>
<div className="text-3xl leading-tight font-semibold">
{payroll_settings}
</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={'mdiCalendarClock' in icon ? icon['mdiCalendarClock' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_SOURCE_WORKERS') && <Link href={'/source_workers/source_workers-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">
Source workers
</div>
<div className="text-3xl leading-tight font-semibold">
{source_workers}
</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={'mdiAccountGroup' in icon ? icon['mdiAccountGroup' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_XERO_EMPLOYEES') && <Link href={'/xero_employees/xero_employees-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">
Xero employees
</div>
<div className="text-3xl leading-tight font-semibold">
{xero_employees}
</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={'mdiBadgeAccount' in icon ? icon['mdiBadgeAccount' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_EMPLOYEE_MAPPINGS') && <Link href={'/employee_mappings/employee_mappings-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">
Employee mappings
</div>
<div className="text-3xl leading-tight font-semibold">
{employee_mappings}
</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={'mdiAccountArrowRight' in icon ? icon['mdiAccountArrowRight' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PAY_LEVELS') && <Link href={'/pay_levels/pay_levels-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">
Pay levels
</div>
<div className="text-3xl leading-tight font-semibold">
{pay_levels}
</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={'mdiLayersTriple' in icon ? icon['mdiLayersTriple' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_XERO_EARNINGS_RATES') && <Link href={'/xero_earnings_rates/xero_earnings_rates-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">
Xero earnings rates
</div>
<div className="text-3xl leading-tight font-semibold">
{xero_earnings_rates}
</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={'mdiCashMultiple' in icon ? icon['mdiCashMultiple' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_EARNINGS_MAPPINGS') && <Link href={'/earnings_mappings/earnings_mappings-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">
Earnings mappings
</div>
<div className="text-3xl leading-tight font-semibold">
{earnings_mappings}
</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={'mdiMapMarkerPath' in icon ? icon['mdiMapMarkerPath' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_XERO_PAYROLL_CALENDARS') && <Link href={'/xero_payroll_calendars/xero_payroll_calendars-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Xero payroll calendars
</div>
<div className="text-3xl leading-tight font-semibold">
{xero_payroll_calendars}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiCalendarSync' in icon ? icon['mdiCalendarSync' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_PAY_PERIODS') && <Link href={'/pay_periods/pay_periods-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">
Pay periods
</div>
<div className="text-3xl leading-tight font-semibold">
{pay_periods}
</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={'mdiCalendarRange' in icon ? icon['mdiCalendarRange' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_EXPORT_LINES') && <Link href={'/export_lines/export_lines-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">
Export lines
</div>
<div className="text-3xl leading-tight font-semibold">
{export_lines}
</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={'mdiFileTableOutline' in icon ? icon['mdiFileTableOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_EXPORT_RUNS') && <Link href={'/export_runs/export_runs-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">
Export runs
</div>
<div className="text-3xl leading-tight font-semibold">
{export_runs}
</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={'mdiRunFast' in icon ? icon['mdiRunFast' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_EXPORT_LINE_RESULTS') && <Link href={'/export_line_results/export_line_results-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">
Export line results
</div>
<div className="text-3xl leading-tight font-semibold">
{export_line_results}
</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={'mdiClipboardCheckOutline' in icon ? icon['mdiClipboardCheckOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_EXPORT_FINGERPRINTS') && <Link href={'/export_fingerprints/export_fingerprints-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">
Export fingerprints
</div>
<div className="text-3xl leading-tight font-semibold">
{export_fingerprints}
</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={'mdiFingerprint' in icon ? icon['mdiFingerprint' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_SOURCE_IMPORT_BATCHES') && <Link href={'/source_import_batches/source_import_batches-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">
Source import batches
</div>
<div className="text-3xl leading-tight font-semibold">
{source_import_batches}
</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={'mdiDatabaseImportOutline' in icon ? icon['mdiDatabaseImportOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_AUDIT_ENTRIES') && <Link href={'/audit_entries/audit_entries-list'}>
<div
className={`${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 ${cardsStyle} dark:border-dark-700 p-6`}
>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">
Audit entries
</div>
<div className="text-3xl leading-tight font-semibold">
{audit_entries}
</div>
</div>
<div>
<BaseIcon
className={`${iconsColor}`}
w="w-16"
h="h-16"
size={48}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
path={'mdiClipboardTextOutline' in icon ? icon['mdiClipboardTextOutline' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
{hasPermission(currentUser, 'READ_API_KEYS') && <Link href={'/api_keys/api_keys-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">
Api keys
</div>
<div className="text-3xl leading-tight font-semibold">
{api_keys}
</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={'mdiKeyVariant' in icon ? icon['mdiKeyVariant' as keyof typeof icon] : icon.mdiTable || icon.mdiTable}
/>
</div>
</div>
</div>
</Link>}
</div>
<SectionTitleLineWithButton icon={icon.mdiHistory} title="Recent Activity" />
<CardBox className="mb-6">
<div className="text-center py-8 text-gray-500">
<BaseIcon path={icon.mdiClockOutline} size={48} className="mx-auto mb-4 opacity-20" />
<p>No recent export activity. Start by connecting to Xero.</p>
</div>
</CardBox>
</SectionMain>
</>
)

View File

@ -1,4 +1,4 @@
import { mdiChartTimelineVariant } from '@mdi/js'
import { mdiChartTimelineVariant, mdiAutoFix } from '@mdi/js'
import Head from 'next/head'
import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react'
@ -14,7 +14,7 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/employee_mappings/employee_mappingsSlice';
import {setRefetch, uploadCsv, autoMap} from '../../stores/employee_mappings/employee_mappingsSlice';
import {hasPermission} from "../../helpers/userPermissions";
@ -29,6 +29,7 @@ const Employee_mappingsTablesPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const { loading } = useAppSelector((state) => state.employee_mappings);
const dispatch = useAppDispatch();
@ -97,6 +98,10 @@ const Employee_mappingsTablesPage = () => {
setIsModalActive(false);
};
const handleAutoMap = () => {
dispatch(autoMap());
};
return (
<>
<Head>
@ -120,11 +125,22 @@ const Employee_mappingsTablesPage = () => {
{hasCreatePermission && (
<BaseButton
className={'mr-3'}
color='info'
label='Upload CSV'
onClick={() => setIsModalActive(true)}
/>
)}
{hasCreatePermission && (
<BaseButton
color='info'
label={loading ? 'Auto-mapping...' : 'Auto-map'}
icon={mdiAutoFix}
onClick={handleAutoMap}
disabled={loading}
/>
)}
<div className='md:inline-flex items-center ms-auto'>
<div id='delete-rows-button'></div>
@ -173,4 +189,4 @@ Employee_mappingsTablesPage.getLayout = function getLayout(page: ReactElement) {
)
}
export default Employee_mappingsTablesPage
export default Employee_mappingsTablesPage

View File

@ -1,4 +1,3 @@
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
@ -7,7 +6,6 @@ import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
@ -111,7 +109,7 @@ export default function Starter() {
}
>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Xero Payroll Export Shell')}</title>
</Head>
<SectionFullScreen bg='violet'>
@ -128,22 +126,30 @@ 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 Xero Payroll Export Shell app!"/>
<CardBoxComponentTitle title="Xero Payroll Export Shell"/>
<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>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className="space-y-4 py-4">
<p className='text-lg font-medium text-gray-700'>
Streamline your Careflo to Xero Payroll AU workflow.
</p>
<ul className='list-disc list-inside text-gray-600 space-y-2'>
<li>Automated Employee & Earnings Rate Mapping</li>
<li>Secure Xero OAuth2 Integration</li>
<li>Support for Timesheets & Allowances (Mode A & B)</li>
<li>Full Audit Trail & Export History</li>
</ul>
<p className='text-sm text-gray-500'>
A professional, multi-tenant integration shell for high-accuracy payroll exports.
</p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
label='Enter Command Center'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
</div>
@ -162,5 +168,4 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
};

View File

@ -1,4 +1,4 @@
import { mdiChartTimelineVariant } from '@mdi/js'
import { mdiChartTimelineVariant, mdiSync } from '@mdi/js'
import Head from 'next/head'
import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react'
@ -14,7 +14,7 @@ import Link from "next/link";
import {useAppDispatch, useAppSelector} from "../../stores/hooks";
import CardBoxModal from "../../components/CardBoxModal";
import DragDropFilePicker from "../../components/DragDropFilePicker";
import {setRefetch, uploadCsv} from '../../stores/xero_employees/xero_employeesSlice';
import {setRefetch, uploadCsv, syncFromXero} from '../../stores/xero_employees/xero_employeesSlice';
import {hasPermission} from "../../helpers/userPermissions";
@ -29,6 +29,7 @@ const Xero_employeesTablesPage = () => {
const { currentUser } = useAppSelector((state) => state.auth);
const { loading } = useAppSelector((state) => state.xero_employees);
const dispatch = useAppDispatch();
@ -85,6 +86,10 @@ const Xero_employeesTablesPage = () => {
setIsModalActive(false);
};
const handleSync = () => {
dispatch(syncFromXero());
};
return (
<>
<Head>
@ -108,11 +113,20 @@ const Xero_employeesTablesPage = () => {
{hasCreatePermission && (
<BaseButton
className={'mr-3'}
color='info'
label='Upload CSV'
onClick={() => setIsModalActive(true)}
/>
)}
<BaseButton
color='info'
label={loading ? 'Syncing...' : 'Sync from Xero'}
icon={mdiSync}
onClick={handleSync}
disabled={loading}
/>
<div className='md:inline-flex items-center ms-auto'>
<div id='delete-rows-button'></div>
@ -161,4 +175,4 @@ Xero_employeesTablesPage.getLayout = function getLayout(page: ReactElement) {
)
}
export default Xero_employeesTablesPage
export default Xero_employeesTablesPage

View File

@ -38,6 +38,21 @@ export const fetch = createAsyncThunk('employee_mappings/fetch', async (data: an
return id ? result.data : {rows: result.data.rows, count: result.data.count};
})
export const autoMap = createAsyncThunk(
'employee_mappings/autoMap',
async (_, { rejectWithValue }) => {
try {
const result = await axios.get('employee_mappings/auto-map');
return result.data;
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error.response.data);
}
}
);
export const deleteItemsByIds = createAsyncThunk(
'employee_mappings/deleteByIds',
async (data: any, { rejectWithValue }) => {
@ -151,6 +166,20 @@ export const employee_mappingsSlice = createSlice({
state.loading = false
})
builder.addCase(autoMap.pending, (state) => {
state.loading = true;
resetNotify(state);
});
builder.addCase(autoMap.fulfilled, (state, action) => {
state.loading = false;
state.refetch = true;
fulfilledNotify(state, `Successfully auto-mapped ${action.payload.count} employees`);
});
builder.addCase(autoMap.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
builder.addCase(deleteItemsByIds.pending, (state) => {
state.loading = true;
resetNotify(state);
@ -228,4 +257,4 @@ export const employee_mappingsSlice = createSlice({
// Action creators are generated for each case reducer function
export const { setRefetch } = employee_mappingsSlice.actions
export default employee_mappingsSlice.reducer
export default employee_mappingsSlice.reducer

View File

@ -38,6 +38,21 @@ export const fetch = createAsyncThunk('xero_employees/fetch', async (data: any)
return id ? result.data : {rows: result.data.rows, count: result.data.count};
})
export const syncFromXero = createAsyncThunk(
'xero_employees/syncFromXero',
async (_, { rejectWithValue }) => {
try {
const result = await axios.get('xero_employees/sync');
return result.data;
} catch (error) {
if (!error.response) {
throw error;
}
return rejectWithValue(error.response.data);
}
}
);
export const deleteItemsByIds = createAsyncThunk(
'xero_employees/deleteByIds',
async (data: any, { rejectWithValue }) => {
@ -151,6 +166,20 @@ export const xero_employeesSlice = createSlice({
state.loading = false
})
builder.addCase(syncFromXero.pending, (state) => {
state.loading = true;
resetNotify(state);
});
builder.addCase(syncFromXero.fulfilled, (state) => {
state.loading = false;
state.refetch = true;
fulfilledNotify(state, 'Xero employees successfully synced');
});
builder.addCase(syncFromXero.rejected, (state, action) => {
state.loading = false;
rejectNotify(state, action);
});
builder.addCase(deleteItemsByIds.pending, (state) => {
state.loading = true;
resetNotify(state);
@ -228,4 +257,4 @@ export const xero_employeesSlice = createSlice({
// Action creators are generated for each case reducer function
export const { setRefetch } = xero_employeesSlice.actions
export default xero_employeesSlice.reducer
export default xero_employeesSlice.reducer