Refactor and enhance various pages for improved UI and functionality
- Updated CashflowPage to streamline calculations and improve layout, including inline summaries for income, expenses, and savings. - Refined ClientsPage to enhance client management with a more organized grid layout and improved client statistics display. - Enhanced DebtsPage with a new view mode toggle and refined debt account presentation for better user experience. - Improved InvoicesPage with a summary of overdue, sent, and draft invoices, along with a more detailed display of recent transactions. - Updated NetWorthPage to include quick stats and a more visually appealing layout for assets and liabilities.
This commit is contained in:
@@ -5,7 +5,6 @@ import {useAppSelector} from '@/store';
|
|||||||
export default function CashflowPage() {
|
export default function CashflowPage() {
|
||||||
const {incomeSources, expenses, transactions} = useAppSelector(state => state.cashflow);
|
const {incomeSources, expenses, transactions} = useAppSelector(state => state.cashflow);
|
||||||
|
|
||||||
// Calculate monthly totals
|
|
||||||
const getMonthlyAmount = (amount: number, frequency: string) => {
|
const getMonthlyAmount = (amount: number, frequency: string) => {
|
||||||
switch (frequency) {
|
switch (frequency) {
|
||||||
case 'weekly': return amount * 4.33;
|
case 'weekly': return amount * 4.33;
|
||||||
@@ -17,27 +16,16 @@ export default function CashflowPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const monthlyIncome = incomeSources
|
const monthlyIncome = incomeSources.filter(i => i.isActive).reduce((sum, i) => sum + getMonthlyAmount(i.amount, i.frequency), 0);
|
||||||
.filter(i => i.isActive)
|
const monthlyExpenses = expenses.filter(e => e.isActive).reduce((sum, e) => sum + getMonthlyAmount(e.amount, e.frequency), 0);
|
||||||
.reduce((sum, i) => sum + getMonthlyAmount(i.amount, i.frequency), 0);
|
|
||||||
|
|
||||||
const monthlyExpenses = expenses
|
|
||||||
.filter(e => e.isActive)
|
|
||||||
.reduce((sum, e) => sum + getMonthlyAmount(e.amount, e.frequency), 0);
|
|
||||||
|
|
||||||
const monthlySavings = monthlyIncome - monthlyExpenses;
|
const monthlySavings = monthlyIncome - monthlyExpenses;
|
||||||
const savingsRate = monthlyIncome > 0 ? (monthlySavings / monthlyIncome) * 100 : 0;
|
const savingsRate = monthlyIncome > 0 ? (monthlySavings / monthlyIncome) * 100 : 0;
|
||||||
|
|
||||||
const essentialExpenses = expenses.filter(e => e.isActive && e.isEssential);
|
const essentialTotal = expenses.filter(e => e.isActive && e.isEssential).reduce((sum, e) => sum + getMonthlyAmount(e.amount, e.frequency), 0);
|
||||||
const discretionaryExpenses = expenses.filter(e => e.isActive && !e.isEssential);
|
const discretionaryTotal = expenses.filter(e => e.isActive && !e.isEssential).reduce((sum, e) => sum + getMonthlyAmount(e.amount, e.frequency), 0);
|
||||||
|
|
||||||
const essentialTotal = essentialExpenses.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 discretionaryTotal = discretionaryExpenses.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);
|
|
||||||
|
|
||||||
// Group expenses by category
|
|
||||||
const expensesByCategory = expenses.filter(e => e.isActive).reduce((acc, e) => {
|
const expensesByCategory = expenses.filter(e => e.isActive).reduce((acc, e) => {
|
||||||
const monthly = getMonthlyAmount(e.amount, e.frequency);
|
const monthly = getMonthlyAmount(e.amount, e.frequency);
|
||||||
acc[e.category] = (acc[e.category] || 0) + monthly;
|
acc[e.category] = (acc[e.category] || 0) + monthly;
|
||||||
@@ -47,73 +35,40 @@ export default function CashflowPage() {
|
|||||||
const sortedCategories = Object.entries(expensesByCategory).sort((a, b) => b[1] - a[1]);
|
const sortedCategories = Object.entries(expensesByCategory).sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-4">
|
||||||
{/* Header */}
|
{/* Header + Summary inline */}
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h1 className="text-xl font-medium">Cashflow</h1>
|
<div className="flex items-center gap-6">
|
||||||
|
<h1 className="text-lg font-semibold">Cashflow</h1>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">Income</span> <span className="font-medium text-green-400">{fmt(monthlyIncome)}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Expenses</span> <span className="font-medium">{fmt(monthlyExpenses)}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Savings</span> <span className={`font-semibold ${monthlySavings >= 0 ? 'text-green-400' : 'text-red-400'}`}>{fmt(monthlySavings)}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Rate</span> <span className={`font-medium ${savingsRate >= 20 ? 'text-green-400' : ''}`}>{savingsRate.toFixed(0)}%</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="secondary" size="sm">Add Income</Button>
|
<Button variant="secondary" size="sm">Add Income</Button>
|
||||||
<Button size="sm">Add Expense</Button>
|
<Button size="sm">Add Expense</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<div className="mb-5 grid grid-cols-4 gap-4">
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Monthly Income</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className="text-xl font-semibold text-green-400">{fmt(monthlyIncome)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Monthly Expenses</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className="text-xl font-semibold">{fmt(monthlyExpenses)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Monthly Savings</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className={`text-xl font-semibold ${monthlySavings >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
|
||||||
{fmt(monthlySavings)}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Savings Rate</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className={`text-xl font-semibold ${savingsRate >= 20 ? 'text-green-400' : savingsRate >= 10 ? 'text-yellow-400' : 'text-red-400'}`}>
|
|
||||||
{savingsRate.toFixed(0)}%
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-5 lg:grid-cols-2">
|
|
||||||
{/* Income Sources */}
|
{/* Income Sources */}
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 pt-3 px-4">
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Income Sources</CardTitle>
|
<CardTitle className="text-sm font-medium">Income Sources</CardTitle>
|
||||||
<span className="text-sm font-semibold text-green-400">{fmt(monthlyIncome)}/mo</span>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="p-3 pt-0">
|
||||||
<div className="divide-y divide-border rounded-md border border-border">
|
<div className="space-y-1.5 max-h-[250px] overflow-y-auto">
|
||||||
{incomeSources.filter(i => i.isActive).map(income => (
|
{incomeSources.filter(i => i.isActive).map(income => (
|
||||||
<div key={income.id} className="flex items-center justify-between px-3 py-2.5">
|
<div key={income.id} className="flex justify-between text-sm py-1 border-b border-border last:border-0">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">{income.name}</p>
|
<p className="text-sm">{income.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{income.category} · {income.frequency}</p>
|
<p className="text-xs text-muted-foreground">{income.frequency}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-medium">{fmt(income.amount)}</p>
|
<p className="font-medium">{fmt(income.amount)}</p>
|
||||||
<p className="text-xs text-muted-foreground">{fmt(getMonthlyAmount(income.amount, income.frequency))}/mo</p>
|
<p className="text-xs text-muted-foreground">{fmt(getMonthlyAmount(income.amount, income.frequency))}/mo</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,26 +79,22 @@ export default function CashflowPage() {
|
|||||||
|
|
||||||
{/* Expenses by Category */}
|
{/* Expenses by Category */}
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 pt-3 px-4">
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Expenses by Category</CardTitle>
|
<CardTitle className="text-sm font-medium">By Category</CardTitle>
|
||||||
<span className="text-sm font-semibold">{fmt(monthlyExpenses)}/mo</span>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="p-3 pt-0">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 max-h-[250px] overflow-y-auto">
|
||||||
{sortedCategories.map(([category, amount]) => {
|
{sortedCategories.map(([category, amount]) => {
|
||||||
const pct = (amount / monthlyExpenses) * 100;
|
const pct = (amount / monthlyExpenses) * 100;
|
||||||
return (
|
return (
|
||||||
<div key={category} className="flex items-center gap-3">
|
<div key={category}>
|
||||||
<div className="flex-1">
|
<div className="flex justify-between text-xs mb-0.5">
|
||||||
<div className="flex justify-between text-sm mb-1">
|
<span>{category}</span>
|
||||||
<span>{category}</span>
|
<span className="font-medium">{fmt(amount)}</span>
|
||||||
<span className="font-medium">{fmt(amount)}</span>
|
</div>
|
||||||
</div>
|
<div className="h-1 rounded-full bg-secondary overflow-hidden">
|
||||||
<div className="h-1.5 overflow-hidden rounded-full bg-secondary">
|
<div className="h-full bg-foreground/40" style={{width: `${pct}%`}} />
|
||||||
<div className="h-full bg-foreground/50" style={{width: `${pct}%`}} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground w-8">{pct.toFixed(0)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -153,32 +104,25 @@ export default function CashflowPage() {
|
|||||||
|
|
||||||
{/* Essential vs Discretionary */}
|
{/* Essential vs Discretionary */}
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated">
|
||||||
<CardHeader className="pb-2 pt-3 px-4">
|
<CardHeader className="p-3 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Essential vs Discretionary</CardTitle>
|
<CardTitle className="text-sm font-medium">Essential vs Discretionary</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="p-3 pt-0">
|
||||||
<div className="grid grid-cols-2 gap-4 mb-4">
|
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||||
<div className="rounded-md border border-border p-3 text-center">
|
<div className="rounded border border-border p-2 text-center">
|
||||||
<p className="text-xs text-muted-foreground mb-1">Essential</p>
|
<p className="text-xs text-muted-foreground">Essential</p>
|
||||||
<p className="text-lg font-semibold">{fmt(essentialTotal)}</p>
|
<p className="font-semibold">{fmt(essentialTotal)}</p>
|
||||||
<p className="text-xs text-muted-foreground">{((essentialTotal / monthlyExpenses) * 100).toFixed(0)}% of expenses</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border p-3 text-center">
|
<div className="rounded border border-border p-2 text-center">
|
||||||
<p className="text-xs text-muted-foreground mb-1">Discretionary</p>
|
<p className="text-xs text-muted-foreground">Discretionary</p>
|
||||||
<p className="text-lg font-semibold">{fmt(discretionaryTotal)}</p>
|
<p className="font-semibold">{fmt(discretionaryTotal)}</p>
|
||||||
<p className="text-xs text-muted-foreground">{((discretionaryTotal / monthlyExpenses) * 100).toFixed(0)}% of expenses</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border rounded-md border border-border max-h-48 overflow-y-auto">
|
<div className="space-y-1 max-h-[150px] overflow-y-auto text-sm">
|
||||||
{expenses.filter(e => e.isActive).sort((a, b) => getMonthlyAmount(b.amount, b.frequency) - getMonthlyAmount(a.amount, a.frequency)).map(expense => (
|
{expenses.filter(e => e.isActive).sort((a, b) => getMonthlyAmount(b.amount, b.frequency) - getMonthlyAmount(a.amount, a.frequency)).slice(0, 8).map(expense => (
|
||||||
<div key={expense.id} className="flex items-center justify-between px-3 py-2">
|
<div key={expense.id} className="flex justify-between py-0.5">
|
||||||
<div className="flex items-center gap-2">
|
<span className={expense.isEssential ? '' : 'text-muted-foreground'}>{expense.name}</span>
|
||||||
<span className="text-sm">{expense.name}</span>
|
<span className="font-medium">{fmt(getMonthlyAmount(expense.amount, expense.frequency))}</span>
|
||||||
{expense.isEssential && (
|
|
||||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-[10px] text-muted-foreground">Essential</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">{fmt(getMonthlyAmount(expense.amount, expense.frequency))}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -187,19 +131,19 @@ export default function CashflowPage() {
|
|||||||
|
|
||||||
{/* Recent Transactions */}
|
{/* Recent Transactions */}
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 pt-3 px-4">
|
<CardHeader className="flex flex-row items-center justify-between p-3 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Recent Transactions</CardTitle>
|
<CardTitle className="text-sm font-medium">Recent</CardTitle>
|
||||||
<Button variant="secondary" size="sm">Add</Button>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs">+ Add</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="p-3 pt-0">
|
||||||
<div className="divide-y divide-border rounded-md border border-border">
|
<div className="space-y-1.5 max-h-[250px] overflow-y-auto">
|
||||||
{transactions.slice(0, 8).map(tx => (
|
{transactions.slice(0, 10).map(tx => (
|
||||||
<div key={tx.id} className="flex items-center justify-between px-3 py-2">
|
<div key={tx.id} className="flex justify-between text-sm py-1 border-b border-border last:border-0">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm">{tx.name}</p>
|
<p>{tx.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{tx.category} · {tx.date}</p>
|
<p className="text-xs text-muted-foreground">{tx.date}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-sm font-medium ${tx.type === 'income' ? 'text-green-400' : ''}`}>
|
<span className={`font-medium ${tx.type === 'income' ? 'text-green-400' : ''}`}>
|
||||||
{tx.type === 'income' ? '+' : '-'}{fmt(tx.amount)}
|
{tx.type === 'income' ? '+' : '-'}{fmt(tx.amount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent} from '@/components/ui/card';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {useAppSelector} from '@/store';
|
import {useAppSelector} from '@/store';
|
||||||
|
|
||||||
@@ -12,83 +12,64 @@ export default function ClientsPage() {
|
|||||||
return {totalBilled, outstanding, count: clientInvoices.length};
|
return {totalBilled, outstanding, count: clientInvoices.length};
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmt = (value: number) =>
|
const fmt = (value: number) => new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
|
||||||
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
|
|
||||||
|
const totalBilled = clients.reduce((sum, c) => sum + getClientStats(c.id).totalBilled, 0);
|
||||||
|
const totalOutstanding = clients.reduce((sum, c) => sum + getClientStats(c.id).outstanding, 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-4">
|
||||||
{/* Header */}
|
{/* Header + Summary inline */}
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h1 className="text-xl font-medium">Clients</h1>
|
<div className="flex items-center gap-6">
|
||||||
|
<h1 className="text-lg font-semibold">Clients</h1>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">Total Clients</span> <span className="font-medium">{clients.length}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Total Billed</span> <span className="font-medium">{fmt(totalBilled)}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Outstanding</span> <span className="font-medium">{fmt(totalOutstanding)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button size="sm">Add Client</Button>
|
<Button size="sm">Add Client</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Clients Grid */}
|
||||||
<div className="mb-5 grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<Card className="card-elevated">
|
{clients.map(client => {
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
const stats = getClientStats(client.id);
|
||||||
<p className="text-xs text-muted-foreground">Total Clients</p>
|
return (
|
||||||
</CardHeader>
|
<Card key={client.id} className="card-elevated">
|
||||||
<CardContent className="pb-3 px-4">
|
<CardContent className="p-4">
|
||||||
<p className="text-xl font-semibold">{clients.length}</p>
|
<div className="flex justify-between items-start mb-2">
|
||||||
</CardContent>
|
<div>
|
||||||
</Card>
|
<p className="font-medium">{client.name}</p>
|
||||||
<Card className="card-elevated">
|
<p className="text-xs text-muted-foreground">{client.company || client.email}</p>
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">With Active Invoices</p>
|
{stats.outstanding > 0 && (
|
||||||
</CardHeader>
|
<span className="text-xs text-yellow-400">{fmt(stats.outstanding)} due</span>
|
||||||
<CardContent className="pb-3 px-4">
|
)}
|
||||||
<p className="text-xl font-semibold">
|
</div>
|
||||||
{clients.filter(c => getClientStats(c.id).count > 0).length}
|
<div className="flex gap-4 text-sm">
|
||||||
</p>
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Billed</p>
|
||||||
|
<p className="font-medium">{fmt(stats.totalBilled)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Invoices</p>
|
||||||
|
<p className="font-medium">{stats.count}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add client card */}
|
||||||
|
<Card className="card-elevated border-dashed cursor-pointer hover:bg-accent/50 transition-colors">
|
||||||
|
<CardContent className="p-4 flex items-center justify-center h-full min-h-[100px]">
|
||||||
|
<span className="text-sm text-muted-foreground">+ Add Client</span>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Clients List */}
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-2 pt-3 px-4">
|
|
||||||
<CardTitle className="text-sm font-medium">All Clients</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 pb-3">
|
|
||||||
{clients.length === 0 ? (
|
|
||||||
<div className="py-8 text-center">
|
|
||||||
<p className="text-muted-foreground">No clients yet</p>
|
|
||||||
<Button size="sm" className="mt-3">Add Client</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border rounded-md border border-border">
|
|
||||||
{clients.map(client => {
|
|
||||||
const stats = getClientStats(client.id);
|
|
||||||
return (
|
|
||||||
<div key={client.id} className="flex items-center justify-between px-3 py-2.5">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">{client.name}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{client.email}{client.company && ` · ${client.company}`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-6 text-right">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Billed</p>
|
|
||||||
<p className="text-sm font-medium">{fmt(stats.totalBilled)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Outstanding</p>
|
|
||||||
<p className="text-sm font-medium">{fmt(stats.outstanding)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Invoices</p>
|
|
||||||
<p className="text-sm font-medium">{stats.count}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import {Button} from '@/components/ui/button';
|
|||||||
import {useAppSelector, type DebtAccount} from '@/store';
|
import {useAppSelector, type DebtAccount} from '@/store';
|
||||||
import AddAccountDialog from '@/components/AddAccountDialog';
|
import AddAccountDialog from '@/components/AddAccountDialog';
|
||||||
|
|
||||||
type ViewMode = 'all' | 'by-category' | 'by-account';
|
type ViewMode = 'category' | 'account';
|
||||||
|
|
||||||
export default function DebtsPage() {
|
export default function DebtsPage() {
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('by-category');
|
const [viewMode, setViewMode] = useState<ViewMode>('category');
|
||||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
const {categories, accounts} = useAppSelector(state => state.debts);
|
const {categories, accounts} = useAppSelector(state => state.debts);
|
||||||
|
|
||||||
@@ -15,90 +15,46 @@ export default function DebtsPage() {
|
|||||||
const totalMinPayment = accounts.reduce((sum, a) => sum + a.minimumPayment, 0);
|
const totalMinPayment = accounts.reduce((sum, a) => sum + a.minimumPayment, 0);
|
||||||
const totalOriginal = accounts.reduce((sum, a) => sum + a.originalBalance, 0);
|
const totalOriginal = accounts.reduce((sum, a) => sum + a.originalBalance, 0);
|
||||||
const totalPaidDown = totalOriginal - totalDebt;
|
const totalPaidDown = totalOriginal - totalDebt;
|
||||||
|
const overallProgress = totalOriginal > 0 ? ((totalOriginal - totalDebt) / totalOriginal) * 100 : 0;
|
||||||
|
|
||||||
const fmt = (value: number) =>
|
const fmt = (value: number) => new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
|
||||||
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
|
|
||||||
|
|
||||||
const getCategoryById = (id: string) => categories.find(c => c.id === id);
|
const getCategoryById = (id: string) => categories.find(c => c.id === id);
|
||||||
const getAccountsByCategory = (categoryId: string) => accounts.filter(a => a.categoryId === categoryId);
|
const getAccountsByCategory = (categoryId: string) => accounts.filter(a => a.categoryId === categoryId);
|
||||||
const getCategoryTotal = (categoryId: string) => getAccountsByCategory(categoryId).reduce((sum, a) => sum + a.currentBalance, 0);
|
const getCategoryTotal = (categoryId: string) => getAccountsByCategory(categoryId).reduce((sum, a) => sum + a.currentBalance, 0);
|
||||||
const getProgress = (account: DebtAccount) =>
|
const getProgress = (account: DebtAccount) => account.originalBalance > 0 ? ((account.originalBalance - account.currentBalance) / account.originalBalance) * 100 : 0;
|
||||||
account.originalBalance > 0 ? ((account.originalBalance - account.currentBalance) / account.originalBalance) * 100 : 0;
|
|
||||||
|
|
||||||
const categoriesWithDebt = categories.filter(c => getCategoryTotal(c.id) > 0);
|
const categoriesWithDebt = categories.filter(c => getCategoryTotal(c.id) > 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-4">
|
||||||
{/* Header */}
|
{/* Header + Summary inline */}
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h1 className="text-xl font-medium">Debts</h1>
|
<div className="flex items-center gap-6">
|
||||||
|
<h1 className="text-lg font-semibold">Debts</h1>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">Total</span> <span className="font-medium">{fmt(totalDebt)}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Paid</span> <span className="font-medium text-green-400">{fmt(totalPaidDown)}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Monthly</span> <span className="font-medium">{fmt(totalMinPayment)}</span></div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="h-1.5 w-16 rounded-full bg-secondary overflow-hidden">
|
||||||
|
<div className="h-full bg-green-400/60" style={{width: `${overallProgress}%`}} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">{overallProgress.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="secondary" size="sm">Categories</Button>
|
<div className="flex gap-0.5 rounded bg-secondary p-0.5">
|
||||||
|
<button onClick={() => setViewMode('category')} className={`px-2 py-1 text-xs rounded ${viewMode === 'category' ? 'bg-background' : ''}`}>Category</button>
|
||||||
|
<button onClick={() => setViewMode('account')} className={`px-2 py-1 text-xs rounded ${viewMode === 'account' ? 'bg-background' : ''}`}>Account</button>
|
||||||
|
</div>
|
||||||
<Button size="sm" onClick={() => setAddDialogOpen(true)}>Add Account</Button>
|
<Button size="sm" onClick={() => setAddDialogOpen(true)}>Add Account</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{viewMode === 'category' ? (
|
||||||
<div className="mb-5 grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Total Debt</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className="text-xl font-semibold">{fmt(totalDebt)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Paid Down</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className="text-xl font-semibold">{fmt(totalPaidDown)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Monthly Min</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className="text-xl font-semibold">{fmt(totalMinPayment)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Accounts</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className="text-xl font-semibold">{accounts.length}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* View Toggle */}
|
|
||||||
<div className="mb-4 flex gap-1 rounded-lg bg-secondary p-1 w-fit">
|
|
||||||
{(['by-category', 'by-account', 'all'] as const).map(mode => (
|
|
||||||
<button
|
|
||||||
key={mode}
|
|
||||||
onClick={() => setViewMode(mode)}
|
|
||||||
className={`rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
||||||
viewMode === mode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{mode === 'by-category' ? 'By Category' : mode === 'by-account' ? 'By Account' : 'All'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{accounts.length === 0 ? (
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardContent className="py-10 text-center">
|
|
||||||
<p className="text-muted-foreground">No debt accounts yet</p>
|
|
||||||
<Button size="sm" className="mt-3" onClick={() => setAddDialogOpen(true)}>Add Account</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : viewMode === 'by-category' ? (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{categoriesWithDebt.map(category => {
|
{categoriesWithDebt.map(category => {
|
||||||
const categoryAccounts = getAccountsByCategory(category.id);
|
const categoryAccounts = getAccountsByCategory(category.id);
|
||||||
const categoryTotal = getCategoryTotal(category.id);
|
const categoryTotal = getCategoryTotal(category.id);
|
||||||
@@ -107,29 +63,32 @@ export default function DebtsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={category.id} className="card-elevated">
|
<Card key={category.id} className="card-elevated">
|
||||||
<CardHeader className="pb-2 pt-3 px-4">
|
<CardHeader className="p-3 pb-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-sm font-medium">{category.name}</CardTitle>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CardTitle className="text-base font-medium">{category.name}</CardTitle>
|
<div className="h-1.5 w-12 rounded-full bg-secondary overflow-hidden">
|
||||||
<span className="text-xs text-muted-foreground">
|
<div className="h-full bg-foreground/40" style={{width: `${categoryProgress}%`}} />
|
||||||
{categoryAccounts.length} account{categoryAccounts.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-secondary">
|
|
||||||
<div className="h-full bg-foreground/50" style={{width: `${categoryProgress}%`}} />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground w-8">{categoryProgress.toFixed(0)}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-base font-semibold">{fmt(categoryTotal)}</span>
|
<span className="font-medium">{fmt(categoryTotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-3 pt-0">
|
<CardContent className="p-3 pt-0">
|
||||||
<div className="divide-y divide-border rounded-md border border-border">
|
<div className="space-y-1.5">
|
||||||
{categoryAccounts.map(account => (
|
{categoryAccounts.map(account => (
|
||||||
<AccountRow key={account.id} account={account} fmt={fmt} getProgress={getProgress} />
|
<div key={account.id} className="flex items-center justify-between text-sm py-1 border-b border-border last:border-0">
|
||||||
|
<div>
|
||||||
|
<span>{account.name}</span>
|
||||||
|
<span className="ml-1.5 text-xs text-muted-foreground">{account.interestRate}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="h-1 w-8 rounded-full bg-secondary overflow-hidden">
|
||||||
|
<div className="h-full bg-foreground/40" style={{width: `${getProgress(account)}%`}} />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium w-16 text-right">{fmt(account.currentBalance)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -137,117 +96,40 @@ export default function DebtsPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : viewMode === 'by-account' ? (
|
) : (
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{accounts.map(account => {
|
{accounts.map(account => {
|
||||||
const category = getCategoryById(account.categoryId);
|
const category = getCategoryById(account.categoryId);
|
||||||
const progress = getProgress(account);
|
const progress = getProgress(account);
|
||||||
return (
|
return (
|
||||||
<Card key={account.id} className="card-elevated">
|
<Card key={account.id} className="card-elevated">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-3">
|
||||||
<div className="mb-3 flex items-start justify-between">
|
<div className="flex justify-between items-start mb-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{account.name}</p>
|
<p className="font-medium">{account.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{account.institution}</p>
|
<p className="text-xs text-muted-foreground">{account.institution} · {category?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-muted-foreground">{category?.name}</span>
|
<span className="text-xs text-muted-foreground">{account.interestRate}%</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-semibold">{fmt(account.currentBalance)}</p>
|
<p className="text-xl font-semibold mb-2">{fmt(account.currentBalance)}</p>
|
||||||
<div className="mt-3 flex items-center gap-2">
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-secondary">
|
<div className="h-1.5 flex-1 rounded-full bg-secondary overflow-hidden">
|
||||||
<div className="h-full bg-foreground/50" style={{width: `${progress}%`}} />
|
<div className="h-full bg-foreground/40" style={{width: `${progress}%`}} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground">{progress.toFixed(0)}%</span>
|
<span className="text-xs text-muted-foreground">{progress.toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex justify-between text-sm text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>{account.interestRate}% APR</span>
|
<span>Min: {fmt(account.minimumPayment)}/mo</span>
|
||||||
<span>{fmt(account.minimumPayment)}/mo</span>
|
<span>Due: {account.dueDay}th</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="divide-y divide-border">
|
|
||||||
{accounts.map(account => (
|
|
||||||
<AccountRow key={account.id} account={account} fmt={fmt} getProgress={getProgress} showCategory getCategoryById={getCategoryById} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Category Summary */}
|
|
||||||
{accounts.length > 0 && (
|
|
||||||
<Card className="card-elevated mt-5">
|
|
||||||
<CardHeader className="pb-2 pt-3 px-4">
|
|
||||||
<CardTitle className="text-sm font-medium text-muted-foreground">Debt by Category</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 pb-3">
|
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
|
||||||
{categories
|
|
||||||
.map(c => ({c, total: getCategoryTotal(c.id), count: getAccountsByCategory(c.id).length}))
|
|
||||||
.filter(x => x.count > 0)
|
|
||||||
.sort((a, b) => b.total - a.total)
|
|
||||||
.map(({c, total}) => (
|
|
||||||
<div key={c.id} className="flex items-center gap-2">
|
|
||||||
<div className="h-2 w-2 rounded-full bg-foreground/40" />
|
|
||||||
<span className="text-sm">{c.name}</span>
|
|
||||||
<span className="text-sm font-medium">{fmt(total)}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">({((total / totalDebt) * 100).toFixed(0)}%)</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AddAccountDialog open={addDialogOpen} onOpenChange={setAddDialogOpen} />
|
<AddAccountDialog open={addDialogOpen} onOpenChange={setAddDialogOpen} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AccountRow({
|
|
||||||
account,
|
|
||||||
fmt,
|
|
||||||
getProgress,
|
|
||||||
showCategory = false,
|
|
||||||
getCategoryById,
|
|
||||||
}: {
|
|
||||||
account: DebtAccount;
|
|
||||||
fmt: (value: number) => string;
|
|
||||||
getProgress: (account: DebtAccount) => number;
|
|
||||||
showCategory?: boolean;
|
|
||||||
getCategoryById?: (id: string) => {name: string} | undefined;
|
|
||||||
}) {
|
|
||||||
const progress = getProgress(account);
|
|
||||||
const category = getCategoryById?.(account.categoryId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between px-3 py-2.5">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">{account.name}</span>
|
|
||||||
{showCategory && category && (
|
|
||||||
<span className="rounded bg-secondary px-1.5 py-0.5 text-xs text-muted-foreground">{category.name}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{account.institution}{account.accountNumber && ` ••${account.accountNumber}`} · {account.interestRate}% APR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-secondary">
|
|
||||||
<div className="h-full bg-foreground/50" style={{width: `${progress}%`}} />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground w-7">{progress.toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium w-20 text-right">{fmt(account.currentBalance)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -10,91 +10,135 @@ export default function InvoicesPage() {
|
|||||||
|
|
||||||
const totalOutstanding = invoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((sum, i) => sum + i.total, 0);
|
const totalOutstanding = invoices.filter(i => i.status === 'sent' || i.status === 'overdue').reduce((sum, i) => sum + i.total, 0);
|
||||||
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((sum, i) => sum + i.total, 0);
|
const totalPaid = invoices.filter(i => i.status === 'paid').reduce((sum, i) => sum + i.total, 0);
|
||||||
|
const overdueCount = invoices.filter(i => i.status === 'overdue').length;
|
||||||
|
|
||||||
const fmt = (value: number) =>
|
const fmt = (value: number) => new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
|
||||||
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
|
|
||||||
|
|
||||||
const statusStyles: Record<string, string> = {
|
const byStatus = {
|
||||||
draft: 'bg-muted text-muted-foreground',
|
overdue: invoices.filter(i => i.status === 'overdue'),
|
||||||
sent: 'bg-blue-500/10 text-blue-400',
|
sent: invoices.filter(i => i.status === 'sent'),
|
||||||
paid: 'bg-green-500/10 text-green-400',
|
draft: invoices.filter(i => i.status === 'draft'),
|
||||||
overdue: 'bg-red-500/10 text-red-400',
|
paid: invoices.filter(i => i.status === 'paid').slice(0, 5),
|
||||||
cancelled: 'bg-muted text-muted-foreground line-through',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-4">
|
||||||
{/* Header */}
|
{/* Header + Summary inline */}
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h1 className="text-xl font-medium">Invoices</h1>
|
<div className="flex items-center gap-6">
|
||||||
|
<h1 className="text-lg font-semibold">Invoices</h1>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">Outstanding</span> <span className="font-medium">{fmt(totalOutstanding)}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Paid</span> <span className="font-medium text-green-400">{fmt(totalPaid)}</span></div>
|
||||||
|
{overdueCount > 0 && <div><span className="text-red-400 font-medium">{overdueCount} overdue</span></div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="secondary" size="sm">Add Client</Button>
|
<Button variant="secondary" size="sm">Add Client</Button>
|
||||||
<Button size="sm">New Invoice</Button>
|
<Button size="sm">New Invoice</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
<div className="grid grid-cols-4 gap-4">
|
||||||
<div className="mb-5 grid grid-cols-3 gap-4">
|
{/* Overdue */}
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated">
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
<CardHeader className="p-3 pb-2">
|
||||||
<p className="text-xs text-muted-foreground">Outstanding</p>
|
<CardTitle className="text-sm font-medium text-red-400">Overdue ({byStatus.overdue.length})</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-3 px-4">
|
<CardContent className="p-3 pt-0">
|
||||||
<p className="text-xl font-semibold">{fmt(totalOutstanding)}</p>
|
{byStatus.overdue.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">None</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{byStatus.overdue.map(inv => (
|
||||||
|
<div key={inv.id} className="text-sm py-1 border-b border-border last:border-0">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="font-medium">{inv.invoiceNumber}</span>
|
||||||
|
<span className="font-medium">{fmt(inv.total)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{getClientName(inv.clientId)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Sent */}
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated">
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
<CardHeader className="p-3 pb-2">
|
||||||
<p className="text-xs text-muted-foreground">Paid (All Time)</p>
|
<CardTitle className="text-sm font-medium text-blue-400">Sent ({byStatus.sent.length})</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-3 px-4">
|
<CardContent className="p-3 pt-0">
|
||||||
<p className="text-xl font-semibold">{fmt(totalPaid)}</p>
|
{byStatus.sent.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">None</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{byStatus.sent.map(inv => (
|
||||||
|
<div key={inv.id} className="text-sm py-1 border-b border-border last:border-0">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="font-medium">{inv.invoiceNumber}</span>
|
||||||
|
<span className="font-medium">{fmt(inv.total)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>{getClientName(inv.clientId)}</span>
|
||||||
|
<span>Due {format(new Date(inv.dueDate), 'MMM d')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Drafts */}
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated">
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
<CardHeader className="p-3 pb-2">
|
||||||
<p className="text-xs text-muted-foreground">Total Invoices</p>
|
<CardTitle className="text-sm font-medium">Drafts ({byStatus.draft.length})</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pb-3 px-4">
|
<CardContent className="p-3 pt-0">
|
||||||
<p className="text-xl font-semibold">{invoices.length}</p>
|
{byStatus.draft.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">None</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{byStatus.draft.map(inv => (
|
||||||
|
<div key={inv.id} className="text-sm py-1 border-b border-border last:border-0">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="font-medium">{inv.invoiceNumber}</span>
|
||||||
|
<span className="font-medium">{fmt(inv.total)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{getClientName(inv.clientId)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recently Paid */}
|
||||||
|
<Card className="card-elevated">
|
||||||
|
<CardHeader className="p-3 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium text-green-400">Recently Paid</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3 pt-0">
|
||||||
|
{byStatus.paid.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">None</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{byStatus.paid.map(inv => (
|
||||||
|
<div key={inv.id} className="text-sm py-1 border-b border-border last:border-0">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="font-medium">{inv.invoiceNumber}</span>
|
||||||
|
<span className="font-medium">{fmt(inv.total)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{getClientName(inv.clientId)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Invoices List */}
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-2 pt-3 px-4">
|
|
||||||
<CardTitle className="text-sm font-medium">Recent Invoices</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="px-4 pb-3">
|
|
||||||
{invoices.length === 0 ? (
|
|
||||||
<div className="py-8 text-center">
|
|
||||||
<p className="text-muted-foreground">No invoices yet</p>
|
|
||||||
<Button size="sm" className="mt-3">Create Invoice</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-border rounded-md border border-border">
|
|
||||||
{invoices.map(invoice => (
|
|
||||||
<div key={invoice.id} className="flex items-center justify-between px-3 py-2.5">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">{invoice.invoiceNumber}</span>
|
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium capitalize ${statusStyles[invoice.status]}`}>
|
|
||||||
{invoice.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{getClientName(invoice.clientId)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<p className="text-sm font-medium">{fmt(invoice.total)}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Due {format(new Date(invoice.dueDate), 'MMM d')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {useAppSelector} from '@/store';
|
import {useAppSelector} from '@/store';
|
||||||
import {AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer} from 'recharts';
|
import {AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer} from 'recharts';
|
||||||
import {format} from 'date-fns';
|
import {format} from 'date-fns';
|
||||||
|
|
||||||
export default function NetWorthPage() {
|
export default function NetWorthPage() {
|
||||||
@@ -20,118 +20,137 @@ export default function NetWorthPage() {
|
|||||||
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
|
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD', maximumFractionDigits: 0}).format(value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-4">
|
||||||
{/* Header */}
|
{/* Header + Summary inline */}
|
||||||
<div className="mb-5 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h1 className="text-xl font-medium">Net Worth</h1>
|
<div className="flex items-center gap-6">
|
||||||
|
<h1 className="text-lg font-semibold">Net Worth</h1>
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<div><span className="text-muted-foreground">Assets</span> <span className="font-medium">{fmt(totalAssets)}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Liabilities</span> <span className="font-medium">{fmt(totalLiabilities)}</span></div>
|
||||||
|
<div><span className="text-muted-foreground">Net</span> <span className="font-semibold text-base">{fmt(netWorth)}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<Button size="sm">Record Snapshot</Button>
|
<Button size="sm">Record Snapshot</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="mb-5 grid grid-cols-3 gap-4">
|
{/* Chart - spans 2 cols */}
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated col-span-2">
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
<CardContent className="p-3">
|
||||||
<p className="text-xs text-muted-foreground">Total Assets</p>
|
<div className="h-[200px]">
|
||||||
</CardHeader>
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<CardContent className="pb-3 px-4">
|
<AreaChart data={chartData}>
|
||||||
<p className="text-xl font-semibold">{fmt(totalAssets)}</p>
|
<defs>
|
||||||
|
<linearGradient id="netWorthGradient" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor="oklch(0.7 0.08 260)" stopOpacity={0.3} />
|
||||||
|
<stop offset="100%" stopColor="oklch(0.7 0.08 260)" stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis dataKey="month" stroke="oklch(0.5 0 0)" tick={{fill: 'oklch(0.5 0 0)', fontSize: 10}} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis stroke="oklch(0.5 0 0)" tick={{fill: 'oklch(0.5 0 0)', fontSize: 10}} tickFormatter={v => `$${v / 1000}k`} axisLine={false} tickLine={false} width={45} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{background: 'oklch(0.28 0.01 260)', border: '1px solid oklch(1 0 0 / 0.1)', borderRadius: '6px', fontSize: 12}}
|
||||||
|
labelStyle={{color: 'oklch(0.9 0 0)'}}
|
||||||
|
formatter={(value: number) => [fmt(value), 'Net Worth']}
|
||||||
|
/>
|
||||||
|
<Area type="monotone" dataKey="netWorth" stroke="oklch(0.7 0.08 260)" strokeWidth={2} fill="url(#netWorthGradient)" />
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Total Liabilities</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className="text-xl font-semibold">{fmt(totalLiabilities)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="card-elevated">
|
|
||||||
<CardHeader className="pb-1 pt-3 px-4">
|
|
||||||
<p className="text-xs text-muted-foreground">Net Worth</p>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pb-3 px-4">
|
|
||||||
<p className="text-xl font-semibold">{fmt(netWorth)}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chart */}
|
{/* Quick stats */}
|
||||||
<Card className="card-elevated mb-5">
|
<div className="space-y-3">
|
||||||
<CardHeader className="pb-2 pt-3 px-4">
|
<Card className="card-elevated">
|
||||||
<CardTitle className="text-sm font-medium">Net Worth Over Time</CardTitle>
|
<CardContent className="p-3">
|
||||||
</CardHeader>
|
<p className="text-xs text-muted-foreground mb-1">Monthly Change</p>
|
||||||
<CardContent className="px-4 pb-3">
|
<p className="text-lg font-semibold text-green-400">+$7,500</p>
|
||||||
<div className="h-[220px]">
|
</CardContent>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
</Card>
|
||||||
<AreaChart data={chartData}>
|
<Card className="card-elevated">
|
||||||
<defs>
|
<CardContent className="p-3">
|
||||||
<linearGradient id="netWorthGradient" x1="0" y1="0" x2="0" y2="1">
|
<p className="text-xs text-muted-foreground mb-1">YTD Growth</p>
|
||||||
<stop offset="0%" stopColor="oklch(0.7 0 0)" stopOpacity={0.3} />
|
<p className="text-lg font-semibold">+23.8%</p>
|
||||||
<stop offset="100%" stopColor="oklch(0.7 0 0)" stopOpacity={0} />
|
</CardContent>
|
||||||
</linearGradient>
|
</Card>
|
||||||
</defs>
|
<Card className="card-elevated">
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="oklch(0.26 0 0)" />
|
<CardContent className="p-3">
|
||||||
<XAxis dataKey="month" stroke="oklch(0.55 0 0)" tick={{fill: 'oklch(0.55 0 0)', fontSize: 11}} />
|
<p className="text-xs text-muted-foreground mb-1">Accounts</p>
|
||||||
<YAxis stroke="oklch(0.55 0 0)" tick={{fill: 'oklch(0.55 0 0)', fontSize: 11}} tickFormatter={v => `$${v / 1000}k`} />
|
<p className="text-lg font-semibold">{assets.length + liabilities.length}</p>
|
||||||
<Tooltip
|
</CardContent>
|
||||||
contentStyle={{background: 'oklch(0.18 0 0)', border: '1px solid oklch(0.26 0 0)', borderRadius: '6px', fontSize: 12}}
|
</Card>
|
||||||
labelStyle={{color: 'oklch(0.92 0 0)'}}
|
</div>
|
||||||
formatter={(value: number) => [fmt(value), 'Net Worth']}
|
|
||||||
/>
|
|
||||||
<Area type="monotone" dataKey="netWorth" stroke="oklch(0.85 0 0)" strokeWidth={2} fill="url(#netWorthGradient)" />
|
|
||||||
</AreaChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Assets & Liabilities */}
|
{/* Assets */}
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 pt-3 px-4">
|
<CardHeader className="flex flex-row items-center justify-between p-3 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Assets</CardTitle>
|
<CardTitle className="text-sm font-medium">Assets</CardTitle>
|
||||||
<Button variant="secondary" size="sm">Add</Button>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs">+ Add</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="p-3 pt-0">
|
||||||
{assets.length === 0 ? (
|
<div className="space-y-1.5 max-h-[180px] overflow-y-auto">
|
||||||
<p className="text-sm text-muted-foreground py-2">No assets added yet</p>
|
{assets.map(asset => (
|
||||||
) : (
|
<div key={asset.id} className="flex justify-between text-sm py-1 border-b border-border last:border-0">
|
||||||
<div className="divide-y divide-border rounded-md border border-border">
|
<div>
|
||||||
{assets.map(asset => (
|
<span>{asset.name}</span>
|
||||||
<div key={asset.id} className="flex justify-between px-3 py-2">
|
<span className="ml-1.5 text-xs text-muted-foreground capitalize">{asset.type}</span>
|
||||||
<div>
|
|
||||||
<span className="text-sm">{asset.name}</span>
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground capitalize">{asset.type}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">{fmt(asset.value)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<span className="font-medium">{fmt(asset.value)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Liabilities */}
|
||||||
<Card className="card-elevated">
|
<Card className="card-elevated">
|
||||||
<CardHeader className="flex flex-row items-center justify-between pb-2 pt-3 px-4">
|
<CardHeader className="flex flex-row items-center justify-between p-3 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Liabilities</CardTitle>
|
<CardTitle className="text-sm font-medium">Liabilities</CardTitle>
|
||||||
<Button variant="secondary" size="sm">Add</Button>
|
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs">+ Add</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="px-4 pb-3">
|
<CardContent className="p-3 pt-0">
|
||||||
{liabilities.length === 0 ? (
|
<div className="space-y-1.5 max-h-[180px] overflow-y-auto">
|
||||||
<p className="text-sm text-muted-foreground py-2">No liabilities added yet</p>
|
{liabilities.map(liability => (
|
||||||
) : (
|
<div key={liability.id} className="flex justify-between text-sm py-1 border-b border-border last:border-0">
|
||||||
<div className="divide-y divide-border rounded-md border border-border">
|
<div>
|
||||||
{liabilities.map(liability => (
|
<span>{liability.name}</span>
|
||||||
<div key={liability.id} className="flex justify-between px-3 py-2">
|
<span className="ml-1.5 text-xs text-muted-foreground capitalize">{liability.type.replace('_', ' ')}</span>
|
||||||
<div>
|
|
||||||
<span className="text-sm">{liability.name}</span>
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground capitalize">{liability.type.replace('_', ' ')}</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm font-medium">{fmt(liability.balance)}</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<span className="font-medium">{fmt(liability.balance)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Asset Allocation */}
|
||||||
|
<Card className="card-elevated">
|
||||||
|
<CardHeader className="p-3 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Allocation</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-3 pt-0">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{['cash', 'investment', 'property', 'vehicle'].map(type => {
|
||||||
|
const total = assets.filter(a => a.type === type).reduce((s, a) => s + a.value, 0);
|
||||||
|
const pct = totalAssets > 0 ? (total / totalAssets) * 100 : 0;
|
||||||
|
if (total === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={type} className="flex items-center gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex justify-between text-xs mb-0.5">
|
||||||
|
<span className="capitalize">{type}</span>
|
||||||
|
<span>{pct.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 rounded-full bg-secondary overflow-hidden">
|
||||||
|
<div className="h-full bg-foreground/40" style={{width: `${pct}%`}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user