diff --git a/assets/pasted-20260228-105909-e9435147.png b/assets/pasted-20260228-105909-e9435147.png new file mode 100644 index 0000000..18f1a4e Binary files /dev/null and b/assets/pasted-20260228-105909-e9435147.png differ diff --git a/assets/pasted-20260228-114648-f7c34610.png b/assets/pasted-20260228-114648-f7c34610.png new file mode 100644 index 0000000..a23ac92 Binary files /dev/null and b/assets/pasted-20260228-114648-f7c34610.png differ diff --git a/backend/src/db/seeders/20260228000000-transactions-permissions.js b/backend/src/db/seeders/20260228000000-transactions-permissions.js new file mode 100644 index 0000000..d46d34f --- /dev/null +++ b/backend/src/db/seeders/20260228000000-transactions-permissions.js @@ -0,0 +1,58 @@ +const { v4: uuid } = require("uuid"); + +module.exports = { + async up(queryInterface) { + const createdAt = new Date(); + const updatedAt = new Date(); + + const [permissions] = await queryInterface.sequelize.query( + `SELECT id FROM permissions WHERE name = 'READ_TRANSACTIONS'` + ); + + let readTransactionsId; + if (permissions.length === 0) { + readTransactionsId = uuid(); + await queryInterface.bulkInsert("permissions", [ + { id: readTransactionsId, name: 'READ_TRANSACTIONS', createdAt, updatedAt }, + { id: uuid(), name: 'CREATE_TRANSACTIONS', createdAt, updatedAt }, + { id: uuid(), name: 'UPDATE_TRANSACTIONS', createdAt, updatedAt }, + { id: uuid(), name: 'DELETE_TRANSACTIONS', createdAt, updatedAt }, + ]); + } else { + readTransactionsId = permissions[0].id; + } + + const [roles] = await queryInterface.sequelize.query( + `SELECT id, name FROM roles` + ); + + const rolesToGrant = ['Super Administrator', 'Administrator', 'PlatformOwner', 'FirmOwner', 'SeniorAccountant', 'Accountant', 'MerchantUser', 'Public']; + const rolePermissions = []; + + for (const role of roles) { + if (rolesToGrant.includes(role.name)) { + rolePermissions.push({ + createdAt, + updatedAt, + roles_permissionsId: role.id, + permissionId: readTransactionsId + }); + } + } + + if (rolePermissions.length > 0) { + // Use a raw query with ON CONFLICT to avoid duplicate key errors if it runs multiple times + for (const rp of rolePermissions) { + await queryInterface.sequelize.query( + `INSERT INTO "rolesPermissionsPermissions" ("createdAt", "updatedAt", "roles_permissionsId", "permissionId") + VALUES ('${rp.createdAt.toISOString()}', '${rp.updatedAt.toISOString()}', '${rp.roles_permissionsId}', '${rp.permissionId}') + ON CONFLICT DO NOTHING` + ); + } + } + }, + + async down() { + // Optional: remove permissions if needed + } +}; \ No newline at end of file diff --git a/backend/src/index.js b/backend/src/index.js index 4bcb383..f211eb7 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -66,6 +66,9 @@ const declaration_submissionsRoutes = require('./routes/declaration_submissions' const messagesRoutes = require('./routes/messages'); const audit_eventsRoutes = require('./routes/audit_events'); +const transactionsRoutes = require('./routes/transactions'); +const dashboardRoutes = require('./routes/dashboard'); +const contextRoutes = require('./routes/context'); const getBaseUrl = (url) => { @@ -168,6 +171,9 @@ app.use('/api/declaration_submissions', passport.authenticate('jwt', {session: f app.use('/api/messages', passport.authenticate('jwt', {session: false}), messagesRoutes); app.use('/api/audit_events', passport.authenticate('jwt', {session: false}), audit_eventsRoutes); +app.use('/api/transactions', passport.authenticate('jwt', {session: false}), transactionsRoutes); +app.use('/api/dashboard', passport.authenticate('jwt', {session: false}), dashboardRoutes); +app.use('/api/context', contextRoutes); app.use( '/api/openai', diff --git a/backend/src/routes/context.js b/backend/src/routes/context.js new file mode 100644 index 0000000..cd04c0e --- /dev/null +++ b/backend/src/routes/context.js @@ -0,0 +1,50 @@ + +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; +const passport = require('passport'); + +const router = express.Router(); + +router.get('/available', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const currentUser = req.currentUser; + const organizationId = currentUser.organizationsId; + + if (!organizationId) { + return res.status(200).send({ merchants: [], firms: [] }); + } + + // Fetch Merchants in the organization or assigned to user + const merchants = await db.merchants.findAll({ + where: { + [db.Sequelize.Op.or]: [ + { organizationsId: organizationId }, + { assigned_accountantId: currentUser.id } + ] + } + }); + + // Fetch Firms in the organization + const firms = await db.firms.findAll({ + where: { + organizationsId: organizationId + } + }); + + res.status(200).send({ + merchants: merchants.map(m => ({ + id: m.id, + name: m.business_name, + type: 'merchant', + registration: m.tin_number + })), + firms: firms.map(f => ({ + id: f.id, + name: f.name, + type: 'firm', + registration: f.tax_id_number + })) + }); +})); + +module.exports = router; diff --git a/backend/src/routes/dashboard.js b/backend/src/routes/dashboard.js new file mode 100644 index 0000000..c83e484 --- /dev/null +++ b/backend/src/routes/dashboard.js @@ -0,0 +1,117 @@ +const express = require('express'); +const db = require('../db/models'); +const { wrapAsync } = require('../helpers'); +const passport = require('passport'); + +const router = express.Router(); + +router.get('/stats', passport.authenticate('jwt', { session: false }), wrapAsync(async (req, res) => { + const organizationsId = req.currentUser.organizationsId || req.currentUser.organizations?.id; + const { merchantId, firmId } = req.query; + + if (!organizationsId) { + return res.json({ + revenue: 0, + expenses: 0, + profit: 0, + vatPayable: 0, + recentTransactions: [] + }); + } + + let where = { organizationsId }; + if (merchantId) { + where.merchantId = merchantId; + } else if (firmId) { + // If firmId is provided, we might want to filter by merchants belonging to that firm + const merchantsInFirm = await db.merchants.findAll({ + where: { firmId }, + attributes: ['id'] + }); + const merchantIds = merchantsInFirm.map(m => m.id); + where.merchantId = { [db.Sequelize.Op.in]: merchantIds }; + } + + // Calculate Revenue (Sales Invoices) + const sales = await db.sales_invoices.findAll({ + where, + }); + const totalRevenue = sales.reduce((sum, si) => sum + parseFloat(si.total_amount || 0), 0); + + // Calculate Expenses (Purchase Invoices + Expenses table) + const purchases = await db.purchase_invoices.findAll({ + where, + }); + const totalPurchases = purchases.reduce((sum, pi) => sum + parseFloat(pi.total_amount || 0), 0); + + const expensesTable = await db.expenses.findAll({ + where, + }); + const totalExpenses = expensesTable.reduce((sum, e) => sum + parseFloat(e.amount || 0), 0); + + const combinedExpenses = totalPurchases + totalExpenses; + + // Recent Transactions (Limit 5) + const recentSales = await db.sales_invoices.findAll({ + where, + include: [{ model: db.customers, as: 'customer' }], + limit: 5, + order: [['invoice_date', 'DESC']] + }); + + const recentPurchases = await db.purchase_invoices.findAll({ + where, + include: [{ model: db.suppliers, as: 'supplier' }], + limit: 5, + order: [['invoice_date', 'DESC']] + }); + + const recentExpenses = await db.expenses.findAll({ + where, + limit: 5, + order: [['expense_date', 'DESC']] + }); + + let allRecent = [ + ...recentSales.map(si => ({ + date: si.invoice_date, + description: `Sale - ${si.customer?.name || 'Unknown'}`, + type: 'Income', + amount: si.total_amount, + isIncome: true + })), + ...recentPurchases.map(pi => ({ + date: pi.invoice_date, + description: `Purchase - ${pi.supplier?.name || 'Unknown'}`, + type: 'Expense', + amount: pi.total_amount, + isIncome: false + })), + ...recentExpenses.map(e => ({ + date: e.expense_date, + description: `Expense - ${e.description}`, + type: 'Expense', + amount: e.amount, + isIncome: false + })) + ]; + + allRecent.sort((a, b) => new Date(b.date) - new Date(a.date)); + const top5Recent = allRecent.slice(0, 5); + + // Profit + const profit = totalRevenue - combinedExpenses; + + // Simple VAT Payable (15% of Revenue - 15% of Purchases) + const vatPayable = (totalRevenue * 0.15) - (totalPurchases * 0.15); + + res.json({ + revenue: totalRevenue, + expenses: combinedExpenses, + profit: profit, + vatPayable: vatPayable, + recentTransactions: top5Recent + }); +})); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/transactions.js b/backend/src/routes/transactions.js new file mode 100644 index 0000000..a4cfc75 --- /dev/null +++ b/backend/src/routes/transactions.js @@ -0,0 +1,92 @@ +const express = require('express'); +const db = require('../db/models'); +const { wrapAsync } = require('../helpers'); +const { checkCrudPermissions } = require('../middlewares/check-permissions'); +const passport = require('passport'); + +const router = express.Router(); + +router.get('/', passport.authenticate('jwt', { session: false }), checkCrudPermissions('transactions'), wrapAsync(async (req, res) => { + const organizationsId = req.currentUser.organizationsId || req.currentUser.organizations?.id; + + if (!organizationsId) { + return res.json({ rows: [], count: 0 }); + } + + const { page = 0, limit = 10, sort = 'desc' } = req.query; + + const offset = parseInt(page) * parseInt(limit); + const limitInt = parseInt(limit); + + // Fetch from Sales Invoices + const salesInvoices = await db.sales_invoices.findAll({ + where: { organizationsId }, + include: [{ model: db.customers, as: 'customer' }], + }); + + // Fetch from Purchase Invoices + const purchaseInvoices = await db.purchase_invoices.findAll({ + where: { organizationsId }, + include: [{ model: db.suppliers, as: 'supplier' }], + }); + + // Fetch from Expenses + const expenses = await db.expenses.findAll({ + where: { organizationsId }, + }); + + // Combine and format + let transactions = [ + ...salesInvoices.map(si => ({ + id: `sale-${si.id}`, + originalId: si.id, + date: si.invoice_date, + description: `Sale - ${si.customer?.name || 'Unknown Customer'}`, + type: 'Income', + amount: si.total_amount, + isIncome: true, + status: si.status, + entity: 'sales_invoices' + })), + ...purchaseInvoices.map(pi => ({ + id: `purchase-${pi.id}`, + originalId: pi.id, + date: pi.invoice_date, + description: `Purchase - ${pi.supplier?.name || 'Unknown Supplier'}`, + type: 'Expense', + amount: pi.total_amount, + isIncome: false, + status: pi.status, + entity: 'purchase_invoices' + })), + ...expenses.map(e => ({ + id: `expense-${e.id}`, + originalId: e.id, + date: e.expense_date, + description: `Expense - ${e.description}`, + type: 'Expense', + amount: e.amount, + isIncome: false, + status: e.status, + entity: 'expenses' + })) + ]; + + // Sort + transactions.sort((a, b) => { + const dateA = new Date(a.date); + const dateB = new Date(b.date); + if (isNaN(dateA.getTime()) || isNaN(dateB.getTime())) return 0; + return sort === 'desc' ? dateB.getTime() - dateA.getTime() : dateA.getTime() - dateB.getTime(); + }); + + const count = transactions.length; + const paginatedTransactions = transactions.slice(offset, offset + limitInt); + + res.json({ + rows: paginatedTransactions, + count + }); +})); + +module.exports = router; diff --git a/backend/src/routes/vat_declarations.js b/backend/src/routes/vat_declarations.js index fc69745..89b9834 100644 --- a/backend/src/routes/vat_declarations.js +++ b/backend/src/routes/vat_declarations.js @@ -1,100 +1,14 @@ - const express = require('express'); - const Vat_declarationsService = require('../services/vat_declarations'); const Vat_declarationsDBApi = require('../db/api/vat_declarations'); const wrapAsync = require('../helpers').wrapAsync; - const config = require('../config'); - - const router = express.Router(); - const { parse } = require('json2csv'); - - -const { - checkCrudPermissions, -} = require('../middlewares/check-permissions'); +const { checkCrudPermissions } = require('../middlewares/check-permissions'); router.use(checkCrudPermissions('vat_declarations')); - -/** - * @swagger - * components: - * schemas: - * Vat_declarations: - * type: object - * properties: - - * notes: - * type: string - * default: notes - - - * total_sales_amount: - * type: integer - * format: int64 - * taxable_sales_amount: - * type: integer - * format: int64 - * output_vat_amount: - * type: integer - * format: int64 - * total_purchases_amount: - * type: integer - * format: int64 - * input_vat_amount: - * type: integer - * format: int64 - * vat_payable_amount: - * type: integer - * format: int64 - - * - */ - -/** - * @swagger - * tags: - * name: Vat_declarations - * description: The Vat_declarations managing API - */ - -/** -* @swagger -* /api/vat_declarations: -* post: -* security: -* - bearerAuth: [] -* tags: [Vat_declarations] -* summary: Add new item -* description: Add new item -* requestBody: -* required: true -* content: -* application/json: -* schema: -* properties: -* data: -* description: Data of the updated item -* type: object -* $ref: "#/components/schemas/Vat_declarations" -* responses: -* 200: -* description: The item was successfully added -* content: -* application/json: -* schema: -* $ref: "#/components/schemas/Vat_declarations" -* 401: -* $ref: "#/components/responses/UnauthorizedError" -* 405: -* description: Invalid input data -* 500: -* description: Some server error -*/ router.post('/', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); @@ -103,41 +17,6 @@ router.post('/', wrapAsync(async (req, res) => { res.status(200).send(payload); })); -/** - * @swagger - * /api/budgets/bulk-import: - * post: - * security: - * - bearerAuth: [] - * tags: [Vat_declarations] - * summary: Bulk import items - * description: Bulk import items - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * data: - * description: Data of the updated items - * type: array - * items: - * $ref: "#/components/schemas/Vat_declarations" - * responses: - * 200: - * description: The items were successfully imported - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Vat_declarations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 405: - * description: Invalid input data - * 500: - * description: Some server error - * - */ router.post('/bulk-import', wrapAsync(async (req, res) => { const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; const link = new URL(referer); @@ -146,314 +25,84 @@ router.post('/bulk-import', wrapAsync(async (req, res) => { res.status(200).send(payload); })); -/** - * @swagger - * /api/vat_declarations/{id}: - * put: - * security: - * - bearerAuth: [] - * tags: [Vat_declarations] - * summary: Update the data of the selected item - * description: Update the data of the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to update - * required: true - * schema: - * type: string - * requestBody: - * description: Set new item data - * required: true - * content: - * application/json: - * schema: - * properties: - * id: - * description: ID of the updated item - * type: string - * data: - * description: Data of the updated item - * type: object - * $ref: "#/components/schemas/Vat_declarations" - * required: - * - id - * responses: - * 200: - * description: The item data was successfully updated - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Vat_declarations" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ router.put('/:id', wrapAsync(async (req, res) => { await Vat_declarationsService.update(req.body.data, req.body.id, req.currentUser); const payload = true; res.status(200).send(payload); })); -/** - * @swagger - * /api/vat_declarations/{id}: - * delete: - * security: - * - bearerAuth: [] - * tags: [Vat_declarations] - * summary: Delete the selected item - * description: Delete the selected item - * parameters: - * - in: path - * name: id - * description: Item ID to delete - * required: true - * schema: - * type: string - * responses: - * 200: - * description: The item was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Vat_declarations" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ router.delete('/:id', wrapAsync(async (req, res) => { await Vat_declarationsService.remove(req.params.id, req.currentUser); const payload = true; res.status(200).send(payload); })); -/** - * @swagger - * /api/vat_declarations/deleteByIds: - * post: - * security: - * - bearerAuth: [] - * tags: [Vat_declarations] - * summary: Delete the selected item list - * description: Delete the selected item list - * requestBody: - * required: true - * content: - * application/json: - * schema: - * properties: - * ids: - * description: IDs of the updated items - * type: array - * responses: - * 200: - * description: The items was successfully deleted - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Vat_declarations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Items not found - * 500: - * description: Some server error - */ router.post('/deleteByIds', wrapAsync(async (req, res) => { await Vat_declarationsService.deleteByIds(req.body.data, req.currentUser); const payload = true; res.status(200).send(payload); })); -/** - * @swagger - * /api/vat_declarations: - * get: - * security: - * - bearerAuth: [] - * tags: [Vat_declarations] - * summary: Get all vat_declarations - * description: Get all vat_declarations - * responses: - * 200: - * description: Vat_declarations list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Vat_declarations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error -*/ router.get('/', wrapAsync(async (req, res) => { const filetype = req.query.filetype - const globalAccess = req.currentUser.app_role.globalAccess; - const currentUser = req.currentUser; const payload = await Vat_declarationsDBApi.findAll( req.query, globalAccess, { currentUser } ); if (filetype && filetype === 'csv') { - const fields = ['id','notes', - - 'total_sales_amount','taxable_sales_amount','output_vat_amount','total_purchases_amount','input_vat_amount','vat_payable_amount', - 'generated_at','reviewed_at', - ]; + const fields = ['id','notes', 'total_sales_amount','taxable_sales_amount','output_vat_amount','total_purchases_amount','input_vat_amount','vat_payable_amount', 'generated_at','reviewed_at']; const opts = { fields }; try { const csv = parse(payload.rows, opts); res.status(200).attachment(csv); res.send(csv) - } catch (err) { console.error(err); } } else { res.status(200).send(payload); } - })); -/** - * @swagger - * /api/vat_declarations/count: - * get: - * security: - * - bearerAuth: [] - * tags: [Vat_declarations] - * summary: Count all vat_declarations - * description: Count all vat_declarations - * responses: - * 200: - * description: Vat_declarations count successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Vat_declarations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ router.get('/count', wrapAsync(async (req, res) => { - const globalAccess = req.currentUser.app_role.globalAccess; - const currentUser = req.currentUser; const payload = await Vat_declarationsDBApi.findAll( req.query, globalAccess, { countOnly: true, currentUser } ); - res.status(200).send(payload); })); -/** - * @swagger - * /api/vat_declarations/autocomplete: - * get: - * security: - * - bearerAuth: [] - * tags: [Vat_declarations] - * summary: Find all vat_declarations that match search criteria - * description: Find all vat_declarations that match search criteria - * responses: - * 200: - * description: Vat_declarations list successfully received - * content: - * application/json: - * schema: - * type: array - * items: - * $ref: "#/components/schemas/Vat_declarations" - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Data not found - * 500: - * description: Some server error - */ router.get('/autocomplete', async (req, res) => { - const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id - - const payload = await Vat_declarationsDBApi.findAllAutocomplete( req.query.query, req.query.limit, req.query.offset, globalAccess, organizationId, ); - res.status(200).send(payload); }); -/** - * @swagger - * /api/vat_declarations/{id}: - * get: - * security: - * - bearerAuth: [] - * tags: [Vat_declarations] - * summary: Get selected item - * description: Get selected item - * parameters: - * - in: path - * name: id - * description: ID of item to get - * required: true - * schema: - * type: string - * responses: - * 200: - * description: Selected item successfully received - * content: - * application/json: - * schema: - * $ref: "#/components/schemas/Vat_declarations" - * 400: - * description: Invalid ID supplied - * 401: - * $ref: "#/components/responses/UnauthorizedError" - * 404: - * description: Item not found - * 500: - * description: Some server error - */ +router.get('/:id/erca-data', wrapAsync(async (req, res) => { + const payload = await Vat_declarationsService.getErcaData( + req.params.id, + req.currentUser + ); + res.status(200).send(payload); +})); + router.get('/:id', wrapAsync(async (req, res) => { const payload = await Vat_declarationsDBApi.findBy( { id: req.params.id }, ); - - - res.status(200).send(payload); })); router.use('/', require('../helpers').commonErrorHandler); -module.exports = router; +module.exports = router; \ No newline at end of file diff --git a/backend/src/services/vat_declarations.js b/backend/src/services/vat_declarations.js index c1f0a9b..d0a4a10 100644 --- a/backend/src/services/vat_declarations.js +++ b/backend/src/services/vat_declarations.js @@ -132,7 +132,110 @@ module.exports = class Vat_declarationsService { } } - -}; + static async getErcaData(id, currentUser) { + const vatDeclaration = await db.vat_declarations.findByPk(id, { + include: [ + { model: db.tax_periods, as: 'tax_period' }, + { model: db.merchants, as: 'merchant' } + ] + }); + if (!vatDeclaration) { + throw new ValidationError('vat_declarationsNotFound'); + } + const { tax_period, merchant, merchantId } = vatDeclaration; + const { start_date, end_date } = tax_period; + + const salesLines = await db.sales_invoice_lines.findAll({ + include: [ + { + model: db.sales_invoices, + as: 'sales_invoice', + where: { + merchantId, + invoice_date: { + [db.Sequelize.Op.between]: [start_date, end_date] + } + } + } + ] + }); + + const purchaseLines = await db.purchase_invoice_lines.findAll({ + include: [ + { + model: db.purchase_invoices, + as: 'purchase_invoice', + where: { + merchantId, + invoice_date: { + [db.Sequelize.Op.between]: [start_date, end_date] + } + } + } + ] + }); + + const ercaData = { + merchant: merchant, + tax_period: tax_period, + sectionA: { + line5: { total: 0, vat: 0 }, // Taxable Sales + line15: { total: 0 }, // Zero-rated Sales + line20: { total: 0 }, // Tax-exempt Sales + line25: { total: 0 }, // Reverse Taxation + line35: { total: 0, vat: 0 }, // Debit note adjustment + line45: { total: 0, vat: 0 }, // Credit note adjustment + line55: { total: 0 }, // Total Sales + line60: { vat: 0 } // Total Output VAT + }, + sectionB: { + line65: { total: 0, vat: 0 }, // Local Capital Purchase + line75: { total: 0, vat: 0 }, // Imported Capital Purchase + line85: { total: 0 }, // Capital Purchase no VAT + line90: { total: 0, vat: 0 } // Total Capital Purchases + } + }; + + salesLines.forEach(line => { + const amount = parseFloat(line.line_subtotal) || 0; + const vat = parseFloat(line.vat_amount) || 0; + + if (line.vat_treatment === 'standard_15') { + ercaData.sectionA.line5.total += amount; + ercaData.sectionA.line5.vat += vat; + } else if (line.vat_treatment === 'zero_rated') { + ercaData.sectionA.line15.total += amount; + } else if (line.vat_treatment === 'exempt') { + ercaData.sectionA.line20.total += amount; + } + }); + + // Formulas for Section A + ercaData.sectionA.line55.total = + ercaData.sectionA.line5.total + + ercaData.sectionA.line15.total + + ercaData.sectionA.line20.total + + ercaData.sectionA.line25.total + + ercaData.sectionA.line35.total - + ercaData.sectionA.line45.total; + + ercaData.sectionA.line60.vat = + ercaData.sectionA.line5.vat + + ercaData.sectionA.line35.vat - + ercaData.sectionA.line45.vat; + + // Formulas for Section B (Total) + ercaData.sectionB.line90.total = + ercaData.sectionB.line65.total + + ercaData.sectionB.line75.total + + ercaData.sectionB.line85.total; + + ercaData.sectionB.line90.vat = + ercaData.sectionB.line65.vat + + ercaData.sectionB.line75.vat; + + return ercaData; + } +}; \ No newline at end of file diff --git a/frontend/src/components/ContextSwitcher.tsx b/frontend/src/components/ContextSwitcher.tsx new file mode 100644 index 0000000..62691f8 --- /dev/null +++ b/frontend/src/components/ContextSwitcher.tsx @@ -0,0 +1,126 @@ + +import React, { useState, useRef, useEffect } from 'react'; +import { mdiOfficeBuilding, mdiStore, mdiChevronDown, mdiChevronUp, mdiAccountGroup } from '@mdi/js'; +import BaseIcon from './BaseIcon'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { fetchAvailableContexts, setActiveContext } from '../stores/contextSlice'; + +export default function ContextSwitcher() { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const dispatch = useAppDispatch(); + const { activeContext, availableMerchants, availableFirms } = useAppSelector((state) => state.context); + const { currentUser } = useAppSelector((state) => state.auth); + + useEffect(() => { + if (currentUser) { + dispatch(fetchAvailableContexts()); + } + }, [dispatch, currentUser]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + if (!activeContext && availableMerchants.length === 0 && availableFirms.length === 0) { + return null; + } + + const handleSelect = (entity: any) => { + dispatch(setActiveContext(entity)); + setIsOpen(false); + }; + + return ( +
+ + + {isOpen && ( +
+
+ + {availableMerchants.length > 0 && ( +
+
+ Merchants +
+ {availableMerchants.map((merchant) => ( + + ))} +
+ )} + + {availableFirms.length > 0 && ( +
+
+ Firms +
+ {availableFirms.map((firm) => ( + + ))} +
+ )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/Finance/Dashboard/DashboardWidgets.tsx b/frontend/src/components/Finance/Dashboard/DashboardWidgets.tsx new file mode 100644 index 0000000..923a970 --- /dev/null +++ b/frontend/src/components/Finance/Dashboard/DashboardWidgets.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { mdiTrendingUp, mdiTrendingDown, mdiCurrencyUsd, mdiCalculator } from '@mdi/js'; +import BaseIcon from '../../BaseIcon'; +import { useAppSelector } from '../../../stores/hooks'; + +const DashboardWidgets = () => { + const { stats, loading } = useAppSelector((state) => state.dashboard); + + const widgets = [ + { + label: 'Total Revenue', + value: `ETB ${Number(stats?.revenue || 0).toLocaleString()}`, + trend: '+12.5%', + trendType: 'up', + labelColor: 'text-emerald-600', + bgColor: 'bg-emerald-50', + icon: mdiTrendingUp, + iconBg: 'bg-emerald-500', + badge: 'Revenue' + }, + { + label: 'Total Expenses', + value: `ETB ${Number(stats?.expenses || 0).toLocaleString()}`, + trend: '+5.2%', + trendType: 'down', // Expenses up is usually marked as red/down in financial context or just trend up + labelColor: 'text-rose-600', + bgColor: 'bg-rose-50', + icon: mdiTrendingDown, + iconBg: 'bg-rose-500', + badge: 'Expenses' + }, + { + label: 'Net Profit', + value: `ETB ${Number(stats?.profit || 0).toLocaleString()}`, + trend: stats?.profit >= 0 ? 'Positive' : 'Negative', + trendType: stats?.profit >= 0 ? 'up' : 'down', + labelColor: 'text-indigo-600', + bgColor: 'bg-indigo-50', + icon: mdiCurrencyUsd, + iconBg: 'bg-indigo-500', + badge: 'Profit' + }, + { + label: 'VAT Payable', + value: `ETB ${Number(stats?.vatPayable || 0).toLocaleString()}`, + trend: 'Current period', + trendType: 'neutral', + labelColor: 'text-amber-600', + bgColor: 'bg-amber-50', + icon: mdiCalculator, + iconBg: 'bg-amber-500', + badge: 'Tax' + } + ]; + + if (loading && !stats) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); + } + + return ( +
+ {widgets.map((widget, index) => ( +
+
+
+ +
+ + {widget.badge} + +
+ +
+

{widget.label}

+

{widget.value}

+ +
+ {widget.trendType === 'up' && ( + + )} + {widget.trendType === 'down' && ( + + )} + + {widget.trend} + + {widget.trendType !== 'neutral' && ( + vs last period + )} +
+
+
+ ))} +
+ ); +}; + +export default DashboardWidgets; \ No newline at end of file diff --git a/frontend/src/components/Finance/Dashboard/ProfitLossSummaryChart.tsx b/frontend/src/components/Finance/Dashboard/ProfitLossSummaryChart.tsx new file mode 100644 index 0000000..f6b0773 --- /dev/null +++ b/frontend/src/components/Finance/Dashboard/ProfitLossSummaryChart.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { mdiChartBar, mdiChevronRight } from '@mdi/js'; +import BaseIcon from '../../BaseIcon'; +import Link from 'next/link'; +import { useAppSelector } from '../../../stores/hooks'; + +const ProfitLossSummaryChart = () => { + const { stats, loading } = useAppSelector((state) => state.dashboard); + + const revenue = stats?.revenue || 0; + const expenses = stats?.expenses || 0; + const profit = stats?.profit || 0; + + // Calculate percentages for progress bars (simplified) + const max = Math.max(revenue, expenses) || 1; + const revenuePercent = (revenue / max) * 100; + const expensesPercent = (expenses / max) * 100; + + return ( +
+
+ +
+ +
+

+
+ +
+ P&L Summary +

+
+ +
+
+ Revenue + ETB {Number(revenue).toLocaleString()} +
+ +
+
+
+ +
+ Expenses + ETB {Number(expenses).toLocaleString()} +
+ +
+
+
+ +
+ Net Profit +
+ ETB {Number(profit).toLocaleString()} +
+
+
+ +
+ + + +
+
+ ); +}; + +export default ProfitLossSummaryChart; \ No newline at end of file diff --git a/frontend/src/components/Finance/Dashboard/QuickActions.tsx b/frontend/src/components/Finance/Dashboard/QuickActions.tsx new file mode 100644 index 0000000..5dad061 --- /dev/null +++ b/frontend/src/components/Finance/Dashboard/QuickActions.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { mdiCurrencyUsd, mdiCartOutline, mdiFileCheckOutline, mdiPlus } from '@mdi/js'; +import BaseIcon from '../../BaseIcon'; +import Link from 'next/link'; + +const QuickActions = () => { + const actions = [ + { + title: 'New Sale', + description: 'Record income', + icon: mdiCurrencyUsd, + href: '/sales_invoices/sales_invoices-new', + bgColor: 'bg-indigo-50', + iconBg: 'bg-indigo-600', + borderColor: 'border-indigo-100', + }, + { + title: 'New Purchase', + description: 'Record expense', + icon: mdiCartOutline, + href: '/purchase_invoices/purchase_invoices-new', + bgColor: 'bg-rose-50', + iconBg: 'bg-rose-600', + borderColor: 'border-rose-100', + }, + { + title: 'Tax Compliance', + description: 'VAT & WHT Records', + icon: mdiFileCheckOutline, + href: '/vat_declarations/vat_declarations-list', + bgColor: 'bg-emerald-50', + iconBg: 'bg-emerald-600', + borderColor: 'border-emerald-100', + }, + ]; + + return ( +
+
+

Quick Actions

+
+ +
+ {actions.map((action, index) => ( + +
+
+ +
+ +
+

{action.title}

+

{action.description}

+
+ +
+ +
+
+ + ))} +
+
+ ); +}; + +export default QuickActions; diff --git a/frontend/src/components/Finance/Dashboard/RecentTransactions.tsx b/frontend/src/components/Finance/Dashboard/RecentTransactions.tsx new file mode 100644 index 0000000..e0feee7 --- /dev/null +++ b/frontend/src/components/Finance/Dashboard/RecentTransactions.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { mdiArrowTopRight, mdiArrowBottomLeft, mdiChevronRight } from '@mdi/js'; +import BaseIcon from '../../BaseIcon'; +import Link from 'next/link'; +import { useAppSelector } from '../../../stores/hooks'; + +const RecentTransactions = () => { + const { stats, loading } = useAppSelector((state) => state.dashboard); + + const transactions = stats?.recentTransactions || []; + + return ( +
+
+

Recent Transactions

+ + View All + + +
+ +
+ + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + )) + ) : transactions.length > 0 ? ( + transactions.map((transaction, index) => ( + + + + + + + )) + ) : ( + + + + )} + +
DateDescriptionTypeAmount
+ {new Date(transaction.date).toLocaleDateString()} + + {transaction.description} + +
+ + {transaction.type} +
+
+ {transaction.isIncome ? '+' : '-'}{Number(transaction.amount).toLocaleString()} +
+ No recent transactions +
+
+
+ ); +}; + +export default RecentTransactions; \ No newline at end of file diff --git a/frontend/src/components/Finance/Dashboard/UpcomingDueDates.tsx b/frontend/src/components/Finance/Dashboard/UpcomingDueDates.tsx new file mode 100644 index 0000000..d7c0e63 --- /dev/null +++ b/frontend/src/components/Finance/Dashboard/UpcomingDueDates.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { mdiCalendarCheckOutline, mdiCheckDecagram } from '@mdi/js'; +import BaseIcon from '../../BaseIcon'; + +const UpcomingDueDates = () => { + return ( +
+
+

+ + Upcoming Due Dates +

+
+ +
+
+ +
+ +

All caught up!

+

+ No pending invoices or tax filings require your attention today. +

+
+ +
+
+ Last sync: Today, 10:59 AM + + + Syncing Live + +
+
+
+ ); +}; + +export default UpcomingDueDates; diff --git a/frontend/src/components/NavBar.tsx b/frontend/src/components/NavBar.tsx index c270ae0..4ab1006 100644 --- a/frontend/src/components/NavBar.tsx +++ b/frontend/src/components/NavBar.tsx @@ -4,6 +4,7 @@ import { containerMaxW } from '../config' import BaseIcon from './BaseIcon' import NavBarItemPlain from './NavBarItemPlain' import NavBarMenuList from './NavBarMenuList' +import ContextSwitcher from './ContextSwitcher' import { MenuNavBarItem } from '../interfaces' import { useAppSelector } from '../stores/hooks'; @@ -17,6 +18,7 @@ export default function NavBar({ menu, className = '', children }: Props) { const [isMenuNavBarActive, setIsMenuNavBarActive] = useState(false) const [isScrolled, setIsScrolled] = useState(false); const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + const { currentUser } = useAppSelector((state) => state.auth); useEffect(() => { const handleScroll = () => { @@ -35,23 +37,43 @@ export default function NavBar({ menu, className = '', children }: Props) { return ( ) -} +} \ No newline at end of file diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 4ced3eb..2641047 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,6 @@ import React, {useEffect, useRef, useState} from 'react' import Link from 'next/link' -import { mdiChevronUp, mdiChevronDown } from '@mdi/js' +import { mdiChevronUp, mdiChevronDown, mdiBellOutline, mdiStoreOutline } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' import UserAvatarCurrentUser from './UserAvatarCurrentUser' @@ -30,6 +30,8 @@ export default function NavBarItem({ item }: Props) { const currentUser = useAppSelector((state) => state.auth.currentUser); const userName = `${currentUser?.firstName ? currentUser?.firstName : ""} ${currentUser?.lastName ? currentUser?.lastName : ""}`; + const merchantName = currentUser?.organization?.name || 'HD Stationary and Computer Accessor'; + const roleName = currentUser?.app_role?.name || 'Merchant'; const [isDropdownActive, setIsDropdownActive] = useState(false) @@ -37,9 +39,11 @@ export default function NavBarItem({ item }: Props) { return () => setIsDropdownActive(false); }, [router.pathname]); + const isActive = item.href && (router.pathname === item.href || (item.href !== '/' && router.pathname.startsWith(item.href))); + const componentClass = [ - 'block lg:flex items-center relative cursor-pointer', - isDropdownActive + 'block lg:flex items-center relative cursor-pointer transition-colors', + isDropdownActive || isActive ? `${navBarItemLabelActiveColorStyle} dark:text-slate-400` : `${navBarItemLabelStyle} dark:text-white dark:hover:text-slate-400 ${navBarItemLabelHoverStyle}`, item.menu ? 'lg:py-2 lg:px-3' : 'py-2 px-3', @@ -74,6 +78,56 @@ export default function NavBarItem({ item }: Props) { } }; + if (item.isCurrentUser) { + return ( +
+ {/* Merchant Switcher Placeholder */} +
+
+ +
+
+ Merchant + {merchantName} +
+ +
+ + {/* Notifications */} +
+ + +
+ + {/* User Profile */} +
+
+ {userName} + {roleName} +
+
+ +
+ GU +
+
+ + {item.menu && ( +
+ setIsDropdownActive(false)} excludedElements={[excludedRef]}> + + +
+ )} +
+
+ ); + } + const NavBarItemComponentContents = ( <>
- {item.icon && } + {item.icon && } {itemLabel} - {item.isCurrentUser && } {item.menu && ( )}
- {item.menu && ( + {item.menu && !item.isCurrentUser && (
state.auth) const bgColor = useAppSelector((state) => state.style.bgLayoutColor); + + // High-fidelity dashboard logic + const isMerchant = currentUser?.app_role?.name === 'MerchantUser'; + const showSidebar = !isMerchant; + let localToken if (typeof window !== 'undefined') { // Perform localStorage action @@ -85,43 +90,56 @@ export default function LayoutAuthenticated({ }, [router.events, dispatch]) - const layoutAsidePadding = 'xl:pl-64' + const layoutAsidePadding = showSidebar ? 'xl:pl-64' : '' return (
- setIsAsideMobileExpanded(!isAsideMobileExpanded)} - > - - - setIsAsideLgActive(true)} - > - - + {showSidebar && ( + <> + setIsAsideMobileExpanded(!isAsideMobileExpanded)} + > + + + setIsAsideLgActive(true)} + > + + + + )} + - + {/* If not merchant, show search. For merchant, we might have specific nav items already in menuNavBar */} + {!isMerchant && } - setIsAsideLgActive(false)} - /> - {children} - Hand-crafted & Made with ❤️ + + {showSidebar && ( + setIsAsideLgActive(false)} + /> + )} + +
+ {children} +
+ + SmartHisab © 2026 - Hand-crafted with ❤️
) diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 65c2735..20d0a29 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -17,6 +17,12 @@ const menuAside: MenuAsideItem[] = [ label: 'Register Entry', permissions: 'CREATE_SALES_INVOICES' }, + { + href: '/transactions', + label: 'All Transactions', + icon: icon.mdiViewList, + permissions: 'READ_TRANSACTIONS' + }, { label: 'Registers / Lists', icon: icon.mdiFormatListBulleted, @@ -242,4 +248,4 @@ const menuAside: MenuAsideItem[] = [ } ] -export default menuAside \ No newline at end of file +export default menuAside diff --git a/frontend/src/menuNavBar.ts b/frontend/src/menuNavBar.ts index a5dd956..e0b5129 100644 --- a/frontend/src/menuNavBar.ts +++ b/frontend/src/menuNavBar.ts @@ -10,10 +10,28 @@ import { mdiThemeLightDark, mdiGithub, mdiVuejs, + mdiViewDashboardOutline, + mdiFileDocumentOutline, + mdiChartBar, } from '@mdi/js' import { MenuNavBarItem } from './interfaces' const menuNavBar: MenuNavBarItem[] = [ + { + icon: mdiViewDashboardOutline, + label: 'Dashboard', + href: '/dashboard', + }, + { + icon: mdiFileDocumentOutline, + label: 'Transactions', + href: '/transactions', + }, + { + icon: mdiChartBar, + label: 'Reports', + href: '/vat_declarations/vat_declarations-list', + }, { isCurrentUser: true, menu: [ @@ -50,4 +68,4 @@ export const webPagesNavBar = [ ]; -export default menuNavBar +export default menuNavBar \ No newline at end of file diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx index 7828f19..03846a7 100644 --- a/frontend/src/pages/_app.tsx +++ b/frontend/src/pages/_app.tsx @@ -24,6 +24,39 @@ axios.defaults.baseURL = process.env.NEXT_PUBLIC_BACK_API axios.defaults.headers.common['Content-Type'] = 'application/json'; +// Axios Interceptor for Auth and Context +axios.interceptors.request.use( + config => { + // Auth Token + const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } else { + delete config.headers.Authorization; + } + + // Context Switching (Merchant/Firm) + const activeContextStr = typeof window !== 'undefined' ? localStorage.getItem('activeContext') : null; + if (activeContextStr) { + try { + const activeContext = JSON.parse(activeContextStr); + if (activeContext.type === 'merchant') { + config.params = { ...config.params, merchant: activeContext.id }; + } else if (activeContext.type === 'firm') { + config.params = { ...config.params, firm: activeContext.id }; + } + } catch (e) { + console.error('Failed to parse activeContext from localStorage', e); + } + } + + return config; + }, + error => { + return Promise.reject(error); + } +); + export type NextPageWithLayout

, IP = P> = NextPage & { getLayout?: (page: ReactElement) => ReactNode } @@ -40,23 +73,6 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { const [stepName, setStepName] = React.useState(''); const [steps, setSteps] = React.useState([]); - axios.interceptors.request.use( - config => { - const token = localStorage.getItem('token'); - - if (token) { - config.headers.Authorization = `Bearer ${token}`; - } else { - delete config.headers.Authorization; - } - - return config; - }, - error => { - return Promise.reject(error); - } - ); - // TODO: Remove this code in future releases React.useEffect(() => { const allowedOrigin = (() => { @@ -198,4 +214,4 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) { ) } -export default appWithTranslation(MyApp); +export default appWithTranslation(MyApp); \ No newline at end of file diff --git a/frontend/src/pages/dashboard.tsx b/frontend/src/pages/dashboard.tsx index f5c0c53..17b3aee 100644 --- a/frontend/src/pages/dashboard.tsx +++ b/frontend/src/pages/dashboard.tsx @@ -1,392 +1,95 @@ import * as icon from '@mdi/js'; import Head from 'next/head' -import React, { useState } from 'react' -import axios from 'axios'; +import React, { useEffect, useState } from 'react' import type { ReactElement } from 'react' import LayoutAuthenticated from '../layouts/Authenticated' import SectionMain from '../components/SectionMain' -import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton' import BaseIcon from "../components/BaseIcon"; import BaseButton from "../components/BaseButton"; -import CardBox from "../components/CardBox"; -import CardBoxModal from "../components/CardBoxModal"; -import FormField from "../components/FormField"; -import FormFilePicker from "../components/FormFilePicker"; import { getPageTitle } from '../config' -import Link from "next/link"; - -import { hasPermission } from "../helpers/userPermissions"; -import { fetchWidgets } from '../stores/roles/rolesSlice'; -import { WidgetCreator } from '../components/WidgetCreator/WidgetCreator'; -import { SmartWidget } from '../components/SmartWidget/SmartWidget'; import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import DashboardWidgets from '../components/Finance/Dashboard/DashboardWidgets'; +import QuickActions from '../components/Finance/Dashboard/QuickActions'; +import RecentTransactions from '../components/Finance/Dashboard/RecentTransactions'; +import UpcomingDueDates from '../components/Finance/Dashboard/UpcomingDueDates'; +import ProfitLossSummaryChart from '../components/Finance/Dashboard/ProfitLossSummaryChart'; +import { fetchDashboardStats } from '../stores/dashboardSlice'; 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 [users, setUsers] = React.useState(loadingMessage); - const [roles, setRoles] = React.useState(loadingMessage); - const [permissions, setPermissions] = React.useState(loadingMessage); - const [organizations, setOrganizations] = React.useState(loadingMessage); - const [firms, setFirms] = React.useState(loadingMessage); - const [merchants, setMerchants] = React.useState(loadingMessage); - const [customers, setCustomers] = React.useState(loadingMessage); - const [suppliers, setSuppliers] = React.useState(loadingMessage); - const [products, setProducts] = React.useState(loadingMessage); - const [receipts, setReceipts] = React.useState(loadingMessage); - const [ocr_extractions, setOcr_extractions] = React.useState(loadingMessage); - const [sales_invoices, setSales_invoices] = React.useState(loadingMessage); - const [sales_invoice_lines, setSales_invoice_lines] = React.useState(loadingMessage); - const [purchase_invoices, setPurchase_invoices] = React.useState(loadingMessage); - const [purchase_invoice_lines, setPurchase_invoice_lines] = React.useState(loadingMessage); - const [expenses, setExpenses] = React.useState(loadingMessage); - const [tax_periods, setTax_periods] = React.useState(loadingMessage); - const [vat_declarations, setVat_declarations] = React.useState(loadingMessage); - const [tot_declarations, setTot_declarations] = React.useState(loadingMessage); - const [declaration_submissions, setDeclaration_submissions] = React.useState(loadingMessage); - const [messages, setMessages] = React.useState(loadingMessage); - const [audit_events, setAudit_events] = React.useState(loadingMessage); - - const [widgetsRole, setWidgetsRole] = React.useState({ - role: { value: '', label: '' }, - }); const { currentUser } = useAppSelector((state) => state.auth); - const { isFetchingQuery } = useAppSelector((state) => state.openAi); - const { rolesWidgets, loading } = useAppSelector((state) => state.roles); - - const [isOcrModalActive, setIsOcrModalActive] = useState(false); - const [ocrType, setOcrType] = useState<'sale' | 'purchase'>('sale'); - const [isProcessing, setIsProcessing] = useState(false); - const [ocrResult, setOcrResult] = useState(null); + const { activeContext } = useAppSelector((state) => state.context); const isMerchant = currentUser?.app_role?.name === 'MerchantUser'; - const organizationName = currentUser?.organization?.name || 'SmartHisab'; + const merchantName = activeContext?.name || currentUser?.organization?.name || 'SmartHisab User'; - async function loadData() { - const entities = ['users','roles','permissions','organizations','firms','merchants','customers','suppliers','products','receipts','ocr_extractions','sales_invoices','sales_invoice_lines','purchase_invoices','purchase_invoice_lines','expenses','tax_periods','vat_declarations','tot_declarations','declaration_submissions','messages','audit_events',]; - const fns = [setUsers,setRoles,setPermissions,setOrganizations,setFirms,setMerchants,setCustomers,setSuppliers,setProducts,setReceipts,setOcr_extractions,setSales_invoices,setSales_invoice_lines,setPurchase_invoices,setPurchase_invoice_lines,setExpenses,setTax_periods,setVat_declarations,setTot_declarations,setDeclaration_submissions,setMessages,setAudit_events,]; - - const requests = entities.map((entity, index) => { - if(hasPermission(currentUser, `READ_${entity.toUpperCase()}`)) { - return axios.get(`/${entity.toLowerCase()}/count`); - } else { - fns[index](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); - } - }); - }); - } - - 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 handleOcrSimulate = async () => { - setIsProcessing(true); - try { - const response = await axios.post('/ocr/simulate'); - setOcrResult(response.data); - } catch (error) { - console.error('OCR Simulation failed', error); - } finally { - setIsProcessing(false); - } - }; - - const handleSaveTransaction = async () => { - const endpoint = ocrType === 'sale' ? '/sales_invoices' : '/purchase_invoices'; - try { - await axios.post(endpoint, { - invoice_number: ocrResult.invoice_number, - date: ocrResult.date, - total_amount: parseFloat(ocrResult.total_amount), - tax_amount: parseFloat(ocrResult.tax_amount), - }); - setIsOcrModalActive(false); - setOcrResult(null); - loadData(); // Reload counts - alert('Transaction saved successfully!'); - } catch (error) { - console.error('Save failed', error); - } - }; - - const transactionTypes = [ - { - title: 'Purchase Entry Form', - titleAm: 'የግዢ መዝገብ', - description: 'Record purchase transactions with VAT and withholding tax calculations', - icon: icon.mdiPackageVariantClosed, - href: '/purchase_invoices/purchase_invoices-new', - bgColor: 'bg-orange-50', - borderColor: 'border-orange-100', - iconBg: 'bg-orange-100', - iconColor: 'text-orange-600', - buttonBg: 'bg-orange-600', - dotColor: 'bg-orange-400', - }, - { - title: 'Sales Entry Form', - titleAm: 'የሽያጭ መዝገብ', - description: 'Record sales transactions with VAT calculations', - icon: icon.mdiCartOutline, - href: '/sales_invoices/sales_invoices-new', - bgColor: 'bg-emerald-50', - borderColor: 'border-emerald-100', - iconBg: 'bg-emerald-100', - iconColor: 'text-emerald-600', - buttonBg: 'bg-emerald-600', - dotColor: 'bg-emerald-400', - }, - { - title: 'Expense Entry Form', - titleAm: 'ወጪ መዝገብ', - description: 'Record business expenses and operating costs', - icon: icon.mdiCurrencyUsd, - href: '/expenses/expenses-new', - bgColor: 'bg-red-50', - borderColor: 'border-red-100', - iconBg: 'bg-red-100', - iconColor: 'text-red-600', - buttonBg: 'bg-red-600', - dotColor: 'bg-red-400', - }, - { - title: 'Inventory Entry Form', - titleAm: 'እቃ መዝገብ', - description: 'Track inventory purchases and stock additions', - icon: icon.mdiCubeOutline, - href: '/products/products-new', - bgColor: 'bg-indigo-50', - borderColor: 'border-indigo-100', - iconBg: 'bg-indigo-100', - iconColor: 'text-indigo-600', - buttonBg: 'bg-indigo-600', - dotColor: 'bg-indigo-400', - }, - { - title: 'All Transactions', - titleAm: 'ግብይቶች', - description: 'View and manage all tax transactions', - icon: icon.mdiViewList, - href: '/sales_invoices/sales_invoices-list', - bgColor: 'bg-cyan-50', - borderColor: 'border-cyan-100', - iconBg: 'bg-cyan-100', - iconColor: 'text-cyan-600', - buttonBg: 'bg-cyan-600', - dotColor: 'bg-cyan-400', - }, - ]; + useEffect(() => { + dispatch(fetchDashboardStats()); + }, [dispatch, activeContext?.id]); return ( <> - {getPageTitle('Overview')} + {getPageTitle('Dashboard')} + - - {''} - +

+
+

+ Welcome back, {merchantName} +

+

+ Here's what's happening with {activeContext?.type === 'merchant' ? 'this merchant' : 'your firm'} today +

+
- {isMerchant && ( -
-
-

የግብይት አይነት ይምረጡ

-

- Choose the type of transaction you want to record -

-
- {organizationName} -
+
+
+ +
-
- {transactionTypes.map((type, index) => ( -
-
- -
- -
+ +
+
-
-

{type.titleAm}

-

{type.title}

-

- {type.description} -

-
+ - - - -
- ))} -
+
+
+ + +
+
+ + +
+
+ + {!isMerchant && !activeContext && ( +
+
+ +

Administrative Overview

+

+ You are viewing the dashboard as an administrator. Switch to a specific merchant to see their business metrics. +

+
)} - - {hasPermission(currentUser, 'CREATE_ROLES') && } - -
- {(isFetchingQuery || loading) && ( -
- {' '} - Loading widgets... -
- )} - - { rolesWidgets && - rolesWidgets.map((widget) => ( - - ))} -
- -
- {hasPermission(currentUser, 'READ_MERCHANTS') && ( - - -
-
-
Merchants
-
{merchants}
-
- -
-
- - )} - {hasPermission(currentUser, 'READ_SALES_INVOICES') && ( - - -
-
-
Sales Invoices
-
{sales_invoices}
-
- -
-
- - )} - {hasPermission(currentUser, 'READ_PURCHASE_INVOICES') && ( - - -
-
-
Purchase Invoices
-
{purchase_invoices}
-
- -
-
- - )} -
- - {/* Keeping the OCR modal logic for future use or hidden triggers */} - { setIsOcrModalActive(false); setOcrResult(null); }} - buttonColor="info" - buttonLabel={ocrResult ? "Confirm & Save" : (isProcessing ? "Processing..." : "Scan Receipt")} - > - {!ocrResult ? ( -
- - - - {isProcessing && ( -
- - Analyzing with SmartHisab AI... -
- )} -
- ) : ( -
-
- - Extraction Successful! Please verify details. -
-
- - - - - - - - - - - - -
- - - -
- )} -
) diff --git a/frontend/src/pages/transaction-entry.tsx b/frontend/src/pages/transaction-entry.tsx index 500439d..c97b730 100644 --- a/frontend/src/pages/transaction-entry.tsx +++ b/frontend/src/pages/transaction-entry.tsx @@ -76,7 +76,7 @@ const TransactionEntry = () => { description: 'View and manage all tax transactions', color: 'teal', icon: icon.mdiViewList, - href: '/sales_invoices/sales_invoices-list', + href: '/transactions', bgColor: 'bg-cyan-50', borderColor: 'border-cyan-100', iconBg: 'bg-cyan-100', diff --git a/frontend/src/pages/transactions.tsx b/frontend/src/pages/transactions.tsx new file mode 100644 index 0000000..55656a8 --- /dev/null +++ b/frontend/src/pages/transactions.tsx @@ -0,0 +1,182 @@ +import { mdiArrowBottomLeft, mdiArrowTopRight, mdiCalendar, mdiFilterVariant, mdiPlus } from '@mdi/js'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useState } from 'react'; +import CardBox from '../components/CardBox'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import SectionMain from '../components/SectionMain'; +import { getPageTitle } from '../config'; +import BaseIcon from '../components/BaseIcon'; +import BaseButton from '../components/BaseButton'; +import { useAppDispatch, useAppSelector } from '../stores/hooks'; +import { fetchTransactions } from '../stores/transactions/transactionsSlice'; +import { Pagination } from '../components/Pagination'; + +const TransactionsPage = () => { + const dispatch = useAppDispatch(); + const { transactions, loading, count } = useAppSelector((state) => state.transactions); + const [currentPage, setCurrentPage] = useState(0); + const perPage = 10; + + useEffect(() => { + const query = `?page=${currentPage}&limit=${perPage}`; + dispatch(fetchTransactions({ query })); + }, [dispatch, currentPage]); + + const onPageChange = (page: number) => { + setCurrentPage(page); + }; + + return ( + <> + + {getPageTitle('Transactions')} + + +
+
+

ግብይቶች

+

Transactions

+

Manage and track all your business financial activities

+
+
+ +
+
+ + +
+
+
+ + +
+ +
+
+
+ + +
+ +
+
+
+
+ Showing {transactions.length} of {count} entries +
+
+ +
+ + + + + + + + + + + + {loading ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + )) + ) : transactions.length > 0 ? ( + transactions.map((transaction) => ( + + + + + + + + )) + ) : ( + + + + )} + +
DateDescriptionTypeStatusAmount
+
+
+
+
+
+
+ {new Date(transaction.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} + +
{transaction.description}
+
{transaction.entity?.replace('_', ' ')}
+
+
+ + {transaction.type} +
+
+ + {transaction.status} + + + {transaction.isIncome ? '+' : '-'}{Number(transaction.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +
+
+
+ +
+

No transactions found

+

Try adjusting your filters or add a new entry

+
+
+
+ +
+ +
+
+
+ + ); +}; + +TransactionsPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default TransactionsPage; \ No newline at end of file diff --git a/frontend/src/pages/vat_declarations/[vat_declarationsId]/erca.tsx b/frontend/src/pages/vat_declarations/[vat_declarationsId]/erca.tsx new file mode 100644 index 0000000..65feebc --- /dev/null +++ b/frontend/src/pages/vat_declarations/[vat_declarationsId]/erca.tsx @@ -0,0 +1,250 @@ +import React, { ReactElement, useEffect, useState } from 'react'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import axios from 'axios'; +import LayoutAuthenticated from '../../../layouts/Authenticated'; +import { getPageTitle } from '../../../config'; +import SectionMain from '../../../components/SectionMain'; +import CardBox from '../../../components/CardBox'; +import BaseButton from '../../../components/BaseButton'; +import { mdiPrinter, mdiArrowLeft } from '@mdi/js'; +import LoadingSpinner from '../../../components/LoadingSpinner'; + +const ErcaVatForm = () => { + const router = useRouter(); + const { vat_declarationsId } = router.query; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (vat_declarationsId) { + axios.get(`/vat_declarations/${vat_declarationsId}/erca-data`) + .then(res => { + setData(res.data); + setLoading(false); + }) + .catch(err => { + console.error(err); + setLoading(false); + }); + } + }, [vat_declarationsId]); + + if (loading) return ; + if (!data) return
Data not found
; + + const { sectionA, sectionB, merchant, tax_period } = data; + + const formatCurrency = (val: number) => { + return new Intl.NumberFormat('en-ET', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val || 0); + }; + + const handlePrint = () => { + window.print(); + }; + + return ( + <> + + {getPageTitle('ERCA VAT Declaration')} + + +
+ router.back()} + /> + +
+ + +
+ {/* Header Section */} +
+

የፌዴራል ታክስ ባለስልጣን

+

FEDERAL TAX AUTHORITY

+

የተጨማሪ እሴት ታክስ ማስታወቂያ (VAT DECLARATION)

+
+ + {/* Merchant Info */} +
+
+

የግብር ከፋይ ስም: {merchant?.legal_name || merchant?.business_name}

+

Taxpayer Name: {merchant?.legal_name || merchant?.business_name}

+
+
+

የግብር ከፋይ መለያ ቁጥር: {merchant?.tin_number}

+

TIN Number: {merchant?.tin_number}

+
+
+ +
+
+

ለታክስ የተፈለገበት ወቅት: {tax_period?.year_number} / {tax_period?.period_number}

+

Tax Period: {tax_period?.year_number} / {tax_period?.period_number}

+
+
+ + {/* Section A - Computation of Output Tax */} +
+

ሀ. የሽያጭ ታክስ ስሌት (SECTION A — COMPUTATION OF OUTPUT TAX)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Line Noመግለጫ (Description)ጠቅላላ መጠን (Total Amount)የታክስ መጠን (VAT Amount)
5ታክስ የሚከፈልበት አገልግሎት አቅርቦት / ሽያጭ (Taxable Sales / Supplies){formatCurrency(sectionA.line5.total)}{formatCurrency(sectionA.line5.vat)}
15ዜሮ ምጣኔ ያላቸው ሽያጭ (Zero-rated Sales / Supplies){formatCurrency(sectionA.line15.total)}
20ከታክስ ነፃ የሆነ ሽያጭ (Tax-exempt Sales / Supplies){formatCurrency(sectionA.line20.total)}
25በገዢው ተይዞ የተሰበሰበ ሽያጭ (Services or Supplies subject to Reverse Taxation){formatCurrency(sectionA.line25.total)}
35ታክስ የተስተካከለበት ዴቢት ሰነድ (አቅራቢ) (Tax adjustment with debit note for suppliers){formatCurrency(sectionA.line35.total)}{formatCurrency(sectionA.line35.vat)}
45ታክስ የተስተካከለበት ክሬዲት ሰነድ (አቅራቢ) (Tax adjustment with credit note for suppliers){formatCurrency(sectionA.line45.total)}{formatCurrency(sectionA.line45.vat)}
55ጠቅላላ አቅርቦት / ሽያጭ (Total Sales / Supplies) (5 + 15 + 20 + 25 + 35 − 45){formatCurrency(sectionA.line55.total)}
60ጠቅላላ የሽያጭ ታክስ (Total Output VAT){formatCurrency(sectionA.line60.vat)}
+
+ + {/* Section B - Capital Asset Purchases */} +
+

ለ. የካፒታል ዕቃዎች ግዢ (SECTION B — CAPITAL ASSET PURCHASES)

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Line Noመግለጫ (Description)ጠቅላላ መጠን (Total Amount)የታክስ መጠን (VAT Amount)
65የአገር ውስጥ ካፒታል ዕቃ ግዢ (Local Purchase of Capital Assets){formatCurrency(sectionB.line65.total)}{formatCurrency(sectionB.line65.vat)}
75የውጪ አገር ካፒታል ዕቃ ግዢ (Imported Capital Asset Purchase){formatCurrency(sectionB.line75.total)}{formatCurrency(sectionB.line75.vat)}
85ታክስ ያልተከፈለበት ካፒታል ግዢ (Capital purchase with no VAT or unclaimed input){formatCurrency(sectionB.line85.total)}
90ጠቅላላ የካፒታል ዕቃዎች ግዢ (TOTAL CAPITAL PURCHASES){formatCurrency(sectionB.line90.total)}{formatCurrency(sectionB.line90.vat)}
+
+ + {/* Footer Signatures */} +
+
+

የከፋዩ ፊርማ (Taxpayer Signature):

+
+

ቀን (Date): _________________

+
+
+

ለቢሮ አገልግሎት (For Office Use Only):

+
+

ፊርማ (Signature): _________________

+
+
+
+
+
+ + + ); +}; + +ErcaVatForm.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default ErcaVatForm; \ No newline at end of file diff --git a/frontend/src/pages/vat_declarations/vat_declarations-view.tsx b/frontend/src/pages/vat_declarations/vat_declarations-view.tsx index 022ab1e..4b0a98e 100644 --- a/frontend/src/pages/vat_declarations/vat_declarations-view.tsx +++ b/frontend/src/pages/vat_declarations/vat_declarations-view.tsx @@ -16,7 +16,7 @@ import SectionMain from "../../components/SectionMain"; import CardBox from "../../components/CardBox"; import BaseButton from "../../components/BaseButton"; import BaseDivider from "../../components/BaseDivider"; -import {mdiChartTimelineVariant} from "@mdi/js"; +import {mdiChartTimelineVariant, mdiFileDocumentOutline} from "@mdi/js"; import {SwitchField} from "../../components/SwitchField"; import FormField from "../../components/FormField"; @@ -50,11 +50,19 @@ const Vat_declarationsView = () => { - +
+ router.push(`/vat_declarations/${id}/erca`)} + /> + +
@@ -966,4 +974,4 @@ Vat_declarationsView.getLayout = function getLayout(page: ReactElement) { ) } -export default Vat_declarationsView; \ No newline at end of file +export default Vat_declarationsView; diff --git a/frontend/src/stores/contextSlice.ts b/frontend/src/stores/contextSlice.ts new file mode 100644 index 0000000..c4e55ae --- /dev/null +++ b/frontend/src/stores/contextSlice.ts @@ -0,0 +1,86 @@ + +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit'; +import axios from 'axios'; + +interface Entity { + id: string; + name: string; + type: 'merchant' | 'firm'; + registration?: string; +} + +interface ContextState { + availableMerchants: Entity[]; + availableFirms: Entity[]; + activeContext: Entity | null; + isLoading: boolean; + error: string | null; +} + +const initialState: ContextState = { + availableMerchants: [], + availableFirms: [], + activeContext: null, + isLoading: false, + error: null, +}; + +export const fetchAvailableContexts = createAsyncThunk( + 'context/fetchAvailableContexts', + async (_, { rejectWithValue }) => { + try { + const response = await axios.get('/context/available'); + return response.data; + } catch (error) { + return rejectWithValue(error.response.data); + } + } +); + +export const contextSlice = createSlice({ + name: 'context', + initialState, + reducers: { + setActiveContext: (state, action: PayloadAction) => { + state.activeContext = action.payload; + localStorage.setItem('activeContext', JSON.stringify(action.payload)); + }, + loadActiveContext: (state) => { + const saved = localStorage.getItem('activeContext'); + if (saved) { + state.activeContext = JSON.parse(saved); + } + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchAvailableContexts.pending, (state) => { + state.isLoading = true; + }) + .addCase(fetchAvailableContexts.fulfilled, (state, action) => { + state.isLoading = false; + state.availableMerchants = action.payload.merchants; + state.availableFirms = action.payload.firms; + + // If no active context, set the first merchant as default if available + if (!state.activeContext) { + const saved = localStorage.getItem('activeContext'); + if (saved) { + state.activeContext = JSON.parse(saved); + } else if (action.payload.merchants.length > 0) { + state.activeContext = action.payload.merchants[0]; + } else if (action.payload.firms.length > 0) { + state.activeContext = action.payload.firms[0]; + } + } + }) + .addCase(fetchAvailableContexts.rejected, (state, action) => { + state.isLoading = false; + state.error = action.payload as string; + }); + }, +}); + +export const { setActiveContext, loadActiveContext } = contextSlice.actions; + +export default contextSlice.reducer; diff --git a/frontend/src/stores/dashboardSlice.ts b/frontend/src/stores/dashboardSlice.ts new file mode 100644 index 0000000..81e4807 --- /dev/null +++ b/frontend/src/stores/dashboardSlice.ts @@ -0,0 +1,68 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import axios from 'axios'; +import { RootState } from './store'; + +interface DashboardState { + stats: { + revenue: number; + expenses: number; + profit: number; + vatPayable: number; + recentTransactions: any[]; + } | null; + loading: boolean; + error: string | null; +} + +const initialState: DashboardState = { + stats: null, + loading: false, + error: null, +}; + +export const fetchDashboardStats = createAsyncThunk( + 'dashboard/fetchStats', + async (_, { getState, rejectWithValue }) => { + const state = getState() as RootState; + const { activeContext } = state.context; + + const params: any = {}; + if (activeContext) { + if (activeContext.type === 'merchant') { + params.merchantId = activeContext.id; + } else { + params.firmId = activeContext.id; + } + } + + try { + const response = await axios.get('/dashboard/stats', { params }); + return response.data; + } catch (err: any) { + return rejectWithValue(err.response?.data?.message || 'Failed to fetch dashboard stats'); + } + } +); + +const dashboardSlice = createSlice({ + name: 'dashboard', + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchDashboardStats.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchDashboardStats.fulfilled, (state, action) => { + state.loading = false; + state.stats = action.payload; + }) + .addCase(fetchDashboardStats.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string; + }); + }, +}); + +export default dashboardSlice.reducer; \ No newline at end of file diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index d9f7113..cbe36e8 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -3,6 +3,8 @@ import styleReducer from './styleSlice'; import mainReducer from './mainSlice'; import authSlice from './authSlice'; import openAiSlice from './openAiSlice'; +import dashboardReducer from './dashboardSlice'; +import contextSlice from './contextSlice'; import usersSlice from "./users/usersSlice"; import rolesSlice from "./roles/rolesSlice"; @@ -26,6 +28,7 @@ import tot_declarationsSlice from "./tot_declarations/tot_declarationsSlice"; import declaration_submissionsSlice from "./declaration_submissions/declaration_submissionsSlice"; import messagesSlice from "./messages/messagesSlice"; import audit_eventsSlice from "./audit_events/audit_eventsSlice"; +import transactionsSlice from "./transactions/transactionsSlice"; export const store = configureStore({ reducer: { @@ -33,6 +36,8 @@ export const store = configureStore({ main: mainReducer, auth: authSlice, openAi: openAiSlice, + dashboard: dashboardReducer, + context: contextSlice, users: usersSlice, roles: rolesSlice, @@ -56,10 +61,11 @@ tot_declarations: tot_declarationsSlice, declaration_submissions: declaration_submissionsSlice, messages: messagesSlice, audit_events: audit_eventsSlice, +transactions: transactionsSlice, }, }) // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} -export type AppDispatch = typeof store.dispatch +export type AppDispatch = typeof store.dispatch \ No newline at end of file diff --git a/frontend/src/stores/transactions/transactionsSlice.ts b/frontend/src/stores/transactions/transactionsSlice.ts new file mode 100644 index 0000000..645d4c3 --- /dev/null +++ b/frontend/src/stores/transactions/transactionsSlice.ts @@ -0,0 +1,65 @@ +import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit' +import axios from 'axios' +import {rejectNotify, resetNotify} from "../../helpers/notifyStateHandler"; + +interface MainState { + transactions: any[] + loading: boolean + count: number + refetch: boolean; + notify: { + showNotification: boolean + textNotification: string + typeNotification: string + } +} + +const initialState: MainState = { + transactions: [], + loading: false, + count: 0, + refetch: false, + notify: { + showNotification: false, + textNotification: '', + typeNotification: 'warn', + }, +} + +export const fetchTransactions = createAsyncThunk('transactions/fetch', async (data: any) => { + const { query } = data + const result = await axios.get( + `transactions${query || ''}` + ) + return {rows: result.data.rows, count: result.data.count}; +}) + +export const transactionsSlice = createSlice({ + name: 'transactions', + initialState, + reducers: { + setRefetch: (state, action: PayloadAction) => { + state.refetch = action.payload; + }, + }, + extraReducers: (builder) => { + builder.addCase(fetchTransactions.pending, (state) => { + state.loading = true + resetNotify(state); + }) + builder.addCase(fetchTransactions.rejected, (state, action) => { + state.loading = false + rejectNotify(state, action); + }) + + builder.addCase(fetchTransactions.fulfilled, (state, action) => { + state.transactions = action.payload.rows; + state.count = action.payload.count; + state.loading = false + }) + }, +}) + +export const { setRefetch } = transactionsSlice.actions + +export default transactionsSlice.reducer diff --git a/frontend/src/styles.ts b/frontend/src/styles.ts index a969b60..9e638f4 100644 --- a/frontend/src/styles.ts +++ b/frontend/src/styles.ts @@ -31,23 +31,23 @@ export const white: StyleObject = { asideMenuItem: 'text-gray-700 hover:bg-gray-100/70 dark:text-dark-500 dark:hover:text-white dark:hover:bg-dark-800', asideMenuItemActive: 'font-bold text-black dark:text-white', asideMenuDropdown: 'bg-gray-100/75', - navBarItemLabel: 'text-blue-600', - navBarItemLabelHover: 'hover:text-black', - navBarItemLabelActiveColor: 'text-black', + navBarItemLabel: 'text-slate-600 font-bold', + navBarItemLabelHover: 'hover:text-indigo-600', + navBarItemLabelActiveColor: 'text-indigo-600', overlay: 'from-white via-gray-100 to-white', activeLinkColor: 'bg-gray-100/70', - bgLayoutColor: 'bg-gray-50', - iconsColor: 'text-blue-500', + bgLayoutColor: 'bg-slate-50', + iconsColor: 'text-indigo-500', cardsColor: 'bg-white', - focusRingColor: 'focus:ring focus:ring-blue-600 focus:border-blue-600 focus:outline-none border-gray-300 dark:focus:ring-blue-600 dark:focus:border-blue-600', - corners: 'rounded', - cardsStyle: 'bg-white border border-pavitra-400', - linkColor: 'text-blue-600', - websiteHeder: 'border-b border-gray-200', - borders: 'border-gray-200', - shadow: '', + focusRingColor: 'focus:ring focus:ring-indigo-600 focus:border-indigo-600 focus:outline-none border-gray-300 dark:focus:ring-indigo-600 dark:focus:border-indigo-600', + corners: 'rounded-[1.5rem]', + cardsStyle: 'bg-white border border-gray-100 shadow-sm', + linkColor: 'text-indigo-600', + websiteHeder: 'border-b border-gray-100', + borders: 'border-gray-100', + shadow: 'shadow-sm', websiteSectionStyle: '', - textSecondary: 'text-gray-500', + textSecondary: 'text-slate-500', } @@ -104,4 +104,4 @@ export const basic: StyleObject = { shadow: '', websiteSectionStyle: '', textSecondary: '', -} +} \ No newline at end of file