Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4edef96187 |
@ -5,8 +5,6 @@ const InvoicesService = require('../services/invoices');
|
|||||||
const InvoicesDBApi = require('../db/api/invoices');
|
const InvoicesDBApi = require('../db/api/invoices');
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
const config = require('../config');
|
|
||||||
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@ -116,6 +114,21 @@ router.post('/', wrapAsync(async (req, res) => {
|
|||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
router.get('/workbench', wrapAsync(async (req, res) => {
|
||||||
|
const payload = await InvoicesService.getWorkbench(req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/workbench', wrapAsync(async (req, res) => {
|
||||||
|
const payload = await InvoicesService.getWorkbench(req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/issue-workflow', wrapAsync(async (req, res) => {
|
||||||
|
const payload = await InvoicesService.issueWorkflow(req.body.data, req.currentUser);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
* /api/budgets/bulk-import:
|
* /api/budgets/bulk-import:
|
||||||
|
|||||||
@ -1,17 +1,900 @@
|
|||||||
const db = require('../db/models');
|
const db = require('../db/models');
|
||||||
const InvoicesDBApi = require('../db/api/invoices');
|
const InvoicesDBApi = require('../db/api/invoices');
|
||||||
const processFile = require("../middlewares/upload");
|
const Invoice_linesDBApi = require('../db/api/invoice_lines');
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const PaymentsDBApi = require('../db/api/payments');
|
||||||
|
const ReceiptsDBApi = require('../db/api/receipts');
|
||||||
|
const Efris_submissionsDBApi = require('../db/api/efris_submissions');
|
||||||
|
const processFile = require('../middlewares/upload');
|
||||||
const csv = require('csv-parser');
|
const csv = require('csv-parser');
|
||||||
const axios = require('axios');
|
|
||||||
const config = require('../config');
|
|
||||||
const stream = require('stream');
|
const stream = require('stream');
|
||||||
|
|
||||||
|
const { Op } = db.Sequelize;
|
||||||
|
|
||||||
|
const PAYMENT_METHODS = new Set([
|
||||||
|
'cash',
|
||||||
|
'bank_transfer',
|
||||||
|
'card',
|
||||||
|
'mobile_money',
|
||||||
|
'cheque',
|
||||||
|
'other',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function createBadRequestError(message) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = 400;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundCurrency(value) {
|
||||||
|
return Math.round((Number(value) + Number.EPSILON) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value, fallback = 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureValidDate(value, fallback) {
|
||||||
|
const date = value ? new Date(value) : new Date(fallback);
|
||||||
|
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
throw createBadRequestError('Please enter a valid date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date, days) {
|
||||||
|
const nextDate = new Date(date);
|
||||||
|
nextDate.setDate(nextDate.getDate() + days);
|
||||||
|
return nextDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForInput(date) {
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDocumentNumber(prefix = 'INV') {
|
||||||
|
const now = new Date();
|
||||||
|
const datePart = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
|
||||||
|
now.getDate(),
|
||||||
|
).padStart(2, '0')}`;
|
||||||
|
const timePart = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(
|
||||||
|
2,
|
||||||
|
'0',
|
||||||
|
)}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||||
|
const entropy = `${Math.floor(100 + Math.random() * 900)}`;
|
||||||
|
|
||||||
|
return `${prefix}-${datePart}-${timePart}${entropy}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContactLabel(contact) {
|
||||||
|
if (!contact) {
|
||||||
|
return 'Unknown customer';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
contact.display_name ||
|
||||||
|
contact.company_name ||
|
||||||
|
[contact.first_name, contact.last_name].filter(Boolean).join(' ').trim() ||
|
||||||
|
contact.email ||
|
||||||
|
'Unnamed customer'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWorkflowUser(currentUser, organizationId) {
|
||||||
|
const organization =
|
||||||
|
currentUser.organization ||
|
||||||
|
currentUser.organizations ||
|
||||||
|
(organizationId
|
||||||
|
? {
|
||||||
|
id: organizationId,
|
||||||
|
}
|
||||||
|
: null);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentUser,
|
||||||
|
organization,
|
||||||
|
organizations: currentUser.organizations || organization,
|
||||||
|
organizationsId: currentUser.organizationsId || organizationId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrganizationId(currentUser) {
|
||||||
|
const membershipOrganizationId =
|
||||||
|
currentUser?.memberships_user?.find((membership) => membership.status === 'active')?.organizationId ||
|
||||||
|
currentUser?.memberships_user?.[0]?.organizationId ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
currentUser?.organization?.id ||
|
||||||
|
currentUser?.organizations?.id ||
|
||||||
|
currentUser?.organizationId ||
|
||||||
|
currentUser?.organizationsId ||
|
||||||
|
membershipOrganizationId ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeJson(payload) {
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWorkbenchInvoice(invoice) {
|
||||||
|
return {
|
||||||
|
id: invoice.id,
|
||||||
|
invoice_number: invoice.invoice_number,
|
||||||
|
status: invoice.status,
|
||||||
|
issue_date: invoice.issue_date,
|
||||||
|
due_date: invoice.due_date,
|
||||||
|
total_amount: invoice.total_amount,
|
||||||
|
amount_paid: invoice.amount_paid,
|
||||||
|
balance_due: invoice.balance_due,
|
||||||
|
createdAt: invoice.createdAt,
|
||||||
|
customerName: getContactLabel(invoice.customer),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWorkbenchPayment(payment) {
|
||||||
|
return {
|
||||||
|
id: payment.id,
|
||||||
|
payment_reference: payment.payment_reference,
|
||||||
|
payment_date: payment.payment_date,
|
||||||
|
amount: payment.amount,
|
||||||
|
method: payment.method,
|
||||||
|
status: payment.status,
|
||||||
|
customerName: getContactLabel(payment.customer),
|
||||||
|
invoiceNumber: payment.invoice?.invoice_number || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapWorkbenchSubmission(submission) {
|
||||||
|
return {
|
||||||
|
id: submission.id,
|
||||||
|
document_type: submission.document_type,
|
||||||
|
submission_status: submission.submission_status,
|
||||||
|
efris_invoice_no: submission.efris_invoice_no,
|
||||||
|
efris_receipt_no: submission.efris_receipt_no,
|
||||||
|
verification_code: submission.verification_code,
|
||||||
|
error_message: submission.error_message,
|
||||||
|
createdAt: submission.createdAt,
|
||||||
|
invoiceId: submission.invoice?.id || null,
|
||||||
|
invoiceNumber: submission.invoice?.invoice_number || null,
|
||||||
|
receiptId: submission.receipt?.id || null,
|
||||||
|
receiptNumber: submission.receipt?.receipt_number || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = class InvoicesService {
|
module.exports = class InvoicesService {
|
||||||
|
static async getWorkbench(currentUser) {
|
||||||
|
const organizationId = getOrganizationId(currentUser);
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
throw createBadRequestError('Your account is not linked to an organization yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const startOfMonth = new Date();
|
||||||
|
startOfMonth.setDate(1);
|
||||||
|
startOfMonth.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const [
|
||||||
|
organization,
|
||||||
|
settings,
|
||||||
|
activeConnection,
|
||||||
|
latestConnection,
|
||||||
|
customerCount,
|
||||||
|
allContactCount,
|
||||||
|
productCount,
|
||||||
|
openInvoiceCount,
|
||||||
|
pendingEfrisCount,
|
||||||
|
paidInvoiceCount,
|
||||||
|
collectedThisMonth,
|
||||||
|
customers,
|
||||||
|
fallbackContacts,
|
||||||
|
products,
|
||||||
|
recentInvoices,
|
||||||
|
recentPayments,
|
||||||
|
recentSubmissions,
|
||||||
|
] = await Promise.all([
|
||||||
|
db.organizations.findByPk(organizationId, {
|
||||||
|
attributes: ['id', 'name'],
|
||||||
|
}),
|
||||||
|
db.organization_settings.findOne({
|
||||||
|
where: { organizationId },
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
}),
|
||||||
|
db.efris_connections.findOne({
|
||||||
|
where: { organizationId, status: 'active' },
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
}),
|
||||||
|
db.efris_connections.findOne({
|
||||||
|
where: { organizationId },
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
}),
|
||||||
|
db.contacts.count({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
contact_type: 'customer',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.contacts.count({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.products.count({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.invoices.count({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
status: {
|
||||||
|
[Op.in]: ['sent', 'partially_paid', 'overdue'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.efris_submissions.count({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
submission_status: {
|
||||||
|
[Op.in]: ['queued', 'submitted', 'failed'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.invoices.count({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
status: 'paid',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.payments.sum('amount', {
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
status: 'posted',
|
||||||
|
payment_date: {
|
||||||
|
[Op.gte]: startOfMonth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.contacts.findAll({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
contact_type: 'customer',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
attributes: ['id', 'display_name', 'company_name', 'email', 'tin'],
|
||||||
|
order: [['display_name', 'ASC']],
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
db.contacts.findAll({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
},
|
||||||
|
attributes: ['id', 'display_name', 'company_name', 'first_name', 'last_name', 'email', 'tin'],
|
||||||
|
order: [['display_name', 'ASC']],
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
db.products.findAll({
|
||||||
|
where: {
|
||||||
|
organizationId,
|
||||||
|
is_active: true,
|
||||||
|
},
|
||||||
|
attributes: ['id', 'name', 'description', 'sales_price', 'is_taxable', 'item_type', 'sku'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.tax_codes,
|
||||||
|
as: 'tax_code',
|
||||||
|
attributes: ['id', 'name', 'code', 'rate'],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['name', 'ASC']],
|
||||||
|
limit: 75,
|
||||||
|
}),
|
||||||
|
db.invoices.findAll({
|
||||||
|
where: { organizationId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.contacts,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'display_name', 'company_name', 'first_name', 'last_name', 'email'],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: 6,
|
||||||
|
}),
|
||||||
|
db.payments.findAll({
|
||||||
|
where: { organizationId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.contacts,
|
||||||
|
as: 'customer',
|
||||||
|
attributes: ['id', 'display_name', 'company_name', 'first_name', 'last_name', 'email'],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.invoices,
|
||||||
|
as: 'invoice',
|
||||||
|
attributes: ['id', 'invoice_number'],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: 6,
|
||||||
|
}),
|
||||||
|
db.efris_submissions.findAll({
|
||||||
|
where: { organizationId },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.invoices,
|
||||||
|
as: 'invoice',
|
||||||
|
attributes: ['id', 'invoice_number'],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: db.receipts,
|
||||||
|
as: 'receipt',
|
||||||
|
attributes: ['id', 'receipt_number'],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
limit: 6,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const connection = activeConnection || latestConnection;
|
||||||
|
const availableCustomers = customers.length ? customers : fallbackContacts;
|
||||||
|
const visibleCustomerCount = customerCount || allContactCount;
|
||||||
|
const defaultIssueDate = new Date();
|
||||||
|
const defaultDueDate = addDays(defaultIssueDate, 7);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organization: organization
|
||||||
|
? {
|
||||||
|
id: organization.id,
|
||||||
|
name: organization.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
summary: {
|
||||||
|
customerCount: visibleCustomerCount,
|
||||||
|
productCount,
|
||||||
|
openInvoiceCount,
|
||||||
|
pendingEfrisCount,
|
||||||
|
paidInvoiceCount,
|
||||||
|
collectedThisMonth: roundCurrency(toNumber(collectedThisMonth, 0)),
|
||||||
|
},
|
||||||
|
connection: connection
|
||||||
|
? {
|
||||||
|
id: connection.id,
|
||||||
|
environment: connection.environment,
|
||||||
|
status: connection.status,
|
||||||
|
branch_code: connection.branch_code,
|
||||||
|
device_number: connection.device_number,
|
||||||
|
business_name_on_efris: connection.business_name_on_efris,
|
||||||
|
tin_on_efris: connection.tin_on_efris,
|
||||||
|
api_base_url: connection.api_base_url,
|
||||||
|
last_connected_at: connection.last_connected_at,
|
||||||
|
last_error_message: connection.last_error_message,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
settings: {
|
||||||
|
default_currency_code: settings?.default_currency_code || 'UGX',
|
||||||
|
invoice_prefix: settings?.invoice_prefix || 'INV',
|
||||||
|
receipt_prefix: settings?.receipt_prefix || 'RCT',
|
||||||
|
default_vat_rate: roundCurrency(toNumber(settings?.default_vat_rate, 0)),
|
||||||
|
invoice_footer_note: settings?.invoice_footer_note || '',
|
||||||
|
},
|
||||||
|
suggestedDefaults: {
|
||||||
|
issueDate: formatDateForInput(defaultIssueDate),
|
||||||
|
dueDate: formatDateForInput(defaultDueDate),
|
||||||
|
currencyCode: settings?.default_currency_code || 'UGX',
|
||||||
|
termsAndConditions: settings?.invoice_footer_note || '',
|
||||||
|
},
|
||||||
|
customers: availableCustomers.map((customer) => ({
|
||||||
|
id: customer.id,
|
||||||
|
display_name: getContactLabel(customer),
|
||||||
|
email: customer.email,
|
||||||
|
tin: customer.tin,
|
||||||
|
})),
|
||||||
|
products: products.map((product) => ({
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
description: product.description,
|
||||||
|
sales_price: product.sales_price,
|
||||||
|
item_type: product.item_type,
|
||||||
|
sku: product.sku,
|
||||||
|
is_taxable: product.is_taxable,
|
||||||
|
tax_rate: product.is_taxable
|
||||||
|
? roundCurrency(toNumber(product.tax_code?.rate, settings?.default_vat_rate || 0))
|
||||||
|
: 0,
|
||||||
|
tax_code: product.tax_code
|
||||||
|
? {
|
||||||
|
id: product.tax_code.id,
|
||||||
|
name: product.tax_code.name,
|
||||||
|
code: product.tax_code.code,
|
||||||
|
rate: product.tax_code.rate,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
})),
|
||||||
|
recentInvoices: recentInvoices.map(mapWorkbenchInvoice),
|
||||||
|
recentPayments: recentPayments.map(mapWorkbenchPayment),
|
||||||
|
recentSubmissions: recentSubmissions.map(mapWorkbenchSubmission),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static async issueWorkflow(data, currentUser) {
|
||||||
|
const organizationId = getOrganizationId(currentUser);
|
||||||
|
|
||||||
|
if (!organizationId) {
|
||||||
|
throw createBadRequestError('Your account is not linked to an organization yet.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
throw createBadRequestError('No invoice data was provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.customerId) {
|
||||||
|
throw createBadRequestError('Please choose a customer before issuing an invoice.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(data.lineItems) || !data.lineItems.length) {
|
||||||
|
throw createBadRequestError('Add at least one product or service line.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflowUser = buildWorkflowUser(currentUser, organizationId);
|
||||||
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const settings = await db.organization_settings.findOne({
|
||||||
|
where: { organizationId },
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeConnection = await db.efris_connections.findOne({
|
||||||
|
where: { organizationId, status: 'active' },
|
||||||
|
order: [['updatedAt', 'DESC']],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customer = await db.contacts.findOne({
|
||||||
|
where: {
|
||||||
|
id: data.customerId,
|
||||||
|
organizationId,
|
||||||
|
},
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
throw createBadRequestError('The selected customer could not be found in this organization.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const issueDate = ensureValidDate(data.issueDate, new Date());
|
||||||
|
const dueDate = ensureValidDate(data.dueDate, addDays(issueDate, 7));
|
||||||
|
|
||||||
|
if (dueDate.getTime() < issueDate.getTime()) {
|
||||||
|
throw createBadRequestError('The due date cannot be earlier than the issue date.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueProductIds = [...new Set(data.lineItems.map((item) => item.productId).filter(Boolean))];
|
||||||
|
|
||||||
|
if (!uniqueProductIds.length) {
|
||||||
|
throw createBadRequestError('Each line needs a product or service.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const products = await db.products.findAll({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
[Op.in]: uniqueProductIds,
|
||||||
|
},
|
||||||
|
organizationId,
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: db.tax_codes,
|
||||||
|
as: 'tax_code',
|
||||||
|
attributes: ['id', 'name', 'code', 'rate'],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
|
const productsById = new Map(products.map((product) => [product.id, product]));
|
||||||
|
const fallbackVatRate = roundCurrency(toNumber(settings?.default_vat_rate, 0));
|
||||||
|
|
||||||
|
const normalizedLineItems = data.lineItems.map((item, index) => {
|
||||||
|
if (!item?.productId) {
|
||||||
|
throw createBadRequestError(`Line ${index + 1} is missing a product or service.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = productsById.get(item.productId);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw createBadRequestError(`Line ${index + 1} refers to a product outside this organization.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quantity = toNumber(item.quantity, NaN);
|
||||||
|
|
||||||
|
if (!Number.isFinite(quantity) || quantity <= 0) {
|
||||||
|
throw createBadRequestError(`Line ${index + 1} must have a quantity greater than zero.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitPrice =
|
||||||
|
item.unitPrice !== undefined && item.unitPrice !== null && item.unitPrice !== ''
|
||||||
|
? toNumber(item.unitPrice, NaN)
|
||||||
|
: toNumber(product.sales_price, NaN);
|
||||||
|
|
||||||
|
if (!Number.isFinite(unitPrice) || unitPrice < 0) {
|
||||||
|
throw createBadRequestError(`Line ${index + 1} has an invalid unit price.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const discountRate = toNumber(item.discountRate, 0);
|
||||||
|
|
||||||
|
if (discountRate < 0 || discountRate > 100) {
|
||||||
|
throw createBadRequestError(`Line ${index + 1} has an invalid discount percentage.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grossAmount = roundCurrency(quantity * unitPrice);
|
||||||
|
const discountAmount = roundCurrency(grossAmount * (discountRate / 100));
|
||||||
|
const lineSubtotal = roundCurrency(grossAmount - discountAmount);
|
||||||
|
const taxRate = product.is_taxable ? roundCurrency(toNumber(product.tax_code?.rate, fallbackVatRate)) : 0;
|
||||||
|
const taxAmount = roundCurrency(lineSubtotal * (taxRate / 100));
|
||||||
|
const lineTotal = roundCurrency(lineSubtotal + taxAmount);
|
||||||
|
|
||||||
|
return {
|
||||||
|
product,
|
||||||
|
quantity,
|
||||||
|
unitPrice,
|
||||||
|
discountRate,
|
||||||
|
discountAmount,
|
||||||
|
taxRate,
|
||||||
|
lineSubtotal,
|
||||||
|
taxAmount,
|
||||||
|
lineTotal,
|
||||||
|
description: item.description || product.description || product.name,
|
||||||
|
taxCodeId: product.tax_code?.id || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtotalAmount = roundCurrency(
|
||||||
|
normalizedLineItems.reduce((sum, item) => sum + item.lineSubtotal, 0),
|
||||||
|
);
|
||||||
|
const discountAmount = roundCurrency(
|
||||||
|
normalizedLineItems.reduce((sum, item) => sum + item.discountAmount, 0),
|
||||||
|
);
|
||||||
|
const taxAmount = roundCurrency(
|
||||||
|
normalizedLineItems.reduce((sum, item) => sum + item.taxAmount, 0),
|
||||||
|
);
|
||||||
|
const totalAmount = roundCurrency(
|
||||||
|
normalizedLineItems.reduce((sum, item) => sum + item.lineTotal, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const takePaymentNow = Boolean(data.takePaymentNow);
|
||||||
|
const paymentAmount = takePaymentNow
|
||||||
|
? roundCurrency(
|
||||||
|
toNumber(
|
||||||
|
data.paymentAmount !== undefined && data.paymentAmount !== null && data.paymentAmount !== ''
|
||||||
|
? data.paymentAmount
|
||||||
|
: totalAmount,
|
||||||
|
NaN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (takePaymentNow) {
|
||||||
|
if (!Number.isFinite(paymentAmount) || paymentAmount <= 0) {
|
||||||
|
throw createBadRequestError('Enter a valid payment amount to capture now.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentAmount > totalAmount) {
|
||||||
|
throw createBadRequestError('The payment amount cannot be greater than the invoice total.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const balanceDue = roundCurrency(totalAmount - paymentAmount);
|
||||||
|
const paymentMethod = takePaymentNow ? data.paymentMethod || 'mobile_money' : null;
|
||||||
|
|
||||||
|
if (paymentMethod && !PAYMENT_METHODS.has(paymentMethod)) {
|
||||||
|
throw createBadRequestError('Choose a supported payment method.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoicePrefix = settings?.invoice_prefix || 'INV';
|
||||||
|
const receiptPrefix = settings?.receipt_prefix || 'RCT';
|
||||||
|
const currencyCode = data.currencyCode || settings?.default_currency_code || 'UGX';
|
||||||
|
const invoiceStatus = !takePaymentNow ? 'sent' : balanceDue <= 0 ? 'paid' : 'partially_paid';
|
||||||
|
|
||||||
|
const invoice = await InvoicesDBApi.create(
|
||||||
|
{
|
||||||
|
customer: customer.id,
|
||||||
|
invoice_number: data.invoiceNumber || buildDocumentNumber(invoicePrefix),
|
||||||
|
invoice_type: 'standard',
|
||||||
|
status: invoiceStatus,
|
||||||
|
issue_date: issueDate,
|
||||||
|
due_date: dueDate,
|
||||||
|
currency_code: currencyCode,
|
||||||
|
exchange_rate: 1,
|
||||||
|
subtotal_amount: subtotalAmount,
|
||||||
|
tax_amount: taxAmount,
|
||||||
|
discount_amount: discountAmount,
|
||||||
|
total_amount: totalAmount,
|
||||||
|
amount_paid: paymentAmount,
|
||||||
|
balance_due: balanceDue,
|
||||||
|
customer_notes: data.customerNotes || null,
|
||||||
|
terms_and_conditions: data.termsAndConditions || settings?.invoice_footer_note || null,
|
||||||
|
invoice_lines: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currentUser: workflowUser,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const createdLineItems = [];
|
||||||
|
|
||||||
|
for (const item of normalizedLineItems) {
|
||||||
|
const createdLine = await Invoice_linesDBApi.create(
|
||||||
|
{
|
||||||
|
invoice: invoice.id,
|
||||||
|
product: item.product.id,
|
||||||
|
tax_code: item.taxCodeId,
|
||||||
|
organizations: organizationId,
|
||||||
|
line_description: item.description,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unit_price: item.unitPrice,
|
||||||
|
discount_rate: item.discountRate,
|
||||||
|
line_subtotal: item.lineSubtotal,
|
||||||
|
tax_amount: item.taxAmount,
|
||||||
|
line_total: item.lineTotal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currentUser: workflowUser,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
createdLineItems.push(createdLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
let payment = null;
|
||||||
|
let receipt = null;
|
||||||
|
|
||||||
|
if (takePaymentNow) {
|
||||||
|
const paymentDate = ensureValidDate(data.paymentDate, new Date());
|
||||||
|
|
||||||
|
payment = await PaymentsDBApi.create(
|
||||||
|
{
|
||||||
|
invoice: invoice.id,
|
||||||
|
customer: customer.id,
|
||||||
|
payment_reference: data.paymentReference || buildDocumentNumber('PAY'),
|
||||||
|
payment_date: paymentDate,
|
||||||
|
amount: paymentAmount,
|
||||||
|
method: paymentMethod,
|
||||||
|
currency_code: currencyCode,
|
||||||
|
exchange_rate: 1,
|
||||||
|
status: 'posted',
|
||||||
|
notes: `Captured from Revenue Desk for ${invoice.invoice_number}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currentUser: workflowUser,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
receipt = await ReceiptsDBApi.create(
|
||||||
|
{
|
||||||
|
invoice: invoice.id,
|
||||||
|
payment: payment.id,
|
||||||
|
receipt_number: data.receiptNumber || buildDocumentNumber(receiptPrefix),
|
||||||
|
receipt_date: paymentDate,
|
||||||
|
receipt_amount: paymentAmount,
|
||||||
|
status: 'issued',
|
||||||
|
notes: `Receipt generated from Revenue Desk for ${invoice.invoice_number}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currentUser: workflowUser,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let efrisSubmission = null;
|
||||||
|
const shouldSubmitToEfris = data.submitToEfris !== false;
|
||||||
|
|
||||||
|
if (shouldSubmitToEfris) {
|
||||||
|
const verificationCode = activeConnection
|
||||||
|
? `VC-${Math.floor(100000 + Math.random() * 900000)}`
|
||||||
|
: null;
|
||||||
|
const efrisInvoiceNo = activeConnection
|
||||||
|
? `UG-EFR-${Math.floor(100000 + Math.random() * 900000)}`
|
||||||
|
: null;
|
||||||
|
const efrisReceiptNo = activeConnection && receipt
|
||||||
|
? `UG-RCT-${Math.floor(100000 + Math.random() * 900000)}`
|
||||||
|
: null;
|
||||||
|
const documentType = receipt ? 'receipt' : 'invoice';
|
||||||
|
const requestPayload = {
|
||||||
|
organizationId,
|
||||||
|
customer: {
|
||||||
|
id: customer.id,
|
||||||
|
displayName: getContactLabel(customer),
|
||||||
|
tin: customer.tin || null,
|
||||||
|
},
|
||||||
|
invoice: {
|
||||||
|
id: invoice.id,
|
||||||
|
invoiceNumber: invoice.invoice_number,
|
||||||
|
totalAmount,
|
||||||
|
currencyCode,
|
||||||
|
},
|
||||||
|
receipt: receipt
|
||||||
|
? {
|
||||||
|
id: receipt.id,
|
||||||
|
receiptNumber: receipt.receipt_number,
|
||||||
|
receiptAmount: receipt.receipt_amount,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
lineItems: normalizedLineItems.map((item) => ({
|
||||||
|
productId: item.product.id,
|
||||||
|
productName: item.product.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
taxRate: item.taxRate,
|
||||||
|
lineTotal: item.lineTotal,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const responsePayload = activeConnection
|
||||||
|
? {
|
||||||
|
status: 'ACCEPTED_PREVIEW',
|
||||||
|
simulation: true,
|
||||||
|
environment: activeConnection.environment,
|
||||||
|
efrisInvoiceNo,
|
||||||
|
efrisReceiptNo,
|
||||||
|
verificationCode,
|
||||||
|
note: 'Revenue Desk created a sandbox-ready preview payload for the first MVP iteration.',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
status: 'QUEUED',
|
||||||
|
note: 'No active EFRIS connection was found. Configure one to push this document upstream.',
|
||||||
|
};
|
||||||
|
|
||||||
|
efrisSubmission = await Efris_submissionsDBApi.create(
|
||||||
|
{
|
||||||
|
invoice: invoice.id,
|
||||||
|
receipt: receipt?.id || null,
|
||||||
|
document_type: documentType,
|
||||||
|
submission_status: activeConnection ? 'accepted' : 'queued',
|
||||||
|
submitted_at: activeConnection ? new Date() : null,
|
||||||
|
last_attempt_at: activeConnection ? new Date() : null,
|
||||||
|
attempt_count: activeConnection ? 1 : 0,
|
||||||
|
request_payload_json: serializeJson(requestPayload),
|
||||||
|
response_payload_json: serializeJson(responsePayload),
|
||||||
|
efris_invoice_no: efrisInvoiceNo,
|
||||||
|
efris_receipt_no: efrisReceiptNo,
|
||||||
|
verification_code: verificationCode,
|
||||||
|
qr_code_data: activeConnection
|
||||||
|
? `URA|${activeConnection.tin_on_efris || 'TIN'}|${invoice.invoice_number}|${verificationCode}`
|
||||||
|
: null,
|
||||||
|
error_message: activeConnection
|
||||||
|
? 'Sandbox preview generated. Replace preview mode with the live URA API handshake next.'
|
||||||
|
: 'Awaiting an active EFRIS connection.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
currentUser: workflowUser,
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.audit_events.create(
|
||||||
|
{
|
||||||
|
event_type: shouldSubmitToEfris ? 'submit_efris' : 'create',
|
||||||
|
entity_name: 'invoices',
|
||||||
|
entity_reference: invoice.invoice_number,
|
||||||
|
event_time: new Date(),
|
||||||
|
details_json: serializeJson({
|
||||||
|
source: 'Revenue Desk',
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
paymentId: payment?.id || null,
|
||||||
|
receiptId: receipt?.id || null,
|
||||||
|
efrisSubmissionId: efrisSubmission?.id || null,
|
||||||
|
totalAmount,
|
||||||
|
balanceDue,
|
||||||
|
}),
|
||||||
|
organizationId,
|
||||||
|
actorId: currentUser.id,
|
||||||
|
createdById: currentUser.id,
|
||||||
|
updatedById: currentUser.id,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: shouldSubmitToEfris
|
||||||
|
? 'Invoice workflow completed and an EFRIS record was created.'
|
||||||
|
: 'Invoice workflow completed.',
|
||||||
|
invoice: {
|
||||||
|
id: invoice.id,
|
||||||
|
invoice_number: invoice.invoice_number,
|
||||||
|
status: invoice.status,
|
||||||
|
total_amount: invoice.total_amount,
|
||||||
|
amount_paid: invoice.amount_paid,
|
||||||
|
balance_due: invoice.balance_due,
|
||||||
|
issue_date: invoice.issue_date,
|
||||||
|
due_date: invoice.due_date,
|
||||||
|
},
|
||||||
|
customer: {
|
||||||
|
id: customer.id,
|
||||||
|
display_name: getContactLabel(customer),
|
||||||
|
email: customer.email,
|
||||||
|
tin: customer.tin,
|
||||||
|
},
|
||||||
|
lineItems: normalizedLineItems.map((item) => ({
|
||||||
|
productId: item.product.id,
|
||||||
|
productName: item.product.name,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
taxRate: item.taxRate,
|
||||||
|
lineTotal: item.lineTotal,
|
||||||
|
discountRate: item.discountRate,
|
||||||
|
})),
|
||||||
|
payment: payment
|
||||||
|
? {
|
||||||
|
id: payment.id,
|
||||||
|
payment_reference: payment.payment_reference,
|
||||||
|
payment_date: payment.payment_date,
|
||||||
|
amount: payment.amount,
|
||||||
|
method: payment.method,
|
||||||
|
status: payment.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
receipt: receipt
|
||||||
|
? {
|
||||||
|
id: receipt.id,
|
||||||
|
receipt_number: receipt.receipt_number,
|
||||||
|
receipt_date: receipt.receipt_date,
|
||||||
|
receipt_amount: receipt.receipt_amount,
|
||||||
|
status: receipt.status,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
efrisSubmission: efrisSubmission
|
||||||
|
? {
|
||||||
|
id: efrisSubmission.id,
|
||||||
|
document_type: efrisSubmission.document_type,
|
||||||
|
submission_status: efrisSubmission.submission_status,
|
||||||
|
efris_invoice_no: efrisSubmission.efris_invoice_no,
|
||||||
|
efris_receipt_no: efrisSubmission.efris_receipt_no,
|
||||||
|
verification_code: efrisSubmission.verification_code,
|
||||||
|
qr_code_data: efrisSubmission.qr_code_data,
|
||||||
|
error_message: efrisSubmission.error_message,
|
||||||
|
preview_mode: Boolean(activeConnection),
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
summary: {
|
||||||
|
subtotalAmount,
|
||||||
|
discountAmount,
|
||||||
|
taxAmount,
|
||||||
|
totalAmount,
|
||||||
|
amountPaid: paymentAmount,
|
||||||
|
balanceDue,
|
||||||
|
currencyCode,
|
||||||
|
connectorStatus: activeConnection ? `${activeConnection.status} (${activeConnection.environment})` : 'not_connected',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async create(data, currentUser) {
|
static async create(data, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
@ -28,9 +911,9 @@ module.exports = class InvoicesService {
|
|||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async bulkImport(req, res, sendInvitationEmails = true, host) {
|
static async bulkImport(req, res) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -38,24 +921,24 @@ module.exports = class InvoicesService {
|
|||||||
const bufferStream = new stream.PassThrough();
|
const bufferStream = new stream.PassThrough();
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
await bufferStream.end(Buffer.from(req.file.buffer, "utf-8")); // convert Buffer to Stream
|
await bufferStream.end(Buffer.from(req.file.buffer, 'utf-8'));
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
bufferStream
|
bufferStream
|
||||||
.pipe(csv())
|
.pipe(csv())
|
||||||
.on('data', (data) => results.push(data))
|
.on('data', (row) => results.push(row))
|
||||||
.on('end', async () => {
|
.on('end', async () => {
|
||||||
console.log('CSV results', results);
|
console.log('CSV results', results);
|
||||||
resolve();
|
resolve();
|
||||||
})
|
})
|
||||||
.on('error', (error) => reject(error));
|
.on('error', (error) => reject(error));
|
||||||
})
|
});
|
||||||
|
|
||||||
await InvoicesDBApi.bulkImport(results, {
|
await InvoicesDBApi.bulkImport(results, {
|
||||||
transaction,
|
transaction,
|
||||||
ignoreDuplicates: true,
|
ignoreDuplicates: true,
|
||||||
validate: true,
|
validate: true,
|
||||||
currentUser: req.currentUser
|
currentUser: req.currentUser,
|
||||||
});
|
});
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@ -68,15 +951,13 @@ module.exports = class InvoicesService {
|
|||||||
static async update(data, id, currentUser) {
|
static async update(data, id, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
let invoices = await InvoicesDBApi.findBy(
|
const invoices = await InvoicesDBApi.findBy(
|
||||||
{id},
|
{ id },
|
||||||
{transaction},
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!invoices) {
|
if (!invoices) {
|
||||||
throw new ValidationError(
|
throw createBadRequestError('Invoice not found.');
|
||||||
'invoicesNotFound',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedInvoices = await InvoicesDBApi.update(
|
const updatedInvoices = await InvoicesDBApi.update(
|
||||||
@ -90,12 +971,11 @@ module.exports = class InvoicesService {
|
|||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
return updatedInvoices;
|
return updatedInvoices;
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await transaction.rollback();
|
await transaction.rollback();
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
static async deleteByIds(ids, currentUser) {
|
static async deleteByIds(ids, currentUser) {
|
||||||
const transaction = await db.sequelize.transaction();
|
const transaction = await db.sequelize.transaction();
|
||||||
@ -131,8 +1011,4 @@ module.exports = class InvoicesService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,8 @@ import { mdiLogout, mdiClose } from '@mdi/js'
|
|||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
import AsideMenuList from './AsideMenuList'
|
import AsideMenuList from './AsideMenuList'
|
||||||
import { MenuAsideItem } from '../interfaces'
|
import { MenuAsideItem } from '../interfaces'
|
||||||
import { useAppSelector } from '../stores/hooks'
|
import { useAppDispatch, useAppSelector } from '../stores/hooks'
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
|
||||||
import { createAsyncThunk } from '@reduxjs/toolkit';
|
import { createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/revenue-desk',
|
||||||
|
icon: icon.mdiChartTimelineVariant,
|
||||||
|
label: 'Revenue desk',
|
||||||
|
permissions: 'CREATE_INVOICES'
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
|
|||||||
@ -1,161 +1,322 @@
|
|||||||
|
import * as icon from '@mdi/js';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import React from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
|
||||||
import LayoutGuest from '../layouts/Guest';
|
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
import BaseButtons from '../components/BaseButtons';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import CardBox from '../components/CardBox';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
const featureCards = [
|
||||||
|
{
|
||||||
|
title: 'Revenue desk',
|
||||||
|
description: 'Issue invoices, capture payment, and create the fiscal trail in one operator flow.',
|
||||||
|
iconPath: icon.mdiChartTimelineVariant,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'CRM + contacts',
|
||||||
|
description: 'Keep customer context, tax IDs, and billing details close to every invoice.',
|
||||||
|
iconPath: icon.mdiAccountMultiple,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Inventory + services',
|
||||||
|
description: 'Blend stocked products and service lines in the same workflow.',
|
||||||
|
iconPath: icon.mdiPackageVariantClosed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'EFRIS-ready records',
|
||||||
|
description: 'Store fiscal submission status, verification codes, and receipt references per tenant.',
|
||||||
|
iconPath: icon.mdiReceiptText,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const workflowSteps = [
|
||||||
|
{
|
||||||
|
title: 'Select customer + line items',
|
||||||
|
description: 'Start from an active customer, choose products/services, and preview totals with tax and discount applied.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Issue invoice',
|
||||||
|
description: 'Generate an invoice number, due date, notes, and tenant-specific defaults in one screen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Capture payment when needed',
|
||||||
|
description: 'If the customer pays immediately, create the payment and receipt without bouncing between pages.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Create EFRIS trail',
|
||||||
|
description: 'Store the submission record, status, and verification metadata to keep the compliance path visible.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const moduleHighlights = [
|
||||||
|
{
|
||||||
|
label: 'Accounting core',
|
||||||
|
text: 'Customers, products, invoices, payments, receipts, expenses, and lightweight reporting in one tenant-aware workspace.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Zoho-style context',
|
||||||
|
text: 'CRM-flavoured contact management, projects/tasks extensions, and room for approval flows without overwhelming the operator.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Compliance first',
|
||||||
|
text: 'URA EFRIS integration points live next to the day-to-day billing flow, not as a separate afterthought.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Multi-tenant by design',
|
||||||
|
text: 'Each organization keeps isolated data, settings, prefixes, users, and connector state inside the same SaaS app.',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function Starter() {
|
export default function Starter() {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
|
||||||
src: undefined,
|
|
||||||
photographer: undefined,
|
|
||||||
photographer_url: undefined,
|
|
||||||
})
|
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('video');
|
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'EFRIS Accounting Suite'
|
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage();
|
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="min-h-screen bg-slate-950 text-white">
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('EFRIS Accounting Suite')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="relative overflow-hidden">
|
||||||
<div
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(59,130,246,0.18),_transparent_38%),radial-gradient(circle_at_top_right,_rgba(16,185,129,0.14),_transparent_30%)]" />
|
||||||
className={`flex ${
|
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
|
||||||
} min-h-screen w-full`}
|
|
||||||
>
|
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
|
||||||
? imageBlock(illustrationImage)
|
|
||||||
: null}
|
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
|
||||||
? videoBlock(illustrationVideo)
|
|
||||||
: null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<CardBoxComponentTitle title="Welcome to your EFRIS Accounting Suite app!"/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="relative mx-auto max-w-7xl px-6 pb-20 pt-6 lg:px-8 lg:pb-28 lg:pt-8">
|
||||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
<header className="flex flex-col gap-4 rounded-full border border-white/10 bg-white/5 px-5 py-4 backdrop-blur md:flex-row md:items-center md:justify-between">
|
||||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
<Link href="/" className="flex items-center gap-3">
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-blue-600 text-white shadow-lg shadow-blue-600/20">
|
||||||
|
<BaseIcon path={icon.mdiChartTimelineVariant} size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-200">
|
||||||
|
EFRIS Accounting Suite
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-300">
|
||||||
|
QuickBooks-style finance ops with Zoho-style business context.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<BaseButtons type="justify-start md:justify-end" mb="mb-0">
|
||||||
|
<BaseButton href="/login" label="Login" color="whiteDark" />
|
||||||
|
<BaseButton href="/dashboard" label="Open admin interface" color="info" />
|
||||||
|
</BaseButtons>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mt-16 grid gap-12 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||||
|
<div className="max-w-3xl space-y-8">
|
||||||
|
<div className="inline-flex items-center rounded-full border border-blue-400/30 bg-blue-500/10 px-4 py-2 text-xs font-semibold uppercase tracking-[0.24em] text-blue-200">
|
||||||
|
Built for SME operators, accountants, and org admins
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-5">
|
||||||
|
<h1 className="text-5xl font-semibold tracking-tight text-white sm:text-6xl">
|
||||||
|
Modern accounting, invoicing, CRM context, and URA EFRIS readiness in one SaaS.
|
||||||
|
</h1>
|
||||||
|
<p className="max-w-2xl text-lg leading-8 text-slate-300">
|
||||||
|
This workspace is designed for small and mid-sized businesses that need cleaner books, faster invoice-to-cash workflows, and tenant-aware fiscal records without juggling disconnected tools.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseButtons type="justify-start" mb="mb-0">
|
||||||
|
<BaseButton href="/revenue-desk" label="Launch revenue desk" color="info" />
|
||||||
|
<BaseButton href="/dashboard" label="Admin interface" color="whiteDark" />
|
||||||
|
<BaseButton href="/login" label="Login" color="lightDark" />
|
||||||
|
</BaseButtons>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-slate-400">Daily operator flow</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-white">Invoice → Payment → Receipt</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-slate-400">Compliance</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-white">EFRIS submission trail</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl border border-white/10 bg-white/5 p-5 backdrop-blur">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-slate-400">Deployment model</p>
|
||||||
|
<p className="mt-3 text-3xl font-semibold text-white">Multi-tenant SaaS</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BaseButtons>
|
<div className="relative">
|
||||||
<BaseButton
|
<div className="absolute -inset-4 rounded-[2rem] bg-gradient-to-br from-blue-500/20 via-transparent to-emerald-400/10 blur-2xl" />
|
||||||
href='/login'
|
<div className="relative rounded-[2rem] border border-white/10 bg-slate-900/90 p-6 shadow-2xl shadow-blue-900/20 backdrop-blur">
|
||||||
label='Login'
|
<div className="flex items-center justify-between gap-4 border-b border-white/10 pb-5">
|
||||||
color='info'
|
<div>
|
||||||
className='w-full'
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-blue-200">First MVP slice</p>
|
||||||
/>
|
<h2 className="mt-2 text-2xl font-semibold text-white">Revenue desk</h2>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-emerald-200">
|
||||||
|
Live admin workflow
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</BaseButtons>
|
<div className="mt-6 space-y-4">
|
||||||
</CardBox>
|
{[
|
||||||
|
'Create an invoice from real customer + product data',
|
||||||
|
'Optionally take payment and auto-create the receipt',
|
||||||
|
'Generate an EFRIS-ready submission record per tenant',
|
||||||
|
'Review recent invoices, payments, and fiscal events together',
|
||||||
|
].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex items-start gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-4"
|
||||||
|
>
|
||||||
|
<div className="mt-1 flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-white">
|
||||||
|
<BaseIcon path={icon.mdiCheckDecagramOutline} size={14} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-slate-200">{item}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-3xl border border-white/10 bg-white/5 p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">What your team gets today</p>
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||||||
|
{featureCards.map((feature) => (
|
||||||
|
<div key={feature.title} className="rounded-2xl border border-white/10 bg-slate-950/80 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-2xl bg-blue-500/10 p-2 text-blue-200">
|
||||||
|
<BaseIcon path={feature.iconPath} size={20} />
|
||||||
|
</div>
|
||||||
|
<p className="font-semibold text-white">{feature.title}</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-slate-400">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionFullScreen>
|
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
<div className="bg-slate-50 text-slate-900">
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
<div className="mx-auto max-w-7xl px-6 py-20 lg:px-8">
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
<div className="max-w-3xl">
|
||||||
Privacy Policy
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">Platform highlights</p>
|
||||||
</Link>
|
<h2 className="mt-4 text-4xl font-semibold tracking-tight text-slate-900">
|
||||||
|
The foundation already spans accounting, operations, customer context, and compliance.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-lg leading-8 text-slate-500">
|
||||||
|
Instead of rebuilding generic CRUD, the app now exposes the first operator workflow that ties those modules together in a way a business owner can actually use daily.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-12 grid gap-6 lg:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{moduleHighlights.map((item) => (
|
||||||
|
<CardBox key={item.label} className="h-full">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-blue-600">{item.label}</p>
|
||||||
|
<p className="mt-4 text-base leading-7 text-slate-600">{item.text}</p>
|
||||||
|
</div>
|
||||||
|
</CardBox>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white text-slate-900">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-20 lg:px-8">
|
||||||
|
<div className="grid gap-12 lg:grid-cols-[0.95fr_1.05fr]">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">Initial workflow</p>
|
||||||
|
<h2 className="mt-4 text-4xl font-semibold tracking-tight text-slate-900">
|
||||||
|
One thin slice that behaves like a real product, not just a brochure.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-lg leading-8 text-slate-500">
|
||||||
|
The Revenue Desk is the first concrete win: create/input, confirmation, recent list review, and drill-through detail pages are all wired together.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 rounded-[2rem] border border-slate-200 bg-slate-50 p-6">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-[0.22em] text-slate-400">Why this first</p>
|
||||||
|
<p className="mt-3 text-base leading-7 text-slate-600">
|
||||||
|
It turns your current entities—customers, products, invoices, payments, receipts, EFRIS submissions—into the core day-to-day workflow a business owner actually cares about.
|
||||||
|
</p>
|
||||||
|
<BaseButtons type="justify-start" mb="mb-0" className="mt-6">
|
||||||
|
<BaseButton href="/revenue-desk" label="Open revenue desk" color="info" />
|
||||||
|
<BaseButton href="/invoices/invoices-list" label="Browse invoices" color="lightDark" />
|
||||||
|
</BaseButtons>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{workflowSteps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={step.title}
|
||||||
|
className="rounded-[2rem] border border-slate-200 bg-slate-50 px-6 py-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-slate-900 text-sm font-semibold text-white">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-semibold text-slate-900">{step.title}</p>
|
||||||
|
<p className="mt-2 text-base leading-7 text-slate-500">{step.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950 text-white">
|
||||||
|
<div className="mx-auto max-w-7xl px-6 py-20 lg:px-8">
|
||||||
|
<div className="rounded-[2rem] border border-white/10 bg-white/5 p-8 backdrop-blur lg:p-12">
|
||||||
|
<div className="grid gap-10 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-200">Next action</p>
|
||||||
|
<h2 className="mt-4 text-4xl font-semibold tracking-tight text-white">
|
||||||
|
Sign in, open the admin interface, and try the first end-to-end flow.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 max-w-2xl text-lg leading-8 text-slate-300">
|
||||||
|
You can start from the Revenue Desk immediately, then expand into quotes, expenses, reporting, approvals, and deeper live EFRIS API handshakes in the next iteration.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[2rem] border border-white/10 bg-slate-950/70 p-6">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">Direct links</p>
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{[
|
||||||
|
{ href: '/login', label: 'Login' },
|
||||||
|
{ href: '/dashboard', label: 'Open admin interface' },
|
||||||
|
{ href: '/revenue-desk', label: 'Launch revenue desk' },
|
||||||
|
].map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className="flex items-center justify-between rounded-2xl border border-white/10 px-4 py-4 text-sm font-medium text-white transition hover:border-blue-400/40 hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<BaseIcon path={icon.mdiArrowRight} size={18} />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="mt-10 flex flex-col gap-4 border-t border-white/10 pt-6 text-sm text-slate-400 md:flex-row md:items-center md:justify-between">
|
||||||
|
<p>© 2026 EFRIS Accounting Suite. Multi-tenant accounting + compliance for modern SMEs.</p>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Link href="/login" className="hover:text-white">
|
||||||
|
Login
|
||||||
|
</Link>
|
||||||
|
<Link href="/dashboard" className="hover:text-white">
|
||||||
|
Admin interface
|
||||||
|
</Link>
|
||||||
|
<Link href="/privacy-policy/" className="hover:text-white">
|
||||||
|
Privacy policy
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -163,4 +324,3 @@ export default function Starter() {
|
|||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
1719
frontend/src/pages/revenue-desk.tsx
Normal file
1719
frontend/src/pages/revenue-desk.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,7 @@
|
|||||||
import React, { ReactElement, useEffect, useState } from 'react';
|
import React, { ReactElement, useEffect, useState } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import 'react-datepicker/dist/react-datepicker.css';
|
import 'react-datepicker/dist/react-datepicker.css';
|
||||||
import { useAppDispatch } from '../stores/hooks';
|
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||||||
|
|
||||||
import { useAppSelector } from '../stores/hooks';
|
|
||||||
|
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user