Files
finance-app/src/app/transactions/page.tsx
T

610 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useCallback, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { SplitModal } from "@/components/split-modal";
import { TagPicker } from "@/components/tag-picker";
function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", {
day: "2-digit",
month: "short",
year: "numeric",
});
}
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(amount: number, type: string) {
const formatted = new Intl.NumberFormat("en-AU", {
style: "currency",
currency: "AUD",
}).format(amount);
return SPEND_TYPES.has(type) ? formatted : `+${formatted}`;
}
const TYPE_COLORS: Record<string, string> = {
debit: "bg-red-900/30 text-red-400",
credit: "bg-green-900/30 text-green-400",
payment: "bg-blue-900/30 text-blue-400",
refund: "bg-emerald-900/30 text-emerald-400",
fee: "bg-yellow-900/30 text-yellow-400",
interest: "bg-orange-900/30 text-orange-400",
transfer: "bg-zinc-800 text-zinc-400",
};
const TYPE_OPTIONS = [
"debit", "credit", "payment", "refund", "fee", "interest", "transfer",
].map((t) => ({ value: t, label: t }));
function TypeBadge({ type }: { type: string }) {
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${TYPE_COLORS[type] || "bg-zinc-800 text-zinc-400"}`}>
{type}
</span>
);
}
function EditableTypeBadge({ type, onSave }: { type: string; onSave: (t: string) => void }) {
const [editing, setEditing] = useState(false);
if (!editing) {
return (
<button onClick={() => setEditing(true)} title="Click to change type">
<TypeBadge type={type} />
</button>
);
}
return (
<select
autoFocus
defaultValue={type}
onBlur={(e) => { onSave(e.target.value); setEditing(false); }}
onChange={(e) => { onSave(e.target.value); setEditing(false); }}
className="bg-zinc-800 border border-zinc-600 rounded px-1 py-0.5 text-xs"
>
{TYPE_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
// Prompt shown after a merchant/category edit — offers to save it as a rule
function SaveAsRulePrompt({
tx,
field,
newValue,
onDone,
}: {
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
field: "category" | "merchant";
newValue: string;
onDone: () => void;
}) {
const createRule = useCreateRule();
const [saving, setSaving] = useState(false);
// Build a sensible default rule from the transaction context.
// Prefer merchant_normalized (full name, exact match) over a partial description word.
const hasMerchant = !!tx.effective_merchant;
const conditionField = hasMerchant ? "merchant_normalized" : "description";
const conditionOperator = hasMerchant ? "equals" : "contains";
const conditionValue = hasMerchant ? tx.effective_merchant : tx.description;
const defaultName =
field === "category"
? `${conditionValue}${formatCategory(newValue)}`
: `Rename ${conditionValue}${newValue}`;
const conditions = [{ field: conditionField, operator: conditionOperator, value: conditionValue }];
const actions =
field === "category"
? { set_category: newValue }
: { set_merchant: newValue };
async function save() {
setSaving(true);
await createRule.mutateAsync({ name: defaultName, conditions, actions, enabled: true, priority: 0 });
onDone();
}
return (
<div className="fixed bottom-4 right-4 z-50 bg-zinc-800 border border-zinc-600 rounded-xl shadow-2xl p-4 w-80 text-sm">
<p className="text-zinc-200 font-medium mb-1">Save as rule?</p>
<p className="text-zinc-400 text-xs mb-3">
Automatically apply this {field === "category" ? "category" : "merchant name"} to future matching transactions.
</p>
<div className="bg-zinc-900 rounded-lg px-3 py-2 text-xs text-zinc-300 mb-3 space-y-1">
<p><span className="text-zinc-500">If</span> {conditionField === "merchant_normalized" ? "merchant" : "description"} {conditionOperator} <span className="text-white">"{conditionValue}"</span></p>
<p>
<span className="text-zinc-500">Then</span>{" "}
{field === "category"
? <>set category <span className="text-white">{formatCategory(newValue)}</span></>
: <>set merchant <span className="text-white">{newValue}</span></>}
</p>
</div>
<div className="flex gap-2">
<button
onClick={save}
disabled={saving}
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded-lg py-1.5 font-medium transition-colors"
>
{saving ? "Saving…" : "Save rule"}
</button>
<button
onClick={onDone}
className="flex-1 bg-zinc-700 hover:bg-zinc-600 text-zinc-200 rounded-lg py-1.5 transition-colors"
>
Dismiss
</button>
</div>
</div>
);
}
function InlineEdit({
value,
onSave,
type = "text",
options,
}: {
value: string;
onSave: (val: string) => void;
type?: "text" | "select";
options?: { value: string; label: string }[];
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
if (!editing) {
return (
<button
onClick={() => { setDraft(value); setEditing(true); }}
className="text-left hover:bg-zinc-800 px-1 -mx-1 rounded cursor-pointer transition-colors w-full truncate"
title="Click to edit"
>
{value || <span className="text-zinc-600 italic">unset</span>}
</button>
);
}
const commit = () => {
if (draft !== value) onSave(draft);
setEditing(false);
};
if (type === "select" && options) {
return (
<select
autoFocus
value={draft}
onChange={(e) => { setDraft(e.target.value); }}
onBlur={commit}
className="bg-zinc-800 border border-zinc-600 rounded px-1 py-0.5 text-sm w-full"
>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
);
}
return (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }}
className="bg-zinc-800 border border-zinc-600 rounded px-1 py-0.5 text-sm w-full"
/>
);
}
export default function TransactionsPage() {
return (
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
<TransactionsContent />
</Suspense>
);
}
function TransactionsContent() {
const searchParams = useSearchParams();
const initialStatementId = searchParams.get("statement_id") || "";
const [filters, setFilters] = useState({
from: "",
to: "",
category: "",
bank_name: "",
search: "",
statement_id: initialStatementId,
tag_id: "",
sort_by: "transaction_date",
sort_dir: "desc",
limit: 50,
offset: 0,
});
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkCategory, setBulkCategory] = useState("");
const [bulkTagId, setBulkTagId] = useState("");
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
const [rulePrompt, setRulePrompt] = useState<{
tx: { id: number; effective_merchant: string; description: string; bank_name: string };
field: "category" | "merchant";
newValue: string;
} | null>(null);
const { data, isLoading } = useTransactions(filters);
const { data: banks } = useBanks();
const { data: tags } = useTags();
const { data: statementInfo } = useStatement(parseInt(filters.statement_id) || 0);
const updateTxn = useUpdateTransaction();
const bulkAction = useBulkAction();
const toggleSelect = useCallback((id: number) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
if (!data?.data) return;
setSelected((prev) => {
if (prev.size === data.data.length) return new Set();
return new Set(data.data.map((t) => t.id));
});
}, [data]);
const setPage = (newOffset: number) => {
setFilters((f) => ({ ...f, offset: newOffset }));
setSelected(new Set());
};
const toggleSort = (col: string) => {
setFilters((f) => ({
...f,
sort_by: col,
sort_dir: f.sort_by === col && f.sort_dir === "desc" ? "asc" : "desc",
offset: 0,
}));
};
const categoryOptions = CATEGORIES.map((c) => ({ value: c, label: formatCategory(c) }));
const totalPages = data ? Math.ceil(data.total / filters.limit) : 0;
const currentPage = Math.floor(filters.offset / filters.limit) + 1;
return (
<div>
<h2 className="text-xl font-semibold mb-4">Transactions</h2>
{/* Statement context banner */}
{filters.statement_id && statementInfo && (
<div className="flex items-center gap-3 mb-4 px-3 py-2 bg-indigo-950/40 border border-indigo-800/50 rounded-lg text-sm">
<span className="text-indigo-300 font-medium">{statementInfo.bank_name}</span>
{statementInfo.billing_start_date && statementInfo.billing_end_date && (
<span className="text-zinc-400">
{new Date(statementInfo.billing_start_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short" })}
{" "}
{new Date(statementInfo.billing_end_date).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" })}
</span>
)}
<span className="text-zinc-500 text-xs">{statementInfo.transaction_count} transactions</span>
<button
onClick={() => setFilters((f) => ({ ...f, statement_id: "", offset: 0 }))}
className="ml-auto text-zinc-500 hover:text-zinc-200 text-xs px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
>
× Clear filter
</button>
</div>
)}
{/* Filter bar */}
<div className="flex flex-wrap gap-3 mb-4">
<input
type="text"
placeholder="Search..."
value={filters.search}
onChange={(e) => setFilters((f) => ({ ...f, search: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-48"
/>
<input
type="date"
value={filters.from}
onChange={(e) => setFilters((f) => ({ ...f, from: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
/>
<input
type="date"
value={filters.to}
onChange={(e) => setFilters((f) => ({ ...f, to: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
/>
<select
value={filters.category}
onChange={(e) => setFilters((f) => ({ ...f, category: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Categories</option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
<select
value={filters.bank_name}
onChange={(e) => setFilters((f) => ({ ...f, bank_name: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Banks</option>
{banks?.map((b) => (
<option key={b} value={b}>{b}</option>
))}
</select>
<select
value={filters.tag_id}
onChange={(e) => setFilters((f) => ({ ...f, tag_id: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm"
>
<option value="">All Tags</option>
{tags?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="flex items-center gap-3 mb-3 p-2 bg-zinc-900 border border-zinc-700 rounded">
<span className="text-sm text-zinc-400">{selected.size} selected</span>
<select
value={bulkCategory}
onChange={(e) => setBulkCategory(e.target.value)}
className="bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm"
>
<option value="">Set category...</option>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{formatCategory(c)}</option>
))}
</select>
<button
disabled={!bulkCategory || bulkAction.isPending}
onClick={() => {
bulkAction.mutate(
{ action: "categorize", ids: Array.from(selected), category: bulkCategory },
{ onSuccess: () => { setSelected(new Set()); setBulkCategory(""); } }
);
}}
className="px-3 py-1 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded text-sm"
>
Apply
</button>
<button
onClick={() =>
setSplitModal({
transactionIds: Array.from(selected),
description: `${selected.size} selected transaction${selected.size !== 1 ? "s" : ""}`,
})
}
className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 rounded text-sm"
>
Split...
</button>
<select
value={bulkTagId}
onChange={(e) => setBulkTagId(e.target.value)}
className="bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm"
>
<option value="">Tag as...</option>
{tags?.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<button
disabled={!bulkTagId || bulkAction.isPending}
onClick={() => {
bulkAction.mutate(
{ action: "tag", ids: Array.from(selected), tag_id: Number(bulkTagId) },
{ onSuccess: () => { setSelected(new Set()); setBulkTagId(""); } }
);
}}
className="px-3 py-1 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 rounded text-sm"
>
Tag
</button>
<button
onClick={() => setSelected(new Set())}
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
>
Clear
</button>
</div>
)}
{/* Table */}
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/50">
<th className="p-2 w-8">
<input
type="checkbox"
checked={data?.data.length ? selected.size === data.data.length : false}
onChange={toggleAll}
className="accent-blue-600"
/>
</th>
<th
className="p-2 text-left cursor-pointer hover:text-white"
onClick={() => toggleSort("transaction_date")}
>
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
</th>
<th className="p-2 text-left">Description</th>
<th className="p-2 text-left">Merchant</th>
<th
className="p-2 text-right cursor-pointer hover:text-white"
onClick={() => toggleSort("amount")}
>
Amount {filters.sort_by === "amount" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
</th>
<th className="p-2 text-left">Type</th>
<th className="p-2 text-left">Category</th>
<th className="p-2 text-left">Bank</th>
<th className="p-2 text-left">Tags</th>
<th className="p-2"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">Loading...</td></tr>
) : !data?.data.length ? (
<tr><td colSpan={10} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
) : (
data.data.map((t) => (
<tr
key={t.id}
className={`border-b border-zinc-800/50 hover:bg-zinc-900/30 ${
selected.has(t.id) ? "bg-zinc-800/40" : ""
}`}
>
<td className="p-2">
<input
type="checkbox"
checked={selected.has(t.id)}
onChange={() => toggleSelect(t.id)}
className="accent-blue-600"
/>
</td>
<td className="p-2 whitespace-nowrap">{formatDate(t.transaction_date)}</td>
<td className="p-2 max-w-xs truncate" title={t.description}>{t.description}</td>
<td className="p-2 max-w-[150px]">
<div className="relative">
<InlineEdit
value={t.effective_merchant || ""}
onSave={(val) => {
updateTxn.mutate({ id: t.id, merchant_normalized: val });
setRulePrompt({ tx: t, field: "merchant", newValue: val });
}}
/>
{t.merchant_override && (
<span className="absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-blue-500 rounded-full" title="Manually overridden" />
)}
</div>
</td>
<td className={`p-2 text-right whitespace-nowrap font-mono ${
SPEND_TYPES.has(t.transaction_type) ? "text-red-400" : "text-green-400"
}`}>
{formatAmount(t.amount, t.transaction_type)}
</td>
<td className="p-2">
<EditableTypeBadge
type={t.transaction_type}
onSave={(val) => updateTxn.mutate({ id: t.id, transaction_type: val })}
/>
</td>
<td className="p-2 max-w-[140px]">
<div className="relative">
<InlineEdit
value={t.effective_category}
onSave={(val) => {
updateTxn.mutate({ id: t.id, category: val });
setRulePrompt({ tx: t, field: "category", newValue: val });
}}
type="select"
options={categoryOptions}
/>
{t.category_override && (
<span className="absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-blue-500 rounded-full" title="Manually overridden" />
)}
</div>
</td>
<td className="p-2 text-zinc-400 whitespace-nowrap">{t.bank_name}</td>
<td className="p-2">
<div className="flex items-center gap-1 flex-wrap">
{t.tags?.map((tag) => (
<span
key={tag.id}
className="px-1.5 py-0.5 rounded text-xs font-medium text-white"
style={{ backgroundColor: tag.color + "99" }}
>
{tag.name}
</span>
))}
<TagPicker transactionId={t.id} currentTags={t.tags ?? []} />
</div>
</td>
<td className="p-2">
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Split this transaction"
>
Split
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Split modal */}
{splitModal && (
<SplitModal
transactionId={splitModal.transactionId}
transactionIds={splitModal.transactionIds}
amount={splitModal.amount}
description={splitModal.description}
merchant={splitModal.merchant}
onClose={() => { setSplitModal(null); if (splitModal.transactionIds) setSelected(new Set()); }}
/>
)}
{rulePrompt && (
<SaveAsRulePrompt
tx={rulePrompt.tx}
field={rulePrompt.field}
newValue={rulePrompt.newValue}
onDone={() => setRulePrompt(null)}
/>
)}
{/* Pagination */}
{data && data.total > filters.limit && (
<div className="flex items-center justify-between mt-4">
<span className="text-sm text-zinc-500">
Showing {filters.offset + 1}-{Math.min(filters.offset + filters.limit, data.total)} of {data.total}
</span>
<div className="flex gap-2">
<button
disabled={currentPage <= 1}
onClick={() => setPage(filters.offset - filters.limit)}
className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 rounded text-sm"
>
Previous
</button>
<span className="px-3 py-1 text-sm text-zinc-400">
Page {currentPage} of {totalPages}
</span>
<button
disabled={currentPage >= totalPages}
onClick={() => setPage(filters.offset + filters.limit)}
className="px-3 py-1 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 rounded text-sm"
>
Next
</button>
</div>
</div>
)}
</div>
);
}