Update frontend-web dependencies and implement routing structure

- Added new dependencies: @fontsource-variable/geist, @radix-ui/react-separator, @radix-ui/react-tooltip, date-fns, react-router-dom, and recharts.
- Updated index.html to set the HTML class to "dark" for dark mode support.
- Refactored App component to implement routing with React Router, replacing the previous UI structure with a layout and multiple pages (NetWorth, Debts, Invoices, Clients).
- Enhanced CSS for dark mode and added depth utilities for improved UI aesthetics.
- Expanded Redux store to include net worth, debts, and invoices slices for comprehensive state management.
This commit is contained in:
2025-12-07 10:49:43 -05:00
parent f1f0032bca
commit bf00261e1d
19 changed files with 1283 additions and 55 deletions

View File

@@ -1,24 +1,21 @@
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {BrowserRouter, Routes, Route} from 'react-router-dom';
import Layout from '@/components/Layout';
import NetWorthPage from '@/pages/NetWorthPage';
import DebtsPage from '@/pages/DebtsPage';
import InvoicesPage from '@/pages/InvoicesPage';
import ClientsPage from '@/pages/ClientsPage';
export default function App() {
return (
<div className="min-h-screen bg-background p-8">
<Card className="mx-auto max-w-md">
<CardHeader>
<CardTitle>Personal Finances</CardTitle>
<CardDescription>Track your income and expenses</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="amount">Amount</Label>
<Input id="amount" type="number" placeholder="0.00" />
</div>
<Button className="w-full">Add Transaction</Button>
</CardContent>
</Card>
</div>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<NetWorthPage />} />
<Route path="debts" element={<DebtsPage />} />
<Route path="invoices" element={<InvoicesPage />} />
<Route path="clients" element={<ClientsPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,53 @@
import {NavLink, Outlet} from 'react-router-dom';
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
const navItems = [
{to: '/', label: 'Net Worth', icon: '◈'},
{to: '/debts', label: 'Debts', icon: '◇'},
{to: '/invoices', label: 'Invoices', icon: '▤'},
{to: '/clients', label: 'Clients', icon: '◉'},
];
export default function Layout() {
return (
<TooltipProvider delayDuration={0}>
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="fixed left-0 top-0 z-40 flex h-screen w-16 flex-col border-r border-border bg-sidebar">
<div className="flex h-16 items-center justify-center border-b border-border">
<span className="text-xl font-semibold text-foreground">W</span>
</div>
<nav className="flex flex-1 flex-col items-center gap-2 py-4">
{navItems.map(item => (
<Tooltip key={item.to}>
<TooltipTrigger asChild>
<NavLink
to={item.to}
className={({isActive}) =>
`flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors ${
isActive
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`
}
>
{item.icon}
</NavLink>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{item.label}
</TooltipContent>
</Tooltip>
))}
</nav>
</aside>
{/* Main content */}
<main className="ml-16 flex-1">
<Outlet />
</main>
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

2
frontend-web/src/fonts.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
declare module '@fontsource-variable/geist';

View File

@@ -77,37 +77,37 @@
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
--background: oklch(0.14 0 0);
--foreground: oklch(0.92 0 0);
--card: oklch(0.18 0 0);
--card-foreground: oklch(0.92 0 0);
--popover: oklch(0.18 0 0);
--popover-foreground: oklch(0.92 0 0);
--primary: oklch(0.92 0 0);
--primary-foreground: oklch(0.14 0 0);
--secondary: oklch(0.22 0 0);
--secondary-foreground: oklch(0.85 0 0);
--muted: oklch(0.22 0 0);
--muted-foreground: oklch(0.55 0 0);
--accent: oklch(0.22 0 0);
--accent-foreground: oklch(0.92 0 0);
--destructive: oklch(0.6 0.2 25);
--border: oklch(0.26 0 0);
--input: oklch(0.2 0 0);
--ring: oklch(0.45 0 0);
--chart-1: oklch(0.7 0 0);
--chart-2: oklch(0.55 0 0);
--chart-3: oklch(0.8 0 0);
--chart-4: oklch(0.45 0 0);
--chart-5: oklch(0.65 0 0);
--sidebar: oklch(0.12 0 0);
--sidebar-foreground: oklch(0.92 0 0);
--sidebar-primary: oklch(0.92 0 0);
--sidebar-primary-foreground: oklch(0.14 0 0);
--sidebar-accent: oklch(0.22 0 0);
--sidebar-accent-foreground: oklch(0.92 0 0);
--sidebar-border: oklch(0.26 0 0);
--sidebar-ring: oklch(0.45 0 0);
}
@layer base {
@@ -116,5 +116,50 @@
}
body {
@apply bg-background text-foreground;
font-family: 'Geist Variable', system-ui, sans-serif;
font-feature-settings: 'ss01' 1, 'ss02' 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
/* Depth utilities */
.dark body {
background:
radial-gradient(ellipse 80% 60% at 50% -20%, oklch(0.22 0 0), transparent),
oklch(0.14 0 0);
}
.card-elevated {
background: linear-gradient(
170deg,
oklch(0.2 0 0) 0%,
oklch(0.17 0 0) 100%
);
border: 1px solid oklch(1 0 0 / 0.06);
box-shadow:
inset 0 1px 0 oklch(1 0 0 / 0.03),
0 2px 8px oklch(0 0 0 / 0.3),
0 8px 32px oklch(0 0 0 / 0.25);
}
.glow-subtle {
box-shadow:
inset 0 1px 0 oklch(1 0 0 / 0.03),
0 2px 8px oklch(0 0 0 / 0.3),
0 8px 32px oklch(0 0 0 / 0.25);
}
.input-depth {
background: oklch(0.12 0 0);
border: 1px solid oklch(1 0 0 / 0.08);
box-shadow: inset 0 1px 2px oklch(0 0 0 / 0.3);
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.input-depth:focus {
border-color: oklch(1 0 0 / 0.2);
box-shadow:
inset 0 1px 2px oklch(0 0 0 / 0.3),
0 0 0 3px oklch(1 0 0 / 0.05);
}

View File

@@ -2,6 +2,7 @@ import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import {Provider} from 'react-redux';
import {store} from './store';
import '@fontsource-variable/geist';
import './index.css';
import App from './App.tsx';

View File

@@ -0,0 +1,117 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector} from '@/store';
export default function ClientsPage() {
const {clients, invoices} = useAppSelector(state => state.invoices);
const getClientStats = (clientId: string) => {
const clientInvoices = invoices.filter(i => i.clientId === clientId);
const totalBilled = clientInvoices.reduce((sum, i) => sum + i.total, 0);
const outstanding = clientInvoices
.filter(i => i.status === 'sent' || i.status === 'overdue')
.reduce((sum, i) => sum + i.total, 0);
return {totalBilled, outstanding, invoiceCount: clientInvoices.length};
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value);
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-medium tracking-tight">Clients</h1>
<p className="text-muted-foreground">Manage your customers and clients</p>
</div>
<Button>Add Client</Button>
</div>
{/* Summary */}
<div className="mb-8 grid gap-4 md:grid-cols-2">
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Clients</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{clients.length}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Active This Month</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">
{
clients.filter(c => {
const stats = getClientStats(c.id);
return stats.invoiceCount > 0;
}).length
}
</p>
</CardContent>
</Card>
</div>
{/* Clients List */}
<Card className="card-elevated">
<CardHeader>
<CardTitle className="text-lg font-medium">All Clients</CardTitle>
<CardDescription>View and manage client information</CardDescription>
</CardHeader>
<CardContent>
{clients.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">No clients added yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Add your first client to start creating invoices
</p>
</div>
) : (
<div className="space-y-2">
{clients.map(client => {
const stats = getClientStats(client.id);
return (
<div
key={client.id}
className="flex items-center justify-between rounded-lg border border-border bg-secondary/30 p-4"
>
<div>
<p className="font-medium">{client.name}</p>
<p className="text-sm text-muted-foreground">
{client.email}
{client.company && ` · ${client.company}`}
</p>
</div>
<div className="flex items-center gap-8">
<div className="text-right">
<p className="text-sm text-muted-foreground">Total Billed</p>
<p className="font-medium">{formatCurrency(stats.totalBilled)}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Outstanding</p>
<p className="font-medium">{formatCurrency(stats.outstanding)}</p>
</div>
<div className="text-right">
<p className="text-sm text-muted-foreground">Invoices</p>
<p className="font-medium">{stats.invoiceCount}</p>
</div>
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector} from '@/store';
export default function DebtsPage() {
const {debts} = useAppSelector(state => state.debts);
const totalDebt = debts.reduce((sum, d) => sum + d.currentBalance, 0);
const totalMinPayment = debts.reduce((sum, d) => sum + d.minimumPayment, 0);
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value);
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-medium tracking-tight">Debt Management</h1>
<p className="text-muted-foreground">Track and pay down your debts</p>
</div>
<Button>Add Debt</Button>
</div>
{/* Summary Cards */}
<div className="mb-8 grid gap-4 md:grid-cols-3">
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Debt</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalDebt)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Monthly Minimum</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalMinPayment)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Active Debts</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{debts.length}</p>
</CardContent>
</Card>
</div>
{/* Debts List */}
<Card className="card-elevated">
<CardHeader>
<CardTitle className="text-lg font-medium">Your Debts</CardTitle>
<CardDescription>Manage and track payments</CardDescription>
</CardHeader>
<CardContent>
{debts.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">No debts added yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Add your first debt to start tracking your payoff progress
</p>
</div>
) : (
<div className="space-y-4">
{debts.map(debt => {
const progress =
((debt.originalBalance - debt.currentBalance) / debt.originalBalance) * 100;
return (
<div
key={debt.id}
className="rounded-lg border border-border bg-secondary/30 p-4"
>
<div className="mb-2 flex items-center justify-between">
<div>
<h3 className="font-medium">{debt.name}</h3>
<p className="text-sm text-muted-foreground">{debt.lender}</p>
</div>
<div className="text-right">
<p className="font-semibold">{formatCurrency(debt.currentBalance)}</p>
<p className="text-sm text-muted-foreground">
{debt.interestRate}% APR
</p>
</div>
</div>
<div className="h-2 overflow-hidden rounded-full bg-secondary">
<div
className="h-full bg-foreground/60 transition-all"
style={{width: `${progress}%`}}
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{progress.toFixed(1)}% paid off
</p>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector} from '@/store';
import {format} from 'date-fns';
export default function InvoicesPage() {
const {invoices, clients} = useAppSelector(state => state.invoices);
const getClientName = (clientId: string) => {
const client = clients.find(c => c.id === clientId);
return client?.name ?? 'Unknown Client';
};
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 formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value);
const statusStyles: Record<string, string> = {
draft: 'bg-muted text-muted-foreground',
sent: 'bg-blue-500/10 text-blue-400',
paid: 'bg-green-500/10 text-green-400',
overdue: 'bg-red-500/10 text-red-400',
cancelled: 'bg-muted text-muted-foreground line-through',
};
return (
<div className="p-8">
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-2xl font-medium tracking-tight">Invoices</h1>
<p className="text-muted-foreground">Create and manage invoices</p>
</div>
<Button>New Invoice</Button>
</div>
{/* Summary Cards */}
<div className="mb-8 grid gap-4 md:grid-cols-3">
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Outstanding</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalOutstanding)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Paid (All Time)</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalPaid)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Invoices</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{invoices.length}</p>
</CardContent>
</Card>
</div>
{/* Invoices List */}
<Card className="card-elevated">
<CardHeader>
<CardTitle className="text-lg font-medium">Recent Invoices</CardTitle>
<CardDescription>View and manage your invoices</CardDescription>
</CardHeader>
<CardContent>
{invoices.length === 0 ? (
<div className="py-8 text-center">
<p className="text-muted-foreground">No invoices created yet</p>
<p className="mt-1 text-sm text-muted-foreground">
Create your first invoice to get started
</p>
</div>
) : (
<div className="space-y-2">
{invoices.map(invoice => (
<div
key={invoice.id}
className="flex items-center justify-between rounded-lg border border-border bg-secondary/30 p-4"
>
<div className="flex items-center gap-4">
<div>
<p className="font-medium">{invoice.invoiceNumber}</p>
<p className="text-sm text-muted-foreground">
{getClientName(invoice.clientId)}
</p>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<p className="font-semibold">{formatCurrency(invoice.total)}</p>
<p className="text-sm text-muted-foreground">
Due {format(new Date(invoice.dueDate), 'MMM d, yyyy')}
</p>
</div>
<span
className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ${statusStyles[invoice.status]}`}
>
{invoice.status}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,180 @@
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import {Button} from '@/components/ui/button';
import {useAppSelector} from '@/store';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
// Demo data for the chart
const demoData = [
{month: 'Jan', netWorth: 45000},
{month: 'Feb', netWorth: 47500},
{month: 'Mar', netWorth: 46200},
{month: 'Apr', netWorth: 52000},
{month: 'May', netWorth: 55800},
{month: 'Jun', netWorth: 58200},
];
export default function NetWorthPage() {
const {assets, liabilities} = useAppSelector(state => state.netWorth);
const totalAssets = assets.reduce((sum, a) => sum + a.value, 0);
const totalLiabilities = liabilities.reduce((sum, l) => sum + l.balance, 0);
const netWorth = totalAssets - totalLiabilities;
const formatCurrency = (value: number) =>
new Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(value);
return (
<div className="p-8">
<div className="mb-8">
<h1 className="text-2xl font-medium tracking-tight">Net Worth</h1>
<p className="text-muted-foreground">Track your wealth over time</p>
</div>
{/* Summary Cards */}
<div className="mb-8 grid gap-4 md:grid-cols-3">
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Assets</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalAssets)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Total Liabilities</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(totalLiabilities)}</p>
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="pb-2">
<CardDescription>Net Worth</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">{formatCurrency(netWorth)}</p>
</CardContent>
</Card>
</div>
{/* Chart */}
<Card className="card-elevated mb-8">
<CardHeader>
<CardTitle className="text-lg font-medium">Net Worth Over Time</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={demoData}>
<defs>
<linearGradient id="netWorthGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="oklch(0.7 0 0)" stopOpacity={0.3} />
<stop offset="100%" stopColor="oklch(0.7 0 0)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(0.26 0 0)" />
<XAxis
dataKey="month"
stroke="oklch(0.55 0 0)"
tick={{fill: 'oklch(0.55 0 0)'}}
/>
<YAxis
stroke="oklch(0.55 0 0)"
tick={{fill: 'oklch(0.55 0 0)'}}
tickFormatter={value => `$${value / 1000}k`}
/>
<Tooltip
contentStyle={{
background: 'oklch(0.18 0 0)',
border: '1px solid oklch(0.26 0 0)',
borderRadius: '8px',
}}
labelStyle={{color: 'oklch(0.92 0 0)'}}
formatter={(value: number) => [formatCurrency(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 */}
<div className="grid gap-6 md:grid-cols-2">
<Card className="card-elevated">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-medium">Assets</CardTitle>
<CardDescription>What you own</CardDescription>
</div>
<Button variant="secondary" size="sm">
Add Asset
</Button>
</CardHeader>
<CardContent>
{assets.length === 0 ? (
<p className="text-sm text-muted-foreground">No assets added yet</p>
) : (
<ul className="space-y-2">
{assets.map(asset => (
<li key={asset.id} className="flex justify-between">
<span>{asset.name}</span>
<span>{formatCurrency(asset.value)}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card className="card-elevated">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-medium">Liabilities</CardTitle>
<CardDescription>What you owe</CardDescription>
</div>
<Button variant="secondary" size="sm">
Add Liability
</Button>
</CardHeader>
<CardContent>
{liabilities.length === 0 ? (
<p className="text-sm text-muted-foreground">No liabilities added yet</p>
) : (
<ul className="space-y-2">
{liabilities.map(liability => (
<li key={liability.id} className="flex justify-between">
<span>{liability.name}</span>
<span>{formatCurrency(liability.balance)}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -5,6 +5,51 @@ export type {RootState, AppDispatch} from './store';
// Hooks
export {useAppDispatch, useAppSelector} from './hooks';
// User slice exports
export {setLoading, setUser, clearUser, setError} from './slices/userSlice';
// User slice
export {setLoading as setUserLoading, setUser, clearUser, setError as setUserError} from './slices/userSlice';
export type {User, UserState} from './slices/userSlice';
// Net Worth slice
export {
setLoading as setNetWorthLoading,
setError as setNetWorthError,
addAsset,
updateAsset,
removeAsset,
addLiability,
updateLiability,
removeLiability,
addSnapshot,
setSnapshots,
} from './slices/netWorthSlice';
export type {Asset, Liability, NetWorthSnapshot, NetWorthState} from './slices/netWorthSlice';
// Debts slice
export {
setLoading as setDebtsLoading,
setError as setDebtsError,
addDebt,
updateDebt,
removeDebt,
addPayment,
removePayment,
setDebts,
setPayments,
} from './slices/debtsSlice';
export type {Debt, DebtPayment, DebtsState} from './slices/debtsSlice';
// Invoices slice
export {
setLoading as setInvoicesLoading,
setError as setInvoicesError,
addClient,
updateClient,
removeClient,
setClients,
addInvoice,
updateInvoice,
removeInvoice,
setInvoices,
updateInvoiceStatus,
} from './slices/invoicesSlice';
export type {Client, Invoice, InvoiceLineItem, InvoicesState} from './slices/invoicesSlice';

View File

@@ -0,0 +1,88 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit';
export interface Debt {
id: string;
name: string;
type: 'credit_card' | 'personal_loan' | 'auto_loan' | 'student_loan' | 'mortgage' | 'other';
originalBalance: number;
currentBalance: number;
interestRate: number;
minimumPayment: number;
dueDay: number;
lender: string;
createdAt: string;
updatedAt: string;
}
export interface DebtPayment {
id: string;
debtId: string;
amount: number;
date: string;
note?: string;
}
export interface DebtsState {
debts: Debt[];
payments: DebtPayment[];
isLoading: boolean;
error: string | null;
}
const initialState: DebtsState = {
debts: [],
payments: [],
isLoading: false,
error: null,
};
const debtsSlice = createSlice({
name: 'debts',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
addDebt: (state, action: PayloadAction<Debt>) => {
state.debts.push(action.payload);
},
updateDebt: (state, action: PayloadAction<Debt>) => {
const index = state.debts.findIndex(d => d.id === action.payload.id);
if (index !== -1) state.debts[index] = action.payload;
},
removeDebt: (state, action: PayloadAction<string>) => {
state.debts = state.debts.filter(d => d.id !== action.payload);
state.payments = state.payments.filter(p => p.debtId !== action.payload);
},
addPayment: (state, action: PayloadAction<DebtPayment>) => {
state.payments.push(action.payload);
},
removePayment: (state, action: PayloadAction<string>) => {
state.payments = state.payments.filter(p => p.id !== action.payload);
},
setDebts: (state, action: PayloadAction<Debt[]>) => {
state.debts = action.payload;
},
setPayments: (state, action: PayloadAction<DebtPayment[]>) => {
state.payments = action.payload;
},
},
});
export const {
setLoading,
setError,
addDebt,
updateDebt,
removeDebt,
addPayment,
removePayment,
setDebts,
setPayments,
} = debtsSlice.actions;
export default debtsSlice.reducer;

View File

@@ -0,0 +1,118 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit';
export interface Client {
id: string;
name: string;
email: string;
phone?: string;
company?: string;
address?: string;
notes?: string;
createdAt: string;
}
export interface InvoiceLineItem {
id: string;
description: string;
quantity: number;
unitPrice: number;
total: number;
}
export interface Invoice {
id: string;
invoiceNumber: string;
clientId: string;
status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
issueDate: string;
dueDate: string;
lineItems: InvoiceLineItem[];
subtotal: number;
tax: number;
total: number;
notes?: string;
createdAt: string;
updatedAt: string;
}
export interface InvoicesState {
clients: Client[];
invoices: Invoice[];
isLoading: boolean;
error: string | null;
}
const initialState: InvoicesState = {
clients: [],
invoices: [],
isLoading: false,
error: null,
};
const invoicesSlice = createSlice({
name: 'invoices',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
// Client actions
addClient: (state, action: PayloadAction<Client>) => {
state.clients.push(action.payload);
},
updateClient: (state, action: PayloadAction<Client>) => {
const index = state.clients.findIndex(c => c.id === action.payload.id);
if (index !== -1) state.clients[index] = action.payload;
},
removeClient: (state, action: PayloadAction<string>) => {
state.clients = state.clients.filter(c => c.id !== action.payload);
},
setClients: (state, action: PayloadAction<Client[]>) => {
state.clients = action.payload;
},
// Invoice actions
addInvoice: (state, action: PayloadAction<Invoice>) => {
state.invoices.push(action.payload);
},
updateInvoice: (state, action: PayloadAction<Invoice>) => {
const index = state.invoices.findIndex(i => i.id === action.payload.id);
if (index !== -1) state.invoices[index] = action.payload;
},
removeInvoice: (state, action: PayloadAction<string>) => {
state.invoices = state.invoices.filter(i => i.id !== action.payload);
},
setInvoices: (state, action: PayloadAction<Invoice[]>) => {
state.invoices = action.payload;
},
updateInvoiceStatus: (
state,
action: PayloadAction<{id: string; status: Invoice['status']}>
) => {
const invoice = state.invoices.find(i => i.id === action.payload.id);
if (invoice) {
invoice.status = action.payload.status;
invoice.updatedAt = new Date().toISOString();
}
},
},
});
export const {
setLoading,
setError,
addClient,
updateClient,
removeClient,
setClients,
addInvoice,
updateInvoice,
removeInvoice,
setInvoices,
updateInvoiceStatus,
} = invoicesSlice.actions;
export default invoicesSlice.reducer;

View File

@@ -0,0 +1,96 @@
import {createSlice, type PayloadAction} from '@reduxjs/toolkit';
export interface Asset {
id: string;
name: string;
type: 'cash' | 'investment' | 'property' | 'vehicle' | 'other';
value: number;
updatedAt: string;
}
export interface Liability {
id: string;
name: string;
type: 'credit_card' | 'loan' | 'mortgage' | 'other';
balance: number;
updatedAt: string;
}
export interface NetWorthSnapshot {
id: string;
date: string;
totalAssets: number;
totalLiabilities: number;
netWorth: number;
}
export interface NetWorthState {
assets: Asset[];
liabilities: Liability[];
snapshots: NetWorthSnapshot[];
isLoading: boolean;
error: string | null;
}
const initialState: NetWorthState = {
assets: [],
liabilities: [],
snapshots: [],
isLoading: false,
error: null,
};
const netWorthSlice = createSlice({
name: 'netWorth',
initialState,
reducers: {
setLoading: (state, action: PayloadAction<boolean>) => {
state.isLoading = action.payload;
},
setError: (state, action: PayloadAction<string | null>) => {
state.error = action.payload;
},
addAsset: (state, action: PayloadAction<Asset>) => {
state.assets.push(action.payload);
},
updateAsset: (state, action: PayloadAction<Asset>) => {
const index = state.assets.findIndex(a => a.id === action.payload.id);
if (index !== -1) state.assets[index] = action.payload;
},
removeAsset: (state, action: PayloadAction<string>) => {
state.assets = state.assets.filter(a => a.id !== action.payload);
},
addLiability: (state, action: PayloadAction<Liability>) => {
state.liabilities.push(action.payload);
},
updateLiability: (state, action: PayloadAction<Liability>) => {
const index = state.liabilities.findIndex(l => l.id === action.payload.id);
if (index !== -1) state.liabilities[index] = action.payload;
},
removeLiability: (state, action: PayloadAction<string>) => {
state.liabilities = state.liabilities.filter(l => l.id !== action.payload);
},
addSnapshot: (state, action: PayloadAction<NetWorthSnapshot>) => {
state.snapshots.push(action.payload);
},
setSnapshots: (state, action: PayloadAction<NetWorthSnapshot[]>) => {
state.snapshots = action.payload;
},
},
});
export const {
setLoading,
setError,
addAsset,
updateAsset,
removeAsset,
addLiability,
updateLiability,
removeLiability,
addSnapshot,
setSnapshots,
} = netWorthSlice.actions;
export default netWorthSlice.reducer;

View File

@@ -1,10 +1,16 @@
import {configureStore} from '@reduxjs/toolkit';
import userReducer from './slices/userSlice';
import netWorthReducer from './slices/netWorthSlice';
import debtsReducer from './slices/debtsSlice';
import invoicesReducer from './slices/invoicesSlice';
export const store = configureStore({
reducer: {
user: userReducer
}
user: userReducer,
netWorth: netWorthReducer,
debts: debtsReducer,
invoices: invoicesReducer,
},
});
export type RootState = ReturnType<typeof store.getState>;