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:
+33
-8
@@ -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 ${
|
||||
|
||||
Reference in New Issue
Block a user