V1
This commit is contained in:
parent
ef45f741d1
commit
16f5d3a7e4
BIN
assets/pasted-20260228-105909-e9435147.png
Normal file
BIN
assets/pasted-20260228-105909-e9435147.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 223 KiB |
BIN
assets/pasted-20260228-114648-f7c34610.png
Normal file
BIN
assets/pasted-20260228-114648-f7c34610.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@ -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
|
||||
}
|
||||
};
|
||||
@ -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',
|
||||
|
||||
50
backend/src/routes/context.js
Normal file
50
backend/src/routes/context.js
Normal 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;
|
||||
117
backend/src/routes/dashboard.js
Normal file
117
backend/src/routes/dashboard.js
Normal 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;
|
||||
92
backend/src/routes/transactions.js
Normal file
92
backend/src/routes/transactions.js
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
};
|
||||
126
frontend/src/components/ContextSwitcher.tsx
Normal file
126
frontend/src/components/ContextSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
frontend/src/components/Finance/Dashboard/DashboardWidgets.tsx
Normal file
115
frontend/src/components/Finance/Dashboard/DashboardWidgets.tsx
Normal 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;
|
||||
@ -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;
|
||||
67
frontend/src/components/Finance/Dashboard/QuickActions.tsx
Normal file
67
frontend/src/components/Finance/Dashboard/QuickActions.tsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -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' : ''
|
||||
|
||||
@ -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 © 2026 - Hand-crafted with ❤️</FooterBar>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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);
|
||||
@ -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's what'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>
|
||||
</>
|
||||
)
|
||||
|
||||
@ -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',
|
||||
|
||||
182
frontend/src/pages/transactions.tsx
Normal file
182
frontend/src/pages/transactions.tsx
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
86
frontend/src/stores/contextSlice.ts
Normal file
86
frontend/src/stores/contextSlice.ts
Normal 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;
|
||||
68
frontend/src/stores/dashboardSlice.ts
Normal file
68
frontend/src/stores/dashboardSlice.ts
Normal 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;
|
||||
@ -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
|
||||
65
frontend/src/stores/transactions/transactionsSlice.ts
Normal file
65
frontend/src/stores/transactions/transactionsSlice.ts
Normal 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
|
||||
@ -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: '',
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user