1034 lines
47 KiB
TypeScript

"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<CsvMapping>;
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<TransactionRow[]>([]);
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<CashflowRow[]>([]);
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
const [datePreset, setDatePreset] = useState("this_month");
const [showFilters, setShowFilters] = useState(false);
const [accounts, setAccounts] = useState<Account[]>([]);
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<File[]>([]);
const [csvPreview, setCsvPreview] = useState<CsvPreview | null>(null);
const [csvMapping, setCsvMapping] = useState<CsvMapping>({
date: "",
description: "",
amount: "",
amountMultiplier: 1,
});
const fileInputRef = useRef<HTMLInputElement>(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<string | null>(null);
const [commentRef, setCommentRef] = useState<string | null>(null);
const [comments, setComments] = useState<TransactionComment[]>([]);
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<CashflowRow[]>("/api/transactions/cashflow?months=6"),
apiFetch<MerchantInsight[]>("/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<unknown>("/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<CsvPreview>("/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<ImportBatchResult>("/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<unknown>("/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);
setStatus("Manual transaction saved.");
await load();
await loadSummary();
await loadInsights();
};
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<TransactionComment[]>(`/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<TransactionComment>(`/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<unknown>(`/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);
setStatus("Transaction updated.");
await load();
await loadSummary();
await loadInsights();
};
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 (
<AppShell title="Transactions" subtitle="View, sync, and categorize your transactions.">
{/* Action bar */}
<div className="flex flex-wrap items-center gap-2 text-sm mb-6">
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border text-muted-foreground">
<span className="h-2 w-2 rounded-full bg-primary" />
{datePreset === "custom" ? "Custom range" : datePreset.replace(/_/g, " ")}
</span>
<button onClick={onSync} className="ml-auto px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors">
{isSyncing ? "Syncing..." : "Sync"}
</button>
<button onClick={() => setAutoSync((prev) => !prev)} className={`px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${autoSync ? "bg-primary/10 border-primary/30 text-primary" : "bg-background border-border text-foreground hover:bg-secondary"}`}>
Auto {autoSync ? "On" : "Off"}
</button>
<button onClick={() => setShowManual((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
{showManual ? "Hide manual" : "Add manual"}
</button>
<button onClick={() => { setShowImport((prev) => !prev); setImportStatus(""); }} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
{showImport ? "Hide import" : "Import CSV"}
</button>
<Link href={`/exports${buildQuery()}`} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
Export
</Link>
<button onClick={() => setShowFilters((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
{showFilters ? "Hide filters" : "Filters"}
</button>
</div>
{/* CSV Import panel */}
{showImport && (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<p className="text-sm font-bold text-foreground mb-1">Import CSV</p>
<p className="text-xs text-muted-foreground mb-4">
Supports Chase, Bank of America, Wells Fargo, and generic CSV formats. Duplicate transactions are skipped automatically.
</p>
<div
className="border-2 border-dashed border-border rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
onPreviewCsv(Array.from(e.dataTransfer.files));
}}
>
<input
ref={fileInputRef}
type="file"
accept=".csv"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length) onPreviewCsv(files);
e.currentTarget.value = "";
}}
/>
<svg className="mx-auto h-8 w-8 text-muted-foreground mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
{importLoading ? (
<p className="text-sm text-muted-foreground">Uploading...</p>
) : (
<p className="text-sm text-muted-foreground">Drop CSV files here or <span className="text-primary font-medium">click to browse</span></p>
)}
</div>
{csvPreview && (
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="rounded-xl border border-border bg-background/40 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<p className="text-sm font-semibold text-foreground">Column mapping</p>
<span className="rounded-full bg-secondary px-2 py-1 text-[11px] font-medium text-muted-foreground">
{csvPreview.remembered ? "Remembered" : "New"}
</span>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{[
["date", "Date"],
["description", "Description"],
["amount", "Amount"],
["category", "Category"],
["notes", "Notes"],
].map(([key, label]) => (
<label key={key} className={labelCls}>
{label}
<select
value={(csvMapping[key as keyof CsvMapping] as string | undefined) ?? ""}
onChange={(e) => setCsvMapping((prev) => ({ ...prev, [key]: e.target.value || undefined }))}
className={inputCls}
>
<option value="">Not mapped</option>
{csvPreview.headers.map((header) => (
<option key={header} value={header}>{header}</option>
))}
</select>
</label>
))}
<label className={labelCls}>
Amount sign
<select
value={csvMapping.amountMultiplier ?? 1}
onChange={(e) => setCsvMapping((prev) => ({ ...prev, amountMultiplier: e.target.value === "-1" ? -1 : 1 }))}
className={inputCls}
>
<option value={1}>Keep file values</option>
<option value={-1}>Flip income/expense signs</option>
</select>
</label>
</div>
<button
type="button"
onClick={onImportCsv}
disabled={importLoading}
className="mt-4 w-full rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
>
{importLoading ? "Importing..." : `Import ${csvFiles.length} file${csvFiles.length === 1 ? "" : "s"}`}
</button>
</div>
<div className="overflow-hidden rounded-xl border border-border bg-background/40">
<div className="border-b border-border px-4 py-3">
<p className="text-sm font-semibold text-foreground">Columns: {csvPreview.fileName}</p>
<p className="mt-1 text-xs text-muted-foreground">{csvPreview.rowCount} detected row{csvPreview.rowCount === 1 ? "" : "s"}</p>
</div>
<div className="max-h-72 overflow-auto">
<table className="w-full text-left text-xs">
<thead className="bg-secondary/30 text-muted-foreground">
<tr>
<th className="whitespace-nowrap px-3 py-2 font-semibold">Column</th>
<th className="whitespace-nowrap px-3 py-2 font-semibold">Mapped as</th>
</tr>
</thead>
<tbody>
{csvPreview.headers.map((header) => {
const mapped = Object.entries(csvMapping).find(([, value]) => value === header)?.[0];
return (
<tr key={header} className="border-t border-border text-foreground">
<td className="max-w-48 truncate px-3 py-2">{header}</td>
<td className="px-3 py-2 capitalize text-muted-foreground">{mapped ?? "Not mapped"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
{importStatus && (
<p className="mt-3 text-sm text-muted-foreground">{importStatus}</p>
)}
</div>
)}
{/* Manual transaction form */}
{showManual && (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<p className="text-sm font-bold text-foreground mb-4">Add Manual Transaction</p>
<form onSubmit={onManualCreate} className="grid gap-3 md:grid-cols-3">
<div>
<label className={labelCls}>Date</label>
<input type="date" value={manualForm.date} onChange={(e) => setManualForm((p) => ({ ...p, date: e.target.value }))} className={inputCls} required />
</div>
<div>
<label className={labelCls}>Description</label>
<input type="text" value={manualForm.description} onChange={(e) => setManualForm((p) => ({ ...p, description: e.target.value }))} className={inputCls} required />
</div>
<div>
<label className={labelCls}>Amount (negative = expense)</label>
<input type="number" step="0.01" value={manualForm.amount} onChange={(e) => setManualForm((p) => ({ ...p, amount: e.target.value }))} className={inputCls} required placeholder="-42.50" />
</div>
<div>
<label className={labelCls}>Category</label>
<input type="text" value={manualForm.category} onChange={(e) => setManualForm((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Account</label>
<select value={manualForm.accountRef} onChange={(e) => setManualForm((p) => ({ ...p, accountRef: e.target.value }))} className={inputCls}>
<option value=""> No account </option>
{accounts.map((a) => (
<option key={a.viewRef} value={a.viewRef}>{a.institutionName} {a.mask ? `••${a.mask}` : ""}</option>
))}
</select>
</div>
<div>
<label className={labelCls}>Note</label>
<input type="text" value={manualForm.note} onChange={(e) => setManualForm((p) => ({ ...p, note: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Attribution</label>
<select value={manualForm.attribution} onChange={(e) => setManualForm((p) => ({ ...p, attribution: e.target.value as "mine" | "yours" | "ours" }))} className={inputCls}>
<option value="mine">Mine</option>
<option value="yours">Yours</option>
<option value="ours">Ours</option>
</select>
</div>
<div>
<label className={labelCls}>Split</label>
<select value={manualForm.splitMode} onChange={(e) => setManualForm((p) => ({ ...p, splitMode: e.target.value as "none" | "equal" | "custom" }))} className={inputCls}>
<option value="none">No split</option>
<option value="equal">50/50</option>
<option value="custom">Custom</option>
</select>
</div>
{manualForm.splitMode === "custom" && (
<>
<div>
<label className={labelCls}>Mine %</label>
<input type="number" min="0" max="100" step="0.01" value={manualForm.splitMinePercent} onChange={(e) => setManualForm((p) => ({ ...p, splitMinePercent: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Yours %</label>
<input type="number" min="0" max="100" step="0.01" value={manualForm.splitYoursPercent} onChange={(e) => setManualForm((p) => ({ ...p, splitYoursPercent: e.target.value }))} className={inputCls} />
</div>
</>
)}
<div className="md:col-span-3 flex justify-end gap-2">
<button type="button" onClick={() => setShowManual(false)} className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-secondary">Cancel</button>
<button type="submit" className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Save</button>
</div>
</form>
</div>
)}
{/* Filters */}
{showFilters && (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className={labelCls}>Date range</label>
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
<option value="this_month">This month</option>
<option value="last_month">Last month</option>
<option value="last_6_months">Last 6 months</option>
<option value="last_year">Last year</option>
<option value="custom">Custom</option>
</select>
</div>
<div>
<label className={labelCls}>Start date</label>
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>End date</label>
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
</div>
<div>
<label className={labelCls}>Search</label>
<input type="text" value={filters.search} onChange={(e) => setFilters((p) => ({ ...p, search: e.target.value }))} className={inputCls} placeholder="Description..." />
</div>
<div>
<label className={labelCls}>Category</label>
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Source</label>
<input type="text" value={filters.source} onChange={(e) => setFilters((p) => ({ ...p, source: e.target.value }))} className={inputCls} placeholder="plaid, manual, csv..." />
</div>
<div>
<label className={labelCls}>Min amount</label>
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
</div>
<div>
<label className={labelCls}>Max amount</label>
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
</div>
<div className="flex items-end gap-2">
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
Include hidden
</label>
</div>
<div className="flex items-end">
<button onClick={() => { load(); loadSummary(); loadInsights(); }} className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Apply</button>
</div>
</div>
</div>
)}
{/* Summary cards */}
{summary && (
<div className="mb-6 grid gap-3 md:grid-cols-3">
{[
{ 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) => (
<div key={c.label} className="glass-panel rounded-xl p-4 shadow-sm">
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">{c.label}</p>
<p className="mt-2 text-xl font-bold text-foreground">{c.value}</p>
<p className="text-xs text-muted-foreground">{c.sub}</p>
</div>
))}
</div>
)}
{(cashflow.length > 0 || merchants.length > 0) && (
<div className="mb-6 grid gap-4 lg:grid-cols-2">
{cashflow.length > 0 && (
<div className="glass-panel rounded-xl p-4 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-bold text-foreground">Cashflow</p>
<span className="text-xs text-muted-foreground">Last 6 months</span>
</div>
<div className="space-y-3">
{cashflow.map((item) => {
const income = Number.parseFloat(item.income);
const expense = Number.parseFloat(item.expense);
const max = Math.max(income, expense, 1);
return (
<div key={item.month} className="grid grid-cols-[5rem_1fr_4.5rem] items-center gap-3 text-xs">
<span className="font-medium text-muted-foreground">{item.month}</span>
<div className="space-y-1">
<div className="h-2 rounded-full bg-secondary">
<div className="h-2 rounded-full bg-primary" style={{ width: `${Math.min((income / max) * 100, 100)}%` }} />
</div>
<div className="h-2 rounded-full bg-secondary">
<div className="h-2 rounded-full bg-foreground/70" style={{ width: `${Math.min((expense / max) * 100, 100)}%` }} />
</div>
</div>
<span className={Number.parseFloat(item.net) >= 0 ? "text-primary font-semibold" : "text-foreground font-semibold"}>
${Number.parseFloat(item.net).toFixed(0)}
</span>
</div>
);
})}
</div>
</div>
)}
{merchants.length > 0 && (
<div className="glass-panel rounded-xl p-4 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<p className="text-sm font-bold text-foreground">Top merchants</p>
<span className="text-xs text-muted-foreground">By spend</span>
</div>
<div className="space-y-3">
{merchants.map((merchant) => (
<div key={merchant.merchant} className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background/40 px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-foreground">{merchant.merchant}</p>
<p className="text-xs text-muted-foreground">{merchant.count} transactions</p>
</div>
<p className="text-sm font-bold text-foreground">${Number.parseFloat(merchant.total).toFixed(2)}</p>
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Transaction table */}
<div className="glass-panel rounded-2xl shadow-sm overflow-hidden">
{status && (
<div className="px-6 py-3 bg-secondary/30 border-b border-border text-sm text-muted-foreground">{status}</div>
)}
{selectedCommentRow && (
<div className="border-b border-border bg-secondary/10 p-5">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transaction chat</p>
<h2 className="mt-1 text-base font-bold text-foreground">{selectedCommentRow.description ?? selectedCommentRow.name ?? "Transaction"}</h2>
<p className="mt-1 text-xs text-muted-foreground">
{new Date(selectedCommentRow.date).toLocaleDateString()} · {formatAmount(selectedCommentRow.amount).display}
</p>
</div>
<button
type="button"
onClick={() => {
setCommentRef(null);
setComments([]);
setCommentDraft("");
setCommentStatus("");
}}
className="rounded border border-border px-3 py-1 text-xs font-semibold text-foreground hover:bg-secondary"
>
Close
</button>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_320px]">
<div className="max-h-64 space-y-3 overflow-y-auto rounded-lg border border-border bg-background/40 p-3">
{comments.map((comment) => (
<div key={comment.id} className="rounded-lg border border-border bg-background p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm font-semibold text-foreground">{comment.author.displayName}</p>
<p className="text-xs text-muted-foreground">{new Date(comment.createdAt).toLocaleString()}</p>
</div>
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{comment.body}</p>
</div>
))}
{!comments.length && !commentStatus && (
<p className="text-sm text-muted-foreground">No comments yet.</p>
)}
{commentStatus && <p className="text-sm text-muted-foreground">{commentStatus}</p>}
</div>
<div>
<textarea
value={commentDraft}
onChange={(event) => setCommentDraft(event.target.value)}
maxLength={1000}
placeholder="Add a note for this transaction"
className="h-32 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
/>
<div className="mt-2 flex items-center justify-between gap-3">
<span className="text-xs text-muted-foreground">{commentDraft.length}/1000</span>
<button
type="button"
onClick={createComment}
disabled={!commentDraft.trim()}
className="rounded bg-primary px-4 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Post comment
</button>
</div>
</div>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full text-left text-xs text-muted-foreground">
<thead className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold bg-secondary/20">
<tr>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Description</th>
<th className="px-4 py-3">Category</th>
<th className="px-4 py-3">Attribution</th>
<th className="px-4 py-3">Split</th>
<th className="px-4 py-3 text-right">Amount</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((row) =>
editingRef === row.viewRef ? (
<tr key={row.viewRef} className="border-b border-border bg-secondary/10">
<td className="px-4 py-3" colSpan={4}>
<div className="flex flex-wrap gap-2">
<input
type="text"
value={editForm.category}
onChange={(e) => setEditForm((p) => ({ ...p, category: e.target.value }))}
placeholder="Category"
className="w-28 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/>
<input
type="text"
value={editForm.note}
onChange={(e) => setEditForm((p) => ({ ...p, note: e.target.value }))}
placeholder="Note"
className="flex-1 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/>
<select
value={editForm.attribution}
onChange={(e) => setEditForm((p) => ({ ...p, attribution: e.target.value as "mine" | "yours" | "ours" }))}
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
>
<option value="mine">Mine</option>
<option value="yours">Yours</option>
<option value="ours">Ours</option>
</select>
<select
value={editForm.splitMode}
onChange={(e) => setEditForm((p) => ({ ...p, splitMode: e.target.value as "none" | "equal" | "custom" }))}
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
>
<option value="none">No split</option>
<option value="equal">50/50</option>
<option value="custom">Custom</option>
</select>
{editForm.splitMode === "custom" && (
<>
<input
type="number"
min="0"
max="100"
step="0.01"
value={editForm.splitMinePercent}
onChange={(e) => setEditForm((p) => ({ ...p, splitMinePercent: e.target.value }))}
placeholder="Mine %"
className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/>
<input
type="number"
min="0"
max="100"
step="0.01"
value={editForm.splitYoursPercent}
onChange={(e) => setEditForm((p) => ({ ...p, splitYoursPercent: e.target.value }))}
placeholder="Yours %"
className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
/>
</>
)}
<label className="flex items-center gap-1 text-xs text-foreground">
<input type="checkbox" checked={editForm.hidden} onChange={(e) => setEditForm((p) => ({ ...p, hidden: e.target.checked }))} />
Hide
</label>
</div>
</td>
<td className="px-4 py-3" colSpan={3}>
<div className="flex gap-2 justify-end">
<button onClick={saveEdit} className="rounded bg-primary px-3 py-1 text-[11px] font-bold text-primary-foreground hover:bg-primary/90">Save</button>
<button onClick={() => setEditingRef(null)} className="rounded border border-border px-3 py-1 text-[11px] text-foreground hover:bg-secondary">Cancel</button>
</div>
</td>
</tr>
) : (
<tr key={row.viewRef} className={`border-b border-border hover:bg-secondary/20 transition-colors ${row.hidden ? "opacity-50" : ""}`}>
<td className="px-4 py-3 font-medium whitespace-nowrap">
{new Date(row.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
</td>
<td className="px-4 py-3 text-foreground font-medium max-w-[200px] truncate">
{row.description ?? row.name ?? "—"}
</td>
<td className="px-4 py-3">
{row.category ? (
<span className="inline-flex rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">{row.category}</span>
) : (
<span className="text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3">
<span className="inline-flex rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium capitalize text-foreground">
{row.attribution ?? "mine"}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground">
{splitLabel(row)}
</span>
</td>
<td className={`px-4 py-3 text-right font-bold ${formatAmount(row.amount).tone}`}>
{formatAmount(row.amount).display}
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-3">
<button onClick={() => openComments(row)} className="text-xs text-primary hover:underline">
Comments{row.commentCount ? ` (${row.commentCount})` : ""}
</button>
<button onClick={() => startEdit(row)} className="text-xs text-primary hover:underline">Edit</button>
</div>
</td>
</tr>
)
)}
{!rows.length && !status && (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-sm text-muted-foreground">
No transactions found. Try adjusting your filters or sync your accounts.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</AppShell>
);
}