Add chase accounts + chase csv upload

This commit is contained in:
YOUNG
2026-03-09 16:05:38 -05:00
parent ab85502415
commit 4802d9a6d3
9 changed files with 567 additions and 18 deletions

View File

@@ -53,4 +53,26 @@ export const api = {
put: <T>(path: string, data: unknown) =>
request<T>(path, { method: "PUT", body: JSON.stringify(data) }),
delete: <T>(path: string) => request<T>(path, { method: "DELETE" }),
upload: async <T>(path: string, file: File): Promise<T> => {
const formData = new FormData();
formData.append("file", file);
const token = getToken();
const headers: Record<string, string> = {};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers,
body: formData,
});
if (res.status === 401) {
clearToken();
window.location.href = "/login";
throw new Error("Unauthorized");
}
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.detail || `Request failed: ${res.status}`);
}
return res.json();
},
};

View File

@@ -29,12 +29,31 @@ export interface Workout {
sets: WorkoutSet[];
}
export interface FinanceAccount {
id: number;
name: string;
account_type: string;
}
export interface Transaction {
id: number;
date: string;
amount: number;
category: string;
description: string | null;
account_id: number | null;
}
export interface CSVUploadResult {
imported: number;
skipped: number;
errors: string[];
}
export interface AccountSpendingSummary {
account_id: number | null;
account_name: string | null;
total: number;
}
export interface Snapshot {

View File

@@ -10,10 +10,19 @@ import {
XAxis,
YAxis,
CartesianGrid,
BarChart,
Bar,
} from "recharts";
import { Trash2 } from "lucide-react";
import { Trash2, Upload } from "lucide-react";
import { api } from "@/api/client";
import type { Transaction, Snapshot, CategorySummary } from "@/api/types";
import type {
Transaction,
Snapshot,
CategorySummary,
FinanceAccount,
CSVUploadResult,
AccountSpendingSummary,
} from "@/api/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -34,28 +43,47 @@ export default function Finances() {
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [snapshots, setSnapshots] = useState<Snapshot[]>([]);
const [summary, setSummary] = useState<CategorySummary[]>([]);
const [tab, setTab] = useState<"transactions" | "networth">("transactions");
const [accounts, setAccounts] = useState<FinanceAccount[]>([]);
const [accountSummary, setAccountSummary] = useState<AccountSpendingSummary[]>([]);
const [tab, setTab] = useState<"transactions" | "networth" | "accounts">("transactions");
const [filterAccountId, setFilterAccountId] = useState<string>("");
// Transaction form
const [tDate, setTDate] = useState(today());
const [tAmount, setTAmount] = useState("");
const [tCategory, setTCategory] = useState("");
const [tDesc, setTDesc] = useState("");
const [tAccountId, setTAccountId] = useState<string>("");
// Snapshot form
const [sDate, setSDate] = useState(today());
const [sNetWorth, setSNetWorth] = useState("");
const [sNotes, setSNotes] = useState("");
// Account form
const [acctName, setAcctName] = useState("");
const [acctType, setAcctType] = useState("checking");
// CSV upload
const [uploadAccountId, setUploadAccountId] = useState<number | null>(null);
const [csvFile, setCsvFile] = useState<File | null>(null);
const [uploadResult, setUploadResult] = useState<CSVUploadResult | null>(null);
const [uploading, setUploading] = useState(false);
const load = () => {
api.get<Transaction[]>("/finances/transactions").then(setTransactions);
const acctFilter = filterAccountId ? `&account_id=${filterAccountId}` : "";
api.get<Transaction[]>(`/finances/transactions?${acctFilter}`).then(setTransactions);
api.get<Snapshot[]>("/finances/snapshots").then(setSnapshots);
api.get<CategorySummary[]>("/finances/summary?period=month").then(setSummary);
api.get<CategorySummary[]>(`/finances/summary?period=month${acctFilter}`).then(setSummary);
api.get<FinanceAccount[]>("/finances/accounts").then(setAccounts);
api.get<AccountSpendingSummary[]>("/finances/summary/by-account?period=month").then(setAccountSummary);
};
useEffect(() => {
load();
}, []);
}, [filterAccountId]);
const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a.name]));
const handleAddTransaction = async (e: React.FormEvent) => {
e.preventDefault();
@@ -64,6 +92,7 @@ export default function Finances() {
amount: parseFloat(tAmount),
category: tCategory,
description: tDesc || null,
account_id: tAccountId ? parseInt(tAccountId) : null,
});
setTAmount("");
setTCategory("");
@@ -88,11 +117,48 @@ export default function Finances() {
load();
};
const handleAddAccount = async (e: React.FormEvent) => {
e.preventDefault();
await api.post("/finances/accounts", { name: acctName, account_type: acctType });
setAcctName("");
setAcctType("checking");
load();
};
const handleDeleteAccount = async (id: number) => {
await api.delete(`/finances/accounts/${id}`);
load();
};
const handleUploadCSV = async () => {
if (!uploadAccountId || !csvFile) return;
setUploading(true);
setUploadResult(null);
try {
const result = await api.upload<CSVUploadResult>(
`/finances/accounts/${uploadAccountId}/upload-csv`,
csvFile
);
setUploadResult(result);
setCsvFile(null);
load();
} catch (err: any) {
setUploadResult({ imported: 0, skipped: 0, errors: [err.message] });
} finally {
setUploading(false);
}
};
const pieData = summary.map((s) => ({
name: s.category,
value: Math.abs(s.total),
}));
const acctBarData = accountSummary.map((s) => ({
name: s.account_name || "Untagged",
total: Math.abs(s.total),
}));
const nwData = [...snapshots].reverse();
return (
@@ -115,6 +181,13 @@ export default function Finances() {
>
Net Worth
</Button>
<Button
variant={tab === "accounts" ? "default" : "outline"}
size="sm"
onClick={() => setTab("accounts")}
>
Accounts
</Button>
</div>
{tab === "transactions" && (
@@ -143,12 +216,25 @@ export default function Finances() {
<Label>Description</Label>
<Input value={tDesc} onChange={(e) => setTDesc(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Account</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={tAccountId}
onChange={(e) => setTAccountId(e.target.value)}
>
<option value="">No Account</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
<Button type="submit" className="w-full">Save</Button>
</form>
</CardContent>
</Card>
{/* Category pie chart */}
{/* Charts */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Monthly Spending by Category</CardTitle>
@@ -179,10 +265,42 @@ export default function Finances() {
</Card>
</div>
{/* Transactions table */}
{/* Per-account spending bar chart */}
{acctBarData.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Monthly Spending by Account</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={acctBarData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="name" className="text-xs" />
<YAxis className="text-xs" />
<Tooltip formatter={(v: number) => `$${v.toFixed(2)}`} />
<Bar dataKey="total" fill="hsl(220 90% 56%)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{/* Account filter + Transactions table */}
<Card>
<CardHeader>
<CardTitle>Recent Transactions</CardTitle>
<div className="flex items-center justify-between">
<CardTitle>Recent Transactions</CardTitle>
<select
className="h-8 rounded-md border border-input bg-transparent px-2 text-sm"
value={filterAccountId}
onChange={(e) => setFilterAccountId(e.target.value)}
>
<option value="">All Accounts</option>
{accounts.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
@@ -193,6 +311,7 @@ export default function Finances() {
<th className="text-left py-2 px-2">Amount</th>
<th className="text-left py-2 px-2">Category</th>
<th className="text-left py-2 px-2">Description</th>
<th className="text-left py-2 px-2">Account</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
@@ -205,6 +324,9 @@ export default function Finances() {
</td>
<td className="py-2 px-2">{t.category}</td>
<td className="py-2 px-2 text-muted-foreground">{t.description || "—"}</td>
<td className="py-2 px-2 text-muted-foreground">
{t.account_id ? accountMap[t.account_id] || "—" : "—"}
</td>
<td className="py-2 px-2">
<Button variant="ghost" size="icon" onClick={() => handleDeleteTransaction(t.id)}>
<Trash2 className="h-4 w-4" />
@@ -269,6 +391,137 @@ export default function Finances() {
</Card>
</div>
)}
{tab === "accounts" && (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Create account form */}
<Card>
<CardHeader>
<CardTitle>Add Account</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleAddAccount} className="space-y-4">
<div className="space-y-2">
<Label>Account Name</Label>
<Input
value={acctName}
onChange={(e) => setAcctName(e.target.value)}
placeholder="e.g. Chase Checking"
required
/>
</div>
<div className="space-y-2">
<Label>Account Type</Label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={acctType}
onChange={(e) => setAcctType(e.target.value)}
>
<option value="checking">Checking</option>
<option value="savings">Savings</option>
<option value="credit_card">Credit Card</option>
</select>
</div>
<Button type="submit" className="w-full">Add Account</Button>
</form>
</CardContent>
</Card>
{/* Accounts list */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Your Accounts</CardTitle>
</CardHeader>
<CardContent>
{accounts.length > 0 ? (
<table className="w-full text-sm">
<thead>
<tr className="border-b">
<th className="text-left py-2 px-2">Name</th>
<th className="text-left py-2 px-2">Type</th>
<th className="py-2 px-2"></th>
</tr>
</thead>
<tbody>
{accounts.map((a) => (
<tr key={a.id} className="border-b">
<td className="py-2 px-2 font-medium">{a.name}</td>
<td className="py-2 px-2 text-muted-foreground">{a.account_type}</td>
<td className="py-2 px-2 flex gap-1 justify-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setUploadAccountId(a.id);
setUploadResult(null);
setCsvFile(null);
}}
>
<Upload className="h-3 w-3 mr-1" /> CSV
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDeleteAccount(a.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="text-muted-foreground text-sm">No accounts yet. Create one to start importing CSVs.</p>
)}
</CardContent>
</Card>
</div>
{/* CSV Upload section */}
{uploadAccountId && (
<Card>
<CardHeader>
<CardTitle>
Upload CSV to {accountMap[uploadAccountId] || "Account"}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Chase CSV File</Label>
<Input
type="file"
accept=".csv"
onChange={(e) => setCsvFile(e.target.files?.[0] || null)}
/>
</div>
<div className="flex gap-2">
<Button onClick={handleUploadCSV} disabled={!csvFile || uploading}>
{uploading ? "Uploading..." : "Upload & Import"}
</Button>
<Button variant="ghost" onClick={() => setUploadAccountId(null)}>
Cancel
</Button>
</div>
{uploadResult && (
<div className="rounded-md border p-3 text-sm space-y-1">
<p className="font-medium">
Imported {uploadResult.imported} transactions
</p>
{uploadResult.skipped > 0 && (
<p className="text-muted-foreground">Skipped: {uploadResult.skipped}</p>
)}
{uploadResult.errors.length > 0 && (
<div className="text-red-500">
{uploadResult.errors.map((err, i) => (
<p key={i}>{err}</p>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
)}
</div>
)}
</div>
);
}