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:
@@ -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);
|
||||
}
|
||||
|
||||
+58
-4
@@ -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,15 +267,18 @@ 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>
|
||||
<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">
|
||||
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)} />}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user