feat(filters): add No tags filter to transactions and shared pages

This commit is contained in:
2026-04-13 05:20:49 +10:00
parent 1296555f17
commit 1b561af9e9
4 changed files with 42 additions and 13 deletions
+4 -2
View File
@@ -7,7 +7,9 @@ export async function GET(req: NextRequest) {
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const tagParam = req.nextUrl.searchParams.get("tag_ids"); const tagParam = req.nextUrl.searchParams.get("tag_ids");
const tagIds = tagParam ? tagParam.split(",").map(Number).filter(Boolean) : undefined; const rawIds = tagParam ? tagParam.split(",").filter(Boolean) : [];
const transactions = await getSharedTransactions(user.id, tagIds); const noTags = rawIds.includes("untagged");
const tagIds = rawIds.filter((id) => id !== "untagged").map(Number).filter((n) => !isNaN(n));
const transactions = await getSharedTransactions(user.id, tagIds.length ? tagIds : undefined, noTags);
return NextResponse.json(transactions); return NextResponse.json(transactions);
} }
+19 -5
View File
@@ -40,12 +40,20 @@ function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[
return () => document.removeEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler);
}, []); }, []);
if (!tags.length) return null; const toggle = (id: string) => {
let next: string[];
const toggle = (id: string) => if (value.includes(id)) {
onChange(value.includes(id) ? value.filter((x) => x !== id) : [...value, 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" 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 === 1 ? (tags.find((t) => String(t.id) === value[0])?.name ?? "1 tag")
: `${value.length} tags`; : `${value.length} tags`;
@@ -61,6 +69,11 @@ function TagFilter({ value, onChange }: { value: string[]; onChange: (v: string[
</button> </button>
{open && ( {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"> <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) => ( {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"> <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))} <input type="checkbox" checked={value.includes(String(t.id))} onChange={() => toggle(String(t.id))}
@@ -259,8 +272,9 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
// ── Main page ───────────────────────────────────────────────────────────────── // ── Main page ─────────────────────────────────────────────────────────────────
export default function SharedPage() { export default function SharedPage() {
const [tagIds, setTagIds] = useState<string[]>([]); const [tagIds, setTagIds] = useState<string[]>([]);
const realTagIds = tagIds.filter((id) => id !== "untagged");
const { data: transactions = [], isLoading: txLoading } = useSharedTransactions(tagIds); const { data: transactions = [], isLoading: txLoading } = useSharedTransactions(tagIds);
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(tagIds); const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
const { data: me } = useCurrentUser(); const { data: me } = useCurrentUser();
const [addingParticipant, setAddingParticipant] = useState(false); const [addingParticipant, setAddingParticipant] = useState(false);
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null); const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
+7 -2
View File
@@ -651,9 +651,14 @@ function TransactionsContent() {
placeholder="All Banks" placeholder="All Banks"
/> />
<MultiSelect <MultiSelect
options={(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))} options={[{ value: "untagged", label: "No tags" }, ...(tags ?? []).map((t) => ({ value: String(t.id), label: t.name }))]}
value={filters.tag_ids} value={filters.tag_ids}
onChange={(v) => setFilters((f) => ({ ...f, tag_ids: v, offset: 0 }))} 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" placeholder="All Tags"
/> />
<MultiSelect <MultiSelect
+11 -3
View File
@@ -115,8 +115,14 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
} }
} }
if (filters.tag_ids?.length) { if (filters.tag_ids?.length) {
const noTags = filters.tag_ids.includes("untagged");
const realTagIds = filters.tag_ids.filter((id) => id !== "untagged").map(Number);
if (noTags) {
conditions.push(`NOT EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id)`);
} else if (realTagIds.length > 0) {
conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`); conditions.push(`EXISTS (SELECT 1 FROM transaction_tags tt2 WHERE tt2.transaction_id = t.id AND tt2.tag_id = ANY($${paramIdx++}::int[]))`);
params.push(filters.tag_ids.map(Number)); params.push(realTagIds);
}
} }
if (filters.transaction_types?.length) { if (filters.transaction_types?.length) {
conditions.push(`t.transaction_type = ANY($${paramIdx++}::text[])`); conditions.push(`t.transaction_type = ANY($${paramIdx++}::text[])`);
@@ -343,10 +349,12 @@ export async function getTags() {
`); `);
} }
export async function getSharedTransactions(ownerId: number, tagIds?: number[]) { export async function getSharedTransactions(ownerId: number, tagIds?: number[], noTags?: boolean) {
const params: unknown[] = [ownerId]; const params: unknown[] = [ownerId];
let tagClause = ""; let tagClause = "";
if (tagIds?.length) { if (noTags) {
tagClause = `AND NOT EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id)`;
} else if (tagIds?.length) {
params.push(tagIds); 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[]))`; tagClause = `AND EXISTS (SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id AND tt.tag_id = ANY($2::int[]))`;
} }