Qweli 1.0.2

This commit is contained in:
Flatlogic Bot 2026-06-04 16:48:03 +00:00
parent 0d2b1e1bf9
commit 69f1e80ee0
16 changed files with 665 additions and 72 deletions

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
};

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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,
});

View File

@ -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;
}
},
};

View File

@ -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.
},
};

View File

@ -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');

View File

@ -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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
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 () => `
<div style="font-family: Arial, sans-serif; color: #071B2C; line-height: 1.6;">
<h2 style="margin-bottom: 8px;">Qweli invoice collaboration</h2>
<p>You have been invited to review invoice <strong>${escapeHtml(invoiceNumber)}</strong>.</p>
<ul>
<li><strong>Status:</strong> ${escapeHtml(invoice.status || 'draft')}</li>
<li><strong>Workflow stage:</strong> ${escapeHtml(workflowStage || 'draft_review')}</li>
<li><strong>Amount due:</strong> ${escapeHtml(currencyCode)} ${escapeHtml(amount)}</li>
</ul>
${note ? `<p><strong>Team note:</strong><br/>${escapeHtml(note)}</p>` : ''}
${sourceUrl ? `<p><a href="${escapeHtml(sourceUrl)}">Open invoice in Qweli</a></p>` : ''}
<p style="font-size: 12px; color: #64748B;">Sent from Qweli Accounting Suite.</p>
</div>
`;
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;

View File

@ -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'

View File

@ -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() {
<title>{getPageTitle('Qweli')}</title>
<meta
name="description"
content="Qweli is a modern accounting and value-chain finance workspace for invoices, expenses, partners, inventory, and cashflow."
content="Qweli is a modern ZAR-first accounting and value-chain finance workspace for invoices, expenses, partners, inventory, AI reporting, email, and cashflow."
/>
</Head>
@ -60,14 +60,14 @@ export default function QweliLanding() {
<div className="mx-auto grid max-w-7xl gap-10 px-6 py-20 lg:grid-cols-[1.08fr_0.92fr] lg:py-28">
<div className="relative z-10">
<p className="mb-5 inline-flex rounded-full border border-[#071B2C]/10 bg-white px-4 py-2 text-xs font-black uppercase tracking-[0.3em] text-emerald-700 shadow-sm">
Xero-inspired, value-chain ready
Independent finance suite, value-chain ready
</p>
<h1 className="max-w-4xl text-5xl font-black leading-[0.95] tracking-[-0.05em] md:text-7xl">
Accounting that sees the whole business network.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
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.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<BaseButton href="/login" label="Open admin interface" color="info" roundedFull className="text-base" />
@ -96,9 +96,9 @@ export default function QweliLanding() {
</div>
<div className="mt-8 space-y-3">
{[
['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]) => (
<div key={label} className="flex items-center justify-between rounded-2xl bg-white p-4 text-[#071B2C]">
<div className="flex items-center gap-3">

View File

@ -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<CollaborationStage, string> = {
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<QweliEntry[]>([])
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<QweliEntry | null>(null)
const [collaborationStage, setCollaborationStage] = useState<CollaborationStage>('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<HTMLFormElement>) => {
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 (
<>
<Head>
@ -257,28 +465,38 @@ const QweliCommandCenter = () => {
<div className="grid gap-6 p-6 md:grid-cols-[1.35fr_0.65fr] md:p-8">
<div>
<p className="mb-3 inline-flex rounded-full bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.28em] text-emerald-200">
Value-chain finance
Production finance workspace · ZAR default
</p>
<h2 className="max-w-3xl text-3xl font-black tracking-tight md:text-5xl">
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.
</h2>
<p className="mt-4 max-w-2xl text-sm leading-6 text-slate-200 md:text-base">
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.
</p>
<div className="mt-6 flex flex-wrap gap-2 text-xs font-black uppercase tracking-wide text-[#071B2C]">
{['ZAR South African Rand', 'AI report', 'Invoice email', 'Approval workflow', 'Audit-ready source records'].map(
(item) => (
<span key={item} className="rounded-full bg-[#5CF2B8] px-3 py-2">
{item}
</span>
),
)}
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-white/10 p-5 backdrop-blur">
<p className="text-sm text-slate-200">Connected value chain</p>
<div className="mt-4 grid grid-cols-2 gap-3 text-center">
<div className="rounded-2xl bg-white p-4 text-[#071B2C]">
<p className="text-3xl font-black">{contactsCount}</p>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Contacts</p>
</div>
<div className="rounded-2xl bg-white p-4 text-[#071B2C]">
<p className="text-3xl font-black">{productsCount}</p>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Products</p>
</div>
{moduleCards.map((item) => (
<div key={item.label} className="rounded-2xl bg-white p-4 text-[#071B2C]">
<p className="text-3xl font-black">{item.value}</p>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">{item.label}</p>
</div>
))}
</div>
<p className="mt-4 rounded-2xl bg-white/10 p-3 text-xs leading-5 text-slate-200">
Currency default: <span className="font-black text-[#5CF2B8]">ZAR · South African Rand</span>
</p>
</div>
</div>
</div>
@ -294,12 +512,22 @@ const QweliCommandCenter = () => {
))}
</div>
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<div className="mb-6 grid gap-4 lg:grid-cols-4">
{moduleCards.map((item) => (
<CardBox key={item.label} className="border-0 shadow-sm">
<p className="text-sm font-black uppercase tracking-[0.22em] text-emerald-600">{item.label}</p>
<p className="mt-3 text-3xl font-black text-slate-900 dark:text-white">{item.value}</p>
<p className="mt-2 text-sm leading-6 text-slate-500">{item.caption}</p>
</CardBox>
))}
</div>
<div className="mb-6 grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
<CardBox className="border-0 shadow-sm">
<div className="mb-5">
<h3 className="text-xl font-black text-slate-900 dark:text-white">Quick capture</h3>
<p className="text-sm text-slate-500">
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.
</p>
</div>
@ -322,18 +550,20 @@ const QweliCommandCenter = () => {
</div>
<label className="block">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Customer / supplier / partner</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">
Customer / supplier / partner
</span>
<input
value={form.counterparty}
onChange={(event) => 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"
/>
</label>
<div className="grid gap-4 md:grid-cols-3">
<label className="block md:col-span-1">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Amount</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Amount (ZAR)</span>
<input
type="number"
min="0"
@ -344,7 +574,7 @@ const QweliCommandCenter = () => {
/>
</label>
<label className="block md:col-span-1">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Tax</span>
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">VAT / tax (ZAR)</span>
<input
type="number"
min="0"
@ -381,7 +611,7 @@ const QweliCommandCenter = () => {
<BaseButton
type="submit"
label={isSaving ? 'Saving...' : `Save ${form.type}`}
label={isSaving ? 'Saving...' : `Save ${form.type} in ZAR`}
color="success"
className="w-full"
disabled={isSaving}
@ -389,11 +619,46 @@ const QweliCommandCenter = () => {
</form>
</CardBox>
<CardBox className="border-0 shadow-sm">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-black text-slate-900 dark:text-white">AI finance report</h3>
<p className="text-sm text-slate-500">Generate an AI operating report from the latest ZAR records.</p>
</div>
<BaseButton
label={isAskingResponse ? 'Generating...' : 'Generate AI report'}
color="info"
small
onClick={generateAiReport}
disabled={isAskingResponse}
/>
</div>
<div className="rounded-3xl bg-[#F6F8FB] p-5 dark:bg-dark-800">
{isAskingResponse ? (
<p className="text-sm font-semibold text-slate-500">Qweli AI is reviewing invoices, expenses, and workflow risk...</p>
) : aiReportText ? (
<div className="whitespace-pre-line text-sm leading-7 text-slate-700 dark:text-slate-200">{aiReportText}</div>
) : (
<div>
<p className="font-black text-slate-900 dark:text-white">AI report ready when you are.</p>
<p className="mt-2 text-sm leading-6 text-slate-500">
The report will highlight cashflow risk, invoice collaboration, email follow-up, VAT/admin hygiene, and
the next best action for your team.
</p>
</div>
)}
{aiErrorMessage && <p className="mt-4 rounded-2xl bg-rose-50 p-3 text-sm font-semibold text-rose-700">{aiErrorMessage}</p>}
</div>
</CardBox>
</div>
<div className="grid gap-6 lg:grid-cols-[1.05fr_0.95fr]">
<CardBox className="border-0 shadow-sm">
<div className="mb-5 flex items-start justify-between gap-4">
<div>
<h3 className="text-xl font-black text-slate-900 dark:text-white">Recent money movement</h3>
<p className="text-sm text-slate-500">Create, confirm, select, and open the source record.</p>
<p className="text-sm text-slate-500">Create, confirm, select, collaborate, and open the source record.</p>
</div>
<BaseButton label="Refresh" color="whiteDark" small onClick={loadWorkspace} />
</div>
@ -405,7 +670,7 @@ const QweliCommandCenter = () => {
) : entries.length === 0 ? (
<div className="rounded-3xl border border-dashed border-slate-300 p-8 text-center">
<p className="font-bold text-slate-900 dark:text-white">No invoices or expenses yet.</p>
<p className="mt-2 text-sm text-slate-500">Use quick capture to create the first money movement.</p>
<p className="mt-2 text-sm text-slate-500">Use quick capture to create the first ZAR money movement.</p>
</div>
) : (
<div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
@ -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 = () => {
<span className="text-sm font-black text-slate-900 dark:text-white">{currency.format(entry.amount)}</span>
</div>
<p className="mt-3 line-clamp-1 font-bold text-slate-900 dark:text-white">{entry.label}</p>
<p className="mt-1 text-xs text-slate-500">{formatDate(entry.date)} · {entry.status}</p>
<p className="mt-1 text-xs text-slate-500">
{formatDate(entry.date)} · {entry.status} · {entry.currencyCode || 'ZAR'}
</p>
</button>
))}
</div>
@ -460,6 +727,74 @@ const QweliCommandCenter = () => {
</div>
)}
</CardBox>
<CardBox className="border-0 shadow-sm">
<div className="mb-5">
<h3 className="text-xl font-black text-slate-900 dark:text-white">Invoice collaboration workflow</h3>
<p className="text-sm text-slate-500">
Guide invoices through internal approval, customer review, email follow-up, and payment readiness.
</p>
</div>
<div className="space-y-3">
{workflowStages.map((stage, index) => (
<button
key={stage.id}
type="button"
onClick={() => 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'
}`}
>
<div className="flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#071B2C] text-xs font-black text-[#5CF2B8]">
{index + 1}
</span>
<span className="font-black text-slate-900 dark:text-white">{stage.label}</span>
</div>
<p className="mt-2 text-sm leading-6 text-slate-500">{stage.caption}</p>
</button>
))}
</div>
<div className="mt-5 rounded-3xl bg-[#F6F8FB] p-5 dark:bg-dark-800">
<label className="block">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Collaborator email</span>
<input
type="email"
value={collaboratorEmail}
onChange={(event) => 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"
/>
</label>
<label className="mt-4 block">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Email note / collaboration brief</span>
<textarea
value={collaborationNote}
onChange={(event) => setCollaborationNote(event.target.value)}
className="mt-2 h-24 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"
/>
</label>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<BaseButton label="Advance workflow" color="whiteDark" onClick={advanceWorkflow} />
<BaseButton
label={isSendingEmail ? 'Sending...' : 'Email invoice pack'}
color="success"
onClick={sendInvoiceEmail}
disabled={isSendingEmail || !selectedInvoice}
/>
</div>
{!selectedInvoice && (
<p className="mt-3 text-xs font-semibold text-slate-500">Select an invoice record to enable email collaboration.</p>
)}
{emailStatus && <p className="mt-4 rounded-2xl bg-emerald-50 p-3 text-sm font-semibold text-emerald-700">{emailStatus}</p>}
</div>
</CardBox>
</div>
</SectionMain>
</>