Compare commits

...

2 Commits

Author SHA1 Message Date
Flatlogic Bot
69f1e80ee0 Qweli 1.0.2 2026-06-04 16:48:03 +00:00
Flatlogic Bot
0d2b1e1bf9 Qweli 1.0.1 2026-06-04 16:07:56 +00:00
21 changed files with 1247 additions and 180 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

@ -3,10 +3,9 @@ import { mdiLogout, mdiClose } from '@mdi/js'
import BaseIcon from './BaseIcon'
import AsideMenuList from './AsideMenuList'
import { MenuAsideItem } from '../interfaces'
import { useAppSelector } from '../stores/hooks'
import { useAppDispatch, useAppSelector } from '../stores/hooks'
import Link from 'next/link';
import { useAppDispatch } from '../stores/hooks';
import { createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';

View File

@ -1,6 +1,5 @@
import React, {useEffect, useRef} from 'react'
import React, { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { useState } from 'react'
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
import BaseDivider from './BaseDivider'
import BaseIcon from './BaseIcon'

View File

@ -1,5 +1,4 @@
import React, { ReactNode, useEffect } from 'react'
import { useState } from 'react'
import React, { ReactNode, useEffect, useState } from 'react'
import jwt from 'jsonwebtoken';
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
import menuAside from '../menuAside'

View File

@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [
icon: icon.mdiViewDashboardOutline,
label: 'Dashboard',
},
{
href: '/qweli-command-center',
icon: 'mdiCashSync' in icon ? icon['mdiCashSync' as keyof typeof icon] : icon.mdiViewDashboardOutline,
label: 'Qweli Command Center',
permissions: 'READ_INVOICES'
},
{
href: '/users/users-list',

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

@ -1,166 +1,166 @@
import React from 'react'
import type { ReactElement } from 'react'
import Head from 'next/head'
import Link from 'next/link'
import BaseButton from '../components/BaseButton'
import LayoutGuest from '../layouts/Guest'
import { getPageTitle } from '../config'
import React, { useEffect, useState } from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const modules = [
'Contacts and partner profiles',
'Invoices, quotes, and payments',
'Bills, expenses, and approvals',
'Inventory and service catalog',
'Bank movement and audit trail',
'ZAR cashflow and VAT readiness',
]
const valueChain = [
{ title: 'Owners', text: 'See cash position, upcoming dues, and business health at a glance.' },
{ title: 'Accountants', text: 'Review clean source records before month-end and tax filing.' },
{ title: 'Suppliers', text: 'Track bills, receipts, and the cost side of every relationship.' },
{ title: 'Customers', text: 'Move from quote to invoice to payment with fewer handoffs.' },
]
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'Qweli Accounting Suite'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
export default function QweliLanding() {
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<div className="min-h-screen bg-[#F5F8F7] text-[#071B2C]">
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Qweli')}</title>
<meta
name="description"
content="Qweli is a modern ZAR-first accounting and value-chain finance workspace for invoices, expenses, partners, inventory, AI reporting, email, and cashflow."
/>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Qweli Accounting Suite app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
<header className="sticky top-0 z-20 border-b border-white/70 bg-[#F5F8F7]/90 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<Link href="/" className="flex items-center gap-3">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-[#071B2C] text-lg font-black text-[#5CF2B8]">
Q
</span>
<span>
<span className="block text-lg font-black tracking-tight">Qweli</span>
<span className="block text-xs font-semibold uppercase tracking-[0.28em] text-slate-500">value-chain finance</span>
</span>
</Link>
<nav className="hidden items-center gap-6 text-sm font-bold text-slate-600 md:flex">
<a href="#modules" className="hover:text-[#071B2C]">Modules</a>
<a href="#workflow" className="hover:text-[#071B2C]">Workflow</a>
<Link href="/login" className="hover:text-[#071B2C]">Login</Link>
</nav>
<BaseButton href="/login" label="Admin interface" color="info" roundedFull />
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</header>
<main>
<section className="relative overflow-hidden">
<div className="absolute -right-32 top-12 h-80 w-80 rounded-full bg-[#5CF2B8]/30 blur-3xl" />
<div className="absolute -left-32 bottom-0 h-96 w-96 rounded-full bg-[#FFB84D]/30 blur-3xl" />
<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">
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, 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" />
<BaseButton href="/qweli-command-center" label="Go to Qweli workflow" color="whiteDark" roundedFull className="text-base" />
</div>
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3 text-center">
{['Invoices', 'Expenses', 'Inventory'].map((item) => (
<div key={item} className="rounded-3xl bg-white/80 p-4 shadow-sm ring-1 ring-slate-900/5">
<p className="text-sm font-black text-[#071B2C]">{item}</p>
<p className="mt-1 text-xs text-slate-500">ready module</p>
</div>
))}
</div>
</div>
<div className="relative z-10 rounded-[2rem] bg-[#071B2C] p-4 text-white shadow-2xl">
<div className="rounded-[1.5rem] border border-white/10 bg-white/10 p-5">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-300">Todays cash signal</p>
<p className="mt-1 text-4xl font-black text-[#5CF2B8]">+18.4%</p>
</div>
<span className="rounded-full bg-[#5CF2B8]/15 px-4 py-2 text-xs font-black uppercase tracking-wider text-[#5CF2B8]">
live slice
</span>
</div>
<div className="mt-8 space-y-3">
{[
['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">
<span className={`h-3 w-3 rounded-full ${color}`} />
<span className="font-bold">{label}</span>
</div>
<span className="font-black">{amount}</span>
</div>
))}
</div>
</div>
</div>
</div>
</section>
<section id="modules" className="mx-auto max-w-7xl px-6 py-16">
<div className="mb-8 max-w-2xl">
<p className="text-sm font-black uppercase tracking-[0.3em] text-emerald-700">Core modules</p>
<h2 className="mt-3 text-3xl font-black tracking-tight md:text-5xl">One product layer for the entire value chain.</h2>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{modules.map((module) => (
<div key={module} className="rounded-3xl bg-white p-6 shadow-sm ring-1 ring-slate-900/5">
<p className="text-lg font-black">{module}</p>
<p className="mt-3 text-sm leading-6 text-slate-500">
Connected to Qwelis authenticated workspace so teams can move from capture to review to source detail.
</p>
</div>
))}
</div>
</section>
<section id="workflow" className="bg-white py-16">
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-5 md:grid-cols-4">
{valueChain.map((item, index) => (
<div key={item.title} className="rounded-3xl border border-slate-100 bg-[#F8FAFC] p-6">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#071B2C] text-sm font-black text-[#5CF2B8]">
{index + 1}
</span>
<h3 className="mt-5 text-xl font-black">{item.title}</h3>
<p className="mt-3 text-sm leading-6 text-slate-500">{item.text}</p>
</div>
))}
</div>
</div>
</section>
</main>
<footer className="bg-[#071B2C] px-6 py-8 text-white">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 text-sm md:flex-row">
<p>© 2026 Qweli. Built for modern business finance.</p>
<div className="flex gap-5">
<Link href="/privacy-policy/" className="text-slate-300 hover:text-white">Privacy Policy</Link>
<Link href="/login" className="text-[#5CF2B8] hover:text-white">Login</Link>
</div>
</div>
</footer>
</div>
);
)
}
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};
QweliLanding.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>
}

View File

@ -0,0 +1,808 @@
import { mdiChartTimelineVariant } from '@mdi/js'
import axios from 'axios'
import Head from 'next/head'
import React, { ReactElement, useEffect, useMemo, useState } from 'react'
import BaseButton from '../components/BaseButton'
import CardBox from '../components/CardBox'
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
type: WorkflowType
label: string
amount: number
status?: string
date?: string
href: string
description?: string
currencyCode?: string
}
type MetricCard = {
label: string
value: string
caption: string
accent: string
}
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: 'ZAR',
maximumFractionDigits: 0,
})
const parseAmount = (value: unknown) => {
const amount = Number(value ?? 0)
return Number.isFinite(amount) ? amount : 0
}
const normalizeRows = (response: unknown) => {
const data = response as { data?: { rows?: 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-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: '12500',
tax: '1875',
dueInDays: '14',
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,
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,
bankAccountsResult,
paymentsResult,
currenciesResult,
].filter((result) => result.status === 'rejected')
if (failed.length) {
console.error('Qweli workspace load failed', failed)
setError('Some Qweli records could not be loaded. Check permissions or try again.')
}
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,
type: 'invoice' as WorkflowType,
label: item.invoice_number || item.reference || 'Customer invoice',
amount: parseAmount(item.amount_due || item.total),
status: item.status || 'draft',
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) => ({
id: item.id,
type: 'expense' as WorkflowType,
label: item.description || 'Supplier expense',
amount: parseAmount(item.amount),
status: item.status || 'draft',
date: item.expense_date,
href: `/expenses/${item.id}`,
description: item.description,
currencyCode: item.currency?.code || 'ZAR',
}))
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((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.')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadWorkspace()
}, [])
const totals = useMemo(() => {
const receivables = entries
.filter((entry) => entry.type === 'invoice')
.reduce((sum, entry) => sum + entry.amount, 0)
const spend = entries
.filter((entry) => entry.type === 'expense')
.reduce((sum, entry) => sum + entry.amount, 0)
return {
receivables,
spend,
net: receivables - spend,
}
}, [entries])
const metricCards: MetricCard[] = [
{
label: 'Receivables',
value: currency.format(totals.receivables),
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, expenses, and reimbursements',
accent: 'from-orange-400 to-rose-500',
},
{
label: 'Net cash signal',
value: currency.format(totals.net),
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('')
setError('')
const amount = parseAmount(form.amount)
const tax = parseAmount(form.tax)
const dueInDays = Number(form.dueInDays || 0)
if (!form.counterparty.trim()) {
setError('Add a customer, supplier, or partner name before saving.')
return
}
if (amount <= 0) {
setError('Amount must be greater than zero.')
return
}
setIsSaving(true)
try {
const today = new Date()
const dueDate = new Date(today)
dueDate.setDate(today.getDate() + (Number.isFinite(dueInDays) ? dueInDays : 14))
if (form.type === 'invoice') {
const subtotal = Math.max(amount - tax, 0)
await axios.post('invoices', {
data: {
invoice_number: `QW-ZA-${Date.now().toString().slice(-6)}`,
status: 'draft',
issue_date: today.toISOString(),
due_date: dueDate.toISOString(),
subtotal,
tax_total: tax,
total: amount,
amount_due: amount,
reference: form.counterparty.trim(),
customer_notes: `${form.description.trim()}\n\nWorkflow: Draft review started in Qweli. Currency: ZAR South African Rand.`,
currency: zarCurrencyId || undefined,
attachments: [],
},
})
} else {
await axios.post('expenses', {
data: {
expense_type: 'general',
status: 'submitted',
expense_date: today.toISOString(),
amount,
description: `${form.counterparty.trim()}${form.description.trim()} (ZAR)`,
currency: zarCurrencyId || undefined,
receipt_files: [],
},
})
}
setMessage(`${form.type === 'invoice' ? 'Invoice' : 'Expense'} captured in ZAR and added to Qweli.`)
await loadWorkspace()
} catch (saveError) {
console.error('Qweli quick capture failed', saveError)
setError('Could not save this record. Please check required permissions and try again.')
} finally {
setIsSaving(false)
}
}
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>
<title>{getPageTitle('Qweli Command Center')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiChartTimelineVariant} title="Qweli Command Center" main>
<BaseButton href="/invoices/invoices-list" label="Open Invoices" color="whiteDark" />
</SectionTitleLineWithButton>
<div className="mb-6 overflow-hidden rounded-3xl bg-[#071B2C] text-white shadow-xl">
<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">
Production finance workspace · ZAR default
</p>
<h2 className="max-w-3xl text-3xl font-black tracking-tight md:text-5xl">
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">
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">
{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>
<div className="mb-6 grid gap-4 md:grid-cols-3">
{metricCards.map((metric) => (
<CardBox key={metric.label} className="overflow-hidden border-0 shadow-sm">
<div className={`mb-4 h-2 rounded-full bg-gradient-to-r ${metric.accent}`} />
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-500">{metric.label}</p>
<p className="mt-2 text-3xl font-black text-slate-900 dark:text-white">{metric.value}</p>
<p className="mt-2 text-sm text-slate-500">{metric.caption}</p>
</CardBox>
))}
</div>
<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 in South African Rand without leaving the cashflow cockpit.
</p>
</div>
<form className="space-y-4" onSubmit={submitCapture}>
<div className="grid grid-cols-2 gap-3 rounded-2xl bg-slate-100 p-1 dark:bg-dark-800">
{(['invoice', 'expense'] as WorkflowType[]).map((type) => (
<button
key={type}
type="button"
onClick={() => setForm((current) => ({ ...current, type }))}
className={`rounded-xl px-4 py-3 text-sm font-bold capitalize transition ${
form.type === type
? 'bg-white text-[#071B2C] shadow dark:bg-dark-900 dark:text-white'
: 'text-slate-500 hover:text-slate-900 dark:hover:text-white'
}`}
>
{type}
</button>
))}
</div>
<label className="block">
<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="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 (ZAR)</span>
<input
type="number"
min="0"
step="0.01"
value={form.amount}
onChange={(event) => setForm((current) => ({ ...current, amount: 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"
/>
</label>
<label className="block md:col-span-1">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">VAT / tax (ZAR)</span>
<input
type="number"
min="0"
step="0.01"
value={form.tax}
onChange={(event) => setForm((current) => ({ ...current, tax: 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"
/>
</label>
<label className="block md:col-span-1">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Due in days</span>
<input
type="number"
min="0"
value={form.dueInDays}
onChange={(event) => setForm((current) => ({ ...current, dueInDays: 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"
disabled={form.type === 'expense'}
/>
</label>
</div>
<label className="block">
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">Memo</span>
<textarea
value={form.description}
onChange={(event) => setForm((current) => ({ ...current, description: 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>
{message && <div className="rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-semibold text-emerald-700">{message}</div>}
{error && <div className="rounded-2xl bg-rose-50 px-4 py-3 text-sm font-semibold text-rose-700">{error}</div>}
<BaseButton
type="submit"
label={isSaving ? 'Saving...' : `Save ${form.type} in ZAR`}
color="success"
className="w-full"
disabled={isSaving}
/>
</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, collaborate, and open the source record.</p>
</div>
<BaseButton label="Refresh" color="whiteDark" small onClick={loadWorkspace} />
</div>
{isLoading ? (
<div className="rounded-3xl border border-dashed border-slate-300 p-8 text-center text-slate-500">
Loading the latest Qweli records...
</div>
) : 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 ZAR money movement.</p>
</div>
) : (
<div className="grid gap-4 lg:grid-cols-[0.95fr_1.05fr]">
<div className="space-y-3">
{entries.map((entry) => (
<button
key={`${entry.type}-${entry.id}`}
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?.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'
}`}
>
<div className="flex items-center justify-between gap-3">
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-black uppercase tracking-wide text-slate-600 dark:bg-dark-800 dark:text-slate-300">
{entry.type}
</span>
<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} · {entry.currencyCode || 'ZAR'}
</p>
</button>
))}
</div>
<div className="rounded-3xl bg-[#F6F8FB] p-5 dark:bg-dark-800">
{selectedEntry ? (
<>
<p className="text-xs font-black uppercase tracking-[0.25em] text-emerald-600">Selected record</p>
<h4 className="mt-3 text-2xl font-black text-slate-900 dark:text-white">{selectedEntry.label}</h4>
<p className="mt-2 text-sm leading-6 text-slate-500">
{selectedEntry.description || 'No memo was added to this record yet.'}
</p>
<dl className="mt-6 grid grid-cols-2 gap-3 text-sm">
<div className="rounded-2xl bg-white p-4 dark:bg-dark-900">
<dt className="text-slate-500">Amount</dt>
<dd className="mt-1 font-black text-slate-900 dark:text-white">{currency.format(selectedEntry.amount)}</dd>
</div>
<div className="rounded-2xl bg-white p-4 dark:bg-dark-900">
<dt className="text-slate-500">Status</dt>
<dd className="mt-1 font-black capitalize text-slate-900 dark:text-white">{selectedEntry.status}</dd>
</div>
</dl>
<BaseButton href={selectedEntry.href} label="Open detail screen" color="info" className="mt-6 w-full" />
</>
) : (
<p className="text-sm text-slate-500">Select a record to see detail.</p>
)}
</div>
</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>
</>
)
}
QweliCommandCenter.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission="READ_INVOICES">{page}</LayoutAuthenticated>
}
export default QweliCommandCenter

View File

@ -1,9 +1,7 @@
import React, { ReactElement, useEffect, useState } from 'react';
import Head from 'next/head';
import 'react-datepicker/dist/react-datepicker.css';
import { useAppDispatch } from '../stores/hooks';
import { useAppSelector } from '../stores/hooks';
import { useAppDispatch, useAppSelector } from '../stores/hooks';
import { useRouter } from 'next/router';
import LayoutAuthenticated from '../layouts/Authenticated';