diff --git a/backend/src/config.js b/backend/src/config.js index 4e39731..c0ae8ec 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -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 ', @@ -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; \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 76670af..3413ddc 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -1,4 +1,3 @@ - const express = require('express'); const cors = require('cors'); const app = express(); @@ -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; \ No newline at end of file diff --git a/backend/src/routes/employee_mappings.js b/backend/src/routes/employee_mappings.js index 2882883..25a1005 100644 --- a/backend/src/routes/employee_mappings.js +++ b/backend/src/routes/employee_mappings.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/xero_auth.js b/backend/src/routes/xero_auth.js new file mode 100644 index 0000000..15be85d --- /dev/null +++ b/backend/src/routes/xero_auth.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/xero_employees.js b/backend/src/routes/xero_employees.js index 2514c89..86c7e1d 100644 --- a/backend/src/routes/xero_employees.js +++ b/backend/src/routes/xero_employees.js @@ -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; \ No newline at end of file diff --git a/backend/src/services/employee_mappings.js b/backend/src/services/employee_mappings.js index 1568957..29f0413 100644 --- a/backend/src/services/employee_mappings.js +++ b/backend/src/services/employee_mappings.js @@ -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; + } + } }; - - diff --git a/backend/src/services/xero_employees.js b/backend/src/services/xero_employees.js index 08ff6ab..a316f21 100644 --- a/backend/src/services/xero_employees.js +++ b/backend/src/services/xero_employees.js @@ -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; + } + } }; - - diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 783eabd..888a99d 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -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 \ No newline at end of file diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index 4b7b279..6534176 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -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>({}); 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 = {}; + 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 ( <> - - {getPageTitle('Overview')} - + {getPageTitle('Payroll Command Center')} - {''} + - - {hasPermission(currentUser, 'CREATE_ROLES') && } - {!!rolesWidgets.length && - hasPermission(currentUser, 'CREATE_ROLES') && ( -

- {`${widgetsRole?.role?.label || 'Users'}'s widgets`} -

- )} -
- {(isFetchingQuery || loading) && ( -
- {' '} - Loading widgets... + {/* Integration Status Bar */} +
+ +
+
+

Xero Connection

+

{counts.xero_connections > 0 ? 'Connected' : 'Disconnected'}

+
+
- )} +
+ {counts.xero_connections > 0 ? ( + + ) : ( + + )} +
+
- { rolesWidgets && - rolesWidgets.map((widget) => ( - +
+
+

Payroll Settings

+

{counts.payroll_settings > 0 ? 'Configured' : 'Needs Setup'}

+
+ +
+
+ - ))} +
+ + + +
+
+

Mappings

+

{counts.employee_mappings || 0} Employees

+
+ +
+
+ + +
+
+
+ +
+ {/* Quick Stats */} + +
+
+
+
Export Runs
+
{counts.export_runs ?? loadingMessage}
+
+ +
+
+ + + +
+
+
+
Source Workers
+
{counts.source_workers ?? loadingMessage}
+
+ +
+
+ + + {/* Placeholder for "Ready to Export" */} +
+
+

Ready to Export?

+

Next pay period starts in 3 days.

+
+ +
- {!!rolesWidgets.length &&
} - -
- - - {hasPermission(currentUser, 'READ_USERS') && -
-
-
-
- Users -
-
- {users} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_ROLES') && -
-
-
-
- Roles -
-
- {roles} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PERMISSIONS') && -
-
-
-
- Permissions -
-
- {permissions} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_ORGANISATIONS') && -
-
-
-
- Organisations -
-
- {organisations} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_XERO_CONNECTIONS') && -
-
-
-
- Xero connections -
-
- {xero_connections} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PAYROLL_SETTINGS') && -
-
-
-
- Payroll settings -
-
- {payroll_settings} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_SOURCE_WORKERS') && -
-
-
-
- Source workers -
-
- {source_workers} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_XERO_EMPLOYEES') && -
-
-
-
- Xero employees -
-
- {xero_employees} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_EMPLOYEE_MAPPINGS') && -
-
-
-
- Employee mappings -
-
- {employee_mappings} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PAY_LEVELS') && -
-
-
-
- Pay levels -
-
- {pay_levels} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_XERO_EARNINGS_RATES') && -
-
-
-
- Xero earnings rates -
-
- {xero_earnings_rates} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_EARNINGS_MAPPINGS') && -
-
-
-
- Earnings mappings -
-
- {earnings_mappings} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_XERO_PAYROLL_CALENDARS') && -
-
-
-
- Xero payroll calendars -
-
- {xero_payroll_calendars} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_PAY_PERIODS') && -
-
-
-
- Pay periods -
-
- {pay_periods} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_EXPORT_LINES') && -
-
-
-
- Export lines -
-
- {export_lines} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_EXPORT_RUNS') && -
-
-
-
- Export runs -
-
- {export_runs} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_EXPORT_LINE_RESULTS') && -
-
-
-
- Export line results -
-
- {export_line_results} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_EXPORT_FINGERPRINTS') && -
-
-
-
- Export fingerprints -
-
- {export_fingerprints} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_SOURCE_IMPORT_BATCHES') && -
-
-
-
- Source import batches -
-
- {source_import_batches} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_AUDIT_ENTRIES') && -
-
-
-
- Audit entries -
-
- {audit_entries} -
-
-
- -
-
-
- } - - {hasPermission(currentUser, 'READ_API_KEYS') && -
-
-
-
- Api keys -
-
- {api_keys} -
-
-
- -
-
-
- } - - -
+ + +
+ +

No recent export activity. Start by connecting to Xero.

+
+
) diff --git a/frontend/src/pages/employee_mappings/employee_mappings-list.tsx b/frontend/src/pages/employee_mappings/employee_mappings-list.tsx index 2281ce8..523872a 100644 --- a/frontend/src/pages/employee_mappings/employee_mappings-list.tsx +++ b/frontend/src/pages/employee_mappings/employee_mappings-list.tsx @@ -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 ( <> @@ -120,11 +125,22 @@ const Employee_mappingsTablesPage = () => { {hasCreatePermission && ( setIsModalActive(true)} /> )} + + {hasCreatePermission && ( + + )}
@@ -173,4 +189,4 @@ Employee_mappingsTablesPage.getLayout = function getLayout(page: ReactElement) { ) } -export default Employee_mappingsTablesPage +export default Employee_mappingsTablesPage \ No newline at end of file diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 47cdbdb..9444964 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -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() { } > - {getPageTitle('Starter Page')} + {getPageTitle('Xero Payroll Export Shell')} @@ -128,22 +126,30 @@ export default function Starter() { : null}
- + -
-

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

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+

+ Streamline your Careflo to Xero Payroll AU workflow. +

+
    +
  • Automated Employee & Earnings Rate Mapping
  • +
  • Secure Xero OAuth2 Integration
  • +
  • Support for Timesheets & Allowances (Mode A & B)
  • +
  • Full Audit Trail & Export History
  • +
+

+ A professional, multi-tenant integration shell for high-accuracy payroll exports. +

-
@@ -162,5 +168,4 @@ export default function Starter() { Starter.getLayout = function getLayout(page: ReactElement) { return {page}; -}; - +}; \ No newline at end of file diff --git a/frontend/src/pages/xero_employees/xero_employees-list.tsx b/frontend/src/pages/xero_employees/xero_employees-list.tsx index deb1125..4f4d77e 100644 --- a/frontend/src/pages/xero_employees/xero_employees-list.tsx +++ b/frontend/src/pages/xero_employees/xero_employees-list.tsx @@ -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 ( <> @@ -108,11 +113,20 @@ const Xero_employeesTablesPage = () => { {hasCreatePermission && ( setIsModalActive(true)} /> )} + +
@@ -161,4 +175,4 @@ Xero_employeesTablesPage.getLayout = function getLayout(page: ReactElement) { ) } -export default Xero_employeesTablesPage +export default Xero_employeesTablesPage \ No newline at end of file diff --git a/frontend/src/stores/employee_mappings/employee_mappingsSlice.ts b/frontend/src/stores/employee_mappings/employee_mappingsSlice.ts index b3b65d1..95afb0f 100644 --- a/frontend/src/stores/employee_mappings/employee_mappingsSlice.ts +++ b/frontend/src/stores/employee_mappings/employee_mappingsSlice.ts @@ -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 \ No newline at end of file diff --git a/frontend/src/stores/xero_employees/xero_employeesSlice.ts b/frontend/src/stores/xero_employees/xero_employeesSlice.ts index 17a0686..385f6ec 100644 --- a/frontend/src/stores/xero_employees/xero_employeesSlice.ts +++ b/frontend/src/stores/xero_employees/xero_employeesSlice.ts @@ -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 \ No newline at end of file