feat: live transaction search in merchant rules, link-import preference toggle, tracker row tweaks
This commit is contained in:
parent
12d869d400
commit
4dd01c13c4
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue