Qweli 1.0.1
This commit is contained in:
parent
d75b57f439
commit
0d2b1e1bf9
@ -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';
|
||||
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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',
|
||||
'Cashflow and tax 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 accounting and value-chain finance workspace for invoices, expenses, partners, inventory, 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">
|
||||
Xero-inspired, 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.
|
||||
</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">Today’s 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', '$2,400', 'bg-[#5CF2B8]'],
|
||||
['Supplier expense submitted', '$380', 'bg-[#FFB84D]'],
|
||||
['Payment matching queued', '$1,120', '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 Qweli’s 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>
|
||||
}
|
||||
|
||||
473
frontend/src/pages/qweli-command-center.tsx
Normal file
473
frontend/src/pages/qweli-command-center.tsx
Normal file
@ -0,0 +1,473 @@
|
||||
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'
|
||||
|
||||
type WorkflowType = 'invoice' | 'expense'
|
||||
|
||||
type QweliEntry = {
|
||||
id: string
|
||||
type: WorkflowType
|
||||
label: string
|
||||
amount: number
|
||||
status?: string
|
||||
date?: string
|
||||
href: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
type MetricCard = {
|
||||
label: string
|
||||
value: string
|
||||
caption: string
|
||||
accent: string
|
||||
}
|
||||
|
||||
const currency = new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
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 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' })
|
||||
}
|
||||
|
||||
const QweliCommandCenter = () => {
|
||||
const [entries, setEntries] = useState<QweliEntry[]>([])
|
||||
const [contactsCount, setContactsCount] = useState(0)
|
||||
const [productsCount, setProductsCount] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [selectedEntry, setSelectedEntry] = useState<QweliEntry | null>(null)
|
||||
const [form, setForm] = useState({
|
||||
type: 'invoice' as WorkflowType,
|
||||
counterparty: '',
|
||||
amount: '1250',
|
||||
tax: '0',
|
||||
dueInDays: '14',
|
||||
description: 'Website services and support retainer',
|
||||
})
|
||||
|
||||
const loadWorkspace = async () => {
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const [invoicesResult, expensesResult, contactsResult, productsResult] = 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'),
|
||||
])
|
||||
|
||||
const failed = [invoicesResult, expensesResult, contactsResult, productsResult].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 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,
|
||||
}))
|
||||
|
||||
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,
|
||||
}))
|
||||
|
||||
setContactsCount(
|
||||
contactsResult.status === 'fulfilled' ? Number(contactsResult.value.data?.count ?? 0) : 0,
|
||||
)
|
||||
setProductsCount(
|
||||
productsResult.status === 'fulfilled' ? Number(productsResult.value.data?.count ?? 0) : 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)
|
||||
} 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 in the workspace',
|
||||
accent: 'from-emerald-400 to-teal-600',
|
||||
},
|
||||
{
|
||||
label: 'Spend captured',
|
||||
value: currency.format(totals.spend),
|
||||
caption: 'Supplier costs and reimbursables',
|
||||
accent: 'from-orange-400 to-rose-500',
|
||||
},
|
||||
{
|
||||
label: 'Net cash signal',
|
||||
value: currency.format(totals.net),
|
||||
caption: 'Simple cashflow view for this slice',
|
||||
accent: 'from-sky-400 to-indigo-600',
|
||||
},
|
||||
]
|
||||
|
||||
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-${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(),
|
||||
attachments: [],
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await axios.post('expenses', {
|
||||
data: {
|
||||
expense_type: 'general',
|
||||
status: 'submitted',
|
||||
expense_date: today.toISOString(),
|
||||
amount,
|
||||
description: `${form.counterparty.trim()} — ${form.description.trim()}`,
|
||||
receipt_files: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
setMessage(`${form.type === 'invoice' ? 'Invoice' : 'Expense'} captured 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)
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
Value-chain finance
|
||||
</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.
|
||||
</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.
|
||||
</p>
|
||||
</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>
|
||||
</div>
|
||||
</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="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.
|
||||
</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="Acme 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>
|
||||
<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">Tax</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}`}
|
||||
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">Recent money movement</h3>
|
||||
<p className="text-sm text-slate-500">Create, confirm, select, 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 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
|
||||
? '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}</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>
|
||||
</div>
|
||||
</SectionMain>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
QweliCommandCenter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutAuthenticated permission="READ_INVOICES">{page}</LayoutAuthenticated>
|
||||
}
|
||||
|
||||
export default QweliCommandCenter
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user