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);
|
const user = await getCurrentUser(req);
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
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);
|
return NextResponse.json(transactions);
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-4
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import {
|
import {
|
||||||
useSharedTransactions,
|
useSharedTransactions,
|
||||||
useParticipantBalances,
|
useParticipantBalances,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
usePaymentHistory,
|
usePaymentHistory,
|
||||||
useDeletePayment,
|
useDeletePayment,
|
||||||
useCurrentUser,
|
useCurrentUser,
|
||||||
|
useTags,
|
||||||
type SplitPayment,
|
type SplitPayment,
|
||||||
} from "@/lib/hooks";
|
} from "@/lib/hooks";
|
||||||
import type { SharedTransactionRow } from "@/lib/queries";
|
import type { SharedTransactionRow } from "@/lib/queries";
|
||||||
@@ -24,6 +25,55 @@ function formatAmount(n: number, type?: string) {
|
|||||||
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
|
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 ───────────────────────────────────────────────────────────
|
// ── Add Participant ───────────────────────────────────────────────────────────
|
||||||
function AddParticipantForm({ onDone }: { onDone: () => void }) {
|
function AddParticipantForm({ onDone }: { onDone: () => void }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -207,7 +257,8 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
|
|||||||
|
|
||||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
export default function SharedPage() {
|
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: balances = [], isLoading: balLoading } = useParticipantBalances();
|
||||||
const { data: me } = useCurrentUser();
|
const { data: me } = useCurrentUser();
|
||||||
const [addingParticipant, setAddingParticipant] = useState(false);
|
const [addingParticipant, setAddingParticipant] = useState(false);
|
||||||
@@ -216,15 +267,18 @@ export default function SharedPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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>
|
<h2 className="text-xl font-semibold">Shared Expenses</h2>
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
<TagFilter value={tagIds} onChange={setTagIds} />
|
||||||
{!addingParticipant && (
|
{!addingParticipant && (
|
||||||
<button onClick={() => setAddingParticipant(true)}
|
<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
|
+ Add Participant
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{addingParticipant && <AddParticipantForm onDone={() => setAddingParticipant(false)} />}
|
{addingParticipant && <AddParticipantForm onDone={() => setAddingParticipant(false)} />}
|
||||||
|
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ function MarkAsPaymentModal({
|
|||||||
const [direction, setDirection] = useState<"received" | "sent">(
|
const [direction, setDirection] = useState<"received" | "sent">(
|
||||||
SPEND_TYPES.has(transaction.transaction_type) ? "sent" : "received"
|
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 [date, setDate] = useState(transaction.transaction_date.slice(0, 10));
|
||||||
const [notes, setNotes] = useState("");
|
const [notes, setNotes] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|||||||
+4
-3
@@ -208,11 +208,12 @@ export function useParticipantBalances() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSharedTransactions() {
|
export function useSharedTransactions(tagIds?: string[]) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["shared-transactions"],
|
queryKey: ["shared-transactions", tagIds],
|
||||||
queryFn: async () => {
|
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();
|
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 }>(`
|
const rows = await queryRaw<TransactionRow & { split_data: string }>(`
|
||||||
SELECT t.*,
|
SELECT t.*,
|
||||||
o.category_override, o.merchant_normalized as merchant_override, o.notes,
|
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 statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
|
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
|
||||||
WHERE (
|
WHERE (
|
||||||
-- I own this transaction and at least one other person has a split
|
|
||||||
COALESCE(t.owner_id, s.owner_id) = $1
|
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)
|
AND EXISTS (SELECT 1 FROM transaction_splits ts2 WHERE ts2.transaction_id = t.id AND ts2.participant_id != $1)
|
||||||
) OR (
|
) OR (
|
||||||
-- Someone else owns this transaction and I have a split on it
|
|
||||||
COALESCE(t.owner_id, s.owner_id) != $1
|
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)
|
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
|
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
|
ORDER BY t.transaction_date DESC
|
||||||
`, [ownerId]);
|
`, params);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
...r,
|
...r,
|
||||||
|
|||||||
Reference in New Issue
Block a user