fix(payment): crash on open due to amount.toFixed on numeric string

feat(shared): tag filter on shared transactions list
This commit is contained in:
2026-03-14 21:27:08 +11:00
parent 084b8764e3
commit 02ac136e19
5 changed files with 81 additions and 18 deletions
+3 -1
View File
@@ -6,6 +6,8 @@ export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const transactions = await getSharedTransactions(user.id);
const tagParam = req.nextUrl.searchParams.get("tag_ids");
const tagIds = tagParam ? tagParam.split(",").map(Number).filter(Boolean) : undefined;
const transactions = await getSharedTransactions(user.id, tagIds);
return NextResponse.json(transactions);
}
+63 -9
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useRef, useEffect } from "react";
import {
useSharedTransactions,
useParticipantBalances,
@@ -9,6 +9,7 @@ import {
usePaymentHistory,
useDeletePayment,
useCurrentUser,
useTags,
type SplitPayment,
} from "@/lib/hooks";
import type { SharedTransactionRow } from "@/lib/queries";
@@ -24,6 +25,55 @@ function formatAmount(n: number, type?: string) {
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);
}, []);
if (!tags.length) return null;
const toggle = (id: string) =>
onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, id]);
const label = value.length === 0 ? "All 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">
{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("");
@@ -207,7 +257,8 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
// ── Main page ─────────────────────────────────────────────────────────────────
export default function SharedPage() {
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions();
const [tagIds, setTagIds] = useState<string[]>([]);
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
const { data: balances = [], isLoading: balLoading } = useParticipantBalances();
const { data: me } = useCurrentUser();
const [addingParticipant, setAddingParticipant] = useState(false);
@@ -216,14 +267,17 @@ export default function SharedPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center justify-between gap-3">
<h2 className="text-xl font-semibold">Shared Expenses</h2>
{!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">
+ Add Participant
</button>
)}
<div className="flex items-center gap-2 ml-auto">
<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)} />}
+1 -1
View File
@@ -225,7 +225,7 @@ function MarkAsPaymentModal({
const [direction, setDirection] = useState<"received" | "sent">(
SPEND_TYPES.has(transaction.transaction_type) ? "sent" : "received"
);
const [amount, setAmount] = useState(transaction.amount.toFixed(2));
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("");
+4 -3
View File
@@ -208,11 +208,12 @@ export function useParticipantBalances() {
});
}
export function useSharedTransactions() {
export function useSharedTransactions(tagIds?: string[]) {
return useQuery({
queryKey: ["shared-transactions"],
queryKey: ["shared-transactions", tagIds],
queryFn: async () => {
const res = await fetch("/api/shared-transactions");
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
const res = await fetch(`/api/shared-transactions${params}`);
return res.json();
},
});
+10 -4
View File
@@ -335,7 +335,14 @@ export async function getTags() {
`);
}
export async function getSharedTransactions(ownerId: number) {
export async function getSharedTransactions(ownerId: number, tagIds?: number[]) {
const params: unknown[] = [ownerId];
let tagClause = "";
if (tagIds?.length) {
params.push(tagIds);
tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
}
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
SELECT t.*,
o.category_override, o.merchant_normalized as merchant_override, o.notes,
@@ -358,17 +365,16 @@ export async function getSharedTransactions(ownerId: number) {
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
WHERE (
-- I own this transaction and at least one other person has a split
COALESCE(t.owner_id, s.owner_id) = $1
AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1)
) OR (
-- Someone else owns this transaction and I have a split on it
COALESCE(t.owner_id, s.owner_id) != $1
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
)
${tagClause}
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name
ORDER BY t.transaction_date DESC
`, [ownerId]);
`, params);
return rows.map((r) => ({
...r,