This commit is contained in:
Flatlogic Bot 2026-02-28 12:24:45 +00:00
parent ef45f741d1
commit 16f5d3a7e4
31 changed files with 1881 additions and 803 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<HTMLDivElement>(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 (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center space-x-3 px-4 py-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-sm hover:border-indigo-400 transition-colors focus:outline-none"
>
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${activeContext?.type === 'merchant' ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-600'}`}>
<BaseIcon path={activeContext?.type === 'merchant' ? mdiStore : mdiOfficeBuilding} size={20} />
</div>
<div className="flex flex-col items-start min-w-[150px]">
<span className="text-sm font-bold text-slate-800 dark:text-white truncate max-w-[200px]">
{activeContext?.name || 'Select Entity'}
</span>
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-semibold">
{activeContext?.type === 'merchant' ? 'Merchant' : 'Accounting Firm'}
</span>
</div>
<BaseIcon path={isOpen ? mdiChevronUp : mdiChevronDown} size={20} className="text-slate-400" />
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-2 w-[320px] bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-2xl z-50 overflow-hidden animate-fade-in-up">
<div className="max-h-[400px] overflow-y-auto aside-scrollbars">
{availableMerchants.length > 0 && (
<div className="py-2">
<div className="px-4 py-2 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
Merchants
</div>
{availableMerchants.map((merchant) => (
<button
key={merchant.id}
onClick={() => handleSelect(merchant)}
className={`w-full flex items-center space-x-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors ${activeContext?.id === merchant.id ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`}
>
<div className="w-8 h-8 rounded-lg bg-blue-100 dark:bg-blue-900/40 text-blue-600 flex items-center justify-center">
<BaseIcon path={mdiStore} size={18} />
</div>
<div className="flex flex-col items-start overflow-hidden">
<span className={`text-sm font-bold truncate w-full ${activeContext?.id === merchant.id ? 'text-indigo-600 dark:text-indigo-400' : 'text-slate-700 dark:text-slate-200'}`}>
{merchant.name}
</span>
{merchant.registration && (
<span className="text-[10px] text-slate-400 font-medium">
TIN: {merchant.registration}
</span>
)}
</div>
</button>
))}
</div>
)}
{availableFirms.length > 0 && (
<div className="py-2 border-t border-slate-100 dark:border-slate-800">
<div className="px-4 py-2 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
Firms
</div>
{availableFirms.map((firm) => (
<button
key={firm.id}
onClick={() => handleSelect(firm)}
className={`w-full flex items-center space-x-3 px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors ${activeContext?.id === firm.id ? 'bg-indigo-50 dark:bg-indigo-900/20' : ''}`}
>
<div className="w-8 h-8 rounded-lg bg-amber-100 dark:bg-amber-900/40 text-amber-600 flex items-center justify-center">
<BaseIcon path={mdiOfficeBuilding} size={18} />
</div>
<div className="flex flex-col items-start overflow-hidden">
<span className={`text-sm font-bold truncate w-full ${activeContext?.id === firm.id ? 'text-indigo-600 dark:text-indigo-400' : 'text-slate-700 dark:text-slate-200'}`}>
{firm.name}
</span>
{firm.registration && (
<span className="text-[10px] text-slate-400 font-medium">
Reg: {firm.registration}
</span>
)}
</div>
</button>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white dark:bg-dark-900 border border-gray-100 dark:border-dark-700 rounded-[1.5rem] p-6 shadow-sm animate-pulse">
<div className="flex justify-between items-start mb-4">
<div className="w-12 h-12 bg-gray-100 dark:bg-dark-800 rounded-xl"></div>
<div className="w-12 h-4 bg-gray-100 dark:bg-dark-800 rounded-full"></div>
</div>
<div className="h-4 bg-gray-100 dark:bg-dark-800 rounded w-1/2 mb-2"></div>
<div className="h-8 bg-gray-100 dark:bg-dark-800 rounded w-3/4"></div>
</div>
))}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{widgets.map((widget, index) => (
<div key={index} className="bg-white dark:bg-dark-900 border border-gray-100 dark:border-dark-700 rounded-[1.5rem] p-6 shadow-sm hover:shadow-md transition-shadow relative overflow-hidden">
<div className="flex justify-between items-start mb-4">
<div className={`w-12 h-12 ${widget.iconBg} text-white rounded-xl flex items-center justify-center shadow-lg`}>
<BaseIcon path={widget.icon} size={24} />
</div>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${widget.bgColor} ${widget.labelColor}`}>
{widget.badge}
</span>
</div>
<div>
<p className="text-gray-500 dark:text-gray-400 text-sm font-medium mb-1">{widget.label}</p>
<h3 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">{widget.value}</h3>
<div className="flex items-center text-xs font-semibold">
{widget.trendType === 'up' && (
<BaseIcon path={mdiTrendingUp} size={14} className="text-emerald-500 mr-1" />
)}
{widget.trendType === 'down' && (
<BaseIcon path={mdiTrendingDown} size={14} className="text-rose-500 mr-1" />
)}
<span className={
widget.trendType === 'up' ? 'text-emerald-500' :
widget.trendType === 'down' ? 'text-rose-500' :
'text-gray-400'
}>
{widget.trend}
</span>
{widget.trendType !== 'neutral' && (
<span className="text-gray-400 ml-1 font-normal text-[10px]">vs last period</span>
)}
</div>
</div>
</div>
))}
</div>
);
};
export default DashboardWidgets;

View File

@ -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 (
<div className="bg-gradient-to-br from-indigo-600 to-indigo-700 dark:from-indigo-800 dark:to-indigo-900 rounded-[1.5rem] p-8 shadow-xl overflow-hidden flex flex-col h-full relative">
<div className="absolute top-0 right-0 p-8 opacity-10 pointer-events-none">
<BaseIcon path={mdiChartBar} size={128} />
</div>
<div className="flex items-center justify-between mb-10 relative z-10">
<h2 className="text-xl font-bold text-white flex items-center">
<div className="w-10 h-10 bg-white/20 backdrop-blur-md rounded-xl flex items-center justify-center mr-3 shadow-lg">
<BaseIcon path={mdiChartBar} size={24} className="text-white" />
</div>
P&L Summary
</h2>
</div>
<div className="flex-grow space-y-6 relative z-10">
<div className="flex items-center justify-between group cursor-default">
<span className="text-indigo-100 text-sm font-medium tracking-wide group-hover:text-white transition-colors">Revenue</span>
<span className="text-white text-lg font-bold tracking-tight">ETB {Number(revenue).toLocaleString()}</span>
</div>
<div className="w-full bg-white/10 backdrop-blur-sm rounded-full h-1.5 mb-6 overflow-hidden">
<div className="bg-emerald-400 h-full rounded-full transition-all duration-1000" style={{ width: `${revenuePercent}%` }}></div>
</div>
<div className="flex items-center justify-between group cursor-default">
<span className="text-indigo-100 text-sm font-medium tracking-wide group-hover:text-white transition-colors">Expenses</span>
<span className="text-white text-lg font-bold tracking-tight">ETB {Number(expenses).toLocaleString()}</span>
</div>
<div className="w-full bg-white/10 backdrop-blur-sm rounded-full h-1.5 mb-6 overflow-hidden">
<div className="bg-rose-400 h-full rounded-full transition-all duration-1000" style={{ width: `${expensesPercent}%` }}></div>
</div>
<div className="pt-4 border-t border-white/10 flex items-center justify-between group cursor-default">
<span className="text-indigo-100 text-base font-bold tracking-wide group-hover:text-white transition-colors">Net Profit</span>
<div className="flex flex-col items-end">
<span className="text-white text-2xl font-black tracking-tight">ETB {Number(profit).toLocaleString()}</span>
</div>
</div>
</div>
<div className="mt-10 relative z-10">
<Link href="/financial-reports">
<button className="w-full py-4 px-6 bg-white/10 backdrop-blur-md hover:bg-white/20 border border-white/20 text-white rounded-2xl text-sm font-bold flex items-center justify-center transition-all active:scale-[0.98] shadow-lg group">
View Full Report
<BaseIcon path={mdiChevronRight} size={20} className="ml-1 group-hover:translate-x-1 transition-transform" />
</button>
</Link>
</div>
</div>
);
};
export default ProfitLossSummaryChart;

View File

@ -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 (
<div className="mb-10">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold text-slate-800 dark:text-white">Quick Actions</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{actions.map((action, index) => (
<Link key={index} href={action.href}>
<div className={`cursor-pointer group relative flex flex-col items-center justify-center p-8 rounded-[1.5rem] border ${action.borderColor} ${action.bgColor} dark:bg-dark-900 shadow-sm hover:shadow-lg transition-all active:scale-[0.98]`}>
<div className={`w-14 h-14 ${action.iconBg} text-white rounded-2xl flex items-center justify-center mb-4 shadow-lg group-hover:scale-110 transition-transform`}>
<BaseIcon path={action.icon} size={28} />
</div>
<div className="text-center">
<h3 className="text-base font-bold text-slate-900 dark:text-white mb-1 group-hover:text-indigo-600 transition-colors">{action.title}</h3>
<p className="text-slate-500 dark:text-gray-400 text-xs">{action.description}</p>
</div>
<div className="absolute top-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
<BaseIcon path={mdiPlus} size={20} className="text-slate-400" />
</div>
</div>
</Link>
))}
</div>
</div>
);
};
export default QuickActions;

View File

@ -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 (
<div className="bg-white dark:bg-dark-900 border border-gray-100 dark:border-dark-700 rounded-[1.5rem] p-6 shadow-sm overflow-hidden flex flex-col h-full">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-bold text-slate-800 dark:text-white">Recent Transactions</h2>
<Link href="/transactions" className="text-sm font-bold text-indigo-600 hover:text-indigo-700 flex items-center transition-colors">
View All
<BaseIcon path={mdiChevronRight} size={20} className="ml-0.5" />
</Link>
</div>
<div className="overflow-x-auto flex-grow">
<table className="w-full text-left">
<thead>
<tr className="border-b border-gray-50 dark:border-dark-700">
<th className="pb-4 text-xs font-bold text-slate-400 uppercase tracking-wider">Date</th>
<th className="pb-4 text-xs font-bold text-slate-400 uppercase tracking-wider">Description</th>
<th className="pb-4 text-xs font-bold text-slate-400 uppercase tracking-wider">Type</th>
<th className="pb-4 text-xs font-bold text-slate-400 uppercase tracking-wider text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-dark-700">
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="animate-pulse">
<td className="py-4 h-4 bg-gray-50 dark:bg-dark-800 rounded w-16 my-2"></td>
<td className="py-4 h-4 bg-gray-50 dark:bg-dark-800 rounded w-32 my-2"></td>
<td className="py-4 h-4 bg-gray-50 dark:bg-dark-800 rounded w-20 my-2"></td>
<td className="py-4 h-4 bg-gray-50 dark:bg-dark-800 rounded w-20 my-2"></td>
</tr>
))
) : transactions.length > 0 ? (
transactions.map((transaction, index) => (
<tr key={index} className="group hover:bg-gray-50 dark:hover:bg-dark-800 transition-colors">
<td className="py-4 text-sm font-medium text-slate-500 dark:text-gray-400 whitespace-nowrap">
{new Date(transaction.date).toLocaleDateString()}
</td>
<td className="py-4 text-sm font-bold text-slate-800 dark:text-white whitespace-nowrap">
{transaction.description}
</td>
<td className="py-4 whitespace-nowrap">
<div className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-bold shadow-sm border border-opacity-20 ${
transaction.isIncome
? 'bg-emerald-50 text-emerald-600 border-emerald-500'
: 'bg-rose-50 text-rose-600 border-rose-500'
}`}>
<BaseIcon path={transaction.isIncome ? mdiArrowTopRight : mdiArrowBottomLeft} size={14} className="mr-1" />
{transaction.type}
</div>
</td>
<td className={`py-4 text-sm font-bold text-right whitespace-nowrap ${
transaction.isIncome ? 'text-emerald-600' : 'text-rose-600'
}`}>
{transaction.isIncome ? '+' : '-'}{Number(transaction.amount).toLocaleString()}
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="py-8 text-center text-gray-400 italic">
No recent transactions
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export default RecentTransactions;

View File

@ -0,0 +1,39 @@
import React from 'react';
import { mdiCalendarCheckOutline, mdiCheckDecagram } from '@mdi/js';
import BaseIcon from '../../BaseIcon';
const UpcomingDueDates = () => {
return (
<div className="bg-white dark:bg-dark-900 border border-gray-100 dark:border-dark-700 rounded-[1.5rem] p-6 shadow-sm overflow-hidden flex flex-col h-full">
<div className="flex items-center justify-between mb-8">
<h2 className="text-xl font-bold text-slate-800 dark:text-white flex items-center">
<BaseIcon path={mdiCalendarCheckOutline} size={22} className="mr-2 text-indigo-500" />
Upcoming Due Dates
</h2>
</div>
<div className="flex flex-col items-center justify-center flex-grow py-12 px-6 text-center">
<div className="w-16 h-16 bg-emerald-50 text-emerald-500 rounded-full flex items-center justify-center mb-6 shadow-sm border border-emerald-100">
<BaseIcon path={mdiCheckDecagram} size={32} />
</div>
<h3 className="text-lg font-bold text-slate-900 dark:text-white mb-2 tracking-tight">All caught up!</h3>
<p className="text-slate-500 dark:text-gray-400 text-sm leading-relaxed max-w-[200px] mx-auto">
No pending invoices or tax filings require your attention today.
</p>
</div>
<div className="mt-auto pt-6 border-t border-gray-50 dark:border-dark-700">
<div className="flex items-center justify-between text-xs text-slate-400 font-medium">
<span>Last sync: Today, 10:59 AM</span>
<span className="flex items-center text-emerald-500">
<span className="w-2 h-2 bg-emerald-500 rounded-full mr-1.5 animate-pulse"></span>
Syncing Live
</span>
</div>
</div>
</div>
);
};
export default UpcomingDueDates;

View File

@ -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 (
<nav
className={`${className} top-0 inset-x-0 fixed ${bgColor} h-14 z-30 transition-position w-screen lg:w-auto dark:bg-dark-800`}
className={`${className} top-0 inset-x-0 fixed ${bgColor} h-20 z-30 transition-all w-screen lg:w-auto dark:bg-dark-800 ${isScrolled ? 'shadow-md shadow-slate-200/50 dark:shadow-none' : ''}`}
>
<div className={`flex lg:items-stretch ${containerMaxW} ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
<div className="flex flex-1 items-stretch h-14">{children}</div>
<div className="flex-none items-stretch flex h-14 lg:hidden">
<div className={`flex lg:items-stretch ${containerMaxW} h-20 ${isScrolled && `border-b border-pavitra-400 dark:border-dark-700`}`}>
{/* Logo Section - Aligned with the mockup */}
<div className="flex items-center px-4 lg:px-0 ml-4 lg:ml-0">
<div className="flex items-center space-x-3 mr-8">
<div className="w-10 h-10 bg-indigo-600 rounded-xl flex items-center justify-center shadow-lg shadow-indigo-200 dark:shadow-none">
<span className="text-white font-bold text-xl">SH</span>
</div>
<div className="flex flex-col">
<b className="text-xl font-black text-slate-800 dark:text-white tracking-tight leading-tight">SmartHisab</b>
<span className="text-[10px] font-bold text-indigo-500 tracking-[0.2em] leading-none uppercase">MODERN ACCOUNTING</span>
</div>
</div>
<div className="hidden lg:flex items-center ml-4">
<ContextSwitcher />
</div>
</div>
<div className="flex flex-1 items-stretch h-20 ml-8">{children}</div>
<div className="flex-none items-stretch flex h-20 lg:hidden">
<NavBarItemPlain onClick={handleMenuNavBarToggleClick}>
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size="24" />
<BaseIcon path={isMenuNavBarActive ? mdiClose : mdiDotsVertical} size={24} />
</NavBarItemPlain>
</div>
<div
className={`${
isMenuNavBarActive ? 'block' : 'hidden'
} flex items-center max-h-screen-menu overflow-y-auto lg:overflow-visible absolute w-screen top-14 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
} flex items-center max-h-screen-menu lg:overflow-visible absolute w-screen top-20 left-0 ${bgColor} shadow-lg lg:w-auto lg:flex lg:static lg:shadow-none dark:bg-dark-800`}
>
<NavBarMenuList menu={menu} />
</div>
</div>
</nav>
)
}
}

View File

@ -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 (
<div className="flex items-center space-x-2 lg:space-x-4 ml-2 lg:ml-6 border-l border-gray-100 dark:border-dark-700 pl-4 lg:pl-8 py-2">
{/* Merchant Switcher Placeholder */}
<div className="hidden md:flex items-center bg-white dark:bg-dark-900 border border-gray-200 dark:border-dark-700 rounded-xl px-3 py-1.5 shadow-sm cursor-pointer hover:border-indigo-300 transition-colors group">
<div className="w-8 h-8 bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 rounded-lg flex items-center justify-center mr-2.5">
<BaseIcon path={mdiStoreOutline} size={18} />
</div>
<div className="flex flex-col mr-6">
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider leading-none mb-0.5">Merchant</span>
<span className="text-xs font-bold text-slate-700 dark:text-white leading-none truncate max-w-[150px]">{merchantName}</span>
</div>
<BaseIcon path={mdiChevronDown} size={18} className="text-slate-400 group-hover:text-indigo-500 transition-colors" />
</div>
{/* Notifications */}
<div className="relative cursor-pointer group p-2 hover:bg-gray-50 dark:hover:bg-dark-900 rounded-xl transition-colors">
<BaseIcon path={mdiBellOutline} size={24} className="text-slate-500 group-hover:text-indigo-600 transition-colors" />
<span className="absolute top-2 right-2 w-2 h-2 bg-rose-500 rounded-full border-2 border-white dark:border-dark-800"></span>
</div>
{/* User Profile */}
<div className="flex items-center cursor-pointer group pl-2" onClick={handleMenuClick} ref={excludedRef}>
<div className="flex flex-col items-end mr-3 hidden sm:flex">
<span className="text-xs font-black text-slate-800 dark:text-white leading-none mb-1">{userName}</span>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest leading-none">{roleName}</span>
</div>
<div className="relative">
<UserAvatarCurrentUser className="w-10 h-10 shadow-md border-2 border-indigo-100 dark:border-dark-700 rounded-full transition-transform group-hover:scale-105" />
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-indigo-600 rounded-full border-2 border-white dark:border-dark-800 flex items-center justify-center">
<span className="text-[8px] text-white font-black uppercase">GU</span>
</div>
</div>
{item.menu && (
<div
className={`${
!isDropdownActive ? 'lg:hidden' : ''
} text-sm border-b border-gray-100 lg:border lg:bg-white lg:absolute lg:top-full lg:right-0 lg:min-w-[200px] lg:z-50 lg:rounded-2xl lg:shadow-xl lg:dark:bg-dark-900 dark:border-dark-700 mt-2`}
>
<ClickOutside onClickOutside={() => setIsDropdownActive(false)} excludedElements={[excludedRef]}>
<NavBarMenuList menu={item.menu} />
</ClickOutside>
</div>
)}
</div>
</div>
);
}
const NavBarItemComponentContents = (
<>
<div
@ -85,15 +139,14 @@ export default function NavBarItem({ item }: Props) {
}`}
onClick={handleMenuClick}
>
{item.icon && <BaseIcon path={item.icon} size={22} className="transition-colors" />}
{item.icon && <BaseIcon path={item.icon} size={22} className={`transition-colors ${isActive ? 'text-indigo-600' : ''}`} />}
<span
className={`px-2 transition-colors w-40 grow ${
className={`px-2 transition-colors grow ${
item.isDesktopNoLabel && item.icon ? 'lg:hidden' : ''
}`}
>
{itemLabel}
</span>
{item.isCurrentUser && <UserAvatarCurrentUser className="w-6 h-6 mr-3 inline-flex" />}
{item.menu && (
<BaseIcon
path={isDropdownActive ? mdiChevronUp : mdiChevronDown}
@ -101,7 +154,7 @@ export default function NavBarItem({ item }: Props) {
/>
)}
</div>
{item.menu && (
{item.menu && !item.isCurrentUser && (
<div
className={`${
!isDropdownActive ? 'lg:hidden' : ''

View File

@ -33,6 +33,11 @@ export default function LayoutAuthenticated({
const router = useRouter()
const { token, currentUser } = useAppSelector((state) => 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 (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
<div
className={`${layoutAsidePadding} ${
isAsideMobileExpanded ? 'ml-64 lg:ml-0' : ''
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
} pt-20 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
>
<NavBar
menu={menuNavBar}
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-64 lg:ml-0' : ''}`}
>
<NavBarItemPlain
display="flex lg:hidden"
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
>
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
</NavBarItemPlain>
<NavBarItemPlain
display="hidden lg:flex xl:hidden"
onClick={() => setIsAsideLgActive(true)}
>
<BaseIcon path={mdiMenu} size="24" />
</NavBarItemPlain>
{showSidebar && (
<>
<NavBarItemPlain
display="flex lg:hidden"
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
>
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
</NavBarItemPlain>
<NavBarItemPlain
display="hidden lg:flex xl:hidden"
onClick={() => setIsAsideLgActive(true)}
>
<BaseIcon path={mdiMenu} size="24" />
</NavBarItemPlain>
</>
)}
<NavBarItemPlain useMargin>
<Search />
{/* If not merchant, show search. For merchant, we might have specific nav items already in menuNavBar */}
{!isMerchant && <Search />}
</NavBarItemPlain>
</NavBar>
<AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded}
isAsideLgActive={isAsideLgActive}
menu={menuAside}
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
{children}
<FooterBar>Hand-crafted & Made with </FooterBar>
{showSidebar && (
<AsideMenu
isAsideMobileExpanded={isAsideMobileExpanded}
isAsideLgActive={isAsideLgActive}
menu={menuAside}
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
)}
<div className={!isMerchant ? "max-w-7xl mx-auto" : ""}>
{children}
</div>
<FooterBar>SmartHisab &copy; 2026 - Hand-crafted with </FooterBar>
</div>
</div>
)

View File

@ -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
export default menuAside

View File

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

View File

@ -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<P = Record<string, unknown>, IP = P> = NextPage<P, IP> & {
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);

View File

@ -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<any>(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 (
<>
<Head>
<title>{getPageTitle('Overview')}</title>
<title>{getPageTitle('Dashboard')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={icon.mdiChartTimelineVariant}
title={isMerchant ? `Dashboard` : 'Overview'}
main
>
{''}
</SectionTitleLineWithButton>
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
<div>
<h1 className="text-3xl font-black text-slate-900 dark:text-white mb-2 tracking-tight">
Welcome back, {merchantName}
</h1>
<p className="text-slate-500 dark:text-gray-400 text-sm font-medium">
Here&apos;s what&apos;s happening with {activeContext?.type === 'merchant' ? 'this merchant' : 'your firm'} today
</p>
</div>
{isMerchant && (
<div className="mb-12">
<div className="flex flex-col items-center justify-center mb-10">
<p className="text-gray-500 font-medium mb-1 text-xs"> </p>
<h2 className="text-2xl font-extrabold text-slate-900 mb-3 text-center">
Choose the type of transaction you want to record
</h2>
<div className="bg-emerald-100 text-emerald-700 px-3 py-0.5 rounded-full text-xs font-bold shadow-sm">
{organizationName}
</div>
<div className="flex items-center space-x-3">
<div className="relative group">
<select className="appearance-none bg-white dark:bg-dark-900 border border-gray-100 dark:border-dark-700 rounded-xl px-4 py-2.5 pr-10 text-sm font-bold text-slate-700 dark:text-white shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all cursor-pointer">
<option>Last 30 days</option>
<option>Last 7 days</option>
<option>Last 3 months</option>
<option>This Year</option>
</select>
<BaseIcon path={icon.mdiChevronDown} size={20} className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{transactionTypes.map((type, index) => (
<div
key={index}
className={`relative flex flex-col p-6 rounded-[2rem] border ${type.borderColor} ${type.bgColor} shadow-sm hover:shadow-md transition-shadow group`}
>
<div className={`absolute top-5 right-5 w-2.5 h-2.5 rounded-full ${type.dotColor}`}></div>
<div className={`w-12 h-12 ${type.iconBg} ${type.iconColor} rounded-xl flex items-center justify-center mb-4`}>
<BaseIcon path={type.icon} size={24} />
</div>
<BaseButton
label="New Sale"
icon={icon.mdiPlus}
color="info"
href="/sales_invoices/sales_invoices-new"
className="shadow-lg shadow-indigo-200 dark:shadow-none font-bold py-2.5 px-5 rounded-xl transition-all active:scale-95"
/>
</div>
</div>
<div className="mb-6 flex-grow">
<h3 className="text-sm font-bold text-slate-900 mb-0.5">{type.titleAm}</h3>
<h4 className="text-base font-bold text-slate-800 mb-2">{type.title}</h4>
<p className="text-slate-500 text-xs leading-relaxed">
{type.description}
</p>
</div>
<DashboardWidgets />
<Link href={type.href}>
<button
className={`w-full py-3 px-4 rounded-xl text-white text-sm font-bold flex items-center justify-center transition-all active:scale-95 ${type.buttonBg} hover:opacity-90 shadow-sm`}
>
Open Form
<BaseIcon path={icon.mdiChevronRight} size={18} className="ml-1" />
</button>
</Link>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
<div className="lg:col-span-2">
<QuickActions />
<RecentTransactions />
</div>
<div className="space-y-8">
<UpcomingDueDates />
<ProfitLossSummaryChart />
</div>
</div>
{!isMerchant && !activeContext && (
<div className="mt-12 pt-8 border-t border-gray-100 dark:border-dark-700">
<div className="bg-white dark:bg-dark-900 rounded-[2rem] p-8 border border-dashed border-gray-200 dark:border-dark-600 text-center">
<BaseIcon path={icon.mdiShieldCheckOutline} size={48} className="text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-slate-900 dark:text-white mb-2">Administrative Overview</h3>
<p className="text-slate-500 dark:text-gray-400 text-sm max-w-md mx-auto">
You are viewing the dashboard as an administrator. Switch to a specific merchant to see their business metrics.
</p>
</div>
</div>
)}
{hasPermission(currentUser, 'CREATE_ROLES') && <WidgetCreator
currentUser={currentUser}
isFetchingQuery={isFetchingQuery}
setWidgetsRole={setWidgetsRole}
widgetsRole={widgetsRole}
/>}
<div className='grid grid-cols-1 gap-6 lg:grid-cols-4 mb-6 grid-flow-dense'>
{(isFetchingQuery || loading) && (
<div className={` ${corners !== 'rounded-full'? corners : 'rounded-3xl'} dark:bg-dark-900 text-lg leading-tight text-gray-500 flex items-center ${cardsStyle} dark:border-dark-700 p-6`}>
<BaseIcon
className={`${iconsColor} animate-spin mr-5`}
w='w-16'
h='h-16'
size={48}
path={icon.mdiLoading}
/>{' '}
Loading widgets...
</div>
)}
{ rolesWidgets &&
rolesWidgets.map((widget) => (
<SmartWidget
key={widget.id}
userId={currentUser?.id}
widget={widget}
roleId={widgetsRole?.role?.value || ''}
admin={hasPermission(currentUser, 'CREATE_ROLES')}
/>
))}
</div>
<div id="dashboard" className='grid grid-cols-1 gap-6 lg:grid-cols-3 mb-6'>
{hasPermission(currentUser, 'READ_MERCHANTS') && (
<Link href={'/merchants/merchants-list'}>
<CardBox className={cardsStyle}>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">Merchants</div>
<div className="text-3xl leading-tight font-semibold">{merchants}</div>
</div>
<BaseIcon className={iconsColor} w="w-16" h="h-16" size={48} path={'mdiStore' in icon ? icon['mdiStore' as keyof typeof icon] : icon.mdiTable} />
</div>
</CardBox>
</Link>
)}
{hasPermission(currentUser, 'READ_SALES_INVOICES') && (
<Link href={'/sales_invoices/sales_invoices-list'}>
<CardBox className={cardsStyle}>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">Sales Invoices</div>
<div className="text-3xl leading-tight font-semibold">{sales_invoices}</div>
</div>
<BaseIcon className={iconsColor} w="w-16" h="h-16" size={48} path={icon.mdiFileDocumentOutline} />
</div>
</CardBox>
</Link>
)}
{hasPermission(currentUser, 'READ_PURCHASE_INVOICES') && (
<Link href={'/purchase_invoices/purchase_invoices-list'}>
<CardBox className={cardsStyle}>
<div className="flex justify-between align-center">
<div>
<div className="text-lg leading-tight text-gray-500 dark:text-gray-400">Purchase Invoices</div>
<div className="text-3xl leading-tight font-semibold">{purchase_invoices}</div>
</div>
<BaseIcon className={iconsColor} w="w-16" h="h-16" size={48} path={icon.mdiFileDocumentMultipleOutline} />
</div>
</CardBox>
</Link>
)}
</div>
{/* Keeping the OCR modal logic for future use or hidden triggers */}
<CardBoxModal
title={`Record ${ocrType === 'sale' ? 'Sale' : 'Purchase'}`}
isActive={isOcrModalActive}
onConfirm={ocrResult ? handleSaveTransaction : handleOcrSimulate}
onCancel={() => { setIsOcrModalActive(false); setOcrResult(null); }}
buttonColor="info"
buttonLabel={ocrResult ? "Confirm & Save" : (isProcessing ? "Processing..." : "Scan Receipt")}
>
{!ocrResult ? (
<div className="space-y-6 py-4">
<FormField label="Snap or Upload Receipt" help="Our AI will extract the amount, date, and invoice number.">
<FormFilePicker label="Upload" color="info" accept="image/*" />
</FormField>
{isProcessing && (
<div className="flex items-center justify-center space-x-3 text-blue-600 animate-pulse">
<BaseIcon path={icon.mdiLoading} className="animate-spin" />
<span className="font-bold">Analyzing with SmartHisab AI...</span>
</div>
)}
</div>
) : (
<div className="space-y-4 py-2">
<div className="bg-emerald-50 p-3 rounded-lg border border-emerald-100 flex items-center space-x-3 text-emerald-800 text-sm">
<BaseIcon path={icon.mdiCheckDecagram} className="text-emerald-500" />
<span className="font-bold">Extraction Successful! Please verify details.</span>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField label="Invoice #">
<input value={ocrResult.invoice_number} readOnly />
</FormField>
<FormField label="Date">
<input value={ocrResult.date} readOnly />
</FormField>
<FormField label="Total (ETB)">
<input value={ocrResult.total_amount} readOnly />
</FormField>
<FormField label="Tax (ETB)">
<input value={ocrResult.tax_amount} readOnly />
</FormField>
</div>
<FormField label="Vendor / Client Name">
<input value={ocrResult.vendor_name} readOnly />
</FormField>
</div>
)}
</CardBoxModal>
</SectionMain>
</>
)

View File

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

View File

@ -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 (
<>
<Head>
<title>{getPageTitle('Transactions')}</title>
</Head>
<SectionMain>
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 gap-4">
<div>
<p className="text-gray-500 font-medium mb-1 text-sm"></p>
<h1 className="text-3xl font-extrabold text-slate-900 tracking-tight mb-1">Transactions</h1>
<p className="text-slate-500 font-medium">Manage and track all your business financial activities</p>
</div>
<div className="flex items-center gap-3">
<BaseButton
label="New Transaction"
icon={mdiPlus}
color="info"
href="/transaction-entry"
className="rounded-xl shadow-md hover:shadow-lg transition-all"
/>
</div>
</div>
<CardBox className="mb-6 rounded-[1.5rem] shadow-sm border-gray-100 overflow-hidden" hasTable>
<div className="p-6 border-b border-gray-50 flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-4">
<div className="relative group">
<select className="pl-10 pr-10 py-2.5 bg-gray-50 hover:bg-gray-100 border-none rounded-xl text-sm font-bold text-slate-600 focus:ring-2 focus:ring-indigo-500 appearance-none cursor-pointer min-w-[140px] transition-colors">
<option>All Types</option>
<option>Income</option>
<option>Expense</option>
</select>
<BaseIcon path={mdiFilterVariant} size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
<div className="relative group">
<select className="pl-10 pr-10 py-2.5 bg-gray-50 hover:bg-gray-100 border-none rounded-xl text-sm font-bold text-slate-600 focus:ring-2 focus:ring-indigo-500 appearance-none cursor-pointer min-w-[160px] transition-colors">
<option>Last 30 days</option>
<option>This Month</option>
<option>Last Quarter</option>
<option>This Year</option>
</select>
<BaseIcon path={mdiCalendar} size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<div className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path></svg>
</div>
</div>
</div>
<div className="text-xs font-bold text-slate-400 uppercase tracking-widest bg-gray-50 px-3 py-1.5 rounded-lg">
Showing {transactions.length} of {count} entries
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-50/50">
<th className="px-6 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest">Date</th>
<th className="px-6 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest">Description</th>
<th className="px-6 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest">Type</th>
<th className="px-6 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest">Status</th>
<th className="px-6 py-4 text-[11px] font-bold text-slate-400 uppercase tracking-widest text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-50">
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="animate-pulse">
<td colSpan={5} className="px-6 py-8">
<div className="flex gap-4 items-center">
<div className="w-12 h-4 bg-gray-100 rounded"></div>
<div className="flex-grow h-4 bg-gray-100 rounded"></div>
<div className="w-20 h-4 bg-gray-100 rounded"></div>
</div>
</td>
</tr>
))
) : transactions.length > 0 ? (
transactions.map((transaction) => (
<tr key={transaction.id} className="group hover:bg-indigo-50/30 transition-colors">
<td className="px-6 py-5 text-sm font-medium text-slate-500">
{new Date(transaction.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</td>
<td className="px-6 py-5">
<div className="text-sm font-bold text-slate-800 group-hover:text-indigo-600 transition-colors">{transaction.description}</div>
<div className="text-[10px] text-slate-400 uppercase font-bold tracking-tight mt-0.5">{transaction.entity?.replace('_', ' ')}</div>
</td>
<td className="px-6 py-5">
<div className={`inline-flex items-center px-3 py-1 rounded-full text-[10px] font-bold shadow-sm border border-opacity-10 ${
transaction.isIncome
? 'bg-emerald-50 text-emerald-600 border-emerald-500'
: 'bg-rose-50 text-rose-600 border-rose-500'
}`}>
<BaseIcon
path={transaction.isIncome ? mdiArrowTopRight : mdiArrowBottomLeft}
size={14}
className="mr-1.5"
/>
{transaction.type}
</div>
</td>
<td className="px-6 py-5">
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase ${
transaction.status === 'approved' || transaction.status === 'submitted'
? 'bg-blue-50 text-blue-600'
: 'bg-slate-100 text-slate-500'
}`}>
{transaction.status}
</span>
</td>
<td className={`px-6 py-5 text-sm font-bold text-right ${
transaction.isIncome ? 'text-emerald-600' : 'text-rose-600'
}`}>
{transaction.isIncome ? '+' : '-'}{Number(transaction.amount).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-16 text-center">
<div className="flex flex-col items-center">
<div className="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mb-4">
<BaseIcon path={mdiFilterVariant} size={32} className="text-slate-300" />
</div>
<p className="text-slate-400 font-bold uppercase tracking-widest text-xs">No transactions found</p>
<p className="text-slate-300 text-sm mt-1">Try adjusting your filters or add a new entry</p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="p-6 border-t border-gray-50 bg-gray-50/20 flex justify-center">
<Pagination
currentPage={currentPage}
numPages={Math.ceil(count / perPage) || 1}
setCurrentPage={onPageChange}
/>
</div>
</CardBox>
</SectionMain>
</>
);
};
TransactionsPage.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission="READ_TRANSACTIONS">{page}</LayoutAuthenticated>;
};
export default TransactionsPage;

View File

@ -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<any>(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 <LoadingSpinner />;
if (!data) return <div className="p-6 text-center text-red-500">Data not found</div>;
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 (
<>
<Head>
<title>{getPageTitle('ERCA VAT Declaration')}</title>
</Head>
<SectionMain>
<div className="flex justify-between items-center mb-6 no-print">
<BaseButton
label="Back"
icon={mdiArrowLeft}
onClick={() => router.back()}
/>
<BaseButton
label="Print Form"
color="info"
icon={mdiPrinter}
onClick={handlePrint}
/>
</div>
<CardBox className="print:shadow-none print:border-none">
<div className="p-8 font-sans text-sm text-black bg-white">
{/* Header Section */}
<div className="text-center mb-8">
<h1 className="text-2xl font-bold mb-2"> </h1>
<h1 className="text-2xl font-bold mb-2">FEDERAL TAX AUTHORITY</h1>
<h2 className="text-xl font-bold mb-4 underline"> (VAT DECLARATION)</h2>
</div>
{/* Merchant Info */}
<div className="grid grid-cols-2 gap-4 mb-6 border-b pb-4">
<div>
<p><strong> :</strong> {merchant?.legal_name || merchant?.business_name}</p>
<p><strong>Taxpayer Name:</strong> {merchant?.legal_name || merchant?.business_name}</p>
</div>
<div>
<p><strong> :</strong> {merchant?.tin_number}</p>
<p><strong>TIN Number:</strong> {merchant?.tin_number}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-8">
<div>
<p><strong> :</strong> {tax_period?.year_number} / {tax_period?.period_number}</p>
<p><strong>Tax Period:</strong> {tax_period?.year_number} / {tax_period?.period_number}</p>
</div>
</div>
{/* Section A - Computation of Output Tax */}
<div className="mb-8">
<h3 className="bg-gray-200 p-2 font-bold border border-black">. (SECTION A COMPUTATION OF OUTPUT TAX)</h3>
<table className="w-full border-collapse border border-black text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-black p-1 w-12 text-center">Line No</th>
<th className="border border-black p-2 text-left"> (Description)</th>
<th className="border border-black p-2 text-right w-32"> (Total Amount)</th>
<th className="border border-black p-2 text-right w-32"> (VAT Amount)</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-black p-1 text-center font-bold">5</td>
<td className="border border-black p-2"> / (Taxable Sales / Supplies)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line5.total)}</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line5.vat)}</td>
</tr>
<tr>
<td className="border border-black p-1 text-center font-bold">15</td>
<td className="border border-black p-2"> (Zero-rated Sales / Supplies)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line15.total)}</td>
<td className="border border-black p-2 bg-gray-100"></td>
</tr>
<tr>
<td className="border border-black p-1 text-center font-bold">20</td>
<td className="border border-black p-2"> (Tax-exempt Sales / Supplies)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line20.total)}</td>
<td className="border border-black p-2 bg-gray-100"></td>
</tr>
<tr>
<td className="border border-black p-1 text-center font-bold">25</td>
<td className="border border-black p-2"> (Services or Supplies subject to Reverse Taxation)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line25.total)}</td>
<td className="border border-black p-2 bg-gray-100"></td>
</tr>
<tr>
<td className="border border-black p-1 text-center font-bold">35</td>
<td className="border border-black p-2"> () (Tax adjustment with debit note for suppliers)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line35.total)}</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line35.vat)}</td>
</tr>
<tr>
<td className="border border-black p-1 text-center font-bold">45</td>
<td className="border border-black p-2"> () (Tax adjustment with credit note for suppliers)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line45.total)}</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line45.vat)}</td>
</tr>
<tr className="font-bold bg-gray-50">
<td className="border border-black p-1 text-center">55</td>
<td className="border border-black p-2 uppercase"> / (Total Sales / Supplies) (5 + 15 + 20 + 25 + 35 45)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionA.line55.total)}</td>
<td className="border border-black p-2 bg-gray-200"></td>
</tr>
<tr className="font-bold bg-gray-50">
<td className="border border-black p-1 text-center">60</td>
<td className="border border-black p-2 uppercase"> (Total Output VAT)</td>
<td className="border border-black p-2 bg-gray-200"></td>
<td className="border border-black p-2 text-right border-double">{formatCurrency(sectionA.line60.vat)}</td>
</tr>
</tbody>
</table>
</div>
{/* Section B - Capital Asset Purchases */}
<div className="mb-8">
<h3 className="bg-gray-200 p-2 font-bold border border-black">. (SECTION B CAPITAL ASSET PURCHASES)</h3>
<table className="w-full border-collapse border border-black text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-black p-1 w-12 text-center">Line No</th>
<th className="border border-black p-2 text-left"> (Description)</th>
<th className="border border-black p-2 text-right w-32"> (Total Amount)</th>
<th className="border border-black p-2 text-right w-32"> (VAT Amount)</th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-black p-1 text-center font-bold">65</td>
<td className="border border-black p-2"> (Local Purchase of Capital Assets)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionB.line65.total)}</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionB.line65.vat)}</td>
</tr>
<tr>
<td className="border border-black p-1 text-center font-bold">75</td>
<td className="border border-black p-2"> (Imported Capital Asset Purchase)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionB.line75.total)}</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionB.line75.vat)}</td>
</tr>
<tr>
<td className="border border-black p-1 text-center font-bold">85</td>
<td className="border border-black p-2"> (Capital purchase with no VAT or unclaimed input)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionB.line85.total)}</td>
<td className="border border-black p-2 bg-gray-100"></td>
</tr>
<tr className="font-bold bg-gray-50">
<td className="border border-black p-1 text-center font-bold">90</td>
<td className="border border-black p-2 uppercase"> (TOTAL CAPITAL PURCHASES)</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionB.line90.total)}</td>
<td className="border border-black p-2 text-right">{formatCurrency(sectionB.line90.vat)}</td>
</tr>
</tbody>
</table>
</div>
{/* Footer Signatures */}
<div className="mt-12 grid grid-cols-2 gap-12">
<div className="border-t border-black pt-2">
<p><strong> (Taxpayer Signature):</strong></p>
<div className="h-12"></div>
<p><strong> (Date):</strong> _________________</p>
</div>
<div className="border-t border-black pt-2">
<p><strong> (For Office Use Only):</strong></p>
<div className="h-12"></div>
<p><strong> (Signature):</strong> _________________</p>
</div>
</div>
</div>
</CardBox>
</SectionMain>
<style jsx global>{`
@media print {
body * {
visibility: hidden;
}
.print-section, .print-section * {
visibility: visible;
}
.print-section {
position: absolute;
left: 0;
top: 0;
width: 100%;
}
.no-print {
display: none !important;
}
aside, nav {
display: none !important;
}
}
`}</style>
</>
);
};
ErcaVatForm.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission={'READ_VAT_DECLARATIONS'}>
{page}
</LayoutAuthenticated>
);
};
export default ErcaVatForm;

View File

@ -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 = () => {
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title={removeLastCharacter('View vat_declarations')} main>
<BaseButton
color='info'
label='Edit'
href={`/vat_declarations/vat_declarations-edit/?id=${id}`}
/>
<div className="flex items-center gap-2">
<BaseButton
color='success'
label='ERCA Form'
icon={mdiFileDocumentOutline}
onClick={() => router.push(`/vat_declarations/${id}/erca`)}
/>
<BaseButton
color='info'
label='Edit'
href={`/vat_declarations/vat_declarations-edit/?id=${id}`}
/>
</div>
</SectionTitleLineWithButton>
<CardBox>
@ -966,4 +974,4 @@ Vat_declarationsView.getLayout = function getLayout(page: ReactElement) {
)
}
export default Vat_declarationsView;
export default Vat_declarationsView;

View File

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

View File

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

View File

@ -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<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch
export type AppDispatch = typeof store.dispatch

View File

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

View File

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