"use client"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { AppShell } from "../../components/app-shell"; import { apiFetch } from "@/lib/api"; type TransactionRow = { viewRef: string; name?: string; description?: string; amount: string; category?: string | null; note?: string | null; attribution?: "mine" | "yours" | "ours"; split?: { mode: "none" | "equal" | "custom"; minePercent: number; yoursPercent: number; mineAmount: number; yoursAmount: number; }; status?: string; hidden?: boolean; commentCount?: number; date: string; }; type TransactionComment = { id: string; body: string; createdAt: string; updatedAt: string; author: { displayName: string; email?: string | null; }; }; type Account = { viewRef: string; institutionName: string; accountType: string; mask?: string | null; }; type ImportResult = { imported: number; skipped: number; total?: number; errors?: string[]; }; type ImportBatchResult = { totalFiles: number; processedFiles: number; failedFiles: number; imported: number; skipped: number; total: number; results: Array<{ fileName: string; imported: number; skipped: number; total: number; error?: string; }>; }; type CsvMapping = { date: string; description: string; amount: string; category?: string; notes?: string; amountMultiplier?: 1 | -1; }; type CsvPreview = { fileName: string; headerSignature: string; headers: string[]; rowCount: number; mapping: Partial; remembered: boolean; }; type CashflowRow = { month: string; income: string; expense: string; net: string; }; type MerchantInsight = { merchant: string; total: string; count: number; }; 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 [cashflow, setCashflow] = useState([]); const [merchants, setMerchants] = useState([]); const [datePreset, setDatePreset] = useState("this_month"); const [showFilters, setShowFilters] = useState(false); const [accounts, setAccounts] = useState([]); const [autoSync, setAutoSync] = useState(true); const [isSyncing, setIsSyncing] = useState(false); const [showManual, setShowManual] = useState(false); const [showImport, setShowImport] = useState(false); const [importStatus, setImportStatus] = useState(""); const [importLoading, setImportLoading] = useState(false); const [csvFiles, setCsvFiles] = useState([]); const [csvPreview, setCsvPreview] = useState(null); const [csvMapping, setCsvMapping] = useState({ date: "", description: "", amount: "", amountMultiplier: 1, }); const fileInputRef = useRef(null); const [manualForm, setManualForm] = useState({ accountRef: "", date: new Date().toISOString().slice(0, 10), description: "", amount: "", category: "", note: "", attribution: "mine" as "mine" | "yours" | "ours", splitMode: "none" as "none" | "equal" | "custom", splitMinePercent: "50", splitYoursPercent: "50", }); const [editingRef, setEditingRef] = useState(null); const [commentRef, setCommentRef] = useState(null); const [comments, setComments] = useState([]); const [commentDraft, setCommentDraft] = useState(""); const [commentStatus, setCommentStatus] = useState(""); const [editForm, setEditForm] = useState({ category: "", note: "", attribution: "mine" as "mine" | "yours" | "ours", splitMode: "none" as "none" | "equal" | "custom", splitMinePercent: "50", splitYoursPercent: "50", 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 fmt = (d: Date) => d.toISOString().slice(0, 10); setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) })); }; const buildQuery = () => { const params = new URLSearchParams(); 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(); const res = await apiFetch<{ transactions: TransactionRow[]; total: number }>(`/api/transactions${query}`); if (res.error) { setStatus(res.error.message ?? "Unable to load transactions."); return; } const txs = res.data?.transactions ?? []; setRows(txs); setStatus(txs.length ? "" : "No transactions yet."); }; const loadAccounts = async () => { const res = await apiFetch<{ accounts: Account[]; total: number }>("/api/accounts"); if (!res.error) setAccounts(res.data?.accounts ?? []); }; const loadSummary = async () => { const query = buildQuery(); const res = await apiFetch<{ total: string; count: number }>(`/api/transactions/summary${query}`); if (!res.error) setSummary(res.data); }; const loadInsights = async () => { const [cashflowRes, merchantsRes] = await Promise.all([ apiFetch("/api/transactions/cashflow?months=6"), apiFetch("/api/transactions/merchants?limit=6"), ]); if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []); if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []); }; useEffect(() => { applyPreset("this_month"); load(); loadSummary(); loadInsights(); loadAccounts(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!autoSync) return; const id = setInterval(() => { onSync(); }, 5 * 60 * 1000); return () => clearInterval(id); // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoSync, filters.startDate, filters.endDate]); const onSync = async () => { if (isSyncing) return; setIsSyncing(true); setStatus("Syncing transactions..."); const res = await apiFetch("/api/transactions/sync", { method: "POST", body: JSON.stringify({ startDate: filters.startDate || undefined, endDate: filters.endDate || undefined }), }); if (res.error) { setStatus(res.error.message ?? "Sync failed."); setIsSyncing(false); return; } setStatus("Sync complete."); await load(); await loadSummary(); await loadInsights(); setIsSyncing(false); }; const onPreviewCsv = async (files: File[]) => { const csvFiles = files.filter((file) => file.name.toLowerCase().endsWith(".csv")); if (!csvFiles.length) { setImportStatus("Select one or more CSV files."); return; } setCsvFiles(csvFiles); setCsvPreview(null); setImportLoading(true); setImportStatus(`Reading ${csvFiles[0].name}...`); const formData = new FormData(); formData.append("file", csvFiles[0]); try { const payload = await apiFetch("/api/transactions/import/preview", { method: "POST", body: formData, }); if (payload.error) { setImportStatus(payload.error?.message ?? "Preview failed."); setImportLoading(false); return; } const preview = payload.data; setCsvPreview(preview); setCsvMapping({ date: preview.mapping.date ?? "", description: preview.mapping.description ?? "", amount: preview.mapping.amount ?? "", category: preview.mapping.category, notes: preview.mapping.notes, amountMultiplier: preview.mapping.amountMultiplier === -1 ? -1 : 1, }); setImportStatus( `${csvFiles.length} CSV file${csvFiles.length === 1 ? "" : "s"} selected. ${preview.remembered ? "Using remembered mapping." : "Review the column mapping before import."}` ); } catch { setImportStatus("Preview failed. Please try again."); } setImportLoading(false); }; const onImportCsv = async () => { const selectedFiles = csvFiles.filter((file) => file.name.toLowerCase().endsWith(".csv")); if (!selectedFiles.length) { setImportStatus("Select one or more CSV files."); return; } if (!csvMapping.date || !csvMapping.description || !csvMapping.amount) { setImportStatus("Map date, description, and amount columns before importing."); return; } setImportLoading(true); setImportStatus(`Uploading ${selectedFiles.length} file${selectedFiles.length === 1 ? "" : "s"}...`); const formData = new FormData(); for (const file of selectedFiles) { formData.append("files", file); } formData.append("mapping", JSON.stringify(csvMapping)); try { const payload = await apiFetch("/api/transactions/import/batch", { method: "POST", body: formData, }); if (payload.error) { setImportStatus(payload.error?.message ?? "Import failed."); setImportLoading(false); return; } const r = payload.data; const failed = r.failedFiles ? ` ${r.failedFiles} file${r.failedFiles === 1 ? "" : "s"} failed.` : ""; const failedNames = r.results .filter((result) => result.error) .map((result) => `${result.fileName}: ${result.error}`) .join(" "); setImportStatus( `Processed ${r.processedFiles}/${r.totalFiles} file${r.totalFiles === 1 ? "" : "s"}. Imported ${r.imported} transaction${r.imported === 1 ? "" : "s"}, skipped ${r.skipped} duplicate${r.skipped === 1 ? "" : "s"}.${failed}${failedNames ? ` ${failedNames}` : ""}` ); setCsvFiles([]); setCsvPreview(null); await load(); await loadSummary(); await loadInsights(); } catch { setImportStatus("Import failed. Please try again."); } setImportLoading(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 splitPayload = (mode: "none" | "equal" | "custom", mine: string, yours: string) => { if (mode === "custom") { return { splitMode: mode, splitMinePercent: Number.parseFloat(mine), splitYoursPercent: Number.parseFloat(yours), }; } return { splitMode: mode }; }; const splitLabel = (row: TransactionRow) => { if (!row.split || row.split.mode === "none") return "No split"; if (row.split.mode === "equal") return "50/50"; return `${row.split.minePercent}/${row.split.yoursPercent}`; }; const onManualCreate = async (event: React.FormEvent) => { event.preventDefault(); const amount = Number.parseFloat(manualForm.amount); if (Number.isNaN(amount)) { setStatus("Invalid amount."); return; } setStatus("Saving manual transaction..."); const res = await apiFetch("/api/transactions/manual", { method: "POST", body: JSON.stringify({ accountId: manualForm.accountRef || undefined, date: manualForm.date, description: manualForm.description, amount, category: manualForm.category || undefined, note: manualForm.note || undefined, attribution: manualForm.attribution, ...splitPayload(manualForm.splitMode, manualForm.splitMinePercent, manualForm.splitYoursPercent), }), }); if (res.error) { setStatus(res.error.message ?? "Unable to save transaction."); return; } setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "", attribution: "mine", splitMode: "none", splitMinePercent: "50", splitYoursPercent: "50" })); setShowManual(false); await load(); await loadSummary(); await loadInsights(); setStatus("Manual transaction saved."); }; const startEdit = (row: TransactionRow) => { setEditingRef(row.viewRef); setEditForm({ category: row.category ?? "", note: row.note ?? "", attribution: row.attribution ?? "mine", splitMode: row.split?.mode ?? "none", splitMinePercent: String(row.split?.minePercent ?? 50), splitYoursPercent: String(row.split?.yoursPercent ?? 50), hidden: Boolean(row.hidden), }); }; const openComments = async (row: TransactionRow) => { setCommentRef(row.viewRef); setComments([]); setCommentDraft(""); setCommentStatus("Loading comments..."); const res = await apiFetch(`/api/transactions/${row.viewRef}/comments`); if (res.error) { setCommentStatus(res.error.message ?? "Unable to load comments."); return; } setComments(res.data ?? []); setCommentStatus(""); }; const createComment = async () => { if (!commentRef || !commentDraft.trim()) return; setCommentStatus("Posting comment..."); const res = await apiFetch(`/api/transactions/${commentRef}/comments`, { method: "POST", body: JSON.stringify({ body: commentDraft }), }); if (res.error) { setCommentStatus(res.error.message ?? "Unable to post comment."); return; } if (res.data) setComments((prev) => [...prev, res.data]); setCommentDraft(""); setCommentStatus(""); await load(); }; const saveEdit = async () => { if (!editingRef) return; setStatus("Saving edits..."); const res = await apiFetch(`/api/transactions/${editingRef}/derived`, { method: "PATCH", body: JSON.stringify({ userCategory: editForm.category || undefined, userNotes: editForm.note || undefined, attribution: editForm.attribution, ...splitPayload(editForm.splitMode, editForm.splitMinePercent, editForm.splitYoursPercent), isHidden: editForm.hidden, }), }); if (res.error) { setStatus(res.error.message ?? "Unable to save edits."); return; } setEditingRef(null); await load(); await loadSummary(); await loadInsights(); setStatus("Transaction updated."); }; const inputCls = "mt-2 w-full rounded-md border border-border bg-background/50 px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none"; const labelCls = "text-xs font-semibold text-muted-foreground uppercase tracking-wider"; const selectedCommentRow = rows.find((row) => row.viewRef === commentRef) ?? null; return ( {/* Action bar */}
{datePreset === "custom" ? "Custom range" : datePreset.replace(/_/g, " ")} Export
{/* CSV Import panel */} {showImport && (

Import CSV

Supports Chase, Bank of America, Wells Fargo, and generic CSV formats. Duplicate transactions are skipped automatically.

fileInputRef.current?.click()} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); onPreviewCsv(Array.from(e.dataTransfer.files)); }} > { const files = Array.from(e.target.files ?? []); if (files.length) onPreviewCsv(files); e.currentTarget.value = ""; }} /> {importLoading ? (

Uploading...

) : (

Drop CSV files here or click to browse

)}
{csvPreview && (

Column mapping

{csvPreview.remembered ? "Remembered" : "New"}
{[ ["date", "Date"], ["description", "Description"], ["amount", "Amount"], ["category", "Category"], ["notes", "Notes"], ].map(([key, label]) => ( ))}

Columns: {csvPreview.fileName}

{csvPreview.rowCount} detected row{csvPreview.rowCount === 1 ? "" : "s"}

{csvPreview.headers.map((header) => { const mapped = Object.entries(csvMapping).find(([, value]) => value === header)?.[0]; return ( ); })}
Column Mapped as
{header} {mapped ?? "Not mapped"}
)} {importStatus && (

{importStatus}

)}
)} {/* Manual transaction form */} {showManual && (

Add Manual Transaction

setManualForm((p) => ({ ...p, date: e.target.value }))} className={inputCls} required />
setManualForm((p) => ({ ...p, description: e.target.value }))} className={inputCls} required />
setManualForm((p) => ({ ...p, amount: e.target.value }))} className={inputCls} required placeholder="-42.50" />
setManualForm((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
setManualForm((p) => ({ ...p, note: e.target.value }))} className={inputCls} />
{manualForm.splitMode === "custom" && ( <>
setManualForm((p) => ({ ...p, splitMinePercent: e.target.value }))} className={inputCls} />
setManualForm((p) => ({ ...p, splitYoursPercent: e.target.value }))} className={inputCls} />
)}
)} {/* Filters */} {showFilters && (
setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
setFilters((p) => ({ ...p, search: e.target.value }))} className={inputCls} placeholder="Description..." />
setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
setFilters((p) => ({ ...p, source: e.target.value }))} className={inputCls} placeholder="plaid, manual, csv..." />
setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
)} {/* Summary cards */} {summary && (
{[ { label: "Total", value: `$${Math.abs(Number.parseFloat(summary.total ?? "0")).toFixed(2)}`, sub: `${summary.count} transactions` }, { label: "Income", value: `+$${Math.abs(Number.parseFloat(summary.income ?? "0")).toFixed(2)}`, sub: "Credits" }, { label: "Expenses", value: `-$${Math.abs(Number.parseFloat(summary.expense ?? "0")).toFixed(2)}`, sub: "Debits" }, ].map((c) => (

{c.label}

{c.value}

{c.sub}

))}
)} {(cashflow.length > 0 || merchants.length > 0) && (
{cashflow.length > 0 && (

Cashflow

Last 6 months
{cashflow.map((item) => { const income = Number.parseFloat(item.income); const expense = Number.parseFloat(item.expense); const max = Math.max(income, expense, 1); return (
{item.month}
= 0 ? "text-primary font-semibold" : "text-foreground font-semibold"}> ${Number.parseFloat(item.net).toFixed(0)}
); })}
)} {merchants.length > 0 && (

Top merchants

By spend
{merchants.map((merchant) => (

{merchant.merchant}

{merchant.count} transactions

${Number.parseFloat(merchant.total).toFixed(2)}

))}
)}
)} {/* Transaction table */}
{status && (
{status}
)} {selectedCommentRow && (

Transaction chat

{selectedCommentRow.description ?? selectedCommentRow.name ?? "Transaction"}

{new Date(selectedCommentRow.date).toLocaleDateString()} · {formatAmount(selectedCommentRow.amount).display}

{comments.map((comment) => (

{comment.author.displayName}

{new Date(comment.createdAt).toLocaleString()}

{comment.body}

))} {!comments.length && !commentStatus && (

No comments yet.

)} {commentStatus &&

{commentStatus}

}