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)}.

+ + ${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 = () => {
+ +

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) => ( + + ))} +
+ +
+ +