Add backend API for personal finance management application
- Introduced a comprehensive backend API using TypeScript, Fastify, and PostgreSQL. - Added essential files including architecture documentation, environment configuration, and Docker setup. - Implemented RESTful routes for managing assets, liabilities, clients, invoices, and cashflow. - Established a robust database schema with Prisma for data management. - Integrated middleware for authentication and error handling. - Created service and repository layers to adhere to SOLID principles and clean architecture. - Included example environment variables for development, staging, and production setups.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import {lazy, Suspense} from 'react';
|
||||
import {BrowserRouter, Routes, Route} from 'react-router-dom';
|
||||
import {BrowserRouter, Routes, Route, Navigate} from 'react-router-dom';
|
||||
import {useAppSelector} from '@/store';
|
||||
import Layout from '@/components/Layout';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
|
||||
// Code splitting: lazy load route components
|
||||
const LandingPage = lazy(() => import('@/pages/LandingPage'));
|
||||
const NetWorthPage = lazy(() => import('@/pages/NetWorthPage'));
|
||||
const CashflowPage = lazy(() => import('@/pages/CashflowPage'));
|
||||
const DebtsPage = lazy(() => import('@/pages/DebtsPage'));
|
||||
@@ -16,10 +19,27 @@ const PageLoader = () => (
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function App() {
|
||||
function AppRoutes() {
|
||||
const {isAuthenticated} = useAppSelector(state => state.user);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Routes>
|
||||
{/* Public route - Landing page */}
|
||||
<Route
|
||||
path="/welcome"
|
||||
element={
|
||||
isAuthenticated ? (
|
||||
<Navigate to="/" replace />
|
||||
) : (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<LandingPage />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route
|
||||
index
|
||||
@@ -62,7 +82,18 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Route>
|
||||
|
||||
{/* Catch-all redirect */}
|
||||
<Route path="*" element={<Navigate to={isAuthenticated ? '/' : '/welcome'} replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {NavLink, Outlet} from 'react-router-dom';
|
||||
import {TrendingUp, CreditCard, FileText, Users, ArrowLeftRight} from 'lucide-react';
|
||||
import {TrendingUp, CreditCard, FileText, Users, ArrowLeftRight, LogOut} from 'lucide-react';
|
||||
import {useAppSelector, useAppDispatch, clearUser} from '@/store';
|
||||
|
||||
const navItems = [
|
||||
{to: '/', label: 'Net Worth', icon: TrendingUp},
|
||||
@@ -10,6 +11,13 @@ const navItems = [
|
||||
];
|
||||
|
||||
export default function Layout() {
|
||||
const dispatch = useAppDispatch();
|
||||
const {currentUser} = useAppSelector(state => state.user);
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(clearUser());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* Sidebar */}
|
||||
@@ -35,6 +43,26 @@ export default function Layout() {
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="border-t border-border p-2">
|
||||
<div className="flex items-center gap-2 px-2.5 py-2 rounded-lg">
|
||||
<div className="h-7 w-7 rounded-full bg-accent flex items-center justify-center text-xs font-medium shrink-0">
|
||||
{currentUser?.name?.charAt(0).toUpperCase() || '?'}
|
||||
</div>
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 overflow-hidden">
|
||||
<p className="text-sm font-medium truncate max-w-24">{currentUser?.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-24">{currentUser?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex h-9 w-full items-center gap-3 rounded-lg px-2.5 text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"
|
||||
>
|
||||
<LogOut className="h-[18px] w-[18px] shrink-0" />
|
||||
<span className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">Log out</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
|
||||
13
frontend-web/src/components/ProtectedRoute.tsx
Normal file
13
frontend-web/src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import {Navigate, Outlet} from 'react-router-dom';
|
||||
import {useAppSelector} from '@/store';
|
||||
|
||||
export default function ProtectedRoute() {
|
||||
const {isAuthenticated} = useAppSelector(state => state.user);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/welcome" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
88
frontend-web/src/components/dialogs/LoginDialog.tsx
Normal file
88
frontend-web/src/components/dialogs/LoginDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {useAppDispatch, setUser} from '@/store';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSwitchToSignUp: () => void;
|
||||
}
|
||||
|
||||
export default function LoginDialog({open, onOpenChange, onSwitchToSignUp}: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [form, setForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Mock login - in production this would validate against an API
|
||||
if (!form.email || !form.password) {
|
||||
setError('Please enter your email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock successful login
|
||||
dispatch(setUser({
|
||||
id: crypto.randomUUID(),
|
||||
email: form.email,
|
||||
name: form.email.split('@')[0],
|
||||
}));
|
||||
|
||||
onOpenChange(false);
|
||||
setForm({email: '', password: ''});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="card-elevated sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Welcome back</DialogTitle>
|
||||
<DialogDescription>Log in to your account</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="login-email">Email</Label>
|
||||
<Input
|
||||
id="login-email"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
value={form.email}
|
||||
onChange={e => setForm({...form, email: e.target.value})}
|
||||
className="input-depth"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="login-password">Password</Label>
|
||||
<Input
|
||||
id="login-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={e => setForm({...form, password: e.target.value})}
|
||||
className="input-depth"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||
<Button type="submit" className="w-full">Log in</Button>
|
||||
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToSignUp}>
|
||||
Don't have an account? Sign up
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
122
frontend-web/src/components/dialogs/SignUpDialog.tsx
Normal file
122
frontend-web/src/components/dialogs/SignUpDialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {useAppDispatch, setUser} from '@/store';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSwitchToLogin: () => void;
|
||||
}
|
||||
|
||||
export default function SignUpDialog({open, onOpenChange, onSwitchToLogin}: Props) {
|
||||
const dispatch = useAppDispatch();
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
if (form.password !== form.confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.password.length < 6) {
|
||||
setError('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock sign up - in production this would call an API
|
||||
dispatch(setUser({
|
||||
id: crypto.randomUUID(),
|
||||
email: form.email,
|
||||
name: form.name,
|
||||
}));
|
||||
|
||||
onOpenChange(false);
|
||||
setForm({name: '', email: '', password: '', confirmPassword: ''});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="card-elevated sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create an account</DialogTitle>
|
||||
<DialogDescription>Enter your details to get started</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="signup-name">Name</Label>
|
||||
<Input
|
||||
id="signup-name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
value={form.name}
|
||||
onChange={e => setForm({...form, name: e.target.value})}
|
||||
className="input-depth"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="signup-email">Email</Label>
|
||||
<Input
|
||||
id="signup-email"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
value={form.email}
|
||||
onChange={e => setForm({...form, email: e.target.value})}
|
||||
className="input-depth"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="signup-password">Password</Label>
|
||||
<Input
|
||||
id="signup-password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={e => setForm({...form, password: e.target.value})}
|
||||
className="input-depth"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="signup-confirm">Confirm Password</Label>
|
||||
<Input
|
||||
id="signup-confirm"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={form.confirmPassword}
|
||||
onChange={e => setForm({...form, confirmPassword: e.target.value})}
|
||||
className="input-depth"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
By signing up, you agree to our Terms of Service and Privacy Policy.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-col">
|
||||
<Button type="submit" className="w-full">Create account</Button>
|
||||
<Button type="button" variant="ghost" className="w-full" onClick={onSwitchToLogin}>
|
||||
Already have an account? Log in
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
144
frontend-web/src/pages/LandingPage.tsx
Normal file
144
frontend-web/src/pages/LandingPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import {useState} from 'react';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent} from '@/components/ui/card';
|
||||
import {TrendingUp, CreditCard, FileText, ArrowLeftRight, Shield, BarChart3} from 'lucide-react';
|
||||
import LoginDialog from '@/components/dialogs/LoginDialog';
|
||||
import SignUpDialog from '@/components/dialogs/SignUpDialog';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Net Worth Tracking',
|
||||
description: 'Monitor your assets and liabilities over time with beautiful charts and insights.',
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: 'Debt Management',
|
||||
description: 'Organize and track debt paydown across multiple accounts and categories.',
|
||||
},
|
||||
{
|
||||
icon: ArrowLeftRight,
|
||||
title: 'Cashflow Analysis',
|
||||
description: 'Understand your income and expenses to optimize your savings rate.',
|
||||
},
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Invoicing',
|
||||
description: 'Create professional invoices and track payments from your clients.',
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: 'Visual Reports',
|
||||
description: 'Clean, minimal dashboards that put your data front and center.',
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: 'Private & Secure',
|
||||
description: 'Your financial data stays on your device. No cloud sync required.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function LandingPage() {
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [signUpOpen, setSignUpOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<header className="border-b border-border">
|
||||
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-semibold">Wealth</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setLoginOpen(true)}>
|
||||
Log in
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setSignUpOpen(true)}>
|
||||
Sign up
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero */}
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight mb-4">
|
||||
Take control of your finances
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground mb-8 max-w-xl mx-auto">
|
||||
A clean, minimal tool to track your net worth, manage debt, monitor cashflow, and invoice clients—all in one place.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-center">
|
||||
<Button size="lg" onClick={() => setSignUpOpen(true)}>
|
||||
Get Started
|
||||
</Button>
|
||||
<Button variant="secondary" size="lg" onClick={() => setLoginOpen(true)}>
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features */}
|
||||
<section className="py-16 px-6 border-t border-border">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<h2 className="text-xl font-semibold text-center mb-10">Everything you need</h2>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{features.map((feature) => (
|
||||
<Card key={feature.title} className="card-elevated">
|
||||
<CardContent className="p-5">
|
||||
<feature.icon className="h-5 w-5 mb-3 text-muted-foreground" />
|
||||
<h3 className="font-medium mb-1">{feature.title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{feature.description}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-16 px-6 border-t border-border">
|
||||
<div className="max-w-xl mx-auto text-center">
|
||||
<h2 className="text-xl font-semibold mb-3">Ready to build wealth?</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Start tracking your finances today. It's free to get started.
|
||||
</p>
|
||||
<Button size="lg" onClick={() => setSignUpOpen(true)}>
|
||||
Create your account
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<section className="py-8 px-6 border-t border-border bg-muted/30">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<p className="text-xs text-muted-foreground text-center leading-relaxed">
|
||||
<strong>Disclaimer:</strong> This application is for informational and personal tracking purposes only.
|
||||
It does not constitute financial, investment, tax, or legal advice. The information provided should not
|
||||
be relied upon for making financial decisions. Always consult with qualified professionals before making
|
||||
any financial decisions. We make no guarantees about the accuracy or completeness of the data you enter
|
||||
or the calculations performed. Use at your own risk. Past performance is not indicative of future results.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-6 px-6 border-t border-border">
|
||||
<div className="max-w-5xl mx-auto flex justify-between items-center text-sm text-muted-foreground">
|
||||
<span>© {new Date().getFullYear()} Wealth</span>
|
||||
<div className="flex gap-4">
|
||||
<span>Privacy</span>
|
||||
<span>Terms</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<LoginDialog open={loginOpen} onOpenChange={setLoginOpen} onSwitchToSignUp={() => { setLoginOpen(false); setSignUpOpen(true); }} />
|
||||
<SignUpDialog open={signUpOpen} onOpenChange={setSignUpOpen} onSwitchToLogin={() => { setSignUpOpen(false); setLoginOpen(true); }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user