2026-02-24 21:47:18 +00:00

766 lines
29 KiB
TypeScript

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AppShell } from "../../components/app-shell";
type ApiResponse<T> = {
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<TransactionRow[]>([]);
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<string | null>(null);
const [accounts, setAccounts] = useState<Account[]>([]);
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<string | null>(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<TransactionRow[]>;
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<Account[]>;
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<unknown>;
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<unknown>;
if (!res.ok || data.error) {
setStatus(data.error?.message ?? "Unable to save edits.");
return;
}
setEditingId(null);
setStatus("Transaction updated.");
await load();
await loadSummary();
};
return (
<AppShell title="Transactions" subtitle="Raw transactions from the last 30 days.">
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground mb-6">
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border shadow-sm">
<span className="h-2 w-2 rounded-full bg-primary" />
{datePreset === "custom" ? "Custom range" : datePreset.replace(/_/g, " ")}
</span>
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border shadow-sm">
<span className="h-2 w-2 rounded-full bg-primary" />
All accounts
</span>
<button
type="button"
onClick={onSync}
className="ml-auto px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors shadow-sm"
>
{isSyncing ? "Syncing..." : "Sync transactions"}
</button>
<button
type="button"
onClick={() => setAutoSync((prev) => !prev)}
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
>
Auto sync {autoSync ? "On" : "Off"}
</button>
<button
type="button"
onClick={() => setShowManual((prev) => !prev)}
className="px-4 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>
<Link
href={`/exports${buildQuery()}`}
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
>
Export CSV
</Link>
<button
type="button"
onClick={() => setShowFilters((prev) => !prev)}
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
>
{showFilters ? "Hide filters" : "Show filters"}
</button>
</div>
{showFilters ? (
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-[1fr_1fr_1fr]">
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Date range
</label>
<select
value={datePreset}
onChange={(event) => applyPreset(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"
>
<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="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Start date
</label>
<input
type="date"
value={filters.startDate}
onChange={(event) =>
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"}
/>
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
End date
</label>
<input
type="date"
value={filters.endDate}
onChange={(event) =>
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"}
/>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_1fr_1fr_1fr]">
<input
type="text"
value={filters.search}
onChange={(event) =>
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"
/>
<input
type="text"
value={filters.category}
onChange={(event) =>
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"
/>
<input
type="number"
value={filters.minAmount}
onChange={(event) =>
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"
/>
<input
type="number"
value={filters.maxAmount}
onChange={(event) =>
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"
/>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-[1fr_auto_auto]">
<input
type="text"
value={filters.source}
onChange={(event) =>
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"
/>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={filters.includeHidden}
onChange={(event) =>
setFilters((prev) => ({ ...prev, includeHidden: event.target.checked }))
}
className="rounded border-border text-primary focus:ring-primary"
/>
Include hidden
</label>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => {
load();
loadSummary();
}}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors"
>
Apply
</button>
<button
type="button"
onClick={() => {
setDatePreset("this_month");
setFilters({
startDate: "",
endDate: "",
minAmount: "",
maxAmount: "",
category: "",
source: "",
search: "",
includeHidden: false
});
applyPreset("this_month");
load();
loadSummary();
}}
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
>
Reset
</button>
</div>
</div>
</div>
) : null}
{showManual ? (
<form className="mb-6 glass-panel rounded-xl p-5 shadow-sm" onSubmit={onManualCreate}>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Manual transaction
</p>
<div className="mt-4 grid gap-3 md:grid-cols-[1.2fr_1fr_1fr]">
<div>
<label className="block text-xs font-medium text-foreground">
Account
</label>
<select
value={manualForm.accountId}
onChange={(event) =>
setManualForm((prev) => ({ ...prev, accountId: 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"
>
<option value="">Default account</option>
{accounts.map((account) => (
<option key={account.id} value={account.id}>
{account.institutionName} {account.mask ? `${account.mask}` : ""}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-foreground">
Date
</label>
<input
type="date"
value={manualForm.date}
onChange={(event) =>
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
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground">
Amount
</label>
<input
type="number"
step="0.01"
value={manualForm.amount}
onChange={(event) =>
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
/>
</div>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-[2fr_1fr_1fr]">
<input
type="text"
value={manualForm.description}
onChange={(event) =>
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
/>
<input
type="text"
value={manualForm.category}
onChange={(event) =>
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"
/>
<input
type="text"
value={manualForm.note}
onChange={(event) =>
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"
/>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="submit"
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors"
>
Save manual transaction
</button>
<button
type="button"
onClick={() => setShowManual(false)}
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
>
Cancel
</button>
</div>
</form>
) : null}
<div className="mt-6 grid gap-4 md:grid-cols-3">
<div className="glass-panel rounded-xl p-5 shadow-sm">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">30-day total</p>
<p className="mt-2 text-2xl font-bold text-foreground">
{summary ? `$${summary.total}` : "$0.00"}
</p>
<p className="text-sm text-muted-foreground">
{summary ? `${summary.count} transactions` : "No data yet."}
</p>
</div>
<div className="glass-panel rounded-xl p-5 shadow-sm">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Income vs expense</p>
<div className="mt-2 flex items-center justify-between text-sm">
<span className="text-primary font-medium">Income</span>
<span className="font-medium text-foreground">{summary?.income ? `$${summary.income}` : "$0.00"}</span>
</div>
<div className="mt-1 flex items-center justify-between text-sm">
<span className="text-muted-foreground font-medium">Expense</span>
<span className="font-medium text-foreground">{summary?.expense ? `$${summary.expense}` : "$0.00"}</span>
</div>
<div className="mt-2 pt-2 border-t border-border flex items-center justify-between text-xs text-muted-foreground">
<span>Net</span>
<span className="font-medium text-foreground">{summary?.net ? `$${summary.net}` : "$0.00"}</span>
</div>
</div>
<div className="glass-panel rounded-xl p-5 shadow-sm">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Auto sync</p>
<p className="mt-2 text-sm text-muted-foreground">
{autoSync ? "Running every 5 minutes." : "Paused."}
</p>
<button
type="button"
onClick={() => setAutoSync((prev) => !prev)}
className="mt-3 px-3 py-1.5 rounded-md bg-background border border-border text-foreground text-xs font-medium hover:bg-secondary transition-colors"
>
{autoSync ? "Pause auto sync" : "Enable auto sync"}
</button>
</div>
</div>
<div className="mt-6 glass-panel rounded-xl shadow-sm overflow-hidden">
<div className="p-6 border-b border-border">
{status ? <p className="text-sm text-muted-foreground">{status}</p> : null}
</div>
{editingId ? (
<div className="p-6 bg-secondary/30 border-b border-border">
<p className="text-sm font-semibold text-foreground">Edit transaction</p>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<input
type="text"
value={editForm.category}
onChange={(event) =>
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"
/>
<input
type="text"
value={editForm.note}
onChange={(event) =>
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"
/>
<label className="flex items-center gap-2 text-sm text-foreground">
<input
type="checkbox"
checked={editForm.hidden}
onChange={(event) =>
setEditForm((prev) => ({ ...prev, hidden: event.target.checked }))
}
className="rounded border-border text-primary focus:ring-primary"
/>
Hide transaction
</label>
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={saveEdit}
className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors"
>
Save edits
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="px-4 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors"
>
Cancel
</button>
</div>
</div>
) : null}
{rows.length ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-border">
<thead className="bg-secondary/30">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Date</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Description</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Amount</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Category</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Status</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-background divide-y divide-border">
{rows.map((row) => (
<tr key={row.id} className="hover:bg-secondary/30 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{row.date}</td>
<td className="px-6 py-4">
<div className="text-sm font-medium text-foreground">{row.name}</div>
<div className="text-xs text-muted-foreground">{row.note || "No notes"}</div>
{row.hidden ? (
<div className="mt-1 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
Hidden
</div>
) : null}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<span className={formatAmount(row.amount).tone}>
{formatAmount(row.amount).display}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">{row.category}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary text-foreground">
{row.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
<button
type="button"
onClick={() => startEdit(row)}
className="text-primary hover:text-primary/80 font-medium"
>
Edit
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
</AppShell>
);
}