CI ran lint with continue-on-error because 23 errors predated it. All are fixed, so the flag is gone and a typecheck step is added beside it — nothing else in CI compiles the test files, so a type error in a fixture could only ever be found on a developer's machine. Real fixes, no blanket disables and no suppressions: - react-hooks/set-state-in-effect (6): each was "reset or seed state when an input changes", so each becomes a state adjustment during render behind a previous-value guard — the documented React pattern for it. Two were also latent bugs. transactions/page.tsx depended on `others`, rebuilt by .filter() every render, so the effect already ran every render, one commit late. shared/page.tsx depended on the `tagIds` array identity, so the row window was reset more often than "changing any filter" claims — including after "Show more", the one interaction it must not undo; the key now joins by value. csv-import-modal reads localStorage once at mount, which is a lazy useState initializer, not an effect (SSR-guarded, though the modal is mounted from client state and never prerenders). - react-hooks/static-components (3): SortIcon was declared inside SharedPage, making it a new component type on every render. Hoisted to module scope with the sort state passed as props. - @typescript-eslint/no-explicit-any (9): the dynamically imported route handlers in two integration tests. Typed as their real signatures with one `as unknown as NextRequest` cast at the fixture boundary, so the call sites and response bodies get checked instead of opted out. - react/no-unescaped-entities (5): exact-equivalent entities (', "), so no rendered text changes. Also fixes the one pre-existing tsc error (unit/order-ingestion.test.ts:511): `options` is optional, and `?? 0` keeps the assertion failing on an absent list rather than silencing it with a non-null bang. 9 warnings remain (no-unused-vars, exhaustive-deps). eslint exits 0 on warnings, so lint blocks on errors only — deliberately, since none of the remaining warnings should stop a push.
765 lines
35 KiB
TypeScript
765 lines
35 KiB
TypeScript
"use client";
|
||
|
||
import { Fragment, memo, useCallback, useMemo, useState, useRef, useEffect } from "react";
|
||
import {
|
||
useSharedTransactions,
|
||
useParticipantBalances,
|
||
useParticipants,
|
||
useCreateParticipant,
|
||
useRecordPayment,
|
||
usePaymentHistory,
|
||
useDeletePayment,
|
||
useCurrentUser,
|
||
useTags,
|
||
useTrips,
|
||
type SplitPayment,
|
||
} from "@/lib/hooks";
|
||
import type { SharedTransactionRow } from "@/lib/queries";
|
||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||
import { OrderDetails } from "@/components/order-details";
|
||
import { formatCategory } from "@/lib/categories";
|
||
import { CATEGORY_COLORS } from "@/lib/category-colors";
|
||
|
||
// One formatter, reused. `toLocaleDateString` constructs a fresh
|
||
// Intl.DateTimeFormat per call, and this page renders every split row at once —
|
||
// 2,558 calls per render was 520ms of the expand-click freeze on its own.
|
||
const DATE_FMT = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||
function formatDate(d: string) {
|
||
return DATE_FMT.format(new Date(d));
|
||
}
|
||
|
||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||
|
||
function formatAmount(n: number, type?: string, currency?: string) {
|
||
// A bare "$" on a non-AUD row was the visible half of the problem: the row
|
||
// read as dollars while the participant balances converted to AUD, so the
|
||
// two disagreed on screen with nothing to explain why.
|
||
const value = Number(n).toFixed(2);
|
||
const formatted = !currency || currency === "AUD" ? `$${value}` : `${currency} ${value}`;
|
||
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
|
||
}
|
||
|
||
// ── Tag multi-select ──────────────────────────────────────────────────────────
|
||
function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
|
||
const { data: tags = [] } = useTags();
|
||
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 = (id: string) => {
|
||
let next: string[];
|
||
if (value.includes(id)) {
|
||
next = value.filter((x) => x !== id);
|
||
} else if (id === "untagged") {
|
||
next = ["untagged"];
|
||
} else {
|
||
next = [...value.filter((x) => x !== "untagged"), id];
|
||
}
|
||
onChange(next);
|
||
};
|
||
|
||
const label = value.length === 0 ? "All Tags"
|
||
: value.includes("untagged") ? "No tags"
|
||
: value.length === 1 ? (tags.find((t) => String(t.id) === value[0])?.name ?? "1 tag")
|
||
: `${value.length} tags`;
|
||
|
||
return (
|
||
<div ref={ref} className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
className={`border rounded px-3 py-1.5 text-sm flex items-center gap-2 min-w-[120px] bg-zinc-900 ${value.length > 0 ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"}`}
|
||
>
|
||
<span className="flex-1 text-left">{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-56 overflow-y-auto">
|
||
<label className="flex items-center gap-2 px-3 py-1.5 hover:bg-zinc-800 cursor-pointer text-sm border-b border-zinc-800">
|
||
<input type="checkbox" checked={value.includes("untagged")} onChange={() => toggle("untagged")}
|
||
className="accent-indigo-500 flex-shrink-0" />
|
||
<span className="text-zinc-400 italic">No tags</span>
|
||
</label>
|
||
{tags.map((t) => (
|
||
<label key={t.id} 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(String(t.id))} onChange={() => toggle(String(t.id))}
|
||
className="accent-indigo-500 flex-shrink-0" />
|
||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ backgroundColor: t.color }} />
|
||
{t.name}
|
||
</label>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Add Participant ───────────────────────────────────────────────────────────
|
||
function AddParticipantForm({ onDone }: { onDone: () => void }) {
|
||
const [name, setName] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [error, setError] = useState("");
|
||
const create = useCreateParticipant();
|
||
|
||
async function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setError("");
|
||
if (!name.trim()) { setError("Name is required"); return; }
|
||
try {
|
||
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
|
||
onDone();
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Failed to create");
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4 space-y-3">
|
||
<p className="text-sm font-medium">Add Participant</p>
|
||
<div className="flex gap-2">
|
||
<input type="text" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)}
|
||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" />
|
||
<input type="email" placeholder="Email (optional)" value={email} onChange={(e) => setEmail(e.target.value)}
|
||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-zinc-500" />
|
||
<button type="submit" disabled={create.isPending}
|
||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
|
||
{create.isPending ? "Adding..." : "Add"}
|
||
</button>
|
||
<button type="button" onClick={onDone}
|
||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded-lg text-sm">
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||
</form>
|
||
);
|
||
}
|
||
|
||
// ── Record Payment modal ──────────────────────────────────────────────────────
|
||
function RecordPaymentModal({
|
||
participant,
|
||
currentUserId,
|
||
currentBalance,
|
||
onClose,
|
||
}: {
|
||
participant: { id: number; name: string };
|
||
currentUserId: number;
|
||
currentBalance: number; // positive = they owe me, negative = I owe them
|
||
onClose: () => void;
|
||
}) {
|
||
const record = useRecordPayment();
|
||
const { data: trips = [] } = useTrips();
|
||
const theyOweMe = currentBalance > 0;
|
||
|
||
// Default direction matches the debt direction
|
||
const [amount, setAmount] = useState(Math.abs(currentBalance).toFixed(2));
|
||
const [date, setDate] = useState(new Date().toISOString().slice(0, 10));
|
||
const [notes, setNotes] = useState("");
|
||
// direction: "received" = they paid me, "sent" = I paid them
|
||
const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent");
|
||
// Which tab this settles. "" = the ongoing household tab (trip_id NULL).
|
||
const [tripId, setTripId] = useState("");
|
||
const [error, setError] = useState("");
|
||
|
||
async function handleSave() {
|
||
setError("");
|
||
const amt = parseFloat(amount);
|
||
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
|
||
try {
|
||
await record.mutateAsync({
|
||
from_participant_id: direction === "received" ? participant.id : currentUserId,
|
||
to_participant_id: direction === "received" ? currentUserId : participant.id,
|
||
amount: amt,
|
||
payment_date: date,
|
||
notes: notes || undefined,
|
||
trip_id: tripId ? Number(tripId) : null,
|
||
});
|
||
onClose();
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Failed to record payment");
|
||
}
|
||
}
|
||
|
||
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()}>
|
||
<h3 className="font-semibold text-sm text-zinc-300">Record Payment</h3>
|
||
|
||
{/* Direction toggle */}
|
||
<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"}`}
|
||
>
|
||
{participant.name} 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 {participant.name}
|
||
</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>
|
||
|
||
{/* Scope. Until now every payment recorded here landed on the household
|
||
tab, because the API dropped trip_id — so a $11k Europe settlement
|
||
silently reduced the ongoing household balance instead. */}
|
||
<div>
|
||
<label className="block text-xs text-zinc-500 mb-1">Settles</label>
|
||
<select value={tripId} onChange={(e) => setTripId(e.target.value)}
|
||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm">
|
||
<option value="">Household (ongoing)</option>
|
||
{trips.filter((t) => !t.archived).map((t) => (
|
||
<option key={t.id} value={t.id}>{t.name}</option>
|
||
))}
|
||
</select>
|
||
<p className="text-[11px] text-zinc-600 mt-1">
|
||
Covering more than one tab? Record it once per tab — the parts add back
|
||
up to the transfer.
|
||
</p>
|
||
</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, cash"
|
||
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"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Payment history inline ────────────────────────────────────────────────────
|
||
function PaymentHistory({ participantId, currentUserId }: { participantId: number; currentUserId: number }) {
|
||
const { data: payments = [], isLoading } = usePaymentHistory(participantId);
|
||
const deletePayment = useDeletePayment();
|
||
|
||
if (isLoading) return <p className="text-xs text-zinc-600 mt-2">Loading payments…</p>;
|
||
if (payments.length === 0) return <p className="text-xs text-zinc-600 italic mt-2">No payments recorded</p>;
|
||
|
||
return (
|
||
<div className="mt-3 space-y-1.5">
|
||
<p className="text-xs text-zinc-500 font-medium">Payment history</p>
|
||
{payments.map((p: SplitPayment) => {
|
||
const theyPaidMe = p.to_participant_id === currentUserId;
|
||
return (
|
||
<div key={p.id} className="flex items-center gap-2 text-xs">
|
||
<span className={`font-mono font-medium ${theyPaidMe ? "text-emerald-400" : "text-blue-400"}`}>
|
||
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)}
|
||
</span>
|
||
<span className="text-zinc-500">{formatDate(p.payment_date)}</span>
|
||
{/* Scope, so a grouped transfer stops looking like a duplicate: two
|
||
rows of the same amount and date differ only by which tab they
|
||
settle, and that was invisible until the API returned trip_id. */}
|
||
<span className="text-[11px] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400 flex-shrink-0">
|
||
{p.trip_name ?? "Household"}
|
||
</span>
|
||
{p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>}
|
||
<button
|
||
onClick={() => deletePayment.mutate(p.id)}
|
||
className="text-zinc-600 hover:text-red-400 leading-none ml-auto flex-shrink-0"
|
||
title="Delete payment"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Split transaction row ─────────────────────────────────────────────────────
|
||
// Memoized: this table renders every split row at once (1,279 today, no
|
||
// pagination), so any state change on the page — expanding a receipt, each of
|
||
// its two query results arriving — re-rendered all of them, ~800ms per pass on
|
||
// a fast machine and a multi-second browser freeze on a slow one. With memo,
|
||
// only the row whose `isExpanded` flipped re-renders. Every prop must stay
|
||
// referentially stable: `tx` objects come straight out of the query cache, and
|
||
// the callbacks are a state setter and a useCallback.
|
||
const SharedTxRow = memo(function SharedTxRow({
|
||
tx,
|
||
isExpanded,
|
||
meId,
|
||
onToggle,
|
||
onEdit,
|
||
}: {
|
||
tx: SharedTransactionRow;
|
||
isExpanded: boolean;
|
||
meId: number | undefined;
|
||
onToggle: (id: number) => void;
|
||
onEdit: (tx: SharedTransactionRow) => void;
|
||
}) {
|
||
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||
return (
|
||
<Fragment>
|
||
<tr className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
||
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
||
<div className="flex items-start gap-1.5">
|
||
{tx.order_platform && (
|
||
<button
|
||
onClick={() => onToggle(tx.id)}
|
||
className="text-zinc-600 hover:text-zinc-300 leading-none mt-0.5 shrink-0"
|
||
title={isExpanded ? "Hide receipt" : "Show the receipt this came from"}
|
||
aria-expanded={isExpanded}
|
||
>
|
||
{isExpanded ? "▾" : "▸"}
|
||
</button>
|
||
)}
|
||
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
|
||
</div>
|
||
{tx.effective_merchant && (
|
||
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
|
||
)}
|
||
{tx.notes && (
|
||
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
|
||
)}
|
||
</td>
|
||
{/* Category is the effective one — the override wins over the
|
||
extracted value, the same COALESCE every other view uses,
|
||
so a correction made elsewhere shows up here too. */}
|
||
<td className="px-4 py-3 whitespace-nowrap">
|
||
{tx.effective_category ? (
|
||
<span
|
||
className="inline-flex items-center gap-1.5 text-xs text-zinc-300"
|
||
title={formatCategory(tx.effective_category)}
|
||
>
|
||
<span
|
||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||
style={{ background: CATEGORY_COLORS[tx.effective_category] ?? "#71717a" }}
|
||
/>
|
||
{formatCategory(tx.effective_category)}
|
||
</span>
|
||
) : (
|
||
<span className="text-xs text-zinc-600 italic">uncategorised</span>
|
||
)}
|
||
</td>
|
||
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
|
||
{formatAmount(tx.amount, tx.transaction_type, tx.currency)}
|
||
{tx.currency !== "AUD" && (
|
||
// Splits settle on the AUD figure, so show it next to the
|
||
// native one rather than leaving the two to differ silently.
|
||
<span className="block text-xs font-normal text-zinc-500">
|
||
{tx.amount_unconverted
|
||
? "AUD value unknown"
|
||
: `≈ ${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`}
|
||
</span>
|
||
)}
|
||
</td>
|
||
{/* Whose money actually left. This is the effective owner —
|
||
COALESCE(t.owner_id, s.owner_id) — so it is the account the
|
||
spend came out of, which is what every balance on this page
|
||
is computed from. "Me" matches the split chips rather than
|
||
printing your own name twice in one row. */}
|
||
<td className="px-4 py-3 whitespace-nowrap">
|
||
<span className={`text-xs ${tx.owner_id === meId ? "text-zinc-400" : "text-indigo-300"}`}>
|
||
{tx.owner_id === meId ? "Me" : tx.owner_name}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex flex-wrap gap-1">
|
||
{splits.map((s) => (
|
||
<span key={s.participant_id}
|
||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
|
||
{s.participant_id === meId ? "Me" : s.name} {s.share_percent}%
|
||
</span>
|
||
))}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<button
|
||
onClick={() => onEdit(tx)}
|
||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||
>
|
||
Edit
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
{isExpanded && (
|
||
<tr className="border-b border-zinc-800/50 bg-zinc-900/40">
|
||
<td colSpan={8} className="px-4 py-3">
|
||
<OrderDetails transactionId={tx.id} currency={tx.currency ?? null} bare />
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</Fragment>
|
||
);
|
||
});
|
||
|
||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||
type SortCol = "transaction_date" | "created_at" | "amount";
|
||
|
||
/**
|
||
* Sort direction marker for a column header.
|
||
*
|
||
* Declared at module scope and given the sort state as props, rather than
|
||
* closing over it inside SharedPage: a component defined during render is a
|
||
* new type on every render, so React unmounts and remounts it each time
|
||
* instead of updating it.
|
||
*/
|
||
function SortIcon({ col, sortCol, sortDir }: { col: SortCol; sortCol: SortCol; sortDir: "asc" | "desc" }) {
|
||
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
|
||
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
|
||
}
|
||
|
||
export default function SharedPage() {
|
||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||
const [participantId, setParticipantId] = useState<number | undefined>(undefined);
|
||
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
|
||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||
const [search, setSearch] = useState("");
|
||
const realTagIds = tagIds.filter((id) => id !== "untagged");
|
||
const { data: participants = [] } = useParticipants();
|
||
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId);
|
||
|
||
// Filtered client-side, like the sort above and unlike the transactions page.
|
||
// This endpoint returns every split row in one go (1,267 today) with no
|
||
// pagination, so there is nothing for a server round-trip to narrow — and a
|
||
// server search would have to be added to a query the balance cards share.
|
||
//
|
||
// Deliberately does NOT match participant names: the participant dropdown
|
||
// already does that properly, and typing "sonu" matching every row she is
|
||
// split on would make the box look broken. The payer IS matched, because
|
||
// nothing else on the page filters by who paid.
|
||
const transactions = useMemo(
|
||
() =>
|
||
[...rawTransactions]
|
||
.filter((tx) => {
|
||
const q = search.trim().toLowerCase();
|
||
if (!q) return true;
|
||
return [
|
||
tx.description,
|
||
tx.effective_merchant,
|
||
tx.notes,
|
||
tx.effective_category ? formatCategory(tx.effective_category) : null,
|
||
tx.owner_name,
|
||
].some((f) => f?.toLowerCase().includes(q));
|
||
})
|
||
.sort((a, b) => {
|
||
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
||
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
||
return sortDir === "desc" ? bv - av : av - bv;
|
||
}),
|
||
[rawTransactions, search, sortCol, sortDir]
|
||
);
|
||
|
||
function toggleSort(col: SortCol) {
|
||
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||
else { setSortCol(col); setSortDir("desc"); }
|
||
}
|
||
|
||
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
|
||
const { data: allTags = [] } = useTags();
|
||
const { data: me } = useCurrentUser();
|
||
|
||
// Names the tag scope when one is active. Non-empty means the cards below are
|
||
// split totals rather than payable balances.
|
||
const tagScopeLabel =
|
||
realTagIds.length === 0
|
||
? null
|
||
: realTagIds.length === 1
|
||
? (allTags.find((t) => String(t.id) === realTagIds[0])?.name ?? "this tag")
|
||
: `${realTagIds.length} tags`;
|
||
const [addingParticipant, setAddingParticipant] = useState(false);
|
||
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
|
||
const [showHistory, setShowHistory] = useState<number | null>(null);
|
||
const [editModal, setEditModal] = useState<SharedTransactionRow | null>(null);
|
||
// Rendering is windowed even though the data is not: the query returns every
|
||
// split row (1,279 as of 2026-08, growing ~600/yr) so search and sort stay
|
||
// instant over full history, but the DOM stops at visibleCount — an unbounded
|
||
// table was what made expanding a receipt freeze the browser. "Show more"
|
||
// extends the window; changing any filter resets it.
|
||
const [visibleCount, setVisibleCount] = useState(100);
|
||
// Reset during render behind a previous-value guard rather than in an effect.
|
||
// Note the key joins `tagIds` by value: as a raw dependency it was a fresh
|
||
// array on many renders, so the window was being reset more often than
|
||
// "changing any filter" claims — including after "Show more", which is the
|
||
// one interaction this must not undo.
|
||
const windowKey = `${search}|${sortCol}|${sortDir}|${tagIds.join(",")}|${participantId ?? ""}`;
|
||
const [lastWindowKey, setLastWindowKey] = useState(windowKey);
|
||
if (windowKey !== lastWindowKey) {
|
||
setLastWindowKey(windowKey);
|
||
setVisibleCount(100);
|
||
}
|
||
const visible = (transactions as SharedTransactionRow[]).slice(0, visibleCount);
|
||
|
||
// Receipt expansion, same pattern as the transactions page. The shared
|
||
// viewer is a split participant, so /api/transactions/[id]/order
|
||
// authorises them — the item list is part of what was shared.
|
||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||
// Stable identity so SharedTxRow's memo holds — an inline closure here would
|
||
// change every render and re-render all 1,279 rows anyway.
|
||
const toggleExpanded = useCallback((id: number) => {
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id); else next.add(id);
|
||
return next;
|
||
});
|
||
}, []);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<h2 className="text-2xl font-display">Shared Expenses</h2>
|
||
<div className="flex items-center gap-2 ml-auto flex-wrap">
|
||
<div className="relative">
|
||
<input
|
||
type="search"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
placeholder="Search description, merchant, category, payer…"
|
||
aria-label="Search split transactions"
|
||
className="w-64 bg-zinc-800 border border-zinc-700 rounded-lg pl-8 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500"
|
||
/>
|
||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm pointer-events-none">⌕</span>
|
||
</div>
|
||
<select
|
||
value={participantId ?? ""}
|
||
onChange={(e) => setParticipantId(e.target.value ? Number(e.target.value) : undefined)}
|
||
className={`border rounded px-3 py-1.5 text-sm bg-zinc-900 ${participantId ? "border-indigo-500 text-white" : "border-zinc-700 text-zinc-400"}`}
|
||
>
|
||
<option value="">All People</option>
|
||
{participants.map((p: { id: number; name: string }) => (
|
||
<option key={p.id} value={p.id}>{p.name}</option>
|
||
))}
|
||
</select>
|
||
<TagFilter value={tagIds} onChange={setTagIds} />
|
||
{!addingParticipant && (
|
||
<button onClick={() => setAddingParticipant(true)}
|
||
className="text-sm px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg whitespace-nowrap">
|
||
+ Add Participant
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{addingParticipant && <AddParticipantForm onDone={() => setAddingParticipant(false)} />}
|
||
|
||
{/* Balance cards */}
|
||
{realTagIds.length === 0 && tagIds.includes("untagged") ? null : realTagIds.length > 0 && (
|
||
<p className="text-xs text-zinc-500 mb-2">Showing split totals for selected tag — payments excluded (payments settle overall debt, not per-tag)</p>
|
||
)}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
{balLoading ? (
|
||
<p className="text-zinc-500 text-sm col-span-3">Loading balances...</p>
|
||
) : balances.length === 0 ? (
|
||
<p className="text-zinc-500 text-sm col-span-3">No participants yet.</p>
|
||
) : (
|
||
balances.map((b) => {
|
||
const theyOweMe = b.total_owed > 0;
|
||
const net = Math.abs(b.total_owed);
|
||
const settled = net < 0.005;
|
||
return (
|
||
<div key={b.id} className="bg-zinc-900 border border-zinc-700 rounded-xl p-4">
|
||
<div className="flex items-start justify-between mb-3">
|
||
<div>
|
||
<p className="font-medium">{b.name}</p>
|
||
<p className="text-xs text-zinc-500">
|
||
{/* With a tag filter on, payments are deliberately not
|
||
subtracted — so this is a split total, not a payable
|
||
balance, and must not claim to be one. */}
|
||
{tagScopeLabel
|
||
? `split total in ${tagScopeLabel}`
|
||
: settled ? "all square" : theyOweMe ? "owes you" : "you owe"}
|
||
</p>
|
||
</div>
|
||
<div className="text-right">
|
||
<p className={`text-lg font-semibold ${tagScopeLabel ? "text-zinc-300" : settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
|
||
${net.toFixed(2)}
|
||
</p>
|
||
{b.unconverted_count > 0 && (
|
||
<p className="text-[11px] text-amber-500/80 mt-0.5">
|
||
approx · {b.unconverted_count} unconverted
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-2">
|
||
{/* Settling against a tag-scoped total would record a payment
|
||
for a figure that never was the debt. */}
|
||
{!tagScopeLabel && (
|
||
<button
|
||
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
|
||
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
|
||
>
|
||
Record Payment
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => setShowHistory(showHistory === b.id ? null : b.id)}
|
||
className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`}
|
||
>
|
||
History
|
||
</button>
|
||
</div>
|
||
|
||
{showHistory === b.id && me && (
|
||
<PaymentHistory participantId={b.id} currentUserId={me.id} />
|
||
)}
|
||
</div>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* Transaction list */}
|
||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-x-auto">
|
||
<div className="px-4 py-3 border-b border-zinc-800 flex items-center gap-2">
|
||
<h3 className="text-sm font-medium">Split Transactions</h3>
|
||
{search.trim() && !txLoading && (
|
||
<span className="text-xs text-zinc-500">
|
||
{transactions.length} of {rawTransactions.length} match “{search.trim()}”
|
||
</span>
|
||
)}
|
||
</div>
|
||
{txLoading ? (
|
||
<p className="text-zinc-500 text-sm px-4 py-6">Loading...</p>
|
||
) : transactions.length === 0 ? (
|
||
// "None yet" is wrong when a search simply matched nothing, and it reads
|
||
// as though the splits were lost.
|
||
search.trim() ? (
|
||
<p className="text-zinc-500 text-sm px-4 py-6">
|
||
Nothing matches “{search.trim()}”.{" "}
|
||
<button onClick={() => setSearch("")} className="text-zinc-400 hover:text-zinc-200 underline">
|
||
Clear search
|
||
</button>
|
||
</p>
|
||
) : (
|
||
<p className="text-zinc-500 text-sm px-4 py-6">
|
||
No split transactions yet. Use the Split button on any transaction.
|
||
</p>
|
||
)
|
||
) : (
|
||
<table className="w-full text-sm min-w-[760px]">
|
||
{/* min-w raised from 520px with the Category and Paid-by columns: the
|
||
wrapper scrolls horizontally, so a too-small minimum crushes cells
|
||
rather than letting them scroll. */}
|
||
<thead>
|
||
<tr className="border-b border-zinc-800">
|
||
<th
|
||
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||
onClick={() => toggleSort("transaction_date")}
|
||
>
|
||
Date <SortIcon col="transaction_date" sortCol={sortCol} sortDir={sortDir} />
|
||
</th>
|
||
<th
|
||
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||
onClick={() => toggleSort("created_at")}
|
||
>
|
||
Imported <SortIcon col="created_at" sortCol={sortCol} sortDir={sortDir} />
|
||
</th>
|
||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
|
||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||
<th
|
||
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
|
||
onClick={() => toggleSort("amount")}
|
||
>
|
||
Amount <SortIcon col="amount" sortCol={sortCol} sortDir={sortDir} />
|
||
</th>
|
||
{/* Paid by sits next to Splits deliberately: together they are the
|
||
two halves of the question this page exists to answer — whose
|
||
money went out, and whose share it was. */}
|
||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium whitespace-nowrap">Paid by</th>
|
||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
|
||
<th className="px-4 py-2"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{visible.map((tx) => (
|
||
<SharedTxRow
|
||
key={tx.id}
|
||
tx={tx}
|
||
isExpanded={expanded.has(tx.id)}
|
||
meId={me?.id}
|
||
onToggle={toggleExpanded}
|
||
onEdit={setEditModal}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
{!txLoading && transactions.length > visible.length && (
|
||
<div className="px-4 py-3 border-t border-zinc-800 flex items-center gap-3 text-xs">
|
||
<button
|
||
onClick={() => setVisibleCount((c) => c + 100)}
|
||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
|
||
>
|
||
Show more
|
||
</button>
|
||
<button
|
||
onClick={() => setVisibleCount(transactions.length)}
|
||
className="text-zinc-500 hover:text-zinc-300 underline underline-offset-2"
|
||
>
|
||
Show all
|
||
</button>
|
||
<span className="text-zinc-600 ml-auto tabular-nums">
|
||
showing {visible.length} of {transactions.length}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Payment modal */}
|
||
{paymentModal && me && (
|
||
<RecordPaymentModal
|
||
participant={{ id: paymentModal.id, name: paymentModal.name }}
|
||
currentUserId={me.id}
|
||
currentBalance={paymentModal.balance}
|
||
onClose={() => setPaymentModal(null)}
|
||
/>
|
||
)}
|
||
|
||
{editModal && (
|
||
<EditTransactionModal
|
||
transaction={editModal}
|
||
onClose={() => setEditModal(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|