Files
finance-app/src/app/transactions/page.tsx
T
siddharthd 3bb67f370d
ci / lint-test (push) Failing after 41s
feat(orders): show where an Uber trip went, in the list
Five rows all reading "Order - Uber Trip" are indistinguishable — the list gives
you a date and an amount and nothing to tell one ride from another (user,
2026-07-27). Where the trip went is exactly what separates them, and it was
already stored on expense_metadata.route since this morning; nothing in the list
read it.

getTransactions now joins the receipt (both directions — transaction_id OR
matched_transaction_id, since a card-settled order points at the statement line
instead) and the description cell renders "Terminal 2, Melbourne Airport (MEL)
→ 19 Lady Penrhyn Dr" in the same italic sub-line notes use.

Two deliberate limits:

- **A note the user wrote always wins.** This only fills an empty sub-line; it
  never occupies the notes field, which is theirs.
- **Deliveries are excluded.** Their merchant already identifies them, so the
  restaurant's street address would be clutter on every food order. Gated on
  platform = 'uber'.

The summary keeps the first two comma-segments of each address — a truncation,
not a guess about geography. Uber puts the venue or street first, which is the
identifying part; the full stops with their times stay in the title attribute.
2026-07-27 11:43:55 +10:00

1170 lines
46 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, useTrips, useAssignTransactionsToTrip, useRules } 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 { CsvImportModal } from "@/components/csv-import-modal";
import type { TransactionRow, RoutePointRow } from "@/lib/queries";
import type { RuleRow } from "@/lib/hooks";
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"]);
// `amount` is in the statement's native currency; pass the currency to label it
// correctly. Callers showing a headline figure should pass amount_aud, which is
// what every analytics query totals.
function formatAmount(amount: number, type: string, currency = "AUD") {
const formatted = new Intl.NumberFormat("en-AU", {
style: "currency",
currency,
}).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 }));
/** Tooltip text for a quick-action button: what the rule will actually do. */
function describeActions(
actions: RuleRow["actions"],
tags: { id: number; name: string }[] = [],
participants: { id: number; name: string }[] = []
): string {
const parts: string[] = [];
if (actions.set_category) parts.push(`category → ${formatCategory(actions.set_category)}`);
if (actions.set_merchant) parts.push(`merchant → ${actions.set_merchant}`);
if (actions.add_tag_ids?.length) {
const names = actions.add_tag_ids.map((id) => tags.find((t) => t.id === id)?.name ?? `tag#${id}`);
parts.push(`tag: ${names.join(", ")}`);
}
if (actions.apply_split?.length) {
const shares = actions.apply_split.map(
(s) => `${participants.find((p) => p.id === s.participant_id)?.name ?? `#${s.participant_id}`} ${s.share_percent}%`
);
parts.push(`split: ${shares.join(" / ")}`);
}
return parts.join(" · ") || "no actions";
}
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>
);
}
/**
* "Melbourne Airport (MEL) → Wyndham Vale VIC 3024" from the two stops on an
* Uber *trip* receipt. Deliveries are excluded by the caller: their merchant
* already identifies them, so the restaurant's street address would be clutter
* on every food order.
*
* Keeps the first two comma-segments of each address — a truncation, not a
* guess about geography. The venue or street comes first in Uber's format and
* is the identifying part; the full text stays in the title attribute.
*/
function routeSummary(route: RoutePointRow[] | null | undefined): string | null {
if (!route || route.length < 2) return null;
const short = (a: string) => a.split(",").slice(0, 2).join(",").trim();
const from = short(route[0].address);
const to = short(route[route.length - 1].address);
if (!from || !to) return null;
return `${from}${to}`;
}
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,
has_split: "" as string,
trip_id: "" as string,
});
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 [bulkTripId, setBulkTripId] = 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 [showImportModal, setShowImportModal] = useState(false);
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 { data: trips = [] } = useTrips();
const assignToTrip = useAssignTransactionsToTrip();
const { data: allRules = [] } = useRules();
const { data: participants = [] } = useParticipants();
const quickActions = allRules.filter((r) => r.manual_only);
const [quickResult, setQuickResult] = useState<string | null>(null);
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-2xl font-display">Transactions</h2>
<div className="flex gap-2">
<button
onClick={() => setShowImportModal(true)}
className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm font-medium"
>
Import CSV
</button>
<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>
</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-full 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"
/>
<select
value={filters.has_split}
onChange={(e) => setFilters((f) => ({ ...f, has_split: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
>
<option value="">All Splits</option>
<option value="yes">Split only</option>
<option value="no">Unsplit only</option>
</select>
<select
value={filters.trip_id}
onChange={(e) => setFilters((f) => ({ ...f, trip_id: e.target.value, offset: 0 }))}
className="bg-zinc-900 border border-zinc-700 rounded px-3 py-1.5 text-sm text-zinc-300"
>
<option value="">All Trips</option>
<option value="unassigned">No Trip</option>
{trips.map((t) => (
<option key={t.id} value={String(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>
<select
value={bulkTripId}
onChange={(e) => setBulkTripId(e.target.value)}
className="bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm"
>
<option value="">Assign trip</option>
<option value="remove">Remove from trip</option>
{trips.filter((t) => !t.archived).map((t) => (
<option key={t.id} value={String(t.id)}>{t.name}</option>
))}
</select>
<button
disabled={!bulkTripId || assignToTrip.isPending}
onClick={() => {
const tripId = bulkTripId === "remove" ? null : Number(bulkTripId);
assignToTrip.mutate(
{ tripId, transactionIds: Array.from(selected) },
{ onSuccess: () => { setSelected(new Set()); setBulkTripId(""); } }
);
}}
className="px-3 py-1 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 rounded text-sm"
>
{bulkTripId === "remove" ? "Remove" : "Assign"}
</button>
{quickActions.length > 0 && (
<div className="flex items-center gap-2 pl-3 ml-1 border-l border-zinc-700">
{quickActions.map((rule) => (
<button
key={rule.id}
disabled={bulkAction.isPending}
title={describeActions(rule.actions, tags, participants)}
onClick={() => {
const count = selected.size;
bulkAction.mutate(
{ action: "apply_rule", ids: Array.from(selected), rule_id: rule.id },
{
onSuccess: () => {
setSelected(new Set());
setQuickResult(`${rule.name} applied to ${count} transaction${count !== 1 ? "s" : ""}`);
},
}
);
}}
className="px-3 py-1 bg-amber-700/80 hover:bg-amber-600 disabled:opacity-50 rounded text-sm"
>
{rule.name}
</button>
))}
</div>
)}
<button
onClick={() => setSelected(new Set())}
className="px-3 py-1 text-zinc-400 hover:text-white text-sm"
>
Clear
</button>
</div>
)}
{quickResult && (
<div className="flex items-center gap-3 mb-3 px-3 py-2 bg-emerald-900/30 border border-emerald-700 rounded text-sm text-emerald-200">
<span>{quickResult}</span>
<a href="/rules" className="text-emerald-400 hover:text-emerald-300 underline">
undo from Rules Apply History
</a>
<button onClick={() => setQuickResult(null)} className="ml-auto text-zinc-400 hover:text-white">
dismiss
</button>
</div>
)}
{/* Table */}
<div className="overflow-x-auto border border-zinc-800 rounded-lg">
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/50">
<th className="p-2 w-8 sticky left-0 z-10 bg-zinc-900">
<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 sticky left-8 z-10 bg-zinc-900 border-r border-zinc-800/80 whitespace-nowrap"
onClick={() => toggleSort("transaction_date")}
>
Date {filters.sort_by === "transaction_date" && (filters.sort_dir === "desc" ? "\u2193" : "\u2191")}
</th>
<th
className="p-2 text-left text-zinc-500 cursor-pointer hover:text-white whitespace-nowrap"
onClick={() => toggleSort("created_at")}
>
Imported {filters.sort_by === "created_at" && (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={11} className="p-8 text-center text-zinc-500">Loading...</td></tr>
) : !data?.data.length ? (
<tr><td colSpan={11} 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 sticky left-0 z-10 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>
<input
type="checkbox"
checked={selected.has(t.id)}
onChange={() => toggleSelect(t.id)}
className="accent-blue-600"
/>
</td>
<td className={`p-2 whitespace-nowrap sticky left-8 z-10 border-r border-zinc-800/80 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>{formatDate(t.transaction_date)}</td>
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</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>
) : t.order_platform === "uber" && routeSummary(t.order_route) && (
// Five rows all reading "Order - Uber Trip" are
// indistinguishable. Where the trip went is what tells
// them apart, and it was already stored. A note the user
// wrote always wins — this only fills an empty line.
<p
className="truncate text-xs text-zinc-500 italic mt-0.5"
title={t.order_route!.map((r) => `${r.label}${r.time ? ` ${r.time}` : ""}: ${r.address}`).join("\n")}
>
{routeSummary(t.order_route)}
</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_aud ?? t.amount, t.transaction_type)}
{t.currency && t.currency !== "AUD" && (
<div className="text-[10px] text-zinc-500 mt-0.5">
{formatAmount(t.amount, t.transaction_type, t.currency)}
</div>
)}
</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_aud ?? 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,
// Duplicates become manual (AUD) transactions, so seed
// them with the converted figure, not the native one.
amount: t.amount_aud ?? 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()); }}
/>
)}
{showImportModal && <CsvImportModal onClose={() => setShowImportModal(false)} />}
{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>
);
}