Qweli 1.0.2
This commit is contained in:
parent
0d2b1e1bf9
commit
69f1e80ee0
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
26
backend/src/db/api/defaultCurrency.js
Normal file
26
backend/src/db/api/defaultCurrency.js
Normal 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,
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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.
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
|
||||
|
||||
|
||||
83
backend/src/routes/qweli.js
Normal file
83
backend/src/routes/qweli.js
Normal 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, '<')
|
||||
.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 () => `
|
||||
<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;
|
||||
@ -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'
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user