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:
@@ -4,7 +4,7 @@ import { queryRaw } from "@/lib/db";
|
|||||||
import { getTransactions } from "@/lib/queries";
|
import { getTransactions } from "@/lib/queries";
|
||||||
|
|
||||||
interface Condition {
|
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";
|
operator: "contains" | "equals" | "starts_with" | "gt" | "lt" | "not_equals";
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,7 @@ interface TxFields {
|
|||||||
bank_name: string;
|
bank_name: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
transaction_type: string;
|
transaction_type: string;
|
||||||
|
tags: { id: number }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function evaluateCondition(cond: Condition, tx: TxFields): boolean {
|
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;
|
let fieldVal: string;
|
||||||
switch (cond.field) {
|
switch (cond.field) {
|
||||||
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
|
case "merchant_normalized": fieldVal = tx.effective_merchant || ""; break;
|
||||||
@@ -97,16 +104,19 @@ export async function POST(req: NextRequest) {
|
|||||||
const user = await getCurrentUser(req);
|
const user = await getCurrentUser(req);
|
||||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
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 }>(
|
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`,
|
ruleId
|
||||||
[user.id]
|
? `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 });
|
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 });
|
const { data: transactions } = await getTransactions(user.id, { limit: 100000, offset: 0 });
|
||||||
|
|
||||||
// --- Pre-pass: find all transactions that will match any rule ---
|
// --- Pre-pass: find all transactions that will match any rule ---
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ export default function RootLayout({
|
|||||||
<Providers>
|
<Providers>
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<Sidebar />
|
<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>
|
</div>
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
+33
-8
@@ -11,6 +11,7 @@ const FIELDS = [
|
|||||||
{ value: "bank_name", label: "Bank" },
|
{ value: "bank_name", label: "Bank" },
|
||||||
{ value: "amount", label: "Amount" },
|
{ value: "amount", label: "Amount" },
|
||||||
{ value: "transaction_type", label: "Transaction Type" },
|
{ value: "transaction_type", label: "Transaction Type" },
|
||||||
|
{ value: "tag", label: "Tag" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const TEXT_OPS = [
|
const TEXT_OPS = [
|
||||||
@@ -35,8 +36,12 @@ type Condition = { field: string; operator: string; value: string };
|
|||||||
type SplitEntry = { participant_id: number; share_percent: number };
|
type SplitEntry = { participant_id: number; share_percent: number };
|
||||||
type Actions = { set_category?: string; add_tag_ids?: number[]; set_merchant?: string; apply_split?: SplitEntry[] };
|
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;
|
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 ops = [...TEXT_OPS, ...AMOUNT_OPS, ...ENUM_OPS];
|
||||||
const opText = ops.find((o) => o.value === c.operator)?.label || c.operator;
|
const opText = ops.find((o) => o.value === c.operator)?.label || c.operator;
|
||||||
return `${fieldLabel} ${opText} "${c.value}"`;
|
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 tagNames = new Map(tags.map((t) => [t.id, t.name]));
|
||||||
const participantNames = new Map(participants.map((p) => [p.id, p.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 [showForm, setShowForm] = useState(false);
|
||||||
const [editingId, setEditingId] = useState<number | null>(null);
|
const [editingId, setEditingId] = useState<number | null>(null);
|
||||||
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
|
||||||
@@ -145,8 +150,8 @@ export default function RulesPage() {
|
|||||||
closeForm();
|
closeForm();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleApply() {
|
async function handleApply(ruleId?: number) {
|
||||||
const result = await applyRules.mutateAsync(applyFrom || undefined);
|
const result = await applyRules.mutateAsync({ splitFrom: applyFrom || undefined, ruleId });
|
||||||
setApplyResult(result);
|
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."
|
title="Split rules only apply to transactions on or after this date. Category/merchant/tag rules apply to all transactions."
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleApply}
|
onClick={() => handleApply()}
|
||||||
disabled={applyRules.isPending}
|
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"
|
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) => {
|
{conditions.map((cond, i) => {
|
||||||
const isAmount = cond.field === "amount";
|
const isAmount = cond.field === "amount";
|
||||||
const isEnum = cond.field === "transaction_type";
|
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 (
|
return (
|
||||||
<div key={i} className="flex gap-2 mb-2 items-center">
|
<div key={i} className="flex gap-2 mb-2 items-center">
|
||||||
<select
|
<select
|
||||||
@@ -227,6 +233,7 @@ export default function RulesPage() {
|
|||||||
const patch: Partial<Condition> = { field: newField };
|
const patch: Partial<Condition> = { field: newField };
|
||||||
if (newField === "amount") { patch.operator = "equals"; patch.value = ""; }
|
if (newField === "amount") { patch.operator = "equals"; patch.value = ""; }
|
||||||
else if (newField === "transaction_type") { patch.operator = "equals"; patch.value = "debit"; }
|
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 = ""; }
|
else { patch.operator = "contains"; patch.value = ""; }
|
||||||
updateCondition(i, patch);
|
updateCondition(i, patch);
|
||||||
}}
|
}}
|
||||||
@@ -249,7 +256,18 @@ export default function RulesPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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
|
<select
|
||||||
value={cond.value}
|
value={cond.value}
|
||||||
onChange={(e) => updateCondition(i, { value: e.target.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>
|
<span className="text-xs text-zinc-500">priority: {rule.priority}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-zinc-400">
|
<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>
|
||||||
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
|
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<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
|
<button
|
||||||
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
onClick={() => updateRule.mutate({ id: rule.id, enabled: !rule.enabled })}
|
||||||
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
className={`relative inline-flex h-5 w-9 rounded-full transition-colors ${
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||||
@@ -60,11 +61,34 @@ const ICONS: Record<string, React.ReactNode> = {
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
// Close drawer on route change
|
||||||
<aside className="w-56 bg-zinc-900 border-r border-zinc-800 flex flex-col min-h-screen">
|
useEffect(() => {
|
||||||
<div className="p-4 border-b border-zinc-800">
|
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>
|
<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>
|
</div>
|
||||||
<nav className="flex-1 p-2">
|
<nav className="flex-1 p-2">
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
@@ -85,6 +109,46 @@ export function Sidebar() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
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
@@ -511,11 +511,11 @@ export function useDeleteRule() {
|
|||||||
export function useApplyRules() {
|
export function useApplyRules() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (splitFrom?: string) => {
|
mutationFn: async (args?: { splitFrom?: string; ruleId?: number }) => {
|
||||||
const res = await fetch("/api/rules/apply", {
|
const res = await fetch("/api/rules/apply", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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");
|
if (!res.ok) throw new Error("Failed to apply rules");
|
||||||
return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>;
|
return res.json() as Promise<{ id: number; matched: number; transactions_affected: number }>;
|
||||||
|
|||||||
Reference in New Issue
Block a user