"use client"; import Link from "next/link"; import { useEffect, useState } from "react"; import { AppShell } from "../../components/app-shell"; type ApiResponse = { data: T; meta: { timestamp: string; version: "v1" }; error: null | { message: string; code?: string }; }; type TransactionRow = { id: string; name: string; amount: string; category: string; note: string; status: string; hidden?: boolean; date: string; }; type Account = { id: string; institutionName: string; accountType: string; mask?: string | null; }; export default function TransactionsPage() { const [rows, setRows] = useState([]); const [status, setStatus] = useState("Loading transactions..."); const [summary, setSummary] = useState<{ total: string; count: number; income?: string; expense?: string; net?: string; } | null>(null); const [datePreset, setDatePreset] = useState("this_month"); const [showFilters, setShowFilters] = useState(false); const [userId, setUserId] = useState(null); const [accounts, setAccounts] = useState([]); const [autoSync, setAutoSync] = useState(true); const [isSyncing, setIsSyncing] = useState(false); const [showManual, setShowManual] = useState(false); const [manualForm, setManualForm] = useState({ accountId: "", date: new Date().toISOString().slice(0, 10), description: "", amount: "", category: "", note: "" }); const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({ category: "", note: "", hidden: false }); const [filters, setFilters] = useState({ startDate: "", endDate: "", minAmount: "", maxAmount: "", category: "", source: "", search: "", includeHidden: false }); const applyPreset = (preset: string) => { setDatePreset(preset); if (preset === "custom") { return; } const now = new Date(); const end = new Date(now.getFullYear(), now.getMonth(), now.getDate()); let start = new Date(end); if (preset === "this_month") { start = new Date(end.getFullYear(), end.getMonth(), 1); } else if (preset === "last_month") { start = new Date(end.getFullYear(), end.getMonth() - 1, 1); end.setDate(0); } else if (preset === "last_6_months") { start = new Date(end.getFullYear(), end.getMonth() - 5, 1); } else if (preset === "last_year") { start = new Date(end.getFullYear() - 1, 0, 1); end.setMonth(11, 31); } const format = (value: Date) => value.toISOString().slice(0, 10); setFilters((prev) => ({ ...prev, startDate: format(start), endDate: format(end) })); }; const buildQuery = () => { const params = new URLSearchParams(); if (userId) { params.set("user_id", userId); } if (filters.startDate) { params.set("start_date", filters.startDate); } if (filters.endDate) { params.set("end_date", filters.endDate); } if (filters.minAmount) { params.set("min_amount", filters.minAmount); } if (filters.maxAmount) { params.set("max_amount", filters.maxAmount); } if (filters.category) { params.set("category", filters.category); } if (filters.source) { params.set("source", filters.source); } if (filters.search) { params.set("search", filters.search); } if (filters.includeHidden) { params.set("include_hidden", "true"); } return params.toString() ? `?${params.toString()}` : ""; }; const load = async () => { const query = buildQuery(); try { const res = await fetch(`/api/transactions${query}`); const payload = (await res.json()) as ApiResponse; if (!res.ok || payload.error) { setStatus(payload.error?.message ?? "Unable to load transactions."); return; } setRows(payload.data); console.log(payload.data); setStatus(payload.data.length ? "" : "No transactions yet."); } catch { setStatus("Unable to load transactions."); } }; const loadAccounts = async () => { const userId = localStorage.getItem("ledgerone_user_id"); if (!userId) { return; } const res = await fetch(`/api/accounts?user_id=${encodeURIComponent(userId)}`); if (!res.ok) { return; } const payload = (await res.json()) as ApiResponse; if (!payload.error) { setAccounts(payload.data); } }; const loadSummary = async () => { const query = buildQuery(); const res = await fetch(`/api/transactions/summary${query}`); if (!res.ok) { return; } const payload = (await res.json()) as ApiResponse<{ total: string; count: number }>; if (!payload.error) { setSummary(payload.data); } }; useEffect(() => { setUserId(localStorage.getItem("ledgerone_user_id")); applyPreset("this_month"); load(); loadSummary(); loadAccounts(); }, []); useEffect(() => { if (!autoSync) { return; } const id = setInterval(() => { onSync(); }, 5 * 60 * 1000); return () => clearInterval(id); }, [autoSync, filters.startDate, filters.endDate]); const onSync = async () => { const userId = localStorage.getItem("ledgerone_user_id"); if (!userId) { setStatus("Missing user id."); return; } if (isSyncing) { return; } setIsSyncing(true); setStatus("Syncing transactions..."); const res = await fetch("/api/transactions/sync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userId, startDate: filters.startDate || undefined, endDate: filters.endDate || undefined }) }); const payload = await res.json(); if (!res.ok || payload.error) { setStatus(payload.error?.message ?? "Sync failed."); setIsSyncing(false); return; } setStatus("Sync complete."); await load(); await loadSummary(); setIsSyncing(false); }; const formatAmount = (value: string) => { const numeric = Number.parseFloat(value.replace(/[^0-9.-]/g, "")); if (Number.isNaN(numeric)) { return { display: value, tone: "text-foreground" }; } return { display: numeric < 0 ? `-$${Math.abs(numeric).toFixed(2)}` : `$${numeric.toFixed(2)}`, tone: numeric < 0 ? "text-foreground" : "text-primary font-bold" }; }; const onManualCreate = async (event: React.FormEvent) => { event.preventDefault(); const userId = localStorage.getItem("ledgerone_user_id"); if (!userId) { setStatus("Missing user id."); return; } const amount = Number.parseFloat(manualForm.amount); if (Number.isNaN(amount)) { setStatus("Invalid amount."); return; } const payload = { userId, accountId: manualForm.accountId || undefined, date: manualForm.date, description: manualForm.description, amount, category: manualForm.category || undefined, note: manualForm.note || undefined }; setStatus("Saving manual transaction..."); const res = await fetch("/api/transactions/manual", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) }); const data = (await res.json()) as ApiResponse; if (!res.ok || data.error) { setStatus(data.error?.message ?? "Unable to save transaction."); return; } setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "" })); setShowManual(false); setStatus("Manual transaction saved."); await load(); await loadSummary(); }; const startEdit = (row: TransactionRow) => { setEditingId(row.id); setEditForm({ category: row.category === "Uncategorized" ? "" : row.category, note: row.note, hidden: Boolean(row.hidden) }); }; const saveEdit = async () => { if (!editingId) { return; } setStatus("Saving edits..."); const res = await fetch(`/api/transactions/${editingId}/derived`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ userCategory: editForm.category || undefined, userNotes: editForm.note || undefined, isHidden: editForm.hidden }) }); const data = (await res.json()) as ApiResponse; if (!res.ok || data.error) { setStatus(data.error?.message ?? "Unable to save edits."); return; } setEditingId(null); setStatus("Transaction updated."); await load(); await loadSummary(); }; return (
{datePreset === "custom" ? "Custom range" : datePreset.replace(/_/g, " ")} All accounts Export CSV
{showFilters ? (
setFilters((prev) => ({ ...prev, startDate: event.target.value })) } className="mt-2 w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" disabled={datePreset !== "custom"} />
setFilters((prev) => ({ ...prev, endDate: event.target.value })) } className="mt-2 w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" disabled={datePreset !== "custom"} />
setFilters((prev) => ({ ...prev, search: event.target.value })) } placeholder="Search description" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" /> setFilters((prev) => ({ ...prev, category: event.target.value })) } placeholder="Category contains" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" /> setFilters((prev) => ({ ...prev, minAmount: event.target.value })) } placeholder="Min amount" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" /> setFilters((prev) => ({ ...prev, maxAmount: event.target.value })) } placeholder="Max amount" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" />
setFilters((prev) => ({ ...prev, source: event.target.value })) } placeholder="Source contains" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" />
) : null} {showManual ? (

Manual transaction

setManualForm((prev) => ({ ...prev, date: event.target.value })) } className="mt-1 block w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" required />
setManualForm((prev) => ({ ...prev, amount: event.target.value })) } placeholder="0.00" className="mt-1 block w-full rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" required />
setManualForm((prev) => ({ ...prev, description: event.target.value })) } placeholder="Description" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" required /> setManualForm((prev) => ({ ...prev, category: event.target.value })) } placeholder="Category (optional)" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" /> setManualForm((prev) => ({ ...prev, note: event.target.value })) } placeholder="Note (optional)" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" />
) : null}

30-day total

{summary ? `$${summary.total}` : "$0.00"}

{summary ? `${summary.count} transactions` : "No data yet."}

Income vs expense

Income {summary?.income ? `$${summary.income}` : "$0.00"}
Expense {summary?.expense ? `$${summary.expense}` : "$0.00"}
Net {summary?.net ? `$${summary.net}` : "$0.00"}

Auto sync

{autoSync ? "Running every 5 minutes." : "Paused."}

{status ?

{status}

: null}
{editingId ? (

Edit transaction

setEditForm((prev) => ({ ...prev, category: event.target.value })) } placeholder="Category" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" /> setEditForm((prev) => ({ ...prev, note: event.target.value })) } placeholder="Note" className="rounded-md border-border bg-background text-foreground shadow-sm focus:border-primary focus:ring-primary sm:text-sm" />
) : null} {rows.length ? (
{rows.map((row) => ( ))}
Date Description Amount Category Status Actions
{row.date}
{row.name}
{row.note || "No notes"}
{row.hidden ? (
Hidden
) : null}
{formatAmount(row.amount).display} {row.category} {row.status}
) : null}
); }