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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
53
frontend-web/src/components/Layout.tsx
Normal file
53
frontend-web/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
26
frontend-web/src/components/ui/separator.tsx
Normal file
26
frontend-web/src/components/ui/separator.tsx
Normal 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 }
|
||||
61
frontend-web/src/components/ui/tooltip.tsx
Normal file
61
frontend-web/src/components/ui/tooltip.tsx
Normal 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
2
frontend-web/src/fonts.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare module '@fontsource-variable/geist';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
117
frontend-web/src/pages/ClientsPage.tsx
Normal file
117
frontend-web/src/pages/ClientsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
113
frontend-web/src/pages/DebtsPage.tsx
Normal file
113
frontend-web/src/pages/DebtsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
128
frontend-web/src/pages/InvoicesPage.tsx
Normal file
128
frontend-web/src/pages/InvoicesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
180
frontend-web/src/pages/NetWorthPage.tsx
Normal file
180
frontend-web/src/pages/NetWorthPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
88
frontend-web/src/store/slices/debtsSlice.ts
Normal file
88
frontend-web/src/store/slices/debtsSlice.ts
Normal 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;
|
||||
|
||||
118
frontend-web/src/store/slices/invoicesSlice.ts
Normal file
118
frontend-web/src/store/slices/invoicesSlice.ts
Normal 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;
|
||||
|
||||
96
frontend-web/src/store/slices/netWorthSlice.ts
Normal file
96
frontend-web/src/store/slices/netWorthSlice.ts
Normal 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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user