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

997 lines
38 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, useRef, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { SplitModal } from "@/components/split-modal";
import { TagPicker } from "@/components/tag-picker";
import { AddTransactionModal } from "@/components/add-transaction-modal";
import { EditTransactionModal } from "@/components/edit-transaction-modal";
import type { TransactionRow } from "@/lib/queries";
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"
/>
);
}
// ── Mark as Payment modal ─────────────────────────────────────────────────────
function MarkAsPaymentModal({
transaction,
onClose,
}: {
transaction: TransactionRow;
onClose: () => void;
}) {
const { data: participants = [] } = useParticipants();
const { data: me } = useCurrentUser();
const record = useRecordPayment();
const others = participants.filter((p) => p.id !== me?.id);
const [participantId, setParticipantId] = useState<number | "">(others[0]?.id ?? "");
useEffect(() => {
if (participantId === "" && others.length > 0) {
setParticipantId(others[0].id);
}
}, [others]);
// For credits/refunds the default direction is "they paid me"
const [direction, setDirection] = useState<"received" | "sent">(
SPEND_TYPES.has(transaction.transaction_type) ? "sent" : "received"
);
const [amount, setAmount] = useState(Number(transaction.amount).toFixed(2));
const [date, setDate] = useState(transaction.transaction_date.slice(0, 10));
const [notes, setNotes] = useState("");
const [error, setError] = useState("");
const selectedParticipant = others.find((p) => p.id === participantId);
async function handleSave() {
setError("");
if (!participantId || !me) { setError("Select a participant"); return; }
const amt = parseFloat(amount);
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
try {
await record.mutateAsync({
from_participant_id: direction === "received" ? participantId : me.id,
to_participant_id: direction === "received" ? me.id : participantId,
amount: amt,
payment_date: date,
notes: notes || undefined,
linked_transaction_id: transaction.id,
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to record");
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div
className="bg-zinc-900 border border-zinc-700 rounded-xl w-full max-w-sm mx-4 shadow-2xl p-6 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div>
<h3 className="font-semibold text-sm text-zinc-300">Record as Debt Payment</h3>
<p className="text-xs text-zinc-500 mt-0.5 truncate">{transaction.description}</p>
</div>
{/* Participant */}
<div>
<label className="block text-xs text-zinc-500 mb-1">Participant</label>
<select
value={participantId}
onChange={(e) => setParticipantId(Number(e.target.value))}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{others.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* Direction */}
<div className="flex rounded-lg overflow-hidden border border-zinc-700 text-sm">
<button
type="button"
onClick={() => setDirection("received")}
className={`flex-1 py-1.5 transition-colors ${direction === "received" ? "bg-emerald-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
{selectedParticipant?.name ?? "They"} paid me
</button>
<button
type="button"
onClick={() => setDirection("sent")}
className={`flex-1 py-1.5 transition-colors ${direction === "sent" ? "bg-blue-700 text-white" : "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"}`}
>
I paid {selectedParticipant?.name ?? "them"}
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-zinc-500 mb-1">Amount</label>
<div className="relative">
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm">$</span>
<input
type="number" step="0.01" min="0.01" value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm pl-6"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Date</label>
<input
type="date" value={date} onChange={(e) => setDate(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
<input
value={notes} onChange={(e) => setNotes(e.target.value)}
placeholder="e.g. bank transfer reference"
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<div className="flex gap-2">
<button type="button" onClick={onClose}
className="flex-1 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
Cancel
</button>
<button type="button" onClick={handleSave} disabled={record.isPending}
className="flex-1 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
{record.isPending ? "Saving…" : "Record Payment"}
</button>
</div>
</div>
</div>
);
}
// ── Query bar parser ──────────────────────────────────────────────────────────
interface QueryToken { key: string; label: string }
interface ParsedQuery { text: string; amountMin?: number; amountMax?: number; tokens: QueryToken[] }
function parseQuery(input: string): ParsedQuery {
let text = input;
let amountMin: number | undefined;
let amountMax: number | undefined;
const tokens: QueryToken[] = [];
// Range shorthand: 500-1500
text = text.replace(/\b(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\b/g, (_, a, b) => {
amountMin = parseFloat(a);
amountMax = parseFloat(b);
tokens.push({ key: "range", label: `$${a}$${b}` });
return "";
});
// Operators: >=500 <=500 >500 <500
text = text.replace(/(>=|<=|>|<)\s*(\d+(?:\.\d+)?)/g, (_, op, num) => {
const val = parseFloat(num);
if (op === ">") amountMin = val + 0.005;
else if (op === ">=") amountMin = val;
else if (op === "<") amountMax = Math.max(0, val - 0.005);
else if (op === "<=") amountMax = val;
const display = op === ">=" ? "≥" : op === "<=" ? "≤" : op;
tokens.push({ key: `amt_${op}`, label: `${display} $${num}` });
return "";
});
return { text: text.trim(), amountMin, amountMax, tokens };
}
// ── MultiSelect dropdown ──────────────────────────────────────────────────────
function MultiSelect({
options,
value,
onChange,
placeholder,
}: {
options: { value: string; label: string }[];
value: string[];
onChange: (v: string[]) => void;
placeholder: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
function handler(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
const toggle = (v: string) =>
onChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
const label =
value.length === 0
? placeholder
: value.length === 1
? (options.find((o) => o.value === value[0])?.label ?? value[0])
: `${value.length} selected`;
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`bg-zinc-900 border rounded px-3 py-1.5 text-sm flex items-center gap-2 min-w-[140px] whitespace-nowrap ${
value.length > 0 ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"
}`}
>
<span className="flex-1 text-left truncate">{label}</span>
<span className="text-zinc-500 text-xs"></span>
</button>
{open && (
<div className="absolute top-full mt-1 z-20 bg-zinc-900 border border-zinc-700 rounded-lg shadow-xl min-w-[160px] max-h-64 overflow-y-auto">
{options.map((o) => (
<label
key={o.value}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm"
>
<input
type="checkbox"
checked={value.includes(o.value)}
onChange={() => toggle(o.value)}
className="accent-indigo-500 flex-shrink-0"
/>
{o.label}
</label>
))}
</div>
)}
</div>
);
}
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: "",
categories: [] as string[],
bank_names: [] as string[],
tag_ids: [] as string[],
transaction_types: [] as string[],
search: "",
statement_id: initialStatementId,
sort_by: "transaction_date",
sort_dir: "desc",
limit: 50,
offset: 0,
amount_min: undefined as number | undefined,
amount_max: undefined as number | undefined,
});
const [queryInput, setQueryInput] = useState("");
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
function handleQueryChange(val: string) {
setQueryInput(val);
const parsed = parseQuery(val);
setQueryTokens(parsed.tokens);
setFilters((f) => ({
...f,
search: parsed.text,
amount_min: parsed.amountMin,
amount_max: parsed.amountMax,
offset: 0,
}));
}
function clearQueryToken(key: string) {
// Rebuild input without the token's contribution by re-running parse on cleared input
// Simplest: just clear the whole query bar
const next = queryInput
.replace(/(>=|<=|>|<)\s*\d+(?:\.\d+)?/g, "")
.replace(/\b\d+(?:\.\d+)?\s*-\s*\d+(?:\.\d+)?\b/g, "")
.trim();
handleQueryChange(next);
}
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 [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
const [paymentModal, setPaymentModal] = useState<TransactionRow | 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: me } = useCurrentUser();
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>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Transactions</h2>
<button
onClick={() => setAddModal({})}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium"
>
+ Add Transaction
</button>
</div>
{/* 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-2">
{/* Smart query bar */}
<div className="flex flex-col gap-1">
<input
type="text"
value={queryInput}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder="Search… or >500 <=1500 200-800"
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm w-64 font-mono placeholder:font-sans placeholder:text-zinc-600"
/>
{queryTokens.length > 0 && (
<div className="flex flex-wrap gap-1">
{queryTokens.map((tok) => (
<span
key={tok.key}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-indigo-900/50 border border-indigo-700/50 text-indigo-300"
>
{tok.label}
<button
type="button"
onClick={() => clearQueryToken(tok.key)}
className="text-indigo-400 hover:text-white leading-none"
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Date range */}
<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"
/>
{/* Multi-select dropdowns */}
<MultiSelect
options={CATEGORIES.map((c) => ({ value: c, label: formatCategory(c) }))}
value={filters.categories}
onChange={(v) => setFilters((f) => ({ ...f, categories: v, offset: 0 }))}
placeholder="All Categories"
/>
<MultiSelect
options={(banks ?? []).map((b) => ({ value: b, label: b }))}
value={filters.bank_names}
onChange={(v) => setFilters((f) => ({ ...f, bank_names: v, offset: 0 }))}
placeholder="All Banks"
/>
<MultiSelect
options={[{ value: "untagged", label: "No tags" }, ...(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))]}
value={filters.tag_ids}
onChange={(v) => {
let next = v;
if (v.includes("untagged") && !filters.tag_ids.includes("untagged")) next = ["untagged"];
else if (filters.tag_ids.includes("untagged") && v.length > 1) next = v.filter((x) => x !== "untagged");
setFilters((f) => ({ ...f, tag_ids: next, offset: 0 }));
}}
placeholder="All Tags"
/>
<MultiSelect
options={TYPE_OPTIONS}
value={filters.transaction_types}
onChange={(v) => setFilters((f) => ({ ...f, transaction_types: v, offset: 0 }))}
placeholder="All Types"
/>
</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">
<p className="truncate" title={t.description}>{t.description}</p>
{t.notes && (
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
)}
</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 whitespace-nowrap">
<div className="flex items-center gap-1 flex-wrap">
{t.splits?.filter((s) => s.participant_id !== me?.id).map((s) => (
<span
key={s.participant_id}
className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium ${
s.settled ? "bg-zinc-800 text-zinc-500" : "bg-amber-900/40 text-amber-300"
}`}
title={`${s.name}: ${s.share_percent}%${s.settled ? " (settled)" : ""}`}
>
{s.name} {s.share_percent}%
</span>
))}
<button
onClick={() => setSplitModal({ transactionId: t.id, amount: t.amount, description: t.description, merchant: t.effective_merchant || undefined, transactionIds: undefined })}
className={`text-xs px-2 py-0.5 rounded transition-colors ${
t.splits?.some((s) => s.participant_id !== me?.id)
? "text-amber-400 hover:text-amber-200 hover:bg-zinc-800"
: "text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800"
}`}
title="Split this transaction"
>
Split
</button>
</div>
<button
onClick={() => setEditModal(t)}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Edit this transaction"
>
Edit
</button>
{!SPEND_TYPES.has(t.transaction_type) && (
<button
onClick={() => setPaymentModal(t)}
className="text-xs text-emerald-600 hover:text-emerald-400 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Record as debt payment"
>
Payment
</button>
)}
<button
onClick={() => setAddModal({
title: "Duplicate Transaction",
prefill: {
date: new Date().toISOString().slice(0, 10),
description: t.description,
amount: t.amount,
transaction_type: t.transaction_type,
merchant_normalized: t.effective_merchant || undefined,
category: t.effective_category || undefined,
},
})}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
title="Duplicate this transaction"
>
Dupe
</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()); }}
/>
)}
{addModal && (
<AddTransactionModal
prefill={addModal.prefill}
title={addModal.title}
onClose={() => setAddModal(null)}
/>
)}
{editModal && (
<EditTransactionModal
transaction={editModal}
onClose={() => setEditModal(null)}
/>
)}
{paymentModal && (
<MarkAsPaymentModal
transaction={paymentModal}
onClose={() => setPaymentModal(null)}
/>
)}
{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>
);
}