feat(ui): mobile-responsive sidebar + rules improvements

- Sidebar: hidden on mobile, opens as slide-out drawer with hamburger
  toggle; auto-closes on navigation; desktop layout unchanged
- Layout: responsive padding accounting for mobile header bar
- Rules: add tag as a condition field (has/not-has tag)
- Rules: apply a single rule via per-rule Apply button
- Rules: splits-from defaults to 2026-01-09
This commit is contained in:
2026-03-21 08:33:08 +11:00
parent ef73a9cea0
commit 0a1f6b48a2
5 changed files with 120 additions and 21 deletions
+16 -6
View File
@@ -4,7 +4,7 @@ import { queryRaw } from "@/lib/db";
import { getTransactions } from "@/lib/queries";
interface Condition {
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type";
field: "merchant_normalized" | "description" | "category" | "bank_name" | "amount" | "transaction_type" | "tag";
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
value: string;
}
@@ -37,6 +37,7 @@ interface TxFields {
bank_name: string;
amount: number;
transaction_type: string;
tags: { id: number }[];
}
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
@@ -52,6 +53,12 @@ function evaluateCondition(cond: Condition, tx: TxFields): boolean {
}
}
if (cond.field === "tag") {
const tagId = Number(cond.value);
const hasTag = tx.tags.some((t) => t.id === tagId);
return cond.operator === "not_equals" ? !hasTag : hasTag;
}
let fieldVal: string;
switch (cond.field) {
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
@@ -97,16 +104,19 @@ export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const body = await req.json().catch(() => ({})) as { splitFrom?: string | null; ruleId?: number | null };
const splitFrom = body.splitFrom || null;
const ruleId = body.ruleId || null;
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>(
`SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
[user.id]
ruleId
? `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND id = $2`
: `SELECT id, conditions, actions FROM rules WHERE owner_id = $1 AND enabled = true ORDER BY priority DESC`,
ruleId ? [user.id, ruleId] : [user.id]
);
if (!rules.length) return NextResponse.json({ matched: 0, transactions_affected: 0 });
const body = await req.json().catch(() => ({})) as { splitFrom?: string | null };
const splitFrom = body.splitFrom || null;
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
// --- Pre-pass: find all transactions that will match any rule ---
+1 -1
View File
@@ -32,7 +32,7 @@ export default function RootLayout({
<Providers>
<div className="flex min-h-screen">
<Sidebar />
<main className="flex-1 p-6 overflow-auto">{children}</main>
<main className="flex-1 pt-[calc(3.5rem+1rem)] px-3 pb-3 md:p-6 md:pt-6 overflow-auto">{children}</main>
</div>
</Providers>
</body>
+33 -8
View File
@@ -11,6 +11,7 @@ const FIELDS = [
{ value: "bank_name", label: "Bank" },
{ value: "amount", label: "Amount" },
{ value: "transaction_type", label: "Transaction Type" },
{ value: "tag", label: "Tag" },
] as const;
const TEXT_OPS = [
@@ -35,8 +36,12 @@ type Condition = { field: string; operator: string; value: string };
type SplitEntry = { participant_id: number; share_percent: number };
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: SplitEntry[] };
function humanCondition(c: Condition): string {
function humanCondition(c: Condition, tagNames?: Map<number, string>): string {
const fieldLabel = FIELDS.find((f) => f.value === c.field)?.label || c.field;
if (c.field === "tag") {
const tagName = tagNames?.get(Number(c.value)) || `tag#${c.value}`;
return `Tag ${c.operator === "not_equals" ? "is not" : "is"} "${tagName}"`;
}
const ops = [...TEXT_OPS, ...AMOUNT_OPS, ...ENUM_OPS];
const opText = ops.find((o) => o.value === c.operator)?.label || c.operator;
return `${fieldLabel} ${opText} "${c.value}"`;
@@ -73,7 +78,7 @@ export default function RulesPage() {
const tagNames = new Map(tags.map((t) => [t.id, t.name]));
const participantNames = new Map(participants.map((p) => [p.id, p.name]));
const [applyFrom, setApplyFrom] = useState("2026-01-08");
const [applyFrom, setApplyFrom] = useState("2026-01-09");
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
@@ -145,8 +150,8 @@ export default function RulesPage() {
closeForm();
}
async function handleApply() {
const result = await applyRules.mutateAsync(applyFrom || undefined);
async function handleApply(ruleId?: number) {
const result = await applyRules.mutateAsync({ splitFrom: applyFrom || undefined, ruleId });
setApplyResult(result);
}
@@ -167,7 +172,7 @@ export default function RulesPage() {
title="Split rules only apply to transactions on or after this date. Category/merchant/tag rules apply to all transactions."
/>
<button
onClick={handleApply}
onClick={() => handleApply()}
disabled={applyRules.isPending}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
>
@@ -217,7 +222,8 @@ export default function RulesPage() {
{conditions.map((cond, i) => {
const isAmount = cond.field === "amount";
const isEnum = cond.field === "transaction_type";
const ops = isAmount ? AMOUNT_OPS : isEnum ? ENUM_OPS : TEXT_OPS;
const isTag = cond.field === "tag";
const ops = isAmount ? AMOUNT_OPS : (isEnum || isTag) ? ENUM_OPS : TEXT_OPS;
return (
<div key={i} className="flex gap-2 mb-2 items-center">
<select
@@ -227,6 +233,7 @@ export default function RulesPage() {
const patch: Partial<Condition> = { field: newField };
if (newField === "amount") { patch.operator = "equals"; patch.value = ""; }
else if (newField === "transaction_type") { patch.operator = "equals"; patch.value = "debit"; }
else if (newField === "tag") { patch.operator = "equals"; patch.value = tags[0] ? String(tags[0].id) : ""; }
else { patch.operator = "contains"; patch.value = ""; }
updateCondition(i, patch);
}}
@@ -249,7 +256,18 @@ export default function RulesPage() {
</option>
))}
</select>
{isEnum ? (
{isTag ? (
<select
value={cond.value}
onChange={(e) => updateCondition(i, { value: e.target.value })}
className="flex-1 bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
>
{tags.map((t) => (
<option key={t.id} value={String(t.id)}>{t.name}</option>
))}
{tags.length === 0 && <option value="">No tags</option>}
</select>
) : isEnum ? (
<select
value={cond.value}
onChange={(e) => updateCondition(i, { value: e.target.value })}
@@ -460,11 +478,18 @@ export default function RulesPage() {
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
</div>
<p className="text-xs text-zinc-400">
{conds.length > 0 ? conds.map(humanCondition).join(" AND ") : "(matches all)"}
{conds.length > 0 ? conds.map((c) => humanCondition(c, tagNames)).join(" AND ") : "(matches all)"}
</p>
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={() => handleApply(rule.id)}
disabled={applyRules.isPending}
className="text-xs text-emerald-400 hover:text-emerald-300 disabled:opacity-50"
>
Apply
</button>
<button
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
+67 -3
View File
@@ -2,6 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect } from "react";
const NAV_ITEMS = [
{ href: "/transactions", label: "Transactions", icon: "receipt" },
@@ -60,11 +61,34 @@ const ICONS: Record<string, React.ReactNode> = {
export function Sidebar() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
return (
<aside className="w-56 bg-zinc-900 border-r border-zinc-800 flex flex-col min-h-screen">
<div className="p-4 border-b border-zinc-800">
// Close drawer on route change
useEffect(() => {
setOpen(false);
}, [pathname]);
// Prevent body scroll when drawer is open
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
return () => { document.body.style.overflow = ""; };
}
}, [open]);
const navContent = (
<>
<div className="p-4 border-b border-zinc-800 flex items-center justify-between">
<h1 className="text-lg font-semibold text-white">Finance</h1>
<button
onClick={() => setOpen(false)}
className="md:hidden p-1 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
aria-label="Close menu"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav className="flex-1 p-2">
{NAV_ITEMS.map((item) => {
@@ -85,6 +109,46 @@ export function Sidebar() {
);
})}
</nav>
</>
);
return (
<>
{/* Mobile header bar */}
<div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-zinc-900 border-b border-zinc-800 flex items-center px-4 h-14">
<button
onClick={() => setOpen(true)}
className="p-1.5 -ml-1.5 rounded text-zinc-400 hover:text-white hover:bg-zinc-800"
aria-label="Open menu"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<span className="ml-3 text-base font-semibold text-white">Finance</span>
</div>
{/* Mobile drawer overlay */}
{open && (
<div
className="md:hidden fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
onClick={() => setOpen(false)}
/>
)}
{/* Mobile drawer */}
<aside
className={`md:hidden fixed top-0 left-0 z-50 w-64 h-full bg-zinc-900 border-r border-zinc-800 flex flex-col transform transition-transform duration-200 ease-in-out ${
open ? "translate-x-0" : "-translate-x-full"
}`}
>
{navContent}
</aside>
{/* Desktop sidebar — unchanged */}
<aside className="hidden md:flex w-56 bg-zinc-900 border-r border-zinc-800 flex-col min-h-screen">
{navContent}
</aside>
</>
);
}
+2 -2
View File
@@ -511,11 +511,11 @@ export function useDeleteRule() {
export function useApplyRules() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (splitFrom?: string) => {
mutationFn: async (args?: { splitFrom?: string; ruleId?: number }) => {
const res = await fetch("/api/rules/apply", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ splitFrom: splitFrom || null }),
body: JSON.stringify({ splitFrom: args?.splitFrom || null, ruleId: args?.ruleId || null }),
});
if (!res.ok) throw new Error("Failed to apply rules");
return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>;