-
Notifications
You must be signed in to change notification settings - Fork 130
feat: add multi-account financial overview dashboard #609
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
GPradaT
wants to merge
5
commits into
rohitdash08:main
Choose a base branch
from
GPradaT:feat/multi-account-dashboard
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
389a9ff
feat: add multi-account financial overview dashboard
7b0ad04
fix: address Copilot review - url_prefix, jwt identity int cast, remo…
4d2219e
feat: add frontend UI, API client, OpenAPI docs for multi-account das…
59fd8d2
fix: address remaining Copilot review — input normalization, error se…
d98c929
fix: resolve CI failures (black formatting + Navbar test)
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { api } from './client'; | ||
|
|
||
| export type AccountType = 'checking' | 'savings' | 'credit' | 'cash' | 'investment'; | ||
|
|
||
| export type Account = { | ||
| id: number; | ||
| name: string; | ||
| account_type: AccountType; | ||
| currency: string; | ||
| balance: number; | ||
| is_active: boolean; | ||
| color: string | null; | ||
| created_at: string; | ||
| }; | ||
|
|
||
| export type AccountCreate = { | ||
| name: string; | ||
| account_type: AccountType; | ||
| currency?: string; | ||
| balance?: number; | ||
| color?: string; | ||
| }; | ||
|
|
||
| export type AccountOverview = { | ||
| total_balance: number; | ||
| net_worth: number; | ||
| account_count: number; | ||
| by_type: Record<string, { count: number; total: number }>; | ||
| accounts: Account[]; | ||
| }; | ||
|
|
||
| export async function listAccounts(includeInactive = false): Promise<Account[]> { | ||
| const qs = includeInactive ? '?include_inactive=true' : ''; | ||
| return api<Account[]>(`/accounts${qs}`); | ||
| } | ||
|
|
||
| export async function createAccount(payload: AccountCreate): Promise<Account> { | ||
| return api<Account>('/accounts', { method: 'POST', body: payload }); | ||
| } | ||
|
|
||
| export async function getAccount(id: number): Promise<Account> { | ||
| return api<Account>(`/accounts/${id}`); | ||
| } | ||
|
|
||
| export async function updateAccount(id: number, payload: Partial<AccountCreate>): Promise<Account> { | ||
| return api<Account>(`/accounts/${id}`, { method: 'PATCH', body: payload }); | ||
| } | ||
|
|
||
| export async function deleteAccount(id: number): Promise<{ message: string }> { | ||
| return api(`/accounts/${id}`, { method: 'DELETE' }); | ||
| } | ||
|
|
||
| export async function getOverview(): Promise<AccountOverview> { | ||
| return api<AccountOverview>('/accounts/overview'); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| import { useState, useEffect, useCallback } from 'react'; | ||
| import { FinancialCard, FinancialCardContent, FinancialCardHeader, FinancialCardTitle } from '@/components/ui/financial-card'; | ||
| import { Button } from '@/components/ui/button'; | ||
| import { Badge } from '@/components/ui/badge'; | ||
| import { Wallet, Plus, Building2, PiggyBank, CreditCard, Banknote, TrendingUp, Trash2 } from 'lucide-react'; | ||
| import { useToast } from '@/hooks/use-toast'; | ||
| import { listAccounts, createAccount, deleteAccount, getOverview, type Account, type AccountOverview, type AccountType } from '@/api/accounts'; | ||
| import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; | ||
| import { formatMoney } from '@/lib/currency'; | ||
|
|
||
| const ACCOUNT_TYPE_ICONS: Record<AccountType, typeof Wallet> = { | ||
| checking: Building2, | ||
| savings: PiggyBank, | ||
| credit: CreditCard, | ||
| cash: Banknote, | ||
| investment: TrendingUp, | ||
| }; | ||
|
|
||
| const ACCOUNT_TYPE_LABELS: Record<AccountType, string> = { | ||
| checking: 'Checking', | ||
| savings: 'Savings', | ||
| credit: 'Credit Card', | ||
| cash: 'Cash', | ||
| investment: 'Investment', | ||
| }; | ||
|
|
||
| export default function Accounts() { | ||
| const { toast } = useToast(); | ||
| const [accounts, setAccounts] = useState<Account[]>([]); | ||
| const [overview, setOverview] = useState<AccountOverview | null>(null); | ||
| const [loading, setLoading] = useState(true); | ||
| const [dialogOpen, setDialogOpen] = useState(false); | ||
| const [newAccount, setNewAccount] = useState<{ name: string; account_type: AccountType; balance: string; currency: string }>({ | ||
| name: '', account_type: 'checking', balance: '0', currency: 'INR', | ||
| }); | ||
|
|
||
| const loadData = useCallback(async () => { | ||
| try { | ||
| const [accs, ov] = await Promise.all([listAccounts(), getOverview()]); | ||
| setAccounts(accs); | ||
| setOverview(ov); | ||
| } catch (e: unknown) { | ||
| toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to load accounts', variant: 'destructive' }); | ||
| } finally { | ||
| setLoading(false); | ||
| } | ||
| }, [toast]); | ||
|
|
||
| useEffect(() => { loadData(); }, [loadData]); | ||
|
|
||
| const handleCreate = async () => { | ||
| try { | ||
| await createAccount({ | ||
| name: newAccount.name, | ||
| account_type: newAccount.account_type, | ||
| balance: parseFloat(newAccount.balance) || 0, | ||
| currency: newAccount.currency, | ||
| }); | ||
| toast({ title: 'Account created' }); | ||
| setDialogOpen(false); | ||
| setNewAccount({ name: '', account_type: 'checking', balance: '0', currency: 'INR' }); | ||
| loadData(); | ||
| } catch (e: unknown) { | ||
| toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to create account', variant: 'destructive' }); | ||
| } | ||
| }; | ||
|
|
||
| const handleDelete = async (id: number) => { | ||
| try { | ||
| await deleteAccount(id); | ||
| toast({ title: 'Account deactivated' }); | ||
| loadData(); | ||
| } catch (e: unknown) { | ||
| toast({ title: 'Error', description: e instanceof Error ? e.message : 'Failed to deactivate', variant: 'destructive' }); | ||
| } | ||
| }; | ||
|
|
||
| if (loading) return <div className="flex justify-center p-8">Loading...</div>; | ||
|
|
||
| return ( | ||
| <div className="space-y-6"> | ||
| <div className="flex items-center justify-between"> | ||
| <div> | ||
| <h1 className="text-2xl font-bold">Accounts</h1> | ||
| <p className="text-muted-foreground">Manage your financial accounts</p> | ||
| </div> | ||
| <Dialog open={dialogOpen} onOpenChange={setDialogOpen}> | ||
| <DialogTrigger asChild> | ||
| <Button><Plus className="mr-2 h-4 w-4" /> Add Account</Button> | ||
| </DialogTrigger> | ||
| <DialogContent> | ||
| <DialogHeader> | ||
| <DialogTitle>Add Account</DialogTitle> | ||
| <DialogDescription>Add a new financial account to track.</DialogDescription> | ||
| </DialogHeader> | ||
| <div className="space-y-4"> | ||
| <input className="w-full rounded-md border px-3 py-2" placeholder="Account name" value={newAccount.name} onChange={e => setNewAccount(p => ({ ...p, name: e.target.value }))} /> | ||
| <select className="w-full rounded-md border px-3 py-2" value={newAccount.account_type} onChange={e => setNewAccount(p => ({ ...p, account_type: e.target.value as AccountType }))}> | ||
| {Object.entries(ACCOUNT_TYPE_LABELS).map(([k, v]) => (<option key={k} value={k}>{v}</option>))} | ||
| </select> | ||
| <input className="w-full rounded-md border px-3 py-2" type="number" placeholder="Balance" value={newAccount.balance} onChange={e => setNewAccount(p => ({ ...p, balance: e.target.value }))} /> | ||
| <input className="w-full rounded-md border px-3 py-2" placeholder="Currency (e.g. INR)" value={newAccount.currency} onChange={e => setNewAccount(p => ({ ...p, currency: e.target.value }))} /> | ||
| </div> | ||
| <DialogFooter> | ||
| <Button onClick={handleCreate} disabled={!newAccount.name}>Create</Button> | ||
| </DialogFooter> | ||
| </DialogContent> | ||
| </Dialog> | ||
| </div> | ||
|
|
||
| {/* Overview Cards */} | ||
| {overview && ( | ||
| <div className="grid gap-4 md:grid-cols-3"> | ||
| <FinancialCard> | ||
| <FinancialCardHeader><FinancialCardTitle>Total Balance</FinancialCardTitle></FinancialCardHeader> | ||
| <FinancialCardContent><p className="text-2xl font-bold">{formatMoney(overview.total_balance)}</p></FinancialCardContent> | ||
| </FinancialCard> | ||
| <FinancialCard> | ||
| <FinancialCardHeader><FinancialCardTitle>Net Worth</FinancialCardTitle></FinancialCardHeader> | ||
| <FinancialCardContent><p className="text-2xl font-bold">{formatMoney(overview.net_worth)}</p></FinancialCardContent> | ||
| </FinancialCard> | ||
| <FinancialCard> | ||
| <FinancialCardHeader><FinancialCardTitle>Active Accounts</FinancialCardTitle></FinancialCardHeader> | ||
| <FinancialCardContent><p className="text-2xl font-bold">{overview.account_count}</p></FinancialCardContent> | ||
| </FinancialCard> | ||
| </div> | ||
| )} | ||
|
|
||
| {/* Account List */} | ||
| {accounts.length === 0 ? ( | ||
| <FinancialCard> | ||
| <FinancialCardContent className="flex flex-col items-center justify-center py-12"> | ||
| <Wallet className="h-12 w-12 text-muted-foreground mb-4" /> | ||
| <p className="text-muted-foreground">No accounts yet. Add your first account to get started.</p> | ||
| </FinancialCardContent> | ||
| </FinancialCard> | ||
| ) : ( | ||
| <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> | ||
| {accounts.map(account => { | ||
| const Icon = ACCOUNT_TYPE_ICONS[account.account_type] || Wallet; | ||
| return ( | ||
| <FinancialCard key={account.id}> | ||
| <FinancialCardHeader className="flex flex-row items-center justify-between pb-2"> | ||
| <div className="flex items-center gap-2"> | ||
| <Icon className="h-5 w-5 text-muted-foreground" /> | ||
| <FinancialCardTitle className="text-base">{account.name}</FinancialCardTitle> | ||
| </div> | ||
| <div className="flex items-center gap-2"> | ||
| <Badge variant="outline">{ACCOUNT_TYPE_LABELS[account.account_type]}</Badge> | ||
| <Button variant="ghost" size="icon" onClick={() => handleDelete(account.id)}> | ||
| <Trash2 className="h-4 w-4 text-destructive" /> | ||
| </Button> | ||
| </div> | ||
| </FinancialCardHeader> | ||
| <FinancialCardContent> | ||
| <p className="text-2xl font-bold">{formatMoney(account.balance, account.currency)}</p> | ||
| <p className="text-xs text-muted-foreground mt-1">{account.currency}</p> | ||
| </FinancialCardContent> | ||
| </FinancialCard> | ||
| ); | ||
| })} | ||
| </div> | ||
| )} | ||
| </div> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new
financial_accountstable is added toschema.sql, but existing Postgres deployments that rely on_ensure_schema_compatibility()won't get this table created automatically (and will 500 when hitting/accounts/*). If this repo’s intended upgrade path is to apply compatibility ALTERs in-app, consider adding aCREATE TABLE IF NOT EXISTS financial_accounts ...there as well, or otherwise document/run a migration step for upgrades.