Add chase accounts + chase csv upload
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user