feat: live transaction search in merchant rules, link-import preference toggle, tracker row tweaks

This commit is contained in:
null 2026-06-06 23:04:53 -05:00
parent 12d869d400
commit 4dd01c13c4
9 changed files with 123 additions and 16 deletions

View File

@ -115,6 +115,8 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
const [retroFeedback, setRetroFeedback] = useState(null);
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
const [liveResults, setLiveResults] = useState([]);
const [liveSearching, setLiveSearching] = useState(false);
const inputRef = useRef(null);
const debouncedInput = useDebounce(input.trim(), 380);
@ -158,6 +160,31 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
return () => { cancelled = true; };
}, [debouncedInput, billId]);
// Live transaction search when user types something
useEffect(() => {
if (!debouncedInput || debouncedInput.length < 2) {
setLiveResults([]);
return;
}
let cancelled = false;
setLiveSearching(true);
api.subscriptionTransactionMatches({ q: debouncedInput, limit: 20 })
.then(data => {
if (cancelled) return;
const rows = Array.isArray(data) ? data : (data?.transactions ?? []);
setLiveResults(rows.filter(tx => tx.match_status !== 'matched').map(tx => ({
id: tx.id,
label: tx.payee || tx.description || tx.memo || '',
normalized: tx.merchant || (tx.payee || tx.description || tx.memo || '').toLowerCase(),
amount: tx.amount,
date: tx.posted_date || '',
})).filter(s => s.label));
})
.catch(() => { if (!cancelled) setLiveResults([]); })
.finally(() => { if (!cancelled) setLiveSearching(false); });
return () => { cancelled = true; };
}, [debouncedInput]);
// Popover handles its own outside-click dismissal no manual handler needed
async function handleAdd(merchantText) {
@ -221,11 +248,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
inputRef.current?.focus();
}
// Filter suggestions: not already a rule, and not already matched to something
const filteredSuggestions = suggestions.filter(s =>
!rules.some(r => r.merchant === s.normalized) &&
(input.length < 2 || s.label.toLowerCase().includes(input.toLowerCase()))
).slice(0, 8);
// When typing: use live search results. When blank: use pre-loaded recent 30.
const filteredSuggestions = (input.trim().length >= 2 ? liveResults : suggestions.filter(s =>
!rules.some(r => r.merchant === s.normalized)
)).slice(0, 10);
if (loading) {
return (
@ -299,10 +325,11 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
{/* Suggestions inline block, no absolute positioning.
Avoids overflow-y-auto clipping AND Radix Dialog pointer-event capture. */}
{showSuggestions && filteredSuggestions.length > 0 && (
{showSuggestions && (liveSearching || filteredSuggestions.length > 0) && (
<div className="overflow-hidden rounded-lg border border-border/80 bg-card shadow-sm">
<p className="border-b border-border/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
Recent unmatched transactions
<p className="flex items-center gap-2 border-b border-border/50 px-3 py-1.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{input.trim().length >= 2 ? 'Matching transactions' : 'Recent unmatched transactions'}
{liveSearching && <Loader2 className="h-3 w-3 animate-spin" />}
</p>
<div className="max-h-40 overflow-y-auto">
{filteredSuggestions.map(s => {
@ -322,6 +349,9 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
</button>
);
})}
{!liveSearching && input.trim().length >= 2 && filteredSuggestions.length === 0 && (
<p className="px-3 py-3 text-xs text-muted-foreground">No transactions found for "{input.trim()}"</p>
)}
</div>
</div>
)}

View File

@ -119,12 +119,12 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
S
</span>
)}
{!!bill.has_merchant_rule && (
{(!!bill.has_merchant_rule || !!bill.has_linked_transactions) && (
<span
className="shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-semibold text-emerald-600 dark:text-emerald-400"
title="Bank matching rule active — transactions import automatically"
title="Linked to bank transactions"
>
Bank
L
</span>
)}
{hasHistory && (

View File

@ -116,6 +116,9 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
{bill.has_2fa && (
<span className="rounded bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-violet-300">2FA</span>
)}
{(bill.has_merchant_rule || bill.has_linked_transactions) && (
<span className="rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400" title="Linked to bank transactions">L</span>
)}
{bill.is_subscription && (
<span className="rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300">S</span>
)}

View File

@ -197,6 +197,14 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
AP
</span>
)}
{(row.has_merchant_rule || row.has_linked_transactions) && (
<span
className="inline-flex shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-emerald-600 dark:text-emerald-400"
title="Linked to bank transactions"
>
L
</span>
)}
{row.is_subscription && (
<span
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"

View File

@ -364,6 +364,14 @@ export function TrackerRow({ row, year, month, refresh, index, onEditBill, moveC
AP
</span>
)}
{(row.has_merchant_rule || row.has_linked_transactions) && (
<span
className="inline-flex shrink-0 rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-emerald-600 dark:text-emerald-400"
title="Linked to bank transactions"
>
L
</span>
)}
{row.is_subscription && (
<span
className="inline-flex shrink-0 rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-bold leading-none text-indigo-600 dark:text-indigo-300"

View File

@ -9,9 +9,15 @@ import { Input } from '@/components/ui/input';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { useTheme } from '@/contexts/ThemeContext';
import { useAuth } from '@/hooks/useAuth';
export const LINK_IMPORT_PREF_KEY = 'link_import_ask';
export function getLinkImportPref() {
return localStorage.getItem(LINK_IMPORT_PREF_KEY) !== 'false';
}
// Card wrapper
function SectionCard({ title, children }) {
@ -208,6 +214,29 @@ function SettingsSkeleton() {
);
}
// Link-import preference toggle (localStorage-backed)
function LinkImportToggle() {
const [enabled, setEnabled] = useState(getLinkImportPref);
function toggle(next) {
localStorage.setItem(LINK_IMPORT_PREF_KEY, String(next));
setEnabled(next);
toast.success(next
? 'Past payments will be offered for import when linking a bill.'
: 'Past payment import prompt is disabled.');
}
return (
<SettingRow
label="Ask to import past payments when linking"
description="When you connect a bill to bank transactions (via merchant rule or recommendation), offer to import matching past payments as well."
>
<Switch checked={enabled} onCheckedChange={toggle} aria-label="Ask to import past payments when linking" />
</SettingRow>
);
}
// SettingsPage
export default function SettingsPage() {
@ -323,6 +352,7 @@ export default function SettingsPage() {
{/* Billing Behavior */}
<SectionCard title="Billing Behavior">
<LinkImportToggle />
<SettingRow
label="Grace period"
description="Days after the due date before a bill is marked overdue."

View File

@ -43,6 +43,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import BillModal from '@/components/BillModal';
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
import { getLinkImportPref } from '@/pages/SettingsPage';
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
const TYPE_LABELS = {
@ -850,6 +852,7 @@ export default function SubscriptionsPage() {
const [txQuery, setTxQuery] = useState('');
const [txResults, setTxResults] = useState([]);
const [txSearching, setTxSearching] = useState(false);
const [importDialog, setImportDialog] = useState(null); // { billId, billName }
const txDebounce = useRef(null);
const subscriptionCategoryId = useMemo(() => {
@ -938,9 +941,12 @@ export default function SubscriptionsPage() {
async function acceptRecommendation(recommendation) {
setBusyId(`rec-${recommendation.id}`);
try {
await api.createSubscriptionFromRecommendation(recommendation);
const created = await api.createSubscriptionFromRecommendation(recommendation);
toast.success(`${recommendation.name} is now tracked.`);
await refreshAll();
if (getLinkImportPref() && recommendation.merchant && created?.id) {
setImportDialog({ billId: created.id, billName: created.name || recommendation.name });
}
} catch (err) {
toast.error(err.message || 'Could not create subscription.');
} finally {
@ -975,6 +981,9 @@ export default function SubscriptionsPage() {
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
setMatchTarget(null);
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
if (getLinkImportPref() && recommendation.merchant) {
setImportDialog({ billId, billName: result.bill_name || recommendation.name });
}
} catch (err) {
toast.error(err.message || 'Could not link recommendation to bill.');
} finally {
@ -1474,6 +1483,14 @@ export default function SubscriptionsPage() {
onConfirm={matchRecommendationToBill}
busy={!!busyId?.startsWith('match-')}
/>
<BillHistoricalImportDialog
billId={importDialog?.billId}
billName={importDialog?.billName}
open={!!importDialog}
onClose={() => setImportDialog(null)}
onImported={() => { setImportDialog(null); refreshAll(); }}
/>
</div>
);
}

View File

@ -23,13 +23,17 @@ router.get('/', (req, res) => {
const db = getDb();
ensureUserDefaultCategories(req.user.id);
const includeInactive = req.query.inactive === 'true';
// LEFT JOIN on a pre-grouped subquery is one query instead of N+1 correlated EXISTS lookups.
// LEFT JOIN on pre-grouped subqueries one query instead of N+1 correlated EXISTS lookups.
const bills = db.prepare(`
SELECT b.*, c.name AS category_name,
CASE WHEN hr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_history_ranges
CASE WHEN hr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_history_ranges,
CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule,
CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
LEFT JOIN (SELECT DISTINCT bill_id FROM bill_history_ranges) hr ON hr.bill_id = b.id
LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id
LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id
WHERE b.user_id = ?
AND b.deleted_at IS NULL
${includeInactive ? '' : 'AND b.active = 1'}
@ -333,7 +337,10 @@ router.get('/:id', (req, res) => {
) THEN 1 ELSE 0 END AS has_history_ranges,
CASE WHEN EXISTS(
SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id
) THEN 1 ELSE 0 END AS has_merchant_rule
) THEN 1 ELSE 0 END AS has_merchant_rule,
CASE WHEN EXISTS(
SELECT 1 FROM transactions WHERE matched_bill_id = b.id AND match_status = 'matched' AND user_id = b.user_id
) THEN 1 ELSE 0 END AS has_linked_transactions
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
WHERE b.id = ? AND b.user_id = ? AND b.deleted_at IS NULL

View File

@ -115,9 +115,13 @@ const FETCH_BILLS_ORDER = {
function fetchActiveBills(db, userId, orderKey = 'due_day') {
const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day;
return db.prepare(`
SELECT b.*, c.name AS category_name
SELECT b.*, c.name AS category_name,
CASE WHEN mr.bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_merchant_rule,
CASE WHEN lt.matched_bill_id IS NOT NULL THEN 1 ELSE 0 END AS has_linked_transactions
FROM bills b
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
LEFT JOIN (SELECT DISTINCT bill_id FROM bill_merchant_rules) mr ON mr.bill_id = b.id
LEFT JOIN (SELECT DISTINCT matched_bill_id FROM transactions WHERE match_status = 'matched') lt ON lt.matched_bill_id = b.id
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
ORDER BY ${orderBy}
`).all(userId);