Add chase accounts + chase csv upload
This commit is contained in:
45
backend/alembic/versions/002_add_finance_accounts.py
Normal file
45
backend/alembic/versions/002_add_finance_accounts.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Add finance accounts
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2026-03-09
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "002"
|
||||
down_revision: Union[str, None] = "001"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"finance_accounts",
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("name", sa.Text, unique=True, nullable=False),
|
||||
sa.Column("account_type", sa.Text, nullable=False),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime,
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
|
||||
op.add_column(
|
||||
"finance_transactions",
|
||||
sa.Column(
|
||||
"account_id",
|
||||
sa.Integer,
|
||||
sa.ForeignKey("finance_accounts.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("finance_transactions", "account_id")
|
||||
op.drop_table("finance_accounts")
|
||||
@@ -1,12 +1,13 @@
|
||||
from app.models.weight import WeightEntry
|
||||
from app.models.workout import Exercise, Workout, WorkoutSet
|
||||
from app.models.finance import FinanceTransaction, FinanceSnapshot
|
||||
from app.models.finance import FinanceAccount, FinanceTransaction, FinanceSnapshot
|
||||
|
||||
__all__ = [
|
||||
"WeightEntry",
|
||||
"Exercise",
|
||||
"Workout",
|
||||
"WorkoutSet",
|
||||
"FinanceAccount",
|
||||
"FinanceTransaction",
|
||||
"FinanceSnapshot",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, Numeric, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy import Date, ForeignKey, Numeric, Text, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class FinanceAccount(Base):
|
||||
__tablename__ = "finance_accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
name: Mapped[str] = mapped_column(Text, unique=True)
|
||||
account_type: Mapped[str] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
transactions: Mapped[list["FinanceTransaction"]] = relationship(
|
||||
back_populates="account"
|
||||
)
|
||||
|
||||
|
||||
class FinanceTransaction(Base):
|
||||
__tablename__ = "finance_transactions"
|
||||
|
||||
@@ -14,8 +27,15 @@ class FinanceTransaction(Base):
|
||||
amount: Mapped[float] = mapped_column(Numeric(12, 2))
|
||||
category: Mapped[str] = mapped_column(Text)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("finance_accounts.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(server_default=func.now())
|
||||
|
||||
account: Mapped["FinanceAccount | None"] = relationship(
|
||||
back_populates="transactions"
|
||||
)
|
||||
|
||||
|
||||
class FinanceSnapshot(Base):
|
||||
__tablename__ = "finance_snapshots"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import get_current_user
|
||||
from app.database import get_db
|
||||
from app.models.finance import FinanceSnapshot, FinanceTransaction
|
||||
from app.models.finance import FinanceAccount, FinanceSnapshot, FinanceTransaction
|
||||
from app.services.chase_csv import parse_chase_csv
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/finances", tags=["finances"], dependencies=[Depends(get_current_user)]
|
||||
@@ -17,11 +18,22 @@ router = APIRouter(
|
||||
# --- Schemas ---
|
||||
|
||||
|
||||
class AccountCreate(BaseModel):
|
||||
name: str
|
||||
account_type: str
|
||||
|
||||
|
||||
class AccountRead(AccountCreate):
|
||||
id: int
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class TransactionCreate(BaseModel):
|
||||
date: date
|
||||
amount: float
|
||||
category: str
|
||||
description: str | None = None
|
||||
account_id: int | None = None
|
||||
|
||||
|
||||
class TransactionRead(TransactionCreate):
|
||||
@@ -45,6 +57,73 @@ class CategorySummary(BaseModel):
|
||||
total: float
|
||||
|
||||
|
||||
class AccountSpendingSummary(BaseModel):
|
||||
account_id: int | None
|
||||
account_name: str | None
|
||||
total: float
|
||||
|
||||
|
||||
class CSVUploadResult(BaseModel):
|
||||
imported: int
|
||||
skipped: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
# --- Account endpoints ---
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=list[AccountRead])
|
||||
def list_accounts(db: Session = Depends(get_db)):
|
||||
return db.scalars(select(FinanceAccount).order_by(FinanceAccount.name)).all()
|
||||
|
||||
|
||||
@router.post("/accounts", response_model=AccountRead, status_code=201)
|
||||
def create_account(body: AccountCreate, db: Session = Depends(get_db)):
|
||||
acct = FinanceAccount(**body.model_dump())
|
||||
db.add(acct)
|
||||
db.commit()
|
||||
db.refresh(acct)
|
||||
return acct
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}", status_code=204)
|
||||
def delete_account(account_id: int, db: Session = Depends(get_db)):
|
||||
acct = db.get(FinanceAccount, account_id)
|
||||
if not acct:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
db.delete(acct)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/upload-csv", response_model=CSVUploadResult)
|
||||
async def upload_csv(
|
||||
account_id: int,
|
||||
file: UploadFile,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
acct = db.get(FinanceAccount, account_id)
|
||||
if not acct:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
|
||||
content = (await file.read()).decode("utf-8")
|
||||
rows, errors = parse_chase_csv(content, acct.account_type)
|
||||
|
||||
transactions = [
|
||||
FinanceTransaction(
|
||||
date=r["date"],
|
||||
amount=r["amount"],
|
||||
category=r["category"],
|
||||
description=r["description"],
|
||||
account_id=account_id,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
db.add_all(transactions)
|
||||
db.commit()
|
||||
|
||||
return CSVUploadResult(imported=len(transactions), skipped=0, errors=errors)
|
||||
|
||||
|
||||
# --- Transaction endpoints ---
|
||||
|
||||
|
||||
@@ -53,6 +132,7 @@ def list_transactions(
|
||||
from_date: date | None = Query(None, alias="from"),
|
||||
to_date: date | None = Query(None, alias="to"),
|
||||
category: str | None = None,
|
||||
account_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
q = select(FinanceTransaction).order_by(FinanceTransaction.date.desc())
|
||||
@@ -62,6 +142,8 @@ def list_transactions(
|
||||
q = q.where(FinanceTransaction.date <= to_date)
|
||||
if category:
|
||||
q = q.where(FinanceTransaction.category == category)
|
||||
if account_id is not None:
|
||||
q = q.where(FinanceTransaction.account_id == account_id)
|
||||
return db.scalars(q).all()
|
||||
|
||||
|
||||
@@ -116,11 +198,40 @@ def create_snapshot(body: SnapshotCreate, db: Session = Depends(get_db)):
|
||||
return snap
|
||||
|
||||
|
||||
# --- Summary endpoint ---
|
||||
# --- Summary endpoints ---
|
||||
|
||||
|
||||
@router.get("/summary", response_model=list[CategorySummary])
|
||||
def spending_summary(
|
||||
period: str = Query("month", pattern="^(month|year)$"),
|
||||
account_id: int | None = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
today = date.today()
|
||||
if period == "month":
|
||||
start = today.replace(day=1)
|
||||
else:
|
||||
start = today.replace(month=1, day=1)
|
||||
|
||||
q = (
|
||||
select(
|
||||
FinanceTransaction.category,
|
||||
func.sum(FinanceTransaction.amount).label("total"),
|
||||
)
|
||||
.where(FinanceTransaction.date >= start)
|
||||
.where(FinanceTransaction.amount < 0)
|
||||
.group_by(FinanceTransaction.category)
|
||||
.order_by(func.sum(FinanceTransaction.amount))
|
||||
)
|
||||
if account_id is not None:
|
||||
q = q.where(FinanceTransaction.account_id == account_id)
|
||||
|
||||
rows = db.execute(q).all()
|
||||
return [CategorySummary(category=r.category, total=float(r.total)) for r in rows]
|
||||
|
||||
|
||||
@router.get("/summary/by-account", response_model=list[AccountSpendingSummary])
|
||||
def spending_by_account(
|
||||
period: str = Query("month", pattern="^(month|year)$"),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -132,12 +243,21 @@ def spending_summary(
|
||||
|
||||
rows = db.execute(
|
||||
select(
|
||||
FinanceTransaction.category,
|
||||
FinanceTransaction.account_id,
|
||||
FinanceAccount.name.label("account_name"),
|
||||
func.sum(FinanceTransaction.amount).label("total"),
|
||||
)
|
||||
.outerjoin(FinanceAccount, FinanceTransaction.account_id == FinanceAccount.id)
|
||||
.where(FinanceTransaction.date >= start)
|
||||
.where(FinanceTransaction.amount < 0)
|
||||
.group_by(FinanceTransaction.category)
|
||||
.group_by(FinanceTransaction.account_id, FinanceAccount.name)
|
||||
.order_by(func.sum(FinanceTransaction.amount))
|
||||
).all()
|
||||
return [CategorySummary(category=r.category, total=float(r.total)) for r in rows]
|
||||
return [
|
||||
AccountSpendingSummary(
|
||||
account_id=r.account_id,
|
||||
account_name=r.account_name or "Untagged",
|
||||
total=float(r.total),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
69
backend/app/services/chase_csv.py
Normal file
69
backend/app/services/chase_csv.py
Normal file
@@ -0,0 +1,69 @@
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def parse_chase_csv(content: str, account_type: str) -> tuple[list[dict], list[str]]:
|
||||
"""Parse a Chase CSV file into transaction dicts.
|
||||
|
||||
Returns (rows, errors) where rows is a list of dicts with keys:
|
||||
date, amount, category, description.
|
||||
"""
|
||||
content = content.lstrip("\ufeff")
|
||||
reader = csv.DictReader(io.StringIO(content))
|
||||
|
||||
if not reader.fieldnames:
|
||||
return [], ["Empty CSV file"]
|
||||
|
||||
first_field = reader.fieldnames[0].strip()
|
||||
if first_field == "Details":
|
||||
return _parse_checking(reader)
|
||||
elif first_field == "Transaction Date":
|
||||
return _parse_credit_card(reader)
|
||||
else:
|
||||
return [], [f"Unrecognized CSV format (first column: '{first_field}')"]
|
||||
|
||||
|
||||
def _parse_checking(reader: csv.DictReader) -> tuple[list[dict], list[str]]:
|
||||
"""Chase checking/savings: Details,Posting Date,Description,Amount,Type,Balance,Check or Slip #"""
|
||||
rows = []
|
||||
errors = []
|
||||
for i, row in enumerate(reader, start=2):
|
||||
try:
|
||||
date_str = row.get("Posting Date", "").strip()
|
||||
description = row.get("Description", "").strip()
|
||||
amount_str = row.get("Amount", "").strip()
|
||||
if not date_str or not amount_str:
|
||||
continue
|
||||
rows.append({
|
||||
"date": datetime.strptime(date_str, "%m/%d/%Y").date(),
|
||||
"amount": float(amount_str),
|
||||
"category": "Uncategorized",
|
||||
"description": description,
|
||||
})
|
||||
except (ValueError, KeyError) as e:
|
||||
errors.append(f"Row {i}: {e}")
|
||||
return rows, errors
|
||||
|
||||
|
||||
def _parse_credit_card(reader: csv.DictReader) -> tuple[list[dict], list[str]]:
|
||||
"""Chase credit card: Transaction Date,Post Date,Description,Category,Type,Amount,Memo"""
|
||||
rows = []
|
||||
errors = []
|
||||
for i, row in enumerate(reader, start=2):
|
||||
try:
|
||||
date_str = row.get("Transaction Date", "").strip()
|
||||
description = row.get("Description", "").strip()
|
||||
amount_str = row.get("Amount", "").strip()
|
||||
category = row.get("Category", "").strip() or "Uncategorized"
|
||||
if not date_str or not amount_str:
|
||||
continue
|
||||
rows.append({
|
||||
"date": datetime.strptime(date_str, "%m/%d/%Y").date(),
|
||||
"amount": float(amount_str),
|
||||
"category": category,
|
||||
"description": description,
|
||||
})
|
||||
except (ValueError, KeyError) as e:
|
||||
errors.append(f"Row {i}: {e}")
|
||||
return rows, errors
|
||||
@@ -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