From d3f5df403df61f5a3582f350236d0d510b263d52 Mon Sep 17 00:00:00 2001 From: Alexander Zinn Date: Sun, 7 Dec 2025 11:44:50 -0500 Subject: [PATCH] Add invoice dialog and enhance Cashflow, Clients, Invoices, and Net Worth pages - Introduced AddInvoiceDialog component for creating new invoices with client selection and form validation. - Updated CashflowPage to include dialogs for adding income, expenses, and transactions, improving user interaction. - Enhanced ClientsPage with a dialog for adding new clients, streamlining client management. - Improved InvoicesPage with a dialog for creating new invoices, facilitating better invoice management. - Updated NetWorthPage to include dialogs for adding assets and liabilities, enhancing financial tracking capabilities. --- .../components/dialogs/AddInvoiceDialog.tsx | 96 +++++++++++ frontend-web/src/pages/CashflowPage.tsx | 157 ++++++++++-------- frontend-web/src/pages/ClientsPage.tsx | 9 +- frontend-web/src/pages/InvoicesPage.tsx | 12 +- frontend-web/src/pages/NetWorthPage.tsx | 12 +- 5 files changed, 214 insertions(+), 72 deletions(-) create mode 100644 frontend-web/src/components/dialogs/AddInvoiceDialog.tsx diff --git a/frontend-web/src/components/dialogs/AddInvoiceDialog.tsx b/frontend-web/src/components/dialogs/AddInvoiceDialog.tsx new file mode 100644 index 0000000..06364b6 --- /dev/null +++ b/frontend-web/src/components/dialogs/AddInvoiceDialog.tsx @@ -0,0 +1,96 @@ +import {useState} from 'react'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {useAppDispatch, useAppSelector, addInvoice} from '@/store'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function AddInvoiceDialog({open, onOpenChange}: Props) { + const dispatch = useAppDispatch(); + const {clients} = useAppSelector(state => state.invoices); + const [form, setForm] = useState({ + clientId: '', + description: '', + amount: '', + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + }); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const now = new Date().toISOString(); + const invoiceNumber = `INV-${new Date().getFullYear()}-${String(Math.floor(Math.random() * 1000)).padStart(3, '0')}`; + const amount = parseFloat(form.amount) || 0; + + dispatch(addInvoice({ + id: crypto.randomUUID(), + invoiceNumber, + clientId: form.clientId, + status: 'draft', + issueDate: now.split('T')[0], + dueDate: form.dueDate, + lineItems: [{ + id: crypto.randomUUID(), + description: form.description, + quantity: 1, + unitPrice: amount, + total: amount, + }], + subtotal: amount, + tax: 0, + total: amount, + createdAt: now, + updatedAt: now, + })); + onOpenChange(false); + setForm({clientId: '', description: '', amount: '', dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}); + }; + + return ( + + + + New Invoice + Create a new invoice + +
+
+
+ + +
+
+ + setForm({...form, description: e.target.value})} className="input-depth" required /> +
+
+
+ + setForm({...form, amount: e.target.value})} className="input-depth" required /> +
+
+ + setForm({...form, dueDate: e.target.value})} className="input-depth" required /> +
+
+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-web/src/pages/CashflowPage.tsx b/frontend-web/src/pages/CashflowPage.tsx index e4781c0..9129237 100644 --- a/frontend-web/src/pages/CashflowPage.tsx +++ b/frontend-web/src/pages/CashflowPage.tsx @@ -1,8 +1,15 @@ +import {useState} from 'react'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Button} from '@/components/ui/button'; import {useAppSelector} from '@/store'; +import AddIncomeDialog from '@/components/dialogs/AddIncomeDialog'; +import AddExpenseDialog from '@/components/dialogs/AddExpenseDialog'; +import AddTransactionDialog from '@/components/dialogs/AddTransactionDialog'; export default function CashflowPage() { + const [incomeDialogOpen, setIncomeDialogOpen] = useState(false); + const [expenseDialogOpen, setExpenseDialogOpen] = useState(false); + const [transactionDialogOpen, setTransactionDialogOpen] = useState(false); const {incomeSources, expenses, transactions} = useAppSelector(state => state.cashflow); const getMonthlyAmount = (amount: number, frequency: string) => { @@ -21,9 +28,6 @@ export default function CashflowPage() { const monthlySavings = monthlyIncome - monthlyExpenses; const savingsRate = monthlyIncome > 0 ? (monthlySavings / monthlyIncome) * 100 : 0; - const essentialTotal = expenses.filter(e => e.isActive && e.isEssential).reduce((sum, e) => sum + getMonthlyAmount(e.amount, e.frequency), 0); - const discretionaryTotal = expenses.filter(e => e.isActive && !e.isEssential).reduce((sum, e) => sum + getMonthlyAmount(e.amount, e.frequency), 0); - const fmt = (value: number) => new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value); const expensesByCategory = expenses.filter(e => e.isActive).reduce((acc, e) => { @@ -33,68 +37,64 @@ export default function CashflowPage() { }, {} as Record); const sortedCategories = Object.entries(expensesByCategory).sort((a, b) => b[1] - a[1]); + const topExpenses = expenses.filter(e => e.isActive).sort((a, b) => getMonthlyAmount(b.amount, b.frequency) - getMonthlyAmount(a.amount, a.frequency)); return (
- {/* Header + Summary inline */} + {/* Header */}

Cashflow

Income {fmt(monthlyIncome)}
Expenses {fmt(monthlyExpenses)}
-
Savings = 0 ? 'text-green-400' : 'text-red-400'}`}>{fmt(monthlySavings)}
-
Rate = 20 ? 'text-green-400' : ''}`}>{savingsRate.toFixed(0)}%
+
Net = 0 ? 'text-green-400' : 'text-red-400'}`}>{fmt(monthlySavings)}
+
Savings = 20 ? 'text-green-400' : ''}`}>{savingsRate.toFixed(0)}%
- - + +
-
+
{/* Income Sources */} - + - Income Sources + Income -
+
{incomeSources.filter(i => i.isActive).map(income => ( -
+
-

{income.name}

+

{income.name}

{income.frequency}

-
-

{fmt(income.amount)}

-

{fmt(getMonthlyAmount(income.amount, income.frequency))}/mo

-
+

{fmt(income.amount)}

))}
- {/* Expenses by Category */} - + {/* Expenses Breakdown */} + - By Category + Expenses by Category -
+
{sortedCategories.map(([category, amount]) => { const pct = (amount / monthlyExpenses) * 100; return ( -
-
- {category} - {fmt(amount)} -
-
-
+
+
{category}
+
+
+
{fmt(amount)}
); })} @@ -102,57 +102,82 @@ export default function CashflowPage() { - {/* Essential vs Discretionary */} - - - Essential vs Discretionary - - -
-
-

Essential

-

{fmt(essentialTotal)}

-
-
-

Discretionary

-

{fmt(discretionaryTotal)}

-
-
-
- {expenses.filter(e => e.isActive).sort((a, b) => getMonthlyAmount(b.amount, b.frequency) - getMonthlyAmount(a.amount, a.frequency)).slice(0, 8).map(expense => ( -
- {expense.name} - {fmt(getMonthlyAmount(expense.amount, expense.frequency))} -
- ))} -
-
-
- {/* Recent Transactions */} - + - Recent - + Recent Activity + -
- {transactions.slice(0, 10).map(tx => ( -
+
+ {transactions.slice(0, 8).map(tx => ( +
-

{tx.name}

+

{tx.name}

{tx.date}

- - {tx.type === 'income' ? '+' : '-'}{fmt(tx.amount)} + + {tx.type === 'income' ? '+' : '−'}{fmt(tx.amount)}
))}
+ + {/* Top Expenses */} + + + Top Monthly Expenses + + +
+ {topExpenses.slice(0, 10).map(expense => ( +
+ {expense.name} + {fmt(getMonthlyAmount(expense.amount, expense.frequency))} +
+ ))} +
+
+
+ + {/* Summary Stats */} + + + Monthly Summary + + +
+
+

{fmt(monthlyIncome)}

+

Total Income

+
+
+

{fmt(monthlyExpenses)}

+

Total Expenses

+
+
+

= 0 ? 'text-green-400' : 'text-red-400'}`}>{fmt(monthlySavings)}

+

Net Savings

+
+
+
+
+ Savings Rate + = 20 ? 'text-green-400' : ''}`}>{savingsRate.toFixed(1)}% +
+
+
= 20 ? 'bg-green-400/50' : 'bg-foreground/30'}`} style={{width: `${Math.min(savingsRate, 100)}%`}} /> +
+
+ +
+ + + +
); } - diff --git a/frontend-web/src/pages/ClientsPage.tsx b/frontend-web/src/pages/ClientsPage.tsx index 6a31069..a2254c7 100644 --- a/frontend-web/src/pages/ClientsPage.tsx +++ b/frontend-web/src/pages/ClientsPage.tsx @@ -1,8 +1,11 @@ +import {useState} from 'react'; import {Card, CardContent} from '@/components/ui/card'; import {Button} from '@/components/ui/button'; import {useAppSelector} from '@/store'; +import AddClientDialog from '@/components/dialogs/AddClientDialog'; export default function ClientsPage() { + const [dialogOpen, setDialogOpen] = useState(false); const {clients, invoices} = useAppSelector(state => state.invoices); const getClientStats = (clientId: string) => { @@ -29,7 +32,7 @@ export default function ClientsPage() {
Outstanding {fmt(totalOutstanding)}
- +
{/* Clients Grid */} @@ -64,12 +67,14 @@ export default function ClientsPage() { })} {/* Add client card */} - + setDialogOpen(true)}> + Add Client
+ +
); } diff --git a/frontend-web/src/pages/InvoicesPage.tsx b/frontend-web/src/pages/InvoicesPage.tsx index 19b032c..f3099d3 100644 --- a/frontend-web/src/pages/InvoicesPage.tsx +++ b/frontend-web/src/pages/InvoicesPage.tsx @@ -1,9 +1,14 @@ +import {useState} from 'react'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Button} from '@/components/ui/button'; import {useAppSelector} from '@/store'; import {format} from 'date-fns'; +import AddClientDialog from '@/components/dialogs/AddClientDialog'; +import AddInvoiceDialog from '@/components/dialogs/AddInvoiceDialog'; export default function InvoicesPage() { + const [clientDialogOpen, setClientDialogOpen] = useState(false); + const [invoiceDialogOpen, setInvoiceDialogOpen] = useState(false); const {invoices, clients} = useAppSelector(state => state.invoices); const getClientName = (clientId: string) => clients.find(c => c.id === clientId)?.name ?? 'Unknown'; @@ -34,8 +39,8 @@ export default function InvoicesPage() {
- - + +
@@ -139,6 +144,9 @@ export default function InvoicesPage() {
+ + +
); } diff --git a/frontend-web/src/pages/NetWorthPage.tsx b/frontend-web/src/pages/NetWorthPage.tsx index 9848ec3..b526499 100644 --- a/frontend-web/src/pages/NetWorthPage.tsx +++ b/frontend-web/src/pages/NetWorthPage.tsx @@ -1,10 +1,15 @@ +import {useState} from 'react'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Button} from '@/components/ui/button'; import {useAppSelector} from '@/store'; import {AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer} from 'recharts'; import {format} from 'date-fns'; +import AddAssetDialog from '@/components/dialogs/AddAssetDialog'; +import AddLiabilityDialog from '@/components/dialogs/AddLiabilityDialog'; export default function NetWorthPage() { + const [assetDialogOpen, setAssetDialogOpen] = useState(false); + const [liabilityDialogOpen, setLiabilityDialogOpen] = useState(false); const {assets, liabilities, snapshots} = useAppSelector(state => state.netWorth); const chartData = snapshots.map(s => ({ @@ -87,7 +92,7 @@ export default function NetWorthPage() { Assets - +
@@ -108,7 +113,7 @@ export default function NetWorthPage() { Liabilities - +
@@ -154,6 +159,9 @@ export default function NetWorthPage() {
+ + +
); }