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
+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 ${