
Invoice Processor
AI International Invoice Extraction and Accounting Tool


Customization
Important: Before customizing this agent, you must first deploy it using the Deploy button above. After installation is complete, you can proceed with the following steps to clone and evolve the agent.
1. Install OpenKBS CLI:
npm install -g openkbs
2. Create and enter project directory:
mkdir my-agent && cd my-agent
3. Clone your app locally:
openkbs login
openkbs ls
openkbs clone <id-from-ls-output>
4. Initialize Git repository:
git init && git stage . && git commit -m "First commit"
5. Add new features using claude code ( npm install -g @anthropic-ai/claude-code ):
Examples:
claude "International accounting invoice processing agent with automatic VAT detection and chart of accounts"
6. Review changes and deploy to OpenKBS:
git diff
openkbs push
Disclaimer
The applications provided through OpenKBS are developmental blueprints and are intended solely as starting points for software engineers and developers. These open-source templates are not production-ready solutions.
Before any production deployment, developers must:
- Conduct comprehensive code reviews and security audits
- Implement robust security measures and safeguards
- Perform extensive testing procedures
- Ensure full compliance with applicable regulations
- Adapt and enhance the codebase for specific use cases
NO WARRANTY DISCLAIMER: These blueprints are provided "as-is" without any warranties, whether express or implied. By using these blueprints, you assume full responsibility for all aspects of development, implementation, and maintenance of any derived applications. OpenKBS shall not be liable for any damages or consequences arising from the use of these blueprints.
Instructions and Source Code
You are a professional document extraction tool for international accounting systems. IMPORTANT: By default, YOUR company is the RECIPIENT/BUYER on the invoice (CompanyRecipient). This means you are processing INCOMING invoices for purchases/expenses. MANDATORY WORKFLOW: 1. User uploads image - you will receive both invoiceText (from OCR processing) AND the original image for visual analysis 2. CRITICAL: Use BOTH data sources for maximum accuracy: - Always cross-reference between OCR text and visual image - OCR is not absolute truth - Combine both sources to ensure complete and accurate data extraction 3. Extract Tax IDs/VAT numbers for both companies using text and visual analysis 4. Identify which company is YOUR company: - DEFAULT: YOUR company = CompanyRecipient (the buyer/receiver of goods/services) - This means you're processing an incoming purchase invoice 5. Use /getChartOfAccounts() to retrieve the company's chart of accounts (accounting structure) 6. After you output the getChartOfAccounts command stop and wait for system response in the next message 7. (Optional) Evaluate invoice items against the chart of accounts: - If items represent specific expenses (utilities, subscriptions, electronics, specific services) that would benefit from separate tracking in financial reports, create subaccounts using /addAccounts() - This ensures proper categorization for financial analysis and reporting ACCOUNT CREATION RULES: When processing invoice items, analyze if they fit well into existing generic accounts. If items are specific (e.g., electricity, internet, fuel, specific materials), CREATE new subaccounts: - For purchases: Create under 5000 (Purchases) or 5100 (Operating Expenses) or 5200 (Personnel) - Only create if the item is specific enough to warrant its own account - Use /addAccount() command to create the account BEFORE outputting the document JSON - Create account numbers that follow the parent numbering (e.g., 5110, 5111, 5112 under 5100) - After creating accounts, use them in the AccountingDetails section 8. Generate final JSON document with proper accounting entries using account numbers from the chart CRITICAL RULES: - ALWAYS use BOTH text data AND visual image analysis for maximum extraction accuracy - Cross-reference information between text and image to ensure no data is missed - ALWAYS check chart of accounts BEFORE generating final JSON - Extract and output ALL invoice items, even if hundreds - Process every single line item from all provided invoice pages - All amounts MUST be strings with exactly 6 decimal places (e.g., "479.990000") - DocumentType: "1" for invoices, "2" for debit notes, "3" for credit notes - PaymentType: "1" for bank transfer, "2" for cash, "3" for card - output only one command per message, then stop and wait for system response in the next message OUTPUT_JSON_STRUCTURE (Example for PURCHASES): IMPORTANT: Wrap your document output in a SAVE_DOCUMENT_REQUEST structure. ALSO: If invoice items require new account categories, use /addAccounts() to create them BEFORE saving the document. { "type": "SAVE_DOCUMENT_REQUEST", "document": { "DocumentId": "123456789_0000000001", // Format: {Sender_TaxID}_{DocumentNumber} "DocumentType": "1", "Number": "0000000001", "Date": "07/29/2025 12:00:00 AM", "Term": "Purchase description", "DueDate": "07/29/2025", "TotalAmount": "479.990000", "TotalVatAmount": "80.000000", "PaymentType": "1", "CompanyRecipient": { "Name": "Your Company Name", "TaxID": "9876543210", "VATNumber": "EU9876543210", "Id": "9876543210", "Addresses": [ { "Location": "..." } ], "BankAccounts": [ { "Name": "...", "IBAN": "..." } ] }, "CompanySender": { "Name": "Supplier Company Name", "TaxID": "123456789", "VATNumber": "EU123456789", "Id": "123456789", "Addresses": [ { "Location": "..." } ], "BankAccounts": [ { "Name": "...", "IBAN": "..." } ] }, "DocumentDetails": [ { "ServiceGood": { "Name": "", "Code": "", "Price": "399.990000", "FixPrice": "399.990000", "EcoTax": "0.000000", "Measure": "pcs", "Barcode": "", "Reference": "", "VatRate": "20", "VatTermId": "7" }, "Qtty": "1", "Amount": "399.990000", "Reference": "", "Measure": "pcs", "VatAmount": "79.998000", "TotalVatAmount": "79.998000" } ], "Accountings": [ { "AccountingDate": "2025-07-29T12:00:00", "AccountingDetails": [ { "VatTermId": "7", "Direction": "Debit", "Amount": "479.990000", "AccountNumber": "Get from ChartOfAccounts", "VatTerm": "7", "Description": "Based on purchase type" }, { "VatTermId": "7", "Direction": "Credit", "Amount": "479.990000", "AccountNumber": "Get from ChartOfAccounts", "VatTerm": "7", "Description": "Accounts Payable" } ] } ] } } ACCOUNTING REPORTS AND ANALYSIS: When users ask for financial reports or analysis, use these commands: 1. DOCUMENT MANAGEMENT: /listDocuments() - Shows all saved invoices with items in expandable cards 2. FINANCIAL REPORTS: /getTrialBalance() - Trial Balance by categories /getIncomeStatement() - Profit & Loss statement showing revenues vs expenses /getVATReport() - VAT analysis for tax returns (Input VAT vs Output VAT) /getAccountsReport() - Payables/Receivables with aging analysis 3. CHART OF ACCOUNTS: /getChartOfAccounts() - View current account structure /addAccounts([{"parentNumber": "5100", "number": "5110", "name": "Utilities"}]) - Add account(s) REPORT USAGE EXAMPLES: - User: "Show me trial balance" → Use /getTrialBalance() - User: "What's my profit?" → Use /getIncomeStatement() - User: "VAT report" → Use /getVATReport() - User: "Who owes me money?" → Use /getAccountsReport() - User: "List all invoices" → Use /listDocuments() AVAILABLE TOOLS: /listDocuments() Description: """ Lists all saved documents with their details including items. Returns a nicely formatted list with expandable document cards showing all invoice items. The response is automatically rendered in a beautiful UI. """ /getTrialBalance() Description: """ Generates a Trial Balance grouped by account categories. Shows debit, credit, and balance for each account within categories: - Assets - Liabilities - Equity - Revenue - Expenses Returns a formatted report with category totals and grand totals. """ /getIncomeStatement() Description: """ Generates Income Statement (Profit & Loss Report). Shows: - All revenue accounts with amounts - All expense accounts with amounts - Net Income (Revenue - Expenses) - Profit margin percentage Sorted by largest amounts first. """ /getVATReport() Description: """ Generates VAT Report for tax reporting. Shows: - Total Input VAT (VAT paid on purchases - can be reclaimed) - Total Output VAT (VAT collected on sales - must be paid) - VAT Payable or Refundable amount - List of all documents with VAT amounts Essential for VAT returns and tax compliance. """ /getAccountsReport() Description: """ Generates Accounts Payable and Receivable Report with aging analysis. Shows: - Accounts Payable: What you owe to each supplier - Accounts Receivable: What each customer owes you - Aging buckets: Current, 31-60 days, 61-90 days, Over 90 days - Net position (Receivables - Payables) Helps manage cash flow and credit control. """ /getChartOfAccounts() Description: """ Get the current chart of accounts structure. Returns the complete chart with all accounts and subaccounts. If no chart exists, creates a default one automatically. """ /addAccounts([{"parentNumber": "5100", "number": "5110", "name": "Utilities"}, {"parentNumber": "5100", "number": "5120", "name": "Office Supplies"}]) Description: """ Add one or more accounts to the chart of accounts. Can accept a single account object or an array of accounts. Parameters for each account: - number: Account number (required) - name: Account name (required) - parentNumber: Parent account number (optional, for subaccounts) - category: Account category (optional, defaults to parent's category) """ FIELD DESCRIPTIONS: - DocumentId: Unique identifier format: {Sender_TaxID}_{DocumentNumber} (e.g., "123456789_INV001") For incoming invoices, this helps track documents from each supplier uniquely - DocumentType: Document classification (1=Invoice, 2=Debit Note, 3=Credit Note) - Number: Document Number - Date: Document Date - Term: Brief transaction description based on goods/services + payment method if cash Examples: "Purchase of goods - cash", "Electricity expenses", "Materials purchase", "Office rent" - CompanyRecipient: Receiving company (buyer) - THIS IS YOUR COMPANY BY DEFAULT - CompanySender: Sending company (seller/supplier) - DocumentDetails: Array of goods/services with complete details: - ServiceGood.Name: Product/service name - ServiceGood.Code: Product code - ServiceGood.Price: Unit price (6 decimal places) - ServiceGood.Measure: Unit of measurement (pcs, kg, m, etc.) - ServiceGood.Barcode: Product barcode - ServiceGood.VatRate: VAT percentage ("20" for 20%) - ServiceGood.VatTermId: Always "7" - Qtty: Quantity (3 decimal places) - Amount: Line total without VAT (6 decimal places) - VatAmount: VAT amount for this line (6 decimal places) - TotalVatAmount: Same as VatAmount for single line - Reference: Optional reference text - Measure: Unit of measurement (same as ServiceGood.Measure) - AccountingDetails: Debit/Credit entries for accounting ACCOUNTING RULES: STEP 1: GET CHART OF ACCOUNTS - Use /getChartOfAccounts() to get the chart of accounts structure - Use the account numbers and descriptions from that response for all accounting entries STEP 2: VAT HANDLING - Always calculate VAT amounts separately when VAT is present on invoice - TotalVatAmount: sum of all VAT amounts - TotalAmount: full invoice amount including VAT - VatAmount in DocumentDetails: calculate VAT per line item CRITICAL: MULTI-CATEGORY INVOICE HANDLING When invoice contains items from different categories (e.g., goods + services): - Create SEPARATE Debit (FOR PURCHASES) entries for EACH category - Each category gets its own AccountingDetails entry with appropriate account - Sum all Debits (FOR PURCHASES) must equal the Credit amount Example: Invoice with 100.00 goods + 50.00 services: - Debit Purchases account: 100.00 - Debit Rent/Service account: 50.00 - Credit Accounts Payable: 150.00 FOR PURCHASES (default scenario - incoming invoices): - Goods/Expense accounts (Debit): Base amounts per category without VAT - Input VAT (2310) account (Debit): VAT amount only - Accounts Payable (2100) account (Credit): Total amount with VAT CASH PAYMENT CLOSURE (when PaymentType: "2"): Add second Accounting object in Accountings array with AccountingDetails: - Accounts Payable (2100) account (Debit), Cash (1000) account (Credit): Total amount FOR SALES (outgoing invoices - rare scenario): - Accounts Receivable (1200) account (Debit): Total amount with VAT - Sales Revenue (4000) account (Credit): Base amount without VAT - Output VAT (2320) account (Credit): VAT amount only DETAILED REPORT DESCRIPTIONS FOR USER QUERIES: When users ask about financial status, reports, or analysis, understand these mappings: TRIAL BALANCE (/getTrialBalance): - Keywords: "trial balance", "account balances", "debit credit" - Shows: All accounts grouped by category with debit/credit/balance - Use when: User wants overview of all accounts INCOME STATEMENT (/getIncomeStatement): - Keywords: "profit", "loss", "P&L", "income", "expenses" - Shows: Revenue vs Expenses = Net Income/Loss - Use when: User asks about profitability VAT REPORT (/getVATReport): - Keywords: "VAT", "tax", "VAT return" - Shows: Input VAT (reclaimable) vs Output VAT (payable) - Use when: User needs VAT information for tax returns ACCOUNTS REPORT (/getAccountsReport): - Keywords: "payables", "receivables", "who owes", "what we owe", "aging" - Shows: Money owed to suppliers and by customers with age analysis - Use when: User asks about debts or credit control DOCUMENT LIST (/listDocuments): - Keywords: "list", "show invoices", "all documents" - Shows: All invoices with expandable item details - Use when: User wants to see saved documents
Events Files (Node.js Backend)
Events/actions.js
const getDefaultChartOfAccounts = () => {
return {
accounts: [
{ number: "1000", name: "Cash", category: "Assets", subAccounts: [] },
{ number: "1100", name: "Bank Accounts", category: "Assets", subAccounts: [] },
{ number: "1200", name: "Accounts Receivable", category: "Assets", subAccounts: [] },
{ number: "1500", name: "Fixed Assets", category: "Assets", subAccounts: [] },
{ number: "2100", name: "Accounts Payable", category: "Liabilities", subAccounts: [] },
{ number: "2300", name: "VAT Payable", category: "Liabilities", subAccounts: [] },
{ number: "2310", name: "Input VAT", category: "Liabilities", subAccounts: [] },
{ number: "2320", name: "Output VAT", category: "Liabilities", subAccounts: [] },
{ number: "2500", name: "Loans", category: "Liabilities", subAccounts: [] },
{ number: "3000", name: "Capital", category: "Equity", subAccounts: [] },
{ number: "3100", name: "Retained Earnings", category: "Equity", subAccounts: [] },
{ number: "4000", name: "Sales", category: "Revenue", subAccounts: [] },
{ number: "4100", name: "Services", category: "Revenue", subAccounts: [] },
{ number: "5000", name: "Purchases", category: "Expenses", subAccounts: [] },
{ number: "5100", name: "Operating Expenses", category: "Expenses", subAccounts: [] },
{ number: "5200", name: "Personnel Expenses", category: "Expenses", subAccounts: [] },
{ number: "5900", name: "Other Expenses", category: "Expenses", subAccounts: [] }
]
};
};
const getOrCreateChartOfAccounts = async () => {
const response = await openkbs.items({
action: 'fetchItems',
field: 'itemId',
from: 'chartOfAccounts',
limit: 1
});
const chartItems = response.items?.filter(item =>
item.meta?.itemType === 'chartOfAccounts'
) || [];
if (chartItems.length > 0) {
const encryptedChart = chartItems[0].item.chart;
const decryptedChart = await openkbs.decrypt(encryptedChart);
const parsedChart = JSON.parse(decryptedChart);
return parsedChart;
} else {
const defaultChart = getDefaultChartOfAccounts();
await openkbs.items({
action: 'createItem',
itemType: 'chartOfAccounts',
attributes: [
{ attrType: "itemId", attrName: "Id", encrypted: false },
{ attrType: "body", attrName: "chart", encrypted: true }
],
item: {
Id: 'chartOfAccounts',
chart: await openkbs.encrypt(JSON.stringify(defaultChart))
}
});
return defaultChart;
}
};
const addAccountToChart = (accounts, parentNumber, newAccount) => {
for (let account of accounts) {
if (account.number === parentNumber) {
if (!account.subAccounts) account.subAccounts = [];
account.subAccounts.push(newAccount);
return true;
}
if (account.subAccounts && account.subAccounts.length > 0) {
if (addAccountToChart(account.subAccounts, parentNumber, newAccount)) {
return true;
}
}
}
return false;
};
const saveChartOfAccounts = async (chart) => {
const encryptedChart = await openkbs.encrypt(JSON.stringify(chart));
const existing = await openkbs.items({
action: 'fetchItems',
field: 'itemId',
from: 'chartOfAccounts',
limit: 1
});
const existingCharts = existing.items?.filter(item =>
item.meta?.itemType === 'chartOfAccounts'
) || [];
if (existingCharts.length > 0) {
await openkbs.items({
action: 'deleteItem',
itemType: 'chartOfAccounts',
itemId: 'chartOfAccounts'
});
}
await openkbs.items({
action: 'createItem',
itemType: 'chartOfAccounts',
attributes: [
{ attrType: "itemId", attrName: "Id", encrypted: false },
{ attrType: "body", attrName: "chart", encrypted: true }
],
item: {
Id: 'chartOfAccounts',
chart: encryptedChart
}
});
return true;
};
const saveDocument = async (document, meta) => {
if (!document.DocumentId) {
throw new Error("Document must have a DocumentId field");
}
const response = await openkbs.items({
action: 'createItem',
itemType: 'document',
attributes: [
{ attrType: "itemId", attrName: "Id", encrypted: false },
{ attrType: "body", attrName: "document", encrypted: true }
],
item: {
Id: document.DocumentId,
document: await openkbs.encrypt(JSON.stringify(document))
}
});
return {
type: "DOCUMENT_SAVED",
data: {
message: `Document saved with ID: ${document.DocumentId}`,
documentId: document.DocumentId,
itemCount: document.DocumentDetails?.length || 0,
documentData: document,
result: response,
},
_meta_actions: []
};
};
export const getActions = (meta) => [
[/\{\s*"type"\s*:\s*"SAVE_DOCUMENT_REQUEST"[\s\S]*\}/, async (match) => {
const requestData = openkbs.parseJSONFromText(match[0]);
const document = requestData.document;
if (!document) {
throw new Error("No document data provided");
}
return await saveDocument(document, meta);
}],
[/\/addAccounts\(([^)]+)\)/, async (match) => {
const params = JSON.parse(match[1]);
const accountsToAdd = Array.isArray(params) ? params : [params]; // Support both single and multiple accounts
const chart = await getOrCreateChartOfAccounts();
const results = [];
const added = [];
const failed = [];
for (const accountParams of accountsToAdd) {
const { parentNumber, number, name, category } = accountParams;
if (!number || !name) {
failed.push({
account: accountParams,
error: "Account number and name are required"
});
continue;
}
const newAccount = {
number,
name,
category: category || "Expenses",
subAccounts: []
};
let addedSuccess = false;
if (parentNumber) {
addedSuccess = addAccountToChart(chart.accounts, parentNumber, newAccount);
if (!addedSuccess) {
failed.push({
account: accountParams,
error: `Parent account ${parentNumber} not found`
});
}
} else {
chart.accounts.push(newAccount);
addedSuccess = true;
}
if (addedSuccess) {
added.push({
number,
name,
parentNumber: parentNumber || "root"
});
}
}
if (added.length > 0) {
await saveChartOfAccounts(chart);
}
return {
type: "ACCOUNTS_ADDED",
message: `Added ${added.length} account(s) successfully${failed.length > 0 ? `, ${failed.length} failed` : ''}`,
added,
failed,
...meta
};
}],
[/\/getChartOfAccounts\(\)/, async (match) => {
const chart = await getOrCreateChartOfAccounts();
return {
type: "CHART_OF_ACCOUNTS",
data: chart,
...meta
};
}],
[/\/listDocuments\(\)/, async (match) => {
const response = await openkbs.items({
action: 'fetchItems',
limit: 1000
});
const documentItems = response.items?.filter(item =>
item.meta?.itemType === 'document'
) || [];
if (documentItems.length === 0) {
return {
type: "DOCUMENTS_LIST",
message: "No documents found",
data: [],
_meta_actions: []
};
}
const data = await Promise.all(documentItems.map(async (item) => {
const decryptedDoc = await openkbs.decrypt(item.item.document);
const parsedDoc = JSON.parse(decryptedDoc);
const items = parsedDoc.DocumentDetails?.map(detail => ({
name: detail.ServiceGood?.Name,
quantity: detail.Qtty,
measure: detail.Measure,
price: detail.ServiceGood?.Price,
amount: detail.Amount,
vatRate: detail.ServiceGood?.VatRate,
vatAmount: detail.VatAmount
})) || [];
return {
id: item.item.Id,
documentId: parsedDoc.DocumentId,
documentType: parsedDoc.DocumentType,
number: parsedDoc.Number,
date: parsedDoc.Date,
totalAmount: parsedDoc.TotalAmount,
totalVatAmount: parsedDoc.TotalVatAmount,
sender: parsedDoc.CompanySender?.Name,
senderTaxId: parsedDoc.CompanySender?.TaxID,
recipient: parsedDoc.CompanyRecipient?.Name,
recipientTaxId: parsedDoc.CompanyRecipient?.TaxID,
itemCount: parsedDoc.DocumentDetails?.length || 0,
items: items,
createdAt: item.meta.createdAt,
updatedAt: item.meta.updatedAt
};
}));
return {
type: "DOCUMENTS_LIST",
data,
count: data.length,
_meta_actions: []
};
}],
[/\/getTrialBalance\(\)/, async (match) => {
const response = await openkbs.items({
action: 'fetchItems',
limit: 1000
});
const documentItems = response.items?.filter(item =>
item.meta?.itemType === 'document'
) || [];
if (documentItems.length === 0) {
return {
type: "TRIAL_BALANCE",
message: "No documents found for trial balance",
data: {
categories: {},
totals: { debit: 0, credit: 0 }
},
_meta_actions: []
};
}
const chart = await getOrCreateChartOfAccounts();
const accountMap = {};
const buildAccountMap = (accounts, parentCategory = null) => {
accounts.forEach(account => {
accountMap[account.number] = {
name: account.name,
category: account.category || parentCategory,
debit: 0,
credit: 0,
balance: 0,
transactions: []
};
if (account.subAccounts && account.subAccounts.length > 0) {
buildAccountMap(account.subAccounts, account.category);
}
});
};
buildAccountMap(chart.accounts);
for (const item of documentItems) {
const decryptedDoc = await openkbs.decrypt(item.item.document);
const doc = JSON.parse(decryptedDoc);
if (doc.Accountings) {
for (const accounting of doc.Accountings) {
if (accounting.AccountingDetails) {
for (const detail of accounting.AccountingDetails) {
const accountNum = detail.AccountNumber;
if (accountMap[accountNum]) {
const amount = parseFloat(detail.Amount || 0);
if (detail.Direction === 'Debit') {
accountMap[accountNum].debit += amount;
} else if (detail.Direction === 'Credit') {
accountMap[accountNum].credit += amount;
}
accountMap[accountNum].transactions.push({
documentId: doc.DocumentId,
date: doc.Date,
description: detail.Description,
direction: detail.Direction,
amount: amount
});
}
}
}
}
}
}
const categorizedAccounts = {};
let totalDebit = 0;
let totalCredit = 0;
Object.entries(accountMap).forEach(([accountNum, account]) => {
if (account.debit > 0 || account.credit > 0) {
const category = account.category || 'Other';
if (category === 'Assets' || category === 'Expenses') {
account.balance = account.debit - account.credit;
} else {
account.balance = account.credit - account.debit;
}
if (!categorizedAccounts[category]) {
categorizedAccounts[category] = {
accounts: [],
totalDebit: 0,
totalCredit: 0,
totalBalance: 0
};
}
categorizedAccounts[category].accounts.push({
number: accountNum,
name: account.name,
debit: account.debit,
credit: account.credit,
balance: account.balance,
transactionCount: account.transactions.length
});
categorizedAccounts[category].totalDebit += account.debit;
categorizedAccounts[category].totalCredit += account.credit;
categorizedAccounts[category].totalBalance += account.balance;
totalDebit += account.debit;
totalCredit += account.credit;
}
});
Object.values(categorizedAccounts).forEach(category => {
category.accounts.sort((a, b) => a.number.localeCompare(b.number));
});
return {
type: "TRIAL_BALANCE",
data: {
categories: categorizedAccounts,
totals: {
debit: totalDebit,
credit: totalCredit,
difference: Math.abs(totalDebit - totalCredit)
},
documentCount: documentItems.length,
generatedAt: new Date().toLocaleString()
},
_meta_actions: []
};
}],
[/\/getIncomeStatement\(\)/, async (match) => {
const response = await openkbs.items({
action: 'fetchItems',
limit: 1000
});
const documentItems = response.items?.filter(item =>
item.meta?.itemType === 'document'
) || [];
if (documentItems.length === 0) {
return {
type: "INCOME_STATEMENT",
message: "No documents found",
data: {
revenue: { accounts: [], total: 0 },
expenses: { accounts: [], total: 0 },
netIncome: 0
},
_meta_actions: []
};
}
const chart = await getOrCreateChartOfAccounts();
const accountMap = {};
const buildAccountMap = (accounts, parentCategory = null) => {
accounts.forEach(account => {
accountMap[account.number] = {
name: account.name,
category: account.category || parentCategory,
amount: 0,
transactions: 0
};
if (account.subAccounts && account.subAccounts.length > 0) {
buildAccountMap(account.subAccounts, account.category);
}
});
};
buildAccountMap(chart.accounts);
for (const item of documentItems) {
const decryptedDoc = await openkbs.decrypt(item.item.document);
const doc = JSON.parse(decryptedDoc);
if (doc.Accountings) {
for (const accounting of doc.Accountings) {
if (accounting.AccountingDetails) {
for (const detail of accounting.AccountingDetails) {
const accountNum = detail.AccountNumber;
if (accountMap[accountNum]) {
const amount = parseFloat(detail.Amount || 0);
const category = accountMap[accountNum].category;
if (category === 'Revenue') {
if (detail.Direction === 'Credit') {
accountMap[accountNum].amount += amount;
} else {
accountMap[accountNum].amount -= amount;
}
} else if (category === 'Expenses') {
if (detail.Direction === 'Debit') {
accountMap[accountNum].amount += amount;
} else {
accountMap[accountNum].amount -= amount;
}
}
accountMap[accountNum].transactions++;
}
}
}
}
}
}
const revenue = { accounts: [], total: 0 };
const expenses = { accounts: [], total: 0 };
Object.entries(accountMap).forEach(([number, account]) => {
if (account.amount !== 0) {
if (account.category === 'Revenue') {
revenue.accounts.push({
number,
name: account.name,
amount: account.amount,
transactions: account.transactions
});
revenue.total += account.amount;
} else if (account.category === 'Expenses') {
expenses.accounts.push({
number,
name: account.name,
amount: account.amount,
transactions: account.transactions
});
expenses.total += account.amount;
}
}
});
revenue.accounts.sort((a, b) => b.amount - a.amount);
expenses.accounts.sort((a, b) => b.amount - a.amount);
const netIncome = revenue.total - expenses.total;
return {
type: "INCOME_STATEMENT",
data: {
revenue,
expenses,
netIncome,
profitMargin: revenue.total > 0 ? (netIncome / revenue.total * 100) : 0,
documentCount: documentItems.length,
generatedAt: new Date().toLocaleString()
},
_meta_actions: []
};
}],
[/\/getVATReport\(\)/, async (match) => {
const response = await openkbs.items({
action: 'fetchItems',
limit: 1000
});
const documentItems = response.items?.filter(item =>
item.meta?.itemType === 'document'
) || [];
if (documentItems.length === 0) {
return {
type: "VAT_REPORT",
message: "No documents found",
data: {
inputVAT: 0,
outputVAT: 0,
vatPayable: 0,
documents: []
},
_meta_actions: []
};
}
let inputVAT = 0;
let outputVAT = 0;
const documents = [];
for (const item of documentItems) {
const decryptedDoc = await openkbs.decrypt(item.item.document);
const doc = JSON.parse(decryptedDoc);
const vatAmount = parseFloat(doc.TotalVatAmount || 0);
if (vatAmount > 0) {
let isInput = false;
let isOutput = false;
if (doc.Accountings) {
for (const accounting of doc.Accountings) {
if (accounting.AccountingDetails) {
for (const detail of accounting.AccountingDetails) {
if (detail.AccountNumber === '2310') {
isInput = true;
} else if (detail.AccountNumber === '2320') {
isOutput = true;
}
}
}
}
}
documents.push({
documentId: doc.DocumentId,
number: doc.Number,
date: doc.Date,
sender: doc.CompanySender?.Name,
recipient: doc.CompanyRecipient?.Name,
totalAmount: parseFloat(doc.TotalAmount || 0),
vatAmount: vatAmount,
type: isInput ? 'Input VAT' : (isOutput ? 'Output VAT' : 'Unknown')
});
if (isInput) {
inputVAT += vatAmount;
} else if (isOutput) {
outputVAT += vatAmount;
}
}
}
documents.sort((a, b) => new Date(b.date) - new Date(a.date));
const vatPayable = outputVAT - inputVAT;
return {
type: "VAT_REPORT",
data: {
inputVAT,
outputVAT,
vatPayable,
vatRefundable: vatPayable < 0 ? Math.abs(vatPayable) : 0,
documents,
summary: {
totalDocuments: documents.length,
inputDocuments: documents.filter(d => d.type === 'Input VAT').length,
outputDocuments: documents.filter(d => d.type === 'Output VAT').length
},
generatedAt: new Date().toLocaleString()
},
_meta_actions: []
};
}],
[/\/getAccountsReport\(\)/, async (match) => {
const response = await openkbs.items({
action: 'fetchItems',
limit: 1000
});
const documentItems = response.items?.filter(item =>
item.meta?.itemType === 'document'
) || [];
if (documentItems.length === 0) {
return {
type: "ACCOUNTS_REPORT",
message: "No documents found",
data: {
payables: [],
receivables: [],
totalPayable: 0,
totalReceivable: 0
},
_meta_actions: []
};
}
const payables = {};
const receivables = {};
for (const item of documentItems) {
const decryptedDoc = await openkbs.decrypt(item.item.document);
const doc = JSON.parse(decryptedDoc);
let hasPayable = false;
let hasReceivable = false;
if (doc.Accountings) {
for (const accounting of doc.Accountings) {
if (accounting.AccountingDetails) {
for (const detail of accounting.AccountingDetails) {
if (detail.AccountNumber === '2100') {
hasPayable = true;
} else if (detail.AccountNumber === '1200') {
hasReceivable = true;
}
}
}
}
}
const amount = parseFloat(doc.TotalAmount || 0);
const daysOld = Math.floor((new Date() - new Date(doc.Date)) / (1000 * 60 * 60 * 24));
if (hasPayable) {
const supplier = doc.CompanySender?.Name || 'Unknown';
if (!payables[supplier]) {
payables[supplier] = {
name: supplier,
taxId: doc.CompanySender?.TaxID,
totalAmount: 0,
documents: []
};
}
payables[supplier].totalAmount += amount;
payables[supplier].documents.push({
documentId: doc.DocumentId,
number: doc.Number,
date: doc.Date,
amount: amount,
daysOld: daysOld,
aging: daysOld <= 30 ? 'Current' : daysOld <= 60 ? '31-60 days' : daysOld <= 90 ? '61-90 days' : 'Over 90 days'
});
}
if (hasReceivable) {
const customer = doc.CompanyRecipient?.Name || 'Unknown';
if (!receivables[customer]) {
receivables[customer] = {
name: customer,
taxId: doc.CompanyRecipient?.TaxID,
totalAmount: 0,
documents: []
};
}
receivables[customer].totalAmount += amount;
receivables[customer].documents.push({
documentId: doc.DocumentId,
number: doc.Number,
date: doc.Date,
amount: amount,
daysOld: daysOld,
aging: daysOld <= 30 ? 'Current' : daysOld <= 60 ? '31-60 days' : daysOld <= 90 ? '61-90 days' : 'Over 90 days'
});
}
}
const payablesList = Object.values(payables);
const receivablesList = Object.values(receivables);
const totalPayable = payablesList.reduce((sum, p) => sum + p.totalAmount, 0);
const totalReceivable = receivablesList.reduce((sum, r) => sum + r.totalAmount, 0);
const calculateAging = (list) => {
const aging = {
current: 0,
days30_60: 0,
days60_90: 0,
over90: 0
};
list.forEach(company => {
company.documents.forEach(doc => {
if (doc.aging === 'Current') aging.current += doc.amount;
else if (doc.aging === '31-60 days') aging.days30_60 += doc.amount;
else if (doc.aging === '61-90 days') aging.days60_90 += doc.amount;
else aging.over90 += doc.amount;
});
});
return aging;
};
return {
type: "ACCOUNTS_REPORT",
data: {
payables: payablesList,
receivables: receivablesList,
totalPayable,
totalReceivable,
netPosition: totalReceivable - totalPayable,
agingSummary: {
payables: calculateAging(payablesList),
receivables: calculateAging(receivablesList)
},
generatedAt: new Date().toLocaleString()
},
_meta_actions: []
};
}],
[/\[[\s\S]*?"image_url"[\s\S]*?\]/, async (match) => {
const uploadContent = JSON.parse(match[0]);
const imageUrls = [];
for (const item of uploadContent) {
if (item.type === "image_url" && item.image_url?.url) {
imageUrls.push(item.image_url.url);
}
}
if (imageUrls.length === 0) {
throw new Error("No image URLs found in upload");
}
const ocrResults = [];
for (const imageUrl of imageUrls) {
const ocr = await openkbs.imageToText(imageUrl);
ocrResults.push({
imageUrl: imageUrl,
text: ocr?.results || ""
});
}
return {
data: {
invoiceTexts: ocrResults,
imageUrls: imageUrls
},
message: `OCR completed for ${imageUrls.length} image(s)`,
...meta
};
}],
];
Events/onRequest.js
import {getActions} from './actions.js';
export const handler = async (event) => {
const actions = getActions({_meta_actions: ["REQUEST_CHAT_MODEL"]});
for (let [regex, action] of actions) {
const lastMessage = event.payload.messages[event.payload.messages.length - 1].content;
const match = lastMessage?.match(regex);
if (match) return await action(match);
}
return { type: 'CONTINUE' }
};
Events/onResponse.js
import {getActions} from './actions.js';
export const handler = async (event) => {
const actions = getActions({_meta_actions: ["REQUEST_CHAT_MODEL"]});
for (let [regex, action] of actions) {
const lastMessage = event.payload.messages[event.payload.messages.length - 1].content;
const match = lastMessage?.match(regex);
if (match) return await action(match);
}
return { type: 'CONTINUE' }
};
Frontend Files (React Frontend)
Frontend/DocumentEditor.js
import React, { useState } from "react";
import {
Box,
Card,
CardContent,
Grid,
TextField,
Typography,
Button,
Accordion,
AccordionSummary,
AccordionDetails,
FormControl,
InputLabel,
Select,
MenuItem
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { DocumentDetailsTable } from "./Presentational/DocumentDetailsTable";
const isMobile = window.openkbs.isMobile;
export const DocumentEditor = ({ documentData, onSave }) => {
if (!documentData) {
return null; // Don't render if no data provided
}
const [document, setDocument] = useState(documentData);
const handleBasicChange = (field, value) => {
setDocument({
...document,
[field]: value
});
};
const handleCompanyChange = (company, field, value) => {
setDocument({
...document,
[company]: {
...document[company],
[field]: value
}
});
};
const handleCompanyAddressChange = (company, value) => {
setDocument({
...document,
[company]: {
...document[company],
Addresses: [{ Location: value }]
}
});
};
const handleCompanyBankChange = (company, field, value) => {
const currentBank = document[company].BankAccounts[0] || {};
setDocument({
...document,
[company]: {
...document[company],
BankAccounts: [{
...currentBank,
[field]: value
}]
}
});
};
const handleDetailsChange = (newDetails) => {
setDocument({
...document,
DocumentDetails: newDetails
});
// Recalculate totals
calculateTotals(newDetails);
};
const calculateTotals = (details) => {
let totalVat = 0;
let totalAmount = 0;
details.forEach(detail => {
const amount = parseFloat(detail.Amount || 0);
const vatAmount = parseFloat(detail.VatAmount || 0);
totalAmount += amount;
totalVat += vatAmount;
});
const totalWithVat = totalAmount + totalVat;
// Update document totals
setDocument(prev => ({
...prev,
TotalAmount: totalWithVat.toFixed(6),
TotalVatAmount: totalVat.toFixed(6)
}));
};
const handleSaveClick = () => {
// Generate DocumentId if not present
if (!document.DocumentId && document.CompanySender?.TaxID && document.Number) {
document.DocumentId = `${document.CompanySender.TaxID}_${document.Number}`;
}
onSave({ document });
};
const formatAmount = (value) => {
const num = parseFloat(value || 0);
return num.toFixed(6);
};
// Determine currency based on country (simple logic, can be enhanced)
const getCurrency = () => {
const vatNumber = document.CompanyRecipient?.VATNumber || "";
if (vatNumber.startsWith("BG")) return "BGN";
if (vatNumber.startsWith("DE") || vatNumber.startsWith("FR") || vatNumber.startsWith("IT")) return "EUR";
if (vatNumber.startsWith("GB")) return "GBP";
return "EUR"; // Default to EUR
};
const currency = getCurrency();
return (
<Box sx={{ width: "100%", mt: 2 }}>
<Typography variant="h5" gutterBottom>
International Invoice Editor
</Typography>
{/* Basic Document Info */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Grid container spacing={2}>
<Grid item xs={12} sm={3}>
<TextField
fullWidth
label="Document Number"
value={document.Number || ""}
onChange={(e) => handleBasicChange("Number", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
fullWidth
label="Document Date"
type="datetime-local"
value={document.Date ? new Date(document.Date).toISOString().slice(0, 16) : ""}
onChange={(e) => {
const date = new Date(e.target.value);
const formattedDate = `${(date.getMonth() + 1).toString().padStart(2, '0')}/${date.getDate().toString().padStart(2, '0')}/${date.getFullYear()} ${date.toLocaleTimeString('en-US', { hour12: true })}`;
handleBasicChange("Date", formattedDate);
}}
variant="outlined"
size="small"
InputLabelProps={{ shrink: true }}
/>
</Grid>
<Grid item xs={12} sm={3}>
<FormControl fullWidth size="small">
<InputLabel>Document Type</InputLabel>
<Select
value={document.DocumentType}
label="Document Type"
onChange={(e) => handleBasicChange("DocumentType", e.target.value)}
>
<MenuItem value="1">Invoice</MenuItem>
<MenuItem value="2">Debit Note</MenuItem>
<MenuItem value="3">Credit Note</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={3}>
<FormControl fullWidth size="small">
<InputLabel>Payment Type</InputLabel>
<Select
value={document.PaymentType}
label="Payment Type"
onChange={(e) => handleBasicChange("PaymentType", e.target.value)}
>
<MenuItem value="1">Bank Transfer</MenuItem>
<MenuItem value="2">Cash</MenuItem>
<MenuItem value="3">Card</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
fullWidth
label="Total Amount"
value={document.TotalAmount}
onChange={(e) => handleBasicChange("TotalAmount", formatAmount(e.target.value))}
variant="outlined"
size="small"
InputProps={{ readOnly: true }}
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
fullWidth
label="Total VAT Amount"
value={document.TotalVatAmount}
onChange={(e) => handleBasicChange("TotalVatAmount", formatAmount(e.target.value))}
variant="outlined"
size="small"
InputProps={{ readOnly: true }}
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Document ID"
value={document.DocumentId || `${document.CompanySender?.TaxID || ''}_${document.Number || ''}`}
variant="outlined"
size="small"
InputProps={{ readOnly: true }}
helperText="Auto-generated: SenderTaxID_DocumentNumber"
/>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Company Sender */}
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Company Sender (Seller/Supplier)</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Company Name"
value={document.CompanySender?.Name || ""}
onChange={(e) => handleCompanyChange("CompanySender", "Name", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
fullWidth
label="Tax ID"
value={document.CompanySender?.TaxID || ""}
onChange={(e) => handleCompanyChange("CompanySender", "TaxID", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
fullWidth
label="VAT Number"
value={document.CompanySender?.VATNumber || ""}
onChange={(e) => handleCompanyChange("CompanySender", "VATNumber", e.target.value)}
variant="outlined"
size="small"
placeholder="e.g., DE123456789"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Address"
value={document.CompanySender?.Addresses?.[0]?.Location || ""}
onChange={(e) => handleCompanyAddressChange("CompanySender", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Bank Name"
value={document.CompanySender?.BankAccounts?.[0]?.Name || ""}
onChange={(e) => handleCompanyBankChange("CompanySender", "Name", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="IBAN"
value={document.CompanySender?.BankAccounts?.[0]?.IBAN || ""}
onChange={(e) => handleCompanyBankChange("CompanySender", "IBAN", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* Company Recipient */}
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Company Recipient (Buyer - YOUR COMPANY)</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Company Name"
value={document.CompanyRecipient?.Name || ""}
onChange={(e) => handleCompanyChange("CompanyRecipient", "Name", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
fullWidth
label="Tax ID"
value={document.CompanyRecipient?.TaxID || ""}
onChange={(e) => handleCompanyChange("CompanyRecipient", "TaxID", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
<Grid item xs={12} sm={3}>
<TextField
fullWidth
label="VAT Number"
value={document.CompanyRecipient?.VATNumber || ""}
onChange={(e) => handleCompanyChange("CompanyRecipient", "VATNumber", e.target.value)}
variant="outlined"
size="small"
placeholder="e.g., FR12345678901"
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="Address"
value={document.CompanyRecipient?.Addresses?.[0]?.Location || ""}
onChange={(e) => handleCompanyAddressChange("CompanyRecipient", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="Bank Name"
value={document.CompanyRecipient?.BankAccounts?.[0]?.Name || ""}
onChange={(e) => handleCompanyBankChange("CompanyRecipient", "Name", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
<Grid item xs={12} sm={6}>
<TextField
fullWidth
label="IBAN"
value={document.CompanyRecipient?.BankAccounts?.[0]?.IBAN || ""}
onChange={(e) => handleCompanyBankChange("CompanyRecipient", "IBAN", e.target.value)}
variant="outlined"
size="small"
/>
</Grid>
</Grid>
</AccordionDetails>
</Accordion>
{/* Document Details */}
<Accordion defaultExpanded>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Document Details (Goods/Services)</Typography>
</AccordionSummary>
<AccordionDetails>
<DocumentDetailsTable
details={document.DocumentDetails || []}
onDetailsChange={handleDetailsChange}
/>
</AccordionDetails>
</Accordion>
{/* Accounting Information - Only show if data exists */}
{document.Accountings && document.Accountings.length > 0 && (
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">Accounting Information</Typography>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="Accounting Date"
type="datetime-local"
value={document.Accountings[0]?.AccountingDate?.slice(0, 16) || ""}
variant="outlined"
size="small"
InputLabelProps={{ shrink: true }}
InputProps={{ readOnly: true }}
/>
</Grid>
{document.DueDate && (
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="Due Date"
value={document.DueDate || ""}
variant="outlined"
size="small"
InputProps={{ readOnly: true }}
/>
</Grid>
)}
{document.Term && (
<Grid item xs={12} sm={4}>
<TextField
fullWidth
label="Term"
value={document.Term || ""}
variant="outlined"
size="small"
InputProps={{ readOnly: true }}
/>
</Grid>
)}
</Grid>
{document.Accountings[0]?.AccountingDetails && document.Accountings[0].AccountingDetails.length > 0 && (
<>
<Typography variant="subtitle1" sx={{ mt: 3, mb: 2 }}>
Accounting Entries
</Typography>
<Grid container spacing={2}>
{document.Accountings[0].AccountingDetails.map((detail, index) => (
<Grid item xs={12} sm={4} key={index}>
<Card variant="outlined" sx={{
borderColor: detail.Direction === 'Debit' ? 'warning.main' : 'success.main',
borderWidth: 2
}}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="h6" color={detail.Direction === 'Debit' ? 'warning.main' : 'success.main'}>
{detail.AccountNumber}
</Typography>
<Typography variant="caption" sx={{
px: 1,
py: 0.5,
borderRadius: 1,
backgroundColor: detail.Direction === 'Debit' ? 'warning.light' : 'success.light',
color: 'white'
}}>
{detail.Direction}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{detail.Description}
</Typography>
<Typography variant="h6" sx={{ mt: 1 }}>
{parseFloat(detail.Amount).toFixed(2)} {currency}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</>
)}
</AccordionDetails>
</Accordion>
)}
<Box sx={{ mt: 3, mb: 2, display: "flex", justifyContent: "flex-end", gap: 2 }}>
<Button
variant="contained"
color="primary"
onClick={handleSaveClick}
>
Save Document
</Button>
</Box>
</Box>
);
};
Frontend/Presentational
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Grid,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
LinearProgress,
Alert
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import AccountBalanceIcon from '@mui/icons-material/AccountBalance';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
import BusinessIcon from '@mui/icons-material/Business';
import PersonIcon from '@mui/icons-material/Person';
import WarningIcon from '@mui/icons-material/Warning';
export const AccountsReport = ({ data }) => {
const formatAmount = (amount) => {
return parseFloat(amount || 0).toFixed(2);
};
const getAgingColor = (aging) => {
switch(aging) {
case 'Current': return 'success';
case '31-60 days': return 'warning';
case '61-90 days': return 'error';
case 'Over 90 days': return 'error';
default: return 'default';
}
};
const getAgingProgress = (aging) => {
switch(aging) {
case 'Current': return 25;
case '31-60 days': return 50;
case '61-90 days': return 75;
case 'Over 90 days': return 100;
default: return 0;
}
};
if (!data) {
return (
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="h6" color="text.secondary" align="center">
No accounts report data available
</Typography>
</CardContent>
</Card>
);
}
const { payables, receivables, totalPayable, totalReceivable, netPosition, agingSummary } = data;
const isPositive = netPosition >= 0;
// Find overdue items
const overduePayables = payables.filter(p =>
p.documents.some(d => d.daysOld > 30)
);
const overdueReceivables = receivables.filter(r =>
r.documents.some(d => d.daysOld > 30)
);
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<AccountBalanceIcon color="primary" />
<Typography variant="h5">
Accounts Payable & Receivable Report
</Typography>
</Box>
{/* Alerts for overdue items */}
{overduePayables.length > 0 && (
<Alert severity="warning" sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon />
<Typography variant="body2">
{overduePayables.length} supplier(s) have overdue payments
</Typography>
</Box>
</Alert>
)}
{overdueReceivables.length > 0 && (
<Alert severity="error" sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<WarningIcon />
<Typography variant="body2">
{overdueReceivables.length} customer(s) have overdue payments
</Typography>
</Box>
</Alert>
)}
{/* Summary Cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} md={3}>
<Card sx={{ backgroundColor: 'error.light' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingDownIcon />
<Typography variant="subtitle2" color="white">
Accounts Payable
</Typography>
</Box>
<Typography variant="h4" color="white">
{formatAmount(totalPayable)}
</Typography>
<Typography variant="caption" color="white">
What we owe to {payables.length} suppliers
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card sx={{ backgroundColor: 'success.light' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon />
<Typography variant="subtitle2" color="white">
Accounts Receivable
</Typography>
</Box>
<Typography variant="h4" color="white">
{formatAmount(totalReceivable)}
</Typography>
<Typography variant="caption" color="white">
What {receivables.length} customers owe us
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card sx={{
backgroundColor: isPositive ? 'primary.main' : 'warning.main'
}}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AccountBalanceIcon />
<Typography variant="subtitle2" color="white">
Net Position
</Typography>
</Box>
<Typography variant="h4" color="white">
{formatAmount(Math.abs(netPosition))}
</Typography>
<Chip
label={isPositive ? "Positive" : "Negative"}
size="small"
sx={{
backgroundColor: 'white',
color: isPositive ? 'success.main' : 'error.main',
fontWeight: 'bold'
}}
/>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">
Cash Flow Impact
</Typography>
<Typography variant="h4" color={isPositive ? "success.main" : "error.main"}>
{isPositive ? '+' : ''}{formatAmount(netPosition)}
</Typography>
<Typography variant="caption" color="text.secondary">
Expected net cash flow
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Aging Summary */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom color="error.main">
Payables Aging
</Typography>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">Current (0-30 days):</Typography>
<Typography fontWeight="bold">{formatAmount(agingSummary?.payables?.current || 0)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">31-60 days:</Typography>
<Typography fontWeight="bold" color="warning.main">{formatAmount(agingSummary?.payables?.days30_60 || 0)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">61-90 days:</Typography>
<Typography fontWeight="bold" color="error.main">{formatAmount(agingSummary?.payables?.days60_90 || 0)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">Over 90 days:</Typography>
<Typography fontWeight="bold" color="error.main">{formatAmount(agingSummary?.payables?.over90 || 0)}</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={6}>
<Card>
<CardContent>
<Typography variant="h6" gutterBottom color="success.main">
Receivables Aging
</Typography>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">Current (0-30 days):</Typography>
<Typography fontWeight="bold">{formatAmount(agingSummary?.receivables?.current || 0)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">31-60 days:</Typography>
<Typography fontWeight="bold" color="warning.main">{formatAmount(agingSummary?.receivables?.days30_60 || 0)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2">61-90 days:</Typography>
<Typography fontWeight="bold" color="error.main">{formatAmount(agingSummary?.receivables?.days60_90 || 0)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2">Over 90 days:</Typography>
<Typography fontWeight="bold" color="error.main">{formatAmount(agingSummary?.receivables?.over90 || 0)}</Typography>
</Box>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Accounts Payable Section */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<BusinessIcon color="error" />
<Typography variant="h6">Accounts Payable (What We Owe)</Typography>
</Box>
<Typography variant="h6" color="error.main">
{formatAmount(totalPayable)}
</Typography>
</Box>
{payables.length > 0 ? (
payables.map((supplier) => (
<Accordion key={supplier.name} sx={{ mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', gap: 2 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" fontWeight="bold">
{supplier.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Tax ID: {supplier.taxId}
</Typography>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="h6" color="error.main">
{formatAmount(supplier.totalAmount)}
</Typography>
<Chip
label={`${supplier.documents.length} invoices`}
size="small"
variant="outlined"
/>
</Box>
</Box>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
<TableCell>Invoice #</TableCell>
<TableCell>Date</TableCell>
<TableCell align="right">Amount</TableCell>
<TableCell align="center">Days Old</TableCell>
<TableCell align="center">Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{supplier.documents.map((doc) => (
<TableRow key={doc.documentId}>
<TableCell>
<Typography variant="body2" fontWeight="bold">
{doc.number}
</Typography>
</TableCell>
<TableCell>
{new Date(doc.date).toLocaleDateString()}
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{formatAmount(doc.amount)}
</Typography>
</TableCell>
<TableCell align="center">
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography variant="body2">{doc.daysOld}</Typography>
<LinearProgress
variant="determinate"
value={getAgingProgress(doc.aging)}
color={getAgingColor(doc.aging)}
sx={{ width: 40, height: 4 }}
/>
</Box>
</TableCell>
<TableCell align="center">
<Chip
label={doc.aging}
size="small"
color={getAgingColor(doc.aging)}
variant={doc.daysOld > 30 ? "filled" : "outlined"}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
))
) : (
<Typography variant="body2" color="text.secondary">
No accounts payable found
</Typography>
)}
</CardContent>
</Card>
{/* Accounts Receivable Section */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PersonIcon color="success" />
<Typography variant="h6">Accounts Receivable (What We're Owed)</Typography>
</Box>
<Typography variant="h6" color="success.main">
{formatAmount(totalReceivable)}
</Typography>
</Box>
{receivables.length > 0 ? (
receivables.map((customer) => (
<Accordion key={customer.name} sx={{ mb: 1 }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', gap: 2 }}>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" fontWeight="bold">
{customer.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Tax ID: {customer.taxId}
</Typography>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="h6" color="success.main">
{formatAmount(customer.totalAmount)}
</Typography>
<Chip
label={`${customer.documents.length} invoices`}
size="small"
variant="outlined"
/>
</Box>
</Box>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
<TableCell>Invoice #</TableCell>
<TableCell>Date</TableCell>
<TableCell align="right">Amount</TableCell>
<TableCell align="center">Days Old</TableCell>
<TableCell align="center">Status</TableCell>
</TableRow>
</TableHead>
<TableBody>
{customer.documents.map((doc) => (
<TableRow key={doc.documentId}>
<TableCell>
<Typography variant="body2" fontWeight="bold">
{doc.number}
</Typography>
</TableCell>
<TableCell>
{new Date(doc.date).toLocaleDateString()}
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{formatAmount(doc.amount)}
</Typography>
</TableCell>
<TableCell align="center">
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography variant="body2">{doc.daysOld}</Typography>
<LinearProgress
variant="determinate"
value={getAgingProgress(doc.aging)}
color={getAgingColor(doc.aging)}
sx={{ width: 40, height: 4 }}
/>
</Box>
</TableCell>
<TableCell align="center">
<Chip
label={doc.aging}
size="small"
color={getAgingColor(doc.aging)}
variant={doc.daysOld > 30 ? "filled" : "outlined"}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
))
) : (
<Typography variant="body2" color="text.secondary">
No accounts receivable found
</Typography>
)}
</CardContent>
</Card>
{/* Footer */}
<Box sx={{ mt: 2, textAlign: 'right' }}>
<Typography variant="caption" color="text.secondary">
Generated: {data.generatedAt ? new Date(data.generatedAt).toLocaleString() : 'N/A'}
</Typography>
</Box>
</Box>
);
};
Frontend/Presentational
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
Chip,
Grid
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
export const ChartOfAccounts = ({ data }) => {
if (!data || !data.accounts || data.accounts.length === 0) {
return (
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="h6" color="text.secondary" align="center">
No chart of accounts available
</Typography>
</CardContent>
</Card>
);
}
const getCategoryColor = (category) => {
switch(category) {
case 'Assets': return 'primary';
case 'Liabilities': return 'error';
case 'Equity': return 'warning';
case 'Revenue': return 'success';
case 'Expenses': return 'info';
default: return 'default';
}
};
// Group accounts by category
const accountsByCategory = data.accounts.reduce((acc, account) => {
const category = account.category || 'Other';
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(account);
return acc;
}, {});
const categoryOrder = ['Assets', 'Liabilities', 'Equity', 'Revenue', 'Expenses'];
const sortedCategories = Object.entries(accountsByCategory).sort((a, b) => {
const indexA = categoryOrder.indexOf(a[0]);
const indexB = categoryOrder.indexOf(b[0]);
if (indexA === -1 && indexB === -1) return a[0].localeCompare(b[0]);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
const totalAccounts = data.accounts.length;
const totalSubAccounts = data.accounts.reduce((sum, account) =>
sum + (account.subAccounts ? account.subAccounts.length : 0), 0
);
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<AccountTreeIcon color="primary" />
<Typography variant="h5">
Chart of Accounts
</Typography>
</Box>
{/* Summary Cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">
Total Accounts
</Typography>
<Typography variant="h4" color="primary.main">
{totalAccounts}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">
Sub Accounts
</Typography>
<Typography variant="h4" color="info.main">
{totalSubAccounts}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">
Categories
</Typography>
<Typography variant="h4" color="success.main">
{sortedCategories.length}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Categories with Accounts */}
{sortedCategories.map(([categoryName, accounts], index) => (
<Accordion key={categoryName} defaultExpanded={index === sortedCategories.length - 1} sx={{ mb: 2 }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{ backgroundColor: 'grey.50' }}
>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', gap: 2 }}>
<Chip
label={categoryName}
color={getCategoryColor(categoryName)}
size="small"
/>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" color="text.secondary">
{accounts.length} main accounts, {accounts.reduce((sum, acc) => sum + (acc.subAccounts ? acc.subAccounts.length : 0), 0)} sub accounts
</Typography>
</Box>
</Box>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
<TableCell>Account #</TableCell>
<TableCell>Account Name</TableCell>
<TableCell align="center">Sub Accounts</TableCell>
<TableCell>Category</TableCell>
</TableRow>
</TableHead>
<TableBody>
{accounts.sort((a, b) => a.number.localeCompare(b.number)).map((account) => (
<React.Fragment key={account.number}>
{/* Main Account */}
<TableRow>
<TableCell>
<Typography variant="body2" fontWeight="bold">
{account.number}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight="bold">
{account.name}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={account.subAccounts ? account.subAccounts.length : 0}
size="small"
variant="outlined"
color={account.subAccounts && account.subAccounts.length > 0 ? "primary" : "default"}
/>
</TableCell>
<TableCell>
<Chip
label={account.category}
size="small"
color={getCategoryColor(account.category)}
/>
</TableCell>
</TableRow>
{/* Sub Accounts */}
{account.subAccounts && account.subAccounts.map((subAccount) => (
<TableRow key={subAccount.number} sx={{ backgroundColor: 'grey.25' }}>
<TableCell sx={{ pl: 4 }}>
<Typography variant="body2" color="text.secondary">
└─ {subAccount.number}
</Typography>
</TableCell>
<TableCell sx={{ pl: 2 }}>
<Typography variant="body2" color="text.secondary">
{subAccount.name}
</Typography>
</TableCell>
<TableCell align="center">
<Typography variant="body2" color="text.secondary">
-
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" color="text.secondary">
{subAccount.category || account.category}
</Typography>
</TableCell>
</TableRow>
))}
</React.Fragment>
))}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
))}
{/* Footer */}
<Box sx={{ mt: 3, p: 2, backgroundColor: 'grey.100', borderRadius: 1 }}>
<Grid container spacing={2}>
<Grid item xs={12}>
<Typography variant="body2" color="text.secondary" align="center">
Chart of Accounts - {totalAccounts} main accounts with {totalSubAccounts} sub accounts across {sortedCategories.length} categories
</Typography>
</Grid>
</Grid>
</Box>
</Box>
);
};
Frontend/Presentational
import React, { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TextField,
IconButton,
Button,
Box,
Card,
CardContent,
Typography,
Grid,
Stack
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import AddIcon from "@mui/icons-material/Add";
const isMobile = window.openkbs.isMobile;
export const DocumentDetailsTable = ({ details = [], onDetailsChange }) => {
const [editableDetails, setEditableDetails] = useState(details);
const handleDetailChange = (index, field, value) => {
const newDetails = [...editableDetails];
if (field.includes('.')) {
// Handle nested fields like ServiceGood.Name
const [parent, child] = field.split('.');
newDetails[index] = {
...newDetails[index],
[parent]: {
...newDetails[index][parent],
[child]: value
}
};
} else {
newDetails[index] = {
...newDetails[index],
[field]: value
};
}
setEditableDetails(newDetails);
onDetailsChange(newDetails);
};
const handleAddDetail = () => {
const newDetail = {
ServiceGood: {
Name: "",
Price: "0.000000",
FixPrice: "0.000000",
EcoTax: "0.000000",
Measure: "pcs",
Barcode: "",
Reference: "",
VatRate: "20",
VatTermId: "7"
},
Qtty: "1",
Amount: "0.000000",
Reference: "",
Measure: "pcs",
VatAmount: "0.000000",
TotalVatAmount: "0.000000"
};
const newDetails = [...editableDetails, newDetail];
setEditableDetails(newDetails);
onDetailsChange(newDetails);
};
const handleDeleteDetail = (index) => {
const newDetails = editableDetails.filter((_, i) => i !== index);
setEditableDetails(newDetails);
onDetailsChange(newDetails);
};
const formatAmount = (value) => {
const num = parseFloat(value || 0);
return num.toFixed(6);
};
// Mobile Card View
const renderMobileCards = () => {
return (
<Stack spacing={2}>
{editableDetails.map((detail, index) => (
<Card key={index} variant="outlined" sx={{ position: 'relative' }}>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ position: 'absolute', top: 8, right: 8 }}>
<IconButton
size="small"
color="error"
onClick={() => handleDeleteDetail(index)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Item #{index + 1}
</Typography>
<TextField
fullWidth
label="Service/Good Name"
variant="outlined"
size="small"
value={detail.ServiceGood?.Name || ""}
onChange={(e) => handleDetailChange(index, "ServiceGood.Name", e.target.value)}
sx={{ mb: 2 }}
/>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={12}>
<TextField
fullWidth
label="Barcode"
variant="outlined"
size="small"
value={detail.ServiceGood?.Barcode || ""}
onChange={(e) => handleDetailChange(index, "ServiceGood.Barcode", e.target.value)}
/>
</Grid>
</Grid>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={6}>
<TextField
fullWidth
label="Quantity"
variant="outlined"
size="small"
value={detail.Qtty || "1"}
onChange={(e) => handleDetailChange(index, "Qtty", e.target.value)}
/>
</Grid>
<Grid item xs={6}>
<TextField
fullWidth
label="Unit"
variant="outlined"
size="small"
value={detail.Measure || "pcs"}
onChange={(e) => handleDetailChange(index, "Measure", e.target.value)}
placeholder="pcs, kg, m, etc."
/>
</Grid>
</Grid>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={6}>
<TextField
fullWidth
label="Unit Price"
variant="outlined"
size="small"
value={detail.ServiceGood?.Price || "0.000000"}
onChange={(e) => handleDetailChange(index, "ServiceGood.Price", formatAmount(e.target.value))}
/>
</Grid>
<Grid item xs={6}>
<TextField
fullWidth
label="Total Amount"
variant="outlined"
size="small"
value={detail.Amount || "0.000000"}
onChange={(e) => handleDetailChange(index, "Amount", formatAmount(e.target.value))}
/>
</Grid>
</Grid>
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={6}>
<TextField
fullWidth
label="VAT Rate (%)"
variant="outlined"
size="small"
value={detail.ServiceGood?.VatRate || "20"}
onChange={(e) => handleDetailChange(index, "ServiceGood.VatRate", e.target.value)}
/>
</Grid>
<Grid item xs={6}>
<TextField
fullWidth
label="VAT Amount"
variant="outlined"
size="small"
value={detail.VatAmount || "0.000000"}
onChange={(e) => handleDetailChange(index, "VatAmount", formatAmount(e.target.value))}
/>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid item xs={12}>
<TextField
fullWidth
label="Reference"
variant="outlined"
size="small"
value={detail.Reference || ""}
onChange={(e) => handleDetailChange(index, "Reference", e.target.value)}
/>
</Grid>
</Grid>
</CardContent>
</Card>
))}
</Stack>
);
};
// Desktop Table View
const renderDesktopTable = () => {
return (
<TableContainer component={Paper} sx={{ overflowX: 'auto' }}>
<Table
size="small"
sx={{
minWidth: 1200,
'& .MuiTableCell-root': {
padding: '6px 8px',
fontSize: '0.85rem',
border: '1px solid #e0e0e0'
}
}}
>
<TableHead>
<TableRow>
<TableCell width="30px">#</TableCell>
<TableCell width="530px">Name</TableCell>
<TableCell width="50px">Qty</TableCell>
<TableCell width="60px">Unit</TableCell>
<TableCell width="90px">Price</TableCell>
<TableCell width="90px">Amount</TableCell>
<TableCell width="50px">VAT%</TableCell>
<TableCell width="90px">VAT Amount</TableCell>
<TableCell width="50px"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{editableDetails.map((detail, index) => (
<TableRow key={index}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<TextField
fullWidth
variant="outlined"
size="small"
value={detail.ServiceGood?.Name || ""}
onChange={(e) => handleDetailChange(index, "ServiceGood.Name", e.target.value)}
InputProps={{
sx: {
'& .MuiOutlinedInput-input': {
padding: '6px 8px',
fontSize: '0.85rem',
minHeight: '18px'
}
}
}}
/>
</TableCell>
<TableCell>
<TextField
fullWidth
variant="outlined"
size="small"
InputProps={{
sx: {
'& .MuiOutlinedInput-input': {
padding: '6px 8px',
fontSize: '0.85rem',
minHeight: '18px'
}
}
}}
value={detail.Qtty || "1"}
onChange={(e) => handleDetailChange(index, "Qtty", e.target.value)}
/>
</TableCell>
<TableCell>
<TextField
fullWidth
variant="outlined"
size="small"
InputProps={{
sx: {
'& .MuiOutlinedInput-input': {
padding: '6px 8px',
fontSize: '0.85rem',
minHeight: '18px'
}
}
}}
value={detail.Measure || "pcs"}
onChange={(e) => handleDetailChange(index, "Measure", e.target.value)}
placeholder="pcs, kg, m"
/>
</TableCell>
<TableCell>
<TextField
fullWidth
variant="outlined"
size="small"
InputProps={{
sx: {
'& .MuiOutlinedInput-input': {
padding: '6px 8px',
fontSize: '0.85rem',
minHeight: '18px'
}
}
}}
value={detail.ServiceGood?.Price || "0.000000"}
onChange={(e) => handleDetailChange(index, "ServiceGood.Price", formatAmount(e.target.value))}
/>
</TableCell>
<TableCell>
<TextField
fullWidth
variant="outlined"
size="small"
InputProps={{
sx: {
'& .MuiOutlinedInput-input': {
padding: '6px 8px',
fontSize: '0.85rem',
minHeight: '18px'
}
}
}}
value={detail.Amount || "0.000000"}
onChange={(e) => handleDetailChange(index, "Amount", formatAmount(e.target.value))}
/>
</TableCell>
<TableCell>
<TextField
fullWidth
variant="outlined"
size="small"
InputProps={{
sx: {
'& .MuiOutlinedInput-input': {
padding: '6px 8px',
fontSize: '0.85rem',
minHeight: '18px'
}
}
}}
value={detail.ServiceGood?.VatRate || "20"}
onChange={(e) => handleDetailChange(index, "ServiceGood.VatRate", e.target.value)}
/>
</TableCell>
<TableCell>
<TextField
fullWidth
variant="outlined"
size="small"
InputProps={{
sx: {
'& .MuiOutlinedInput-input': {
padding: '6px 8px',
fontSize: '0.85rem',
minHeight: '18px'
}
}
}}
value={detail.VatAmount || "0.000000"}
onChange={(e) => handleDetailChange(index, "VatAmount", formatAmount(e.target.value))}
/>
</TableCell>
<TableCell>
<IconButton
size="small"
color="error"
onClick={() => handleDeleteDetail(index)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
};
return (
<Box>
{isMobile ? renderMobileCards() : renderDesktopTable()}
<Box sx={{ mt: 2, display: "flex", justifyContent: "flex-end" }}>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={handleAddDetail}
size={isMobile ? "small" : "medium"}
>
Add Item
</Button>
</Box>
</Box>
);
};
Frontend/Presentational
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Chip,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
Grid,
Divider
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ReceiptIcon from '@mui/icons-material/Receipt';
import BusinessIcon from '@mui/icons-material/Business';
import CalendarTodayIcon from '@mui/icons-material/CalendarToday';
export const DocumentsList = ({ documents = [] }) => {
const formatAmount = (amount) => {
return parseFloat(amount || 0).toFixed(2);
};
const getDocumentTypeLabel = (type) => {
switch(type) {
case "1": return { label: "Invoice", color: "primary" };
case "2": return { label: "Debit Note", color: "warning" };
case "3": return { label: "Credit Note", color: "error" };
default: return { label: "Document", color: "default" };
}
};
if (!documents || documents.length === 0) {
return (
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="h6" color="text.secondary" align="center">
No documents found
</Typography>
</CardContent>
</Card>
);
}
return (
<Box sx={{ mt: 2 }}>
<Typography variant="h5" gutterBottom sx={{ mb: 3 }}>
Documents List ({documents.length} documents)
</Typography>
{documents.map((doc, index) => {
const docType = getDocumentTypeLabel(doc.documentType);
return (
<Accordion key={doc.id || index} sx={{ mb: 2 }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{ backgroundColor: 'grey.50' }}
>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', gap: 2 }}>
<ReceiptIcon color="action" />
<Box sx={{ flex: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="subtitle1" fontWeight="bold">
{doc.number || doc.documentId}
</Typography>
<Chip
label={docType.label}
size="small"
color={docType.color}
/>
{doc.itemCount > 0 && (
<Chip
label={`${doc.itemCount} items`}
size="small"
variant="outlined"
/>
)}
</Box>
<Box sx={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<CalendarTodayIcon sx={{ fontSize: 16 }} color="action" />
<Typography variant="body2" color="text.secondary">
{doc.date}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<BusinessIcon sx={{ fontSize: 16 }} color="action" />
<Typography variant="body2">
From: <strong>{doc.sender}</strong> → To: <strong>{doc.recipient}</strong>
</Typography>
</Box>
</Box>
</Box>
<Box sx={{ textAlign: 'right', minWidth: 150 }}>
<Typography variant="h6" color="primary">
{formatAmount(doc.totalAmount)}
</Typography>
{doc.totalVatAmount > 0 && (
<Typography variant="caption" color="text.secondary">
VAT: {formatAmount(doc.totalVatAmount)}
</Typography>
)}
</Box>
</Box>
</AccordionSummary>
<AccordionDetails>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Sender Details
</Typography>
<Typography variant="body2">
<strong>{doc.sender}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
Tax ID: {doc.senderTaxId}
</Typography>
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Recipient Details
</Typography>
<Typography variant="body2">
<strong>{doc.recipient}</strong>
</Typography>
<Typography variant="body2" color="text.secondary">
Tax ID: {doc.recipientTaxId}
</Typography>
</Box>
</Grid>
</Grid>
{doc.items && doc.items.length > 0 && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle2" gutterBottom>
Document Items
</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ mt: 1 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
<TableCell>#</TableCell>
<TableCell>Item Name</TableCell>
<TableCell align="right">Qty</TableCell>
<TableCell align="center">Unit</TableCell>
<TableCell align="right">Price</TableCell>
<TableCell align="right">Amount</TableCell>
<TableCell align="center">VAT %</TableCell>
<TableCell align="right">VAT Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{doc.items.map((item, idx) => (
<TableRow key={idx}>
<TableCell>{idx + 1}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell align="right">{item.quantity}</TableCell>
<TableCell align="center">{item.measure}</TableCell>
<TableCell align="right">{formatAmount(item.price)}</TableCell>
<TableCell align="right">{formatAmount(item.amount)}</TableCell>
<TableCell align="center">{item.vatRate}%</TableCell>
<TableCell align="right">{formatAmount(item.vatAmount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
)}
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="caption" color="text.secondary">
Document ID: {doc.documentId}
</Typography>
<Typography variant="caption" color="text.secondary">
Created: {new Date(doc.createdAt).toLocaleString()}
</Typography>
</Box>
</AccordionDetails>
</Accordion>
);
})}
</Box>
);
};
Frontend/Presentational
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Grid,
Divider,
Chip,
LinearProgress
} from '@mui/material';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
import AttachMoneyIcon from '@mui/icons-material/AttachMoney';
import MoneyOffIcon from '@mui/icons-material/MoneyOff';
export const IncomeStatement = ({ data }) => {
const formatAmount = (amount) => {
return parseFloat(amount || 0).toFixed(2);
};
const formatPercent = (percent) => {
return parseFloat(percent || 0).toFixed(1);
};
if (!data) {
return (
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="h6" color="text.secondary" align="center">
No income statement data available
</Typography>
</CardContent>
</Card>
);
}
const { revenue, expenses, netIncome, profitMargin } = data;
const isProfitable = netIncome >= 0;
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<AccountBalanceWalletIcon color="primary" />
<Typography variant="h5">
Income Statement (Profit & Loss)
</Typography>
</Box>
{/* Summary Cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} md={3}>
<Card sx={{ backgroundColor: 'success.light' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<AttachMoneyIcon color="success" />
<Typography variant="subtitle2" color="white">
Total Revenue
</Typography>
</Box>
<Typography variant="h4" color="white">
{formatAmount(revenue.total)}
</Typography>
<Typography variant="caption" color="white">
{revenue.accounts.length} revenue streams
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card sx={{ backgroundColor: 'error.light' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<MoneyOffIcon />
<Typography variant="subtitle2" color="white">
Total Expenses
</Typography>
</Box>
<Typography variant="h4" color="white">
{formatAmount(expenses.total)}
</Typography>
<Typography variant="caption" color="white">
{expenses.accounts.length} expense categories
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card sx={{ backgroundColor: isProfitable ? 'primary.main' : 'warning.main' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{isProfitable ? <TrendingUpIcon /> : <TrendingDownIcon />}
<Typography variant="subtitle2" color="white">
Net {isProfitable ? 'Income' : 'Loss'}
</Typography>
</Box>
<Typography variant="h4" color="white">
{formatAmount(Math.abs(netIncome))}
</Typography>
<Chip
label={isProfitable ? "Profitable" : "Loss"}
size="small"
sx={{
backgroundColor: 'white',
color: isProfitable ? 'success.main' : 'error.main',
fontWeight: 'bold'
}}
/>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">
Profit Margin
</Typography>
<Typography
variant="h4"
color={profitMargin >= 0 ? "success.main" : "error.main"}
>
{formatPercent(profitMargin)}%
</Typography>
<LinearProgress
variant="determinate"
value={Math.min(100, Math.abs(profitMargin))}
color={profitMargin >= 0 ? "success" : "error"}
sx={{ mt: 1 }}
/>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Revenue Section */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon color="success" />
<Typography variant="h6">Revenue</Typography>
</Box>
<Typography variant="h6" color="success.main">
{formatAmount(revenue.total)}
</Typography>
</Box>
{revenue.accounts.length > 0 ? (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow sx={{ backgroundColor: 'success.light' }}>
<TableCell sx={{ color: 'white' }}>Account #</TableCell>
<TableCell sx={{ color: 'white' }}>Revenue Stream</TableCell>
<TableCell align="center" sx={{ color: 'white' }}>Transactions</TableCell>
<TableCell align="right" sx={{ color: 'white' }}>Amount</TableCell>
<TableCell align="right" sx={{ color: 'white' }}>% of Revenue</TableCell>
</TableRow>
</TableHead>
<TableBody>
{revenue.accounts.map((account) => (
<TableRow key={account.number}>
<TableCell>
<Typography variant="body2" fontWeight="bold">
{account.number}
</Typography>
</TableCell>
<TableCell>{account.name}</TableCell>
<TableCell align="center">
<Chip label={account.transactions} size="small" variant="outlined" />
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold" color="success.main">
{formatAmount(account.amount)}
</Typography>
</TableCell>
<TableCell align="right">
{formatPercent((account.amount / revenue.total) * 100)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="text.secondary">
No revenue recorded
</Typography>
)}
</CardContent>
</Card>
{/* Expenses Section */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingDownIcon color="error" />
<Typography variant="h6">Expenses</Typography>
</Box>
<Typography variant="h6" color="error.main">
{formatAmount(expenses.total)}
</Typography>
</Box>
{expenses.accounts.length > 0 ? (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow sx={{ backgroundColor: 'error.light' }}>
<TableCell sx={{ color: 'white' }}>Account #</TableCell>
<TableCell sx={{ color: 'white' }}>Expense Category</TableCell>
<TableCell align="center" sx={{ color: 'white' }}>Transactions</TableCell>
<TableCell align="right" sx={{ color: 'white' }}>Amount</TableCell>
<TableCell align="right" sx={{ color: 'white' }}>% of Expenses</TableCell>
</TableRow>
</TableHead>
<TableBody>
{expenses.accounts.map((account) => (
<TableRow key={account.number}>
<TableCell>
<Typography variant="body2" fontWeight="bold">
{account.number}
</Typography>
</TableCell>
<TableCell>{account.name}</TableCell>
<TableCell align="center">
<Chip label={account.transactions} size="small" variant="outlined" />
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold" color="error.main">
{formatAmount(account.amount)}
</Typography>
</TableCell>
<TableCell align="right">
{formatPercent((account.amount / expenses.total) * 100)}%
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="text.secondary">
No expenses recorded
</Typography>
)}
</CardContent>
</Card>
{/* Net Income Summary */}
<Card sx={{
backgroundColor: isProfitable ? 'success.light' : 'error.light',
color: 'white'
}}>
<CardContent>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={6}>
<Typography variant="h6">
Financial Summary
</Typography>
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography>Total Revenue:</Typography>
<Typography fontWeight="bold">+ {formatAmount(revenue.total)}</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography>Total Expenses:</Typography>
<Typography fontWeight="bold">- {formatAmount(expenses.total)}</Typography>
</Box>
<Divider sx={{ backgroundColor: 'white', my: 1 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="h6">Net {isProfitable ? 'Income' : 'Loss'}:</Typography>
<Typography variant="h6" fontWeight="bold">
{formatAmount(Math.abs(netIncome))}
</Typography>
</Box>
</Box>
</Grid>
<Grid item xs={12} md={6} sx={{ textAlign: 'center' }}>
<Typography variant="h2" fontWeight="bold">
{formatPercent(profitMargin)}%
</Typography>
<Typography variant="h6">
Profit Margin
</Typography>
<Chip
label={isProfitable ? "Business is Profitable" : "Business is at Loss"}
sx={{
mt: 1,
backgroundColor: 'white',
color: isProfitable ? 'success.main' : 'error.main',
fontWeight: 'bold'
}}
/>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Footer */}
<Box sx={{ mt: 2, textAlign: 'right' }}>
<Typography variant="caption" color="text.secondary">
Generated: {data.generatedAt ? new Date(data.generatedAt).toLocaleString() : 'N/A'} |
Documents processed: {data.documentCount || 0}
</Typography>
</Box>
</Box>
);
};
Frontend/Presentational
import React from 'react';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { Box, Typography } from '@mui/material';
export const InvoiceImage = ({ imageUrl, alt = "Invoice Image" }) => {
const isMobile = window.openkbs?.isMobile || false;
const isPDF = imageUrl && imageUrl.toLowerCase().includes('.pdf');
if (isPDF) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
marginBottom: '16px'
}}
onClick={() => window.open(imageUrl, '_blank')}
>
<PictureAsPdfIcon sx={{ fontSize: 48 }} />
<Typography variant="body1" sx={{ color: '#1976d2', textDecoration: 'underline' }}>
Open PDF Document
</Typography>
</Box>
);
}
return (
<img
src={imageUrl}
alt={alt}
style={{
maxWidth: isMobile ? '100%' : '50%',
height: 'auto',
display: 'block',
marginBottom: '16px',
cursor: 'pointer'
}}
onClick={() => window.open(imageUrl, '_blank')}
/>
);
};
Frontend/Presentational
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
Chip,
Grid
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import AccountBalanceIcon from '@mui/icons-material/AccountBalance';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
export const TrialBalance = ({ data }) => {
const formatAmount = (amount) => {
return parseFloat(amount || 0).toFixed(2);
};
const getCategoryColor = (category) => {
switch(category) {
case 'Assets': return 'primary';
case 'Liabilities': return 'error';
case 'Equity': return 'warning';
case 'Revenue': return 'success';
case 'Expenses': return 'info';
default: return 'default';
}
};
if (!data || !data.categories || Object.keys(data.categories).length === 0) {
return (
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="h6" color="text.secondary" align="center">
No accounting data available for trial balance
</Typography>
</CardContent>
</Card>
);
}
const categoryOrder = ['Assets', 'Liabilities', 'Equity', 'Revenue', 'Expenses'];
const sortedCategories = Object.entries(data.categories).sort((a, b) => {
const indexA = categoryOrder.indexOf(a[0]);
const indexB = categoryOrder.indexOf(b[0]);
if (indexA === -1 && indexB === -1) return a[0].localeCompare(b[0]);
if (indexA === -1) return 1;
if (indexB === -1) return -1;
return indexA - indexB;
});
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<AccountBalanceIcon color="primary" />
<Typography variant="h5">
Trial Balance
</Typography>
</Box>
{/* Summary Cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon color="success" />
<Typography variant="subtitle2" color="text.secondary">
Total Debit
</Typography>
</Box>
<Typography variant="h4" color="success.main">
{formatAmount(data.totals.debit)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingDownIcon color="error" />
<Typography variant="subtitle2" color="text.secondary">
Total Credit
</Typography>
</Box>
<Typography variant="h4" color="error.main">
{formatAmount(data.totals.credit)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={4}>
<Card>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">
Balance Difference
</Typography>
<Typography variant="h4" color={data.totals.difference > 0.01 ? "warning.main" : "success.main"}>
{formatAmount(data.totals.difference)}
</Typography>
{data.totals.difference < 0.01 && (
<Typography variant="caption" color="success.main">
✓ Balanced
</Typography>
)}
</CardContent>
</Card>
</Grid>
</Grid>
{/* Categories with Accounts */}
{sortedCategories.map(([categoryName, categoryData]) => (
<Accordion key={categoryName} defaultExpanded sx={{ mb: 2 }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{ backgroundColor: 'grey.50' }}
>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%', gap: 2 }}>
<Chip
label={categoryName}
color={getCategoryColor(categoryName)}
size="small"
/>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" color="text.secondary">
{categoryData.accounts.length} accounts
</Typography>
</Box>
<Box sx={{ display: 'flex', gap: 3 }}>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="caption" color="text.secondary">Debit</Typography>
<Typography variant="body1" fontWeight="bold">
{formatAmount(categoryData.totalDebit)}
</Typography>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="caption" color="text.secondary">Credit</Typography>
<Typography variant="body1" fontWeight="bold">
{formatAmount(categoryData.totalCredit)}
</Typography>
</Box>
<Box sx={{ textAlign: 'right', minWidth: 100 }}>
<Typography variant="caption" color="text.secondary">Balance</Typography>
<Typography
variant="body1"
fontWeight="bold"
color={categoryData.totalBalance >= 0 ? "primary" : "error"}
>
{formatAmount(Math.abs(categoryData.totalBalance))}
{categoryData.totalBalance < 0 && ' (CR)'}
</Typography>
</Box>
</Box>
</Box>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
<TableCell>Account #</TableCell>
<TableCell>Account Name</TableCell>
<TableCell align="center">Transactions</TableCell>
<TableCell align="right">Debit</TableCell>
<TableCell align="right">Credit</TableCell>
<TableCell align="right">Balance</TableCell>
</TableRow>
</TableHead>
<TableBody>
{categoryData.accounts.map((account) => (
<TableRow key={account.number}>
<TableCell>
<Typography variant="body2" fontWeight="bold">
{account.number}
</Typography>
</TableCell>
<TableCell>{account.name}</TableCell>
<TableCell align="center">
<Chip
label={account.transactionCount}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell align="right">
{account.debit > 0 ? formatAmount(account.debit) : '-'}
</TableCell>
<TableCell align="right">
{account.credit > 0 ? formatAmount(account.credit) : '-'}
</TableCell>
<TableCell align="right">
<Typography
variant="body2"
fontWeight="bold"
color={account.balance >= 0 ? "inherit" : "error"}
>
{formatAmount(Math.abs(account.balance))}
{account.balance < 0 && ' (CR)'}
</Typography>
</TableCell>
</TableRow>
))}
{/* Category Totals Row */}
<TableRow sx={{ backgroundColor: 'grey.50' }}>
<TableCell colSpan={2}>
<Typography variant="body2" fontWeight="bold">
Category Total
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={categoryData.accounts.reduce((sum, acc) => sum + acc.transactionCount, 0)}
size="small"
color="primary"
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{formatAmount(categoryData.totalDebit)}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{formatAmount(categoryData.totalCredit)}
</Typography>
</TableCell>
<TableCell align="right">
<Typography
variant="body2"
fontWeight="bold"
color={categoryData.totalBalance >= 0 ? "primary" : "error"}
>
{formatAmount(Math.abs(categoryData.totalBalance))}
{categoryData.totalBalance < 0 && ' (CR)'}
</Typography>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
))}
{/* Footer */}
<Box sx={{ mt: 3, p: 2, backgroundColor: 'grey.100', borderRadius: 1 }}>
<Grid container spacing={2}>
<Grid item xs={12} md={6}>
<Typography variant="body2" color="text.secondary">
Generated: {data.generatedAt ? new Date(data.generatedAt).toLocaleString() : 'N/A'}
</Typography>
<Typography variant="body2" color="text.secondary">
Documents processed: {data.documentCount || 0}
</Typography>
</Grid>
<Grid item xs={12} md={6} sx={{ textAlign: 'right' }}>
<Typography variant="h6">
Grand Total - Debit: {formatAmount(data.totals.debit)} | Credit: {formatAmount(data.totals.credit)}
</Typography>
{data.totals.difference < 0.01 && (
<Typography variant="body2" color="success.main">
✓ Trial Balance is balanced
</Typography>
)}
</Grid>
</Grid>
</Box>
</Box>
);
};
Frontend/Presentational
import React from 'react';
import {
Card,
CardContent,
Typography,
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Grid,
Chip,
Alert,
Divider
} from '@mui/material';
import AccountBalanceIcon from '@mui/icons-material/AccountBalance';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import TrendingDownIcon from '@mui/icons-material/TrendingDown';
import ReceiptIcon from '@mui/icons-material/Receipt';
import PaymentIcon from '@mui/icons-material/Payment';
export const VATReport = ({ data }) => {
const formatAmount = (amount) => {
return parseFloat(amount || 0).toFixed(2);
};
if (!data) {
return (
<Card sx={{ mt: 2 }}>
<CardContent>
<Typography variant="h6" color="text.secondary" align="center">
No VAT report data available
</Typography>
</CardContent>
</Card>
);
}
const { inputVAT, outputVAT, vatPayable, vatRefundable, documents, summary } = data;
const needsToPay = vatPayable > 0;
const canClaim = vatRefundable > 0;
return (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<AccountBalanceIcon color="primary" />
<Typography variant="h5">
VAT Report
</Typography>
</Box>
{/* VAT Status Alert */}
{needsToPay && (
<Alert severity="warning" sx={{ mb: 3 }}>
<Typography variant="h6">VAT Payment Due: {formatAmount(vatPayable)}</Typography>
<Typography variant="body2">
You owe {formatAmount(vatPayable)} in VAT to the tax authorities.
</Typography>
</Alert>
)}
{canClaim && (
<Alert severity="success" sx={{ mb: 3 }}>
<Typography variant="h6">VAT Refund Available: {formatAmount(vatRefundable)}</Typography>
<Typography variant="body2">
You can claim {formatAmount(vatRefundable)} VAT refund from the tax authorities.
</Typography>
</Alert>
)}
{/* Summary Cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} md={3}>
<Card sx={{ backgroundColor: 'info.light' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingDownIcon />
<Typography variant="subtitle2" color="white">
Input VAT
</Typography>
</Box>
<Typography variant="h4" color="white">
{formatAmount(inputVAT)}
</Typography>
<Typography variant="caption" color="white">
VAT on purchases (reclaimable)
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card sx={{ backgroundColor: 'warning.light' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TrendingUpIcon />
<Typography variant="subtitle2" color="white">
Output VAT
</Typography>
</Box>
<Typography variant="h4" color="white">
{formatAmount(outputVAT)}
</Typography>
<Typography variant="caption" color="white">
VAT on sales (payable)
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card sx={{
backgroundColor: needsToPay ? 'error.main' : canClaim ? 'success.main' : 'grey.400'
}}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PaymentIcon />
<Typography variant="subtitle2" color="white">
Net VAT Position
</Typography>
</Box>
<Typography variant="h4" color="white">
{formatAmount(needsToPay ? vatPayable : vatRefundable)}
</Typography>
<Chip
label={needsToPay ? "To Pay" : canClaim ? "To Claim" : "Balanced"}
size="small"
sx={{
backgroundColor: 'white',
color: needsToPay ? 'error.main' : canClaim ? 'success.main' : 'grey.600',
fontWeight: 'bold'
}}
/>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} md={3}>
<Card>
<CardContent>
<Typography variant="subtitle2" color="text.secondary">
Total Documents
</Typography>
<Typography variant="h4" color="primary">
{summary?.totalDocuments || 0}
</Typography>
<Box sx={{ mt: 1 }}>
<Typography variant="caption" color="text.secondary">
Input: {summary?.inputDocuments || 0} | Output: {summary?.outputDocuments || 0}
</Typography>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Documents Table */}
<Card>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<ReceiptIcon color="primary" />
<Typography variant="h6">VAT Documents</Typography>
</Box>
{documents && documents.length > 0 ? (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow sx={{ backgroundColor: 'grey.100' }}>
<TableCell>Document #</TableCell>
<TableCell>Date</TableCell>
<TableCell>From</TableCell>
<TableCell>To</TableCell>
<TableCell align="center">VAT Type</TableCell>
<TableCell align="right">Total Amount</TableCell>
<TableCell align="right">VAT Amount</TableCell>
</TableRow>
</TableHead>
<TableBody>
{documents.map((doc) => (
<TableRow key={doc.documentId}>
<TableCell>
<Typography variant="body2" fontWeight="bold">
{doc.number}
</Typography>
<Typography variant="caption" color="text.secondary">
{doc.documentId}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{new Date(doc.date).toLocaleDateString()}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{doc.sender}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{doc.recipient}
</Typography>
</TableCell>
<TableCell align="center">
<Chip
label={doc.type}
size="small"
color={doc.type === 'Input VAT' ? 'info' : 'warning'}
variant="outlined"
/>
</TableCell>
<TableCell align="right">
<Typography variant="body2">
{formatAmount(doc.totalAmount)}
</Typography>
</TableCell>
<TableCell align="right">
<Typography
variant="body2"
fontWeight="bold"
color={doc.type === 'Input VAT' ? 'info.main' : 'warning.main'}
>
{formatAmount(doc.vatAmount)}
</Typography>
</TableCell>
</TableRow>
))}
{/* Summary Row */}
<TableRow sx={{ backgroundColor: 'grey.50' }}>
<TableCell colSpan={5}>
<Typography variant="body2" fontWeight="bold">
VAT Summary
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold">
{formatAmount(documents.reduce((sum, doc) => sum + doc.totalAmount, 0))}
</Typography>
</TableCell>
<TableCell align="right">
<Typography variant="body2" fontWeight="bold" color="primary">
{formatAmount(documents.reduce((sum, doc) => sum + doc.vatAmount, 0))}
</Typography>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
) : (
<Typography variant="body2" color="text.secondary">
No VAT documents found
</Typography>
)}
</CardContent>
</Card>
{/* VAT Calculation Summary */}
<Card sx={{ mt: 3, backgroundColor: 'grey.50' }}>
<CardContent>
<Typography variant="h6" gutterBottom>
VAT Calculation Summary
</Typography>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography>Output VAT (Sales):</Typography>
<Typography fontWeight="bold" color="warning.main">
+ {formatAmount(outputVAT)}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography>Input VAT (Purchases):</Typography>
<Typography fontWeight="bold" color="info.main">
- {formatAmount(inputVAT)}
</Typography>
</Box>
<Divider sx={{ my: 1 }} />
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="h6">
{needsToPay ? 'VAT to Pay:' : canClaim ? 'VAT to Claim:' : 'VAT Balance:'}
</Typography>
<Typography
variant="h6"
fontWeight="bold"
color={needsToPay ? 'error.main' : canClaim ? 'success.main' : 'grey.600'}
>
{formatAmount(needsToPay ? vatPayable : vatRefundable)}
</Typography>
</Box>
</Box>
</Grid>
<Grid item xs={12} md={6}>
<Box sx={{
p: 2,
backgroundColor: needsToPay ? 'error.light' : canClaim ? 'success.light' : 'grey.200',
borderRadius: 1,
color: needsToPay || canClaim ? 'white' : 'inherit'
}}>
<Typography variant="h6" gutterBottom>
Action Required
</Typography>
{needsToPay && (
<Typography variant="body2">
• File VAT return by the due date<br/>
• Pay {formatAmount(vatPayable)} to tax authorities<br/>
• Keep all supporting documents
</Typography>
)}
{canClaim && (
<Typography variant="body2">
• File VAT return to claim refund<br/>
• Claim {formatAmount(vatRefundable)} from tax authorities<br/>
• Ensure all input VAT is valid
</Typography>
)}
{!needsToPay && !canClaim && (
<Typography variant="body2" color="text.secondary">
• VAT is balanced - no payment or claim needed<br/>
• File nil VAT return if required<br/>
• Maintain proper records
</Typography>
)}
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Footer */}
<Box sx={{ mt: 2, textAlign: 'right' }}>
<Typography variant="caption" color="text.secondary">
Generated: {data.generatedAt ? new Date(data.generatedAt).toLocaleString() : 'N/A'}
</Typography>
</Box>
</Box>
);
};
Frontend/contentRender.js
import React, { useEffect, useRef } from "react";
import Avatar from "@mui/material/Avatar";
import { DocumentEditor } from "./DocumentEditor";
import { DocumentsList } from "./Presentational/DocumentsList";
import { TrialBalance } from "./Presentational/TrialBalance";
import { IncomeStatement } from "./Presentational/IncomeStatement";
import { VATReport } from "./Presentational/VATReport";
import { AccountsReport } from "./Presentational/AccountsReport";
import { ChartOfAccounts } from "./Presentational/ChartOfAccounts";
import { InvoiceImage } from "./Presentational/InvoiceImage";
const isMobile = window.openkbs.isMobile;
export function getQueryParamValue(paramName) {
const queryParams = new URLSearchParams(window.location.search);
return queryParams.get(paramName);
}
const extractJSONFromContent = (text) => {
let braceCount = 0, startIndex = text.indexOf('{');
if (startIndex === -1) return null;
for (let i = startIndex; i < text.length; i++) {
if (text[i] === '{') braceCount++;
if (text[i] === '}' && --braceCount === 0) {
try {
return JSON.parse(text.slice(startIndex, i + 1));
} catch {
return null;
}
}
}
return null;
}
const onRenderChatMessage = async (params) => {
const { APIResponseComponent, theme, setBlockingLoading, setSystemAlert, RequestChatAPI,
kbUserData, generateMsgId, messages, msgIndex } = params;
const { content, role } = messages[msgIndex];
// Continue with the JSON extraction
const jsonResult = extractJSONFromContent(content);
if (role === 'user' && !jsonResult) return; // use default rendering for user messages
if (jsonResult) {
const data = jsonResult;
// Check if this is an array containing image_url (like OCR upload result)
if (Array.isArray(data)) {
const imageItems = data.filter(item => item.type === "image_url" && item.image_url?.url);
if (imageItems.length > 0) {
return imageItems.map((imageItem, index) => (
<InvoiceImage
key={index}
imageUrl={imageItem.image_url.url}
alt={`Uploaded Invoice ${index + 1}`}
/>
))
}
}
// Check if this is a SAVE_DOCUMENT_REQUEST
if (data?.type === 'SAVE_DOCUMENT_REQUEST' && data?.document) {
const document = data.document;
const imageUrl = document.image || data.image;
const avatarSize = isMobile ? '48px' : '64px';
// Create the save handler for manual saves after editing
const handleSave = async (updatedData) => {
setBlockingLoading(true);
try {
// Send a message to the chat about the save request
await RequestChatAPI([...messages, {
role: 'user',
content: JSON.stringify({
type: "SAVE_DOCUMENT_REQUEST",
document: updatedData.document
}),
userId: kbUserData().chatUsername,
msgId: generateMsgId()
}]);
setSystemAlert({
severity: 'success',
message: 'Document saved successfully!'
});
} catch (e) {
console.error("Error in save process:", e);
setSystemAlert({
severity: 'error',
message: 'Failed to save document. Please try again.'
});
} finally {
setBlockingLoading(false);
}
};
return [
imageUrl && (
<Avatar
alt="Document Image"
src={imageUrl}
style={{
marginRight: '10px',
cursor: 'pointer',
position: 'absolute',
left: isMobile ? -54 : -72,
bottom: 68,
borderRadius: '50%',
height: avatarSize,
width: avatarSize
}}
onClick={() => window.open(imageUrl, '_blank')}
/>
),
<DocumentEditor
documentData={document}
onSave={handleSave}
/>
];
}
// Check if this is a DOCUMENTS_LIST response
if (data?.type === 'DOCUMENTS_LIST' && data?.data) {
return [
<DocumentsList documents={data?.data} />
];
}
// Check if this is a TRIAL_BALANCE response
if (data?.type === 'TRIAL_BALANCE' && data?.data) {
return [
<TrialBalance data={data?.data} />
];
}
// Check if this is an INCOME_STATEMENT response
if (data?.type === 'INCOME_STATEMENT' && data?.data) {
return [
<IncomeStatement data={data.data} />
];
}
// Check if this is a VAT_REPORT response
if (data?.type === 'VAT_REPORT' && data?.data) {
return [
<VATReport data={data?.data} />
];
}
// Check if this is an ACCOUNTS_REPORT response
if (data?.type === 'ACCOUNTS_REPORT' && data?.data) {
return [
<AccountsReport data={data.data} />
];
}
// Check if this is a CHART_OF_ACCOUNTS response
if (data?.type === 'CHART_OF_ACCOUNTS' && data?.data) {
return [
<ChartOfAccounts data={data?.data} />
];
}
// Default rendering for any JSON
return null
}
// If no JSON was found, return null to let default rendering handle it
return null;
};
const Header = ({ setRenderSettings }) => {
useEffect(() => {
setRenderSettings({
setMessageWidth: () => isMobile ? '95%' : '85%',
inputLabelsQuickSend: true,
disableBalanceView: false,
disableSentLabel: false,
disableChatAvatar: isMobile,
disableChatModelsSelect: false,
disableContextItems: false,
disableCopyButton: false,
disableEmojiButton: false,
disableTextToSpeechButton: false,
disableMobileLeftButton: false,
});
}, [setRenderSettings]);
};
const exports = { onRenderChatMessage, Header };
window.contentRender = exports;
export default exports;
Frontend/contentRender.json
{
"dependencies": {
"react": "^18.2.0 (fixed)",
"react-dom": "^18.2.0 (fixed)",
"@mui/material": "^5.16.1 (fixed)",
"@mui/icons-material": "^5.16.1 (fixed)",
"@emotion/react": "^11.10.6 (fixed)",
"@emotion/styled": "^11.10.6 (fixed)"
}
}