diff --git a/backend/src/db/api/bank_accounts.js b/backend/src/db/api/bank_accounts.js
index 5c94e9f..ad54b06 100644
--- a/backend/src/db/api/bank_accounts.js
+++ b/backend/src/db/api/bank_accounts.js
@@ -3,6 +3,7 @@ const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
+const { resolveCurrencyId } = require('./defaultCurrency');
@@ -63,8 +64,8 @@ module.exports = class Bank_accountsDBApi {
await bank_accounts.setOrganization(currentUser.organization.id || null, {
transaction,
});
-
- await bank_accounts.setCurrency( data.currency || null, {
+ const currencyId = await resolveCurrencyId(db, data.currency, transaction);
+ await bank_accounts.setCurrency(currencyId, {
transaction,
});
diff --git a/backend/src/db/api/bank_transactions.js b/backend/src/db/api/bank_transactions.js
index 57606ee..1a977d9 100644
--- a/backend/src/db/api/bank_transactions.js
+++ b/backend/src/db/api/bank_transactions.js
@@ -3,6 +3,7 @@ const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
+const { resolveCurrencyId } = require('./defaultCurrency');
@@ -71,8 +72,8 @@ module.exports = class Bank_transactionsDBApi {
await bank_transactions.setBank_account( data.bank_account || null, {
transaction,
});
-
- await bank_transactions.setCurrency( data.currency || null, {
+ const currencyId = await resolveCurrencyId(db, data.currency, transaction);
+ await bank_transactions.setCurrency(currencyId, {
transaction,
});
diff --git a/backend/src/db/api/bills.js b/backend/src/db/api/bills.js
index 253f4e5..010c3e5 100644
--- a/backend/src/db/api/bills.js
+++ b/backend/src/db/api/bills.js
@@ -3,6 +3,7 @@ const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
+const { resolveCurrencyId } = require('./defaultCurrency');
@@ -86,8 +87,8 @@ module.exports = class BillsDBApi {
await bills.setSupplier( data.supplier || null, {
transaction,
});
-
- await bills.setCurrency( data.currency || null, {
+ const currencyId = await resolveCurrencyId(db, data.currency, transaction);
+ await bills.setCurrency(currencyId, {
transaction,
});
diff --git a/backend/src/db/api/contacts.js b/backend/src/db/api/contacts.js
index 9bc8c39..cb680fb 100644
--- a/backend/src/db/api/contacts.js
+++ b/backend/src/db/api/contacts.js
@@ -3,6 +3,7 @@ const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
+const { resolveCurrencyId } = require('./defaultCurrency');
@@ -88,8 +89,8 @@ module.exports = class ContactsDBApi {
await contacts.setOrganization(currentUser.organization.id || null, {
transaction,
});
-
- await contacts.setCurrency( data.currency || null, {
+ const currencyId = await resolveCurrencyId(db, data.currency, transaction);
+ await contacts.setCurrency(currencyId, {
transaction,
});
diff --git a/backend/src/db/api/defaultCurrency.js b/backend/src/db/api/defaultCurrency.js
new file mode 100644
index 0000000..7ed951d
--- /dev/null
+++ b/backend/src/db/api/defaultCurrency.js
@@ -0,0 +1,26 @@
+const DEFAULT_CURRENCY_CODE = 'ZAR';
+
+async function resolveCurrencyId(db, providedCurrencyId, transaction) {
+ if (providedCurrencyId) {
+ return providedCurrencyId;
+ }
+
+ const [currency] = await db.currencies.findOrCreate({
+ where: { code: DEFAULT_CURRENCY_CODE },
+ defaults: {
+ code: DEFAULT_CURRENCY_CODE,
+ name: 'South African Rand',
+ symbol: 'R',
+ minor_unit: 2,
+ importHash: 'qweli-default-zar',
+ },
+ transaction,
+ });
+
+ return currency ? currency.id : null;
+}
+
+module.exports = {
+ DEFAULT_CURRENCY_CODE,
+ resolveCurrencyId,
+};
diff --git a/backend/src/db/api/expenses.js b/backend/src/db/api/expenses.js
index c212ece..0d109cf 100644
--- a/backend/src/db/api/expenses.js
+++ b/backend/src/db/api/expenses.js
@@ -3,6 +3,7 @@ const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
+const { resolveCurrencyId } = require('./defaultCurrency');
@@ -61,8 +62,8 @@ module.exports = class ExpensesDBApi {
await expenses.setContact( data.contact || null, {
transaction,
});
-
- await expenses.setCurrency( data.currency || null, {
+ const currencyId = await resolveCurrencyId(db, data.currency, transaction);
+ await expenses.setCurrency(currencyId, {
transaction,
});
diff --git a/backend/src/db/api/invoices.js b/backend/src/db/api/invoices.js
index 82dc549..4205e74 100644
--- a/backend/src/db/api/invoices.js
+++ b/backend/src/db/api/invoices.js
@@ -3,6 +3,7 @@ const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
+const { resolveCurrencyId } = require('./defaultCurrency');
@@ -86,8 +87,8 @@ module.exports = class InvoicesDBApi {
await invoices.setCustomer( data.customer || null, {
transaction,
});
-
- await invoices.setCurrency( data.currency || null, {
+ const currencyId = await resolveCurrencyId(db, data.currency, transaction);
+ await invoices.setCurrency(currencyId, {
transaction,
});
diff --git a/backend/src/db/api/payments.js b/backend/src/db/api/payments.js
index 8750726..70103bf 100644
--- a/backend/src/db/api/payments.js
+++ b/backend/src/db/api/payments.js
@@ -3,6 +3,7 @@ const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
+const { resolveCurrencyId } = require('./defaultCurrency');
@@ -62,8 +63,8 @@ module.exports = class PaymentsDBApi {
await payments.setOrganization(currentUser.organization.id || null, {
transaction,
});
-
- await payments.setCurrency( data.currency || null, {
+ const currencyId = await resolveCurrencyId(db, data.currency, transaction);
+ await payments.setCurrency(currencyId, {
transaction,
});
diff --git a/backend/src/db/api/quotes.js b/backend/src/db/api/quotes.js
index d8dbe40..2f744ea 100644
--- a/backend/src/db/api/quotes.js
+++ b/backend/src/db/api/quotes.js
@@ -3,6 +3,7 @@ const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
+const { resolveCurrencyId } = require('./defaultCurrency');
@@ -76,8 +77,8 @@ module.exports = class QuotesDBApi {
await quotes.setCustomer( data.customer || null, {
transaction,
});
-
- await quotes.setCurrency( data.currency || null, {
+ const currencyId = await resolveCurrencyId(db, data.currency, transaction);
+ await quotes.setCurrency(currencyId, {
transaction,
});
diff --git a/backend/src/db/migrations/20260604161000-add-zar-default-currency.js b/backend/src/db/migrations/20260604161000-add-zar-default-currency.js
new file mode 100644
index 0000000..5e87a6f
--- /dev/null
+++ b/backend/src/db/migrations/20260604161000-add-zar-default-currency.js
@@ -0,0 +1,68 @@
+const crypto = require('crypto');
+
+module.exports = {
+ async up(queryInterface) {
+ const transaction = await queryInterface.sequelize.transaction();
+ try {
+ const now = new Date();
+ const [existingRows] = await queryInterface.sequelize.query(
+ 'SELECT id FROM currencies WHERE code = :code AND "deletedAt" IS NULL LIMIT 1',
+ {
+ replacements: { code: 'ZAR' },
+ transaction,
+ },
+ );
+
+ if (existingRows.length) {
+ await queryInterface.bulkUpdate(
+ 'currencies',
+ {
+ name: 'South African Rand',
+ symbol: 'R',
+ minor_unit: 2,
+ updatedAt: now,
+ },
+ { code: 'ZAR' },
+ { transaction },
+ );
+ } else {
+ await queryInterface.bulkInsert(
+ 'currencies',
+ [
+ {
+ id: crypto.randomUUID(),
+ code: 'ZAR',
+ name: 'South African Rand',
+ symbol: 'R',
+ minor_unit: 2,
+ importHash: 'qweli-default-zar',
+ createdAt: now,
+ updatedAt: now,
+ },
+ ],
+ { transaction },
+ );
+ }
+
+ await transaction.commit();
+ } catch (err) {
+ await transaction.rollback();
+ throw err;
+ }
+ },
+
+ async down(queryInterface) {
+ const transaction = await queryInterface.sequelize.transaction();
+ try {
+ await queryInterface.bulkDelete(
+ 'currencies',
+ { code: 'ZAR', importHash: 'qweli-default-zar' },
+ { transaction },
+ );
+ await transaction.commit();
+ } catch (err) {
+ await transaction.rollback();
+ throw err;
+ }
+ },
+};
diff --git a/backend/src/db/migrations/20260604162500-backfill-zar-currency.js b/backend/src/db/migrations/20260604162500-backfill-zar-currency.js
new file mode 100644
index 0000000..da03ca2
--- /dev/null
+++ b/backend/src/db/migrations/20260604162500-backfill-zar-currency.js
@@ -0,0 +1,71 @@
+const crypto = require('crypto');
+
+const CURRENCY_TABLES = [
+ 'contacts',
+ 'quotes',
+ 'invoices',
+ 'bills',
+ 'expenses',
+ 'bank_accounts',
+ 'bank_transactions',
+ 'payments',
+];
+
+module.exports = {
+ async up(queryInterface) {
+ const transaction = await queryInterface.sequelize.transaction();
+ try {
+ const now = new Date();
+ const [existingRows] = await queryInterface.sequelize.query(
+ 'SELECT id FROM currencies WHERE code = :code AND "deletedAt" IS NULL LIMIT 1',
+ {
+ replacements: { code: 'ZAR' },
+ transaction,
+ },
+ );
+
+ let zarCurrencyId = existingRows[0]?.id;
+
+ if (!zarCurrencyId) {
+ zarCurrencyId = crypto.randomUUID();
+ await queryInterface.bulkInsert(
+ 'currencies',
+ [
+ {
+ id: zarCurrencyId,
+ code: 'ZAR',
+ name: 'South African Rand',
+ symbol: 'R',
+ minor_unit: 2,
+ importHash: 'qweli-default-zar-backfill',
+ createdAt: now,
+ updatedAt: now,
+ },
+ ],
+ { transaction },
+ );
+ }
+
+ for (const tableName of CURRENCY_TABLES) {
+ await queryInterface.bulkUpdate(
+ tableName,
+ {
+ currencyId: zarCurrencyId,
+ updatedAt: now,
+ },
+ { deletedAt: null },
+ { transaction },
+ );
+ }
+
+ await transaction.commit();
+ } catch (err) {
+ await transaction.rollback();
+ throw err;
+ }
+ },
+
+ async down() {
+ // Intentionally no-op: previous currency choices cannot be reconstructed safely.
+ },
+};
diff --git a/backend/src/index.js b/backend/src/index.js
index 97ef20e..73506b1 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -16,6 +16,7 @@ const fileRoutes = require('./routes/file');
const searchRoutes = require('./routes/search');
const sqlRoutes = require('./routes/sql');
const pexelsRoutes = require('./routes/pexels');
+const qweliRoutes = require('./routes/qweli');
const organizationForAuthRoutes = require('./routes/organizationLogin');
@@ -130,6 +131,7 @@ app.use(bodyParser.json());
app.use('/api/auth', authRoutes);
app.use('/api/file', fileRoutes);
app.use('/api/pexels', pexelsRoutes);
+app.use('/api/qweli', passport.authenticate('jwt', { session: false }), qweliRoutes);
app.enable('trust proxy');
diff --git a/backend/src/routes/qweli.js b/backend/src/routes/qweli.js
new file mode 100644
index 0000000..8aafd76
--- /dev/null
+++ b/backend/src/routes/qweli.js
@@ -0,0 +1,83 @@
+const express = require('express');
+const EmailSender = require('../services/email');
+const InvoicesDBApi = require('../db/api/invoices');
+const wrapAsync = require('../helpers').wrapAsync;
+const { checkPermissions } = require('../middlewares/check-permissions');
+
+const router = express.Router();
+
+const escapeHtml = (value) => String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+
+router.post('/invoice-email', checkPermissions('READ_INVOICES'), wrapAsync(async (req, res) => {
+ const { invoiceId, recipientEmail, note, workflowStage, sourceUrl } = req.body || {};
+
+ if (!invoiceId) {
+ return res.status(400).send({ message: 'invoiceId is required' });
+ }
+
+ if (!recipientEmail || !/^\S+@\S+\.\S+$/.test(recipientEmail)) {
+ return res.status(400).send({ message: 'A valid recipientEmail is required' });
+ }
+
+ const invoice = await InvoicesDBApi.findBy({ id: invoiceId });
+
+ if (!invoice) {
+ return res.status(404).send({ message: 'Invoice not found' });
+ }
+
+ const invoiceNumber = invoice.invoice_number || invoice.reference || invoice.id;
+ const amount = invoice.amount_due || invoice.total || 0;
+ const currencyCode = invoice.currency?.code || 'ZAR';
+ const subject = `Qweli invoice collaboration: ${invoiceNumber}`;
+ const html = async () => `
+
+
Qweli invoice collaboration
+
You have been invited to review invoice ${escapeHtml(invoiceNumber)} .
+
+ Status: ${escapeHtml(invoice.status || 'draft')}
+ Workflow stage: ${escapeHtml(workflowStage || 'draft_review')}
+ Amount due: ${escapeHtml(currencyCode)} ${escapeHtml(amount)}
+
+ ${note ? `
Team note: ${escapeHtml(note)}
` : ''}
+ ${sourceUrl ? `
Open invoice in Qweli
` : ''}
+
Sent from Qweli Accounting Suite.
+
+ `;
+
+ if (!EmailSender.isConfigured) {
+ return res.status(200).send({
+ sent: false,
+ configured: false,
+ message: 'Email transport is not configured. Set EMAIL_USER and EMAIL_PASS to send production email.',
+ preview: {
+ to: recipientEmail,
+ subject,
+ invoiceNumber,
+ workflowStage: workflowStage || 'draft_review',
+ },
+ });
+ }
+
+ const sender = new EmailSender({
+ to: recipientEmail,
+ subject,
+ html,
+ });
+
+ const result = await sender.send();
+
+ res.status(200).send({
+ sent: true,
+ configured: true,
+ messageId: result.messageId,
+ });
+}));
+
+router.use('/', require('../helpers').commonErrorHandler);
+
+module.exports = router;
diff --git a/frontend/src/pages/_app.tsx b/frontend/src/pages/_app.tsx
index 046b318..e127850 100644
--- a/frontend/src/pages/_app.tsx
+++ b/frontend/src/pages/_app.tsx
@@ -150,7 +150,7 @@ function MyApp({ Component, pageProps }: AppPropsWithLayout) {
};
const title = 'Qweli Accounting Suite'
- const description = "Xero-like multi-tenant accounting suite for invoicing, bills, expenses, payments, bank feeds, inventory, and reporting."
+ const description = "ZAR-first multi-tenant accounting suite for invoicing, bills, expenses, payments, bank feeds, inventory, AI reporting, email, and collaboration workflows."
const url = "https://flatlogic.com/"
const image = "https://project-screens.s3.amazonaws.com/screenshots/40202/app-hero-20260604-154915.png"
const imageWidth = '1920'
diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx
index a959298..400bec2 100644
--- a/frontend/src/pages/index.tsx
+++ b/frontend/src/pages/index.tsx
@@ -12,7 +12,7 @@ const modules = [
'Bills, expenses, and approvals',
'Inventory and service catalog',
'Bank movement and audit trail',
- 'Cashflow and tax readiness',
+ 'ZAR cashflow and VAT readiness',
]
const valueChain = [
@@ -29,7 +29,7 @@ export default function QweliLanding() {
{getPageTitle('Qweli')}
@@ -60,14 +60,14 @@ export default function QweliLanding() {
- Xero-inspired, value-chain ready
+ Independent finance suite, value-chain ready
Accounting that sees the whole business network.
- Qweli brings owners, accountants, customers, suppliers, products, invoices, expenses, and payments into one
- clean operating workspace so cashflow decisions happen faster.
+ Qweli brings owners, accountants, customers, suppliers, products, invoices, expenses, payments, AI reports, and email workflows into one
+ clean ZAR-first workspace so cashflow decisions happen faster.
@@ -96,9 +96,9 @@ export default function QweliLanding() {
{[
- ['Customer invoice drafted', '$2,400', 'bg-[#5CF2B8]'],
- ['Supplier expense submitted', '$380', 'bg-[#FFB84D]'],
- ['Payment matching queued', '$1,120', 'bg-[#61A5FF]'],
+ ['Customer invoice drafted', 'R24,000', 'bg-[#5CF2B8]'],
+ ['Supplier expense submitted', 'R3,800', 'bg-[#FFB84D]'],
+ ['Payment matching queued', 'R11,200', 'bg-[#61A5FF]'],
].map(([label, amount, color]) => (
diff --git a/frontend/src/pages/qweli-command-center.tsx b/frontend/src/pages/qweli-command-center.tsx
index d9f3e7b..65b1cff 100644
--- a/frontend/src/pages/qweli-command-center.tsx
+++ b/frontend/src/pages/qweli-command-center.tsx
@@ -8,8 +8,11 @@ import LayoutAuthenticated from '../layouts/Authenticated'
import SectionMain from '../components/SectionMain'
import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'
import { getPageTitle } from '../config'
+import { aiResponse } from '../stores/openAiSlice'
+import { useAppDispatch, useAppSelector } from '../stores/hooks'
type WorkflowType = 'invoice' | 'expense'
+type CollaborationStage = 'draft_review' | 'internal_approval' | 'customer_review' | 'payment_follow_up'
type QweliEntry = {
id: string
@@ -20,6 +23,7 @@ type QweliEntry = {
date?: string
href: string
description?: string
+ currencyCode?: string
}
type MetricCard = {
@@ -29,9 +33,45 @@ type MetricCard = {
accent: string
}
-const currency = new Intl.NumberFormat('en-US', {
+type ModuleCard = {
+ label: string
+ caption: string
+ value: string
+}
+
+const workflowStages: Array<{ id: CollaborationStage; label: string; caption: string }> = [
+ {
+ id: 'draft_review',
+ label: 'Draft review',
+ caption: 'Finance prepares the invoice and checks VAT, totals, and attachments.',
+ },
+ {
+ id: 'internal_approval',
+ label: 'Internal approval',
+ caption: 'Owner or manager reviews margin, scope, and payment terms before sending.',
+ },
+ {
+ id: 'customer_review',
+ label: 'Customer review',
+ caption: 'Customer receives the invoice pack, comments, and confirms acceptance.',
+ },
+ {
+ id: 'payment_follow_up',
+ label: 'Payment follow-up',
+ caption: 'Team tracks promises to pay, reminders, and payment matching.',
+ },
+]
+
+const stageToInvoiceStatus: Record
= {
+ draft_review: 'draft',
+ internal_approval: 'draft',
+ customer_review: 'sent',
+ payment_follow_up: 'sent',
+}
+
+const currency = new Intl.NumberFormat('en-ZA', {
style: 'currency',
- currency: 'USD',
+ currency: 'ZAR',
maximumFractionDigits: 0,
})
@@ -45,46 +85,105 @@ const normalizeRows = (response: unknown) => {
return Array.isArray(data?.data?.rows) ? data.data.rows : []
}
+const getCount = (response: unknown) => {
+ const data = response as { data?: { count?: number } }
+ return Number(data?.data?.count ?? 0)
+}
+
const formatDate = (value?: string) => {
if (!value) return 'Today'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return 'Today'
- return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ return date.toLocaleDateString('en-ZA', { month: 'short', day: 'numeric', year: 'numeric' })
+}
+
+const extractAiText = (response: any) => {
+ if (!response) return ''
+
+ const output = response.output || response.data?.output
+ if (Array.isArray(output)) {
+ const message = output.find((item) => item?.type === 'message')
+ const content = Array.isArray(message?.content) ? message.content : []
+ const textContent = content.find((item) => item?.type === 'output_text')
+
+ if (typeof textContent?.text === 'string') {
+ return textContent.text
+ }
+ }
+
+ if (typeof response.text === 'string') return response.text
+ if (typeof response.data?.text === 'string') return response.data.text
+
+ return ''
}
const QweliCommandCenter = () => {
+ const dispatch = useAppDispatch()
+ const { aiResponse: aiReportResponse, isAskingResponse, errorMessage: aiErrorMessage } = useAppSelector(
+ (state) => state.openAi,
+ )
+
const [entries, setEntries] = useState([])
const [contactsCount, setContactsCount] = useState(0)
const [productsCount, setProductsCount] = useState(0)
+ const [bankAccountsCount, setBankAccountsCount] = useState(0)
+ const [paymentsCount, setPaymentsCount] = useState(0)
+ const [zarCurrencyId, setZarCurrencyId] = useState('')
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
+ const [isSendingEmail, setIsSendingEmail] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
+ const [emailStatus, setEmailStatus] = useState('')
const [selectedEntry, setSelectedEntry] = useState(null)
+ const [collaborationStage, setCollaborationStage] = useState('draft_review')
+ const [collaboratorEmail, setCollaboratorEmail] = useState('')
+ const [collaborationNote, setCollaborationNote] = useState(
+ 'Please review the invoice totals, VAT treatment, supporting notes, and payment timing.',
+ )
const [form, setForm] = useState({
type: 'invoice' as WorkflowType,
counterparty: '',
- amount: '1250',
- tax: '0',
+ amount: '12500',
+ tax: '1875',
dueInDays: '14',
- description: 'Website services and support retainer',
+ description: 'Monthly services, delivery support, and customer success retainer',
})
+ const aiReportText = useMemo(() => extractAiText(aiReportResponse), [aiReportResponse])
+
const loadWorkspace = async () => {
setIsLoading(true)
setError('')
try {
- const [invoicesResult, expensesResult, contactsResult, productsResult] = await Promise.allSettled([
+ const [
+ invoicesResult,
+ expensesResult,
+ contactsResult,
+ productsResult,
+ bankAccountsResult,
+ paymentsResult,
+ currenciesResult,
+ ] = await Promise.allSettled([
axios.get('invoices?limit=8&page=0'),
axios.get('expenses?limit=8&page=0'),
axios.get('contacts?limit=1&page=0'),
axios.get('products?limit=1&page=0'),
+ axios.get('bank_accounts?limit=1&page=0'),
+ axios.get('payments?limit=1&page=0'),
+ axios.get('currencies?limit=50&page=0'),
])
- const failed = [invoicesResult, expensesResult, contactsResult, productsResult].filter(
- (result) => result.status === 'rejected',
- )
+ const failed = [
+ invoicesResult,
+ expensesResult,
+ contactsResult,
+ productsResult,
+ bankAccountsResult,
+ paymentsResult,
+ currenciesResult,
+ ].filter((result) => result.status === 'rejected')
if (failed.length) {
console.error('Qweli workspace load failed', failed)
@@ -93,6 +192,10 @@ const QweliCommandCenter = () => {
const invoices = invoicesResult.status === 'fulfilled' ? normalizeRows(invoicesResult.value) : []
const expenses = expensesResult.status === 'fulfilled' ? normalizeRows(expensesResult.value) : []
+ const currencies = currenciesResult.status === 'fulfilled' ? normalizeRows(currenciesResult.value) : []
+ const defaultCurrency = currencies.find((item: any) => String(item.code).toUpperCase() === 'ZAR') as any
+
+ setZarCurrencyId(defaultCurrency?.id || '')
const normalizedInvoices = invoices.map((item: any) => ({
id: item.id,
@@ -103,6 +206,7 @@ const QweliCommandCenter = () => {
date: item.due_date || item.issue_date,
href: `/invoices/${item.id}`,
description: item.customer_notes || item.reference,
+ currencyCode: item.currency?.code || 'ZAR',
}))
const normalizedExpenses = expenses.map((item: any) => ({
@@ -114,21 +218,23 @@ const QweliCommandCenter = () => {
date: item.expense_date,
href: `/expenses/${item.id}`,
description: item.description,
+ currencyCode: item.currency?.code || 'ZAR',
}))
- setContactsCount(
- contactsResult.status === 'fulfilled' ? Number(contactsResult.value.data?.count ?? 0) : 0,
- )
- setProductsCount(
- productsResult.status === 'fulfilled' ? Number(productsResult.value.data?.count ?? 0) : 0,
- )
+ setContactsCount(contactsResult.status === 'fulfilled' ? getCount(contactsResult.value) : 0)
+ setProductsCount(productsResult.status === 'fulfilled' ? getCount(productsResult.value) : 0)
+ setBankAccountsCount(bankAccountsResult.status === 'fulfilled' ? getCount(bankAccountsResult.value) : 0)
+ setPaymentsCount(paymentsResult.status === 'fulfilled' ? getCount(paymentsResult.value) : 0)
const nextEntries = [...normalizedInvoices, ...normalizedExpenses]
.sort((a, b) => new Date(b.date || '').getTime() - new Date(a.date || '').getTime())
.slice(0, 10)
setEntries(nextEntries)
- setSelectedEntry(nextEntries[0] ?? null)
+ setSelectedEntry((current) => {
+ if (!current) return nextEntries[0] ?? null
+ return nextEntries.find((entry) => entry.id === current.id && entry.type === current.type) ?? nextEntries[0] ?? null
+ })
} catch (loadError) {
console.error('Qweli workspace load crashed', loadError)
setError('Unable to load the Qweli command center right now.')
@@ -160,23 +266,33 @@ const QweliCommandCenter = () => {
{
label: 'Receivables',
value: currency.format(totals.receivables),
- caption: 'Customer invoices in the workspace',
+ caption: 'Customer invoices defaulting to South African Rand',
accent: 'from-emerald-400 to-teal-600',
},
{
label: 'Spend captured',
value: currency.format(totals.spend),
- caption: 'Supplier costs and reimbursables',
+ caption: 'Supplier costs, expenses, and reimbursements',
accent: 'from-orange-400 to-rose-500',
},
{
label: 'Net cash signal',
value: currency.format(totals.net),
- caption: 'Simple cashflow view for this slice',
+ caption: 'ZAR cashflow signal across current records',
accent: 'from-sky-400 to-indigo-600',
},
]
+ const moduleCards: ModuleCard[] = [
+ { label: 'Contacts', caption: 'Customers, suppliers, accountants, and partners', value: String(contactsCount) },
+ { label: 'Products', caption: 'Items and services ready for invoicing', value: String(productsCount) },
+ { label: 'Banking', caption: 'Bank accounts available for reconciliation', value: String(bankAccountsCount) },
+ { label: 'Payments', caption: 'Payment records linked to invoices and contacts', value: String(paymentsCount) },
+ ]
+
+ const activeWorkflowIndex = Math.max(0, workflowStages.findIndex((stage) => stage.id === collaborationStage))
+ const selectedInvoice = selectedEntry?.type === 'invoice' ? selectedEntry : null
+
const submitCapture = async (event: React.FormEvent) => {
event.preventDefault()
setMessage('')
@@ -207,7 +323,7 @@ const QweliCommandCenter = () => {
const subtotal = Math.max(amount - tax, 0)
await axios.post('invoices', {
data: {
- invoice_number: `QW-${Date.now().toString().slice(-6)}`,
+ invoice_number: `QW-ZA-${Date.now().toString().slice(-6)}`,
status: 'draft',
issue_date: today.toISOString(),
due_date: dueDate.toISOString(),
@@ -216,7 +332,8 @@ const QweliCommandCenter = () => {
total: amount,
amount_due: amount,
reference: form.counterparty.trim(),
- customer_notes: form.description.trim(),
+ customer_notes: `${form.description.trim()}\n\nWorkflow: Draft review started in Qweli. Currency: ZAR South African Rand.`,
+ currency: zarCurrencyId || undefined,
attachments: [],
},
})
@@ -227,13 +344,14 @@ const QweliCommandCenter = () => {
status: 'submitted',
expense_date: today.toISOString(),
amount,
- description: `${form.counterparty.trim()} — ${form.description.trim()}`,
+ description: `${form.counterparty.trim()} — ${form.description.trim()} (ZAR)`,
+ currency: zarCurrencyId || undefined,
receipt_files: [],
},
})
}
- setMessage(`${form.type === 'invoice' ? 'Invoice' : 'Expense'} captured and added to Qweli.`)
+ setMessage(`${form.type === 'invoice' ? 'Invoice' : 'Expense'} captured in ZAR and added to Qweli.`)
await loadWorkspace()
} catch (saveError) {
console.error('Qweli quick capture failed', saveError)
@@ -243,6 +361,96 @@ const QweliCommandCenter = () => {
}
}
+ const advanceWorkflow = () => {
+ const nextStage = workflowStages[Math.min(activeWorkflowIndex + 1, workflowStages.length - 1)]
+ setCollaborationStage(nextStage.id)
+ setEmailStatus(`Workflow moved to ${nextStage.label}.`)
+ }
+
+ const sendInvoiceEmail = async () => {
+ setEmailStatus('')
+ setError('')
+
+ if (!selectedInvoice) {
+ setError('Select an invoice before sending a collaboration email.')
+ return
+ }
+
+ if (!/^\S+@\S+\.\S+$/.test(collaboratorEmail)) {
+ setError('Add a valid collaborator email address.')
+ return
+ }
+
+ setIsSendingEmail(true)
+
+ try {
+ const sourceUrl = typeof window !== 'undefined' ? `${window.location.origin}${selectedInvoice.href}` : selectedInvoice.href
+ const response = await axios.post('qweli/invoice-email', {
+ invoiceId: selectedInvoice.id,
+ recipientEmail: collaboratorEmail,
+ note: collaborationNote,
+ workflowStage: collaborationStage,
+ sourceUrl,
+ })
+
+ if (response.data?.sent) {
+ await axios.put(`invoices/${selectedInvoice.id}`, {
+ id: selectedInvoice.id,
+ data: { status: stageToInvoiceStatus[collaborationStage] },
+ })
+ setEmailStatus('Collaboration email sent and invoice workflow status updated.')
+ await loadWorkspace()
+ } else {
+ setEmailStatus(response.data?.message || 'Email preview created. Configure SMTP to send production email.')
+ }
+ } catch (sendError) {
+ console.error('Qweli invoice email failed', sendError)
+ setError('Could not send the invoice collaboration email. Check the recipient and email configuration.')
+ } finally {
+ setIsSendingEmail(false)
+ }
+ }
+
+ const generateAiReport = async () => {
+ setError('')
+
+ const recordsSummary = entries
+ .slice(0, 8)
+ .map(
+ (entry) =>
+ `${entry.type.toUpperCase()} | ${entry.label} | ${currency.format(entry.amount)} | ${entry.status || 'draft'} | ${formatDate(
+ entry.date,
+ )}`,
+ )
+ .join('\n')
+
+ const payload = {
+ input: [
+ {
+ role: 'system',
+ content:
+ 'You are Qweli AI, a concise finance operations analyst for South African SMEs. Produce practical, non-legal, non-tax-advice operating insights.',
+ },
+ {
+ role: 'user',
+ content: `Create a production-ready finance report in 5 bullets for Qweli. Default currency is ZAR South African Rand. Metrics: receivables ${currency.format(
+ totals.receivables,
+ )}, spend ${currency.format(totals.spend)}, net cash signal ${currency.format(
+ totals.net,
+ )}. Recent records:\n${recordsSummary || 'No records captured yet.'}\nInclude: cashflow risk, invoice collaboration action, email follow-up suggestion, VAT/admin hygiene, and next best action.`,
+ },
+ ],
+ options: { poll_interval: 5, poll_timeout: 300 },
+ }
+
+ try {
+ await dispatch(aiResponse(payload)).unwrap()
+ } catch (reportError) {
+ console.error('Qweli AI report failed', { payload, reportError })
+ setError('AI report could not be generated right now. Try again after confirming AI proxy configuration.')
+ }
+ }
+
return (
<>
@@ -257,28 +465,38 @@ const QweliCommandCenter = () => {
- Value-chain finance
+ Production finance workspace · ZAR default
- Capture money in, money out, and the people behind every transaction.
+ Run invoices, expenses, AI reporting, email, and invoice collaboration in one value-chain cockpit.
- This first Qweli workflow turns the generic accounting tables into a guided operating console for owners,
- accountants, suppliers, and customer-facing teams.
+ Qweli is positioned as its own finance operations suite for owners, accountants, suppliers, customers, and
+ managers. New money records default to South African Rand and connect into existing source screens.
+
+ {['ZAR South African Rand', 'AI report', 'Invoice email', 'Approval workflow', 'Audit-ready source records'].map(
+ (item) => (
+
+ {item}
+
+ ),
+ )}
+
Connected value chain
-
-
{contactsCount}
-
Contacts
-
-
-
{productsCount}
-
Products
-
+ {moduleCards.map((item) => (
+
+
{item.value}
+
{item.label}
+
+ ))}
+
+ Currency default: ZAR · South African Rand
+
@@ -294,12 +512,22 @@ const QweliCommandCenter = () => {
))}
-
+
+ {moduleCards.map((item) => (
+
+ {item.label}
+ {item.value}
+ {item.caption}
+
+ ))}
+
+
+
Quick capture
- Create a draft invoice or submitted expense without leaving the cashflow cockpit.
+ Create a draft invoice or submitted expense in South African Rand without leaving the cashflow cockpit.
@@ -322,18 +550,20 @@ const QweliCommandCenter = () => {
- Customer / supplier / partner
+
+ Customer / supplier / partner
+
setForm((current) => ({ ...current, counterparty: event.target.value }))}
className="mt-2 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 dark:border-dark-700 dark:bg-dark-900"
- placeholder="Acme Coffee Roasters"
+ placeholder="Cape Town Coffee Roasters"
/>
- Amount
+ Amount (ZAR)
{
/>
- Tax
+ VAT / tax (ZAR)
{
{
+
+
+
+
AI finance report
+
Generate an AI operating report from the latest ZAR records.
+
+
+
+
+
+ {isAskingResponse ? (
+
Qweli AI is reviewing invoices, expenses, and workflow risk...
+ ) : aiReportText ? (
+
{aiReportText}
+ ) : (
+
+
AI report ready when you are.
+
+ The report will highlight cashflow risk, invoice collaboration, email follow-up, VAT/admin hygiene, and
+ the next best action for your team.
+
+
+ )}
+ {aiErrorMessage &&
{aiErrorMessage}
}
+
+
+
+
+
Recent money movement
-
Create, confirm, select, and open the source record.
+
Create, confirm, select, collaborate, and open the source record.
@@ -405,7 +670,7 @@ const QweliCommandCenter = () => {
) : entries.length === 0 ? (
No invoices or expenses yet.
-
Use quick capture to create the first money movement.
+
Use quick capture to create the first ZAR money movement.
) : (
@@ -416,7 +681,7 @@ const QweliCommandCenter = () => {
type="button"
onClick={() => setSelectedEntry(entry)}
className={`w-full rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-md ${
- selectedEntry?.id === entry.id
+ selectedEntry?.id === entry.id && selectedEntry?.type === entry.type
? 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/20'
: 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'
}`}
@@ -428,7 +693,9 @@ const QweliCommandCenter = () => {
{currency.format(entry.amount)}
{entry.label}
- {formatDate(entry.date)} · {entry.status}
+
+ {formatDate(entry.date)} · {entry.status} · {entry.currencyCode || 'ZAR'}
+
))}
@@ -460,6 +727,74 @@ const QweliCommandCenter = () => {
)}
+
+
+
+
Invoice collaboration workflow
+
+ Guide invoices through internal approval, customer review, email follow-up, and payment readiness.
+
+
+
+
+ {workflowStages.map((stage, index) => (
+
setCollaborationStage(stage.id)}
+ className={`w-full rounded-2xl border p-4 text-left transition ${
+ collaborationStage === stage.id
+ ? 'border-emerald-400 bg-emerald-50 dark:bg-emerald-900/20'
+ : index < activeWorkflowIndex
+ ? 'border-emerald-200 bg-white dark:border-emerald-900 dark:bg-dark-900'
+ : 'border-slate-200 bg-white dark:border-dark-700 dark:bg-dark-900'
+ }`}
+ >
+
+
+ {index + 1}
+
+ {stage.label}
+
+ {stage.caption}
+
+ ))}
+
+
+
+
+ Collaborator email
+ setCollaboratorEmail(event.target.value)}
+ className="mt-2 w-full rounded-xl border border-slate-200 bg-white px-4 py-3 outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-200 dark:border-dark-700 dark:bg-dark-900"
+ placeholder="finance@customer.co.za"
+ />
+
+
+ Email note / collaboration brief
+
+
+
+
+
+ {!selectedInvoice && (
+
Select an invoice record to enable email collaboration.
+ )}
+ {emailStatus &&
{emailStatus}
}
+
+
>