Qweli 1.0.1

This commit is contained in:
Flatlogic Bot 2026-06-04 16:07:56 +00:00
parent d75b57f439
commit 0d2b1e1bf9
7 changed files with 637 additions and 163 deletions

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

@ -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">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', '$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 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,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

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