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 [retroFeedback, setRetroFeedback] = useState(null);
|
||||||
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
|
const [showHistoricalDialog, setShowHistoricalDialog] = useState(false);
|
||||||
const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
|
const [togglingAutoAttr, setTogglingAutoAttr] = useState(null);
|
||||||
|
const [liveResults, setLiveResults] = useState([]);
|
||||||
|
const [liveSearching, setLiveSearching] = useState(false);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
const debouncedInput = useDebounce(input.trim(), 380);
|
const debouncedInput = useDebounce(input.trim(), 380);
|
||||||
|
|
@ -158,6 +160,31 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [debouncedInput, billId]);
|
}, [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
|
// Popover handles its own outside-click dismissal — no manual handler needed
|
||||||
|
|
||||||
async function handleAdd(merchantText) {
|
async function handleAdd(merchantText) {
|
||||||
|
|
@ -221,11 +248,10 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter suggestions: not already a rule, and not already matched to something
|
// When typing: use live search results. When blank: use pre-loaded recent 30.
|
||||||
const filteredSuggestions = suggestions.filter(s =>
|
const filteredSuggestions = (input.trim().length >= 2 ? liveResults : suggestions.filter(s =>
|
||||||
!rules.some(r => r.merchant === s.normalized) &&
|
!rules.some(r => r.merchant === s.normalized)
|
||||||
(input.length < 2 || s.label.toLowerCase().includes(input.toLowerCase()))
|
)).slice(0, 10);
|
||||||
).slice(0, 8);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -299,10 +325,11 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
|
|
||||||
{/* Suggestions — inline block, no absolute positioning.
|
{/* Suggestions — inline block, no absolute positioning.
|
||||||
Avoids overflow-y-auto clipping AND Radix Dialog pointer-event capture. */}
|
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">
|
<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">
|
<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">
|
||||||
Recent unmatched transactions
|
{input.trim().length >= 2 ? 'Matching transactions' : 'Recent unmatched transactions'}
|
||||||
|
{liveSearching && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
</p>
|
</p>
|
||||||
<div className="max-h-40 overflow-y-auto">
|
<div className="max-h-40 overflow-y-auto">
|
||||||
{filteredSuggestions.map(s => {
|
{filteredSuggestions.map(s => {
|
||||||
|
|
@ -322,6 +349,9 @@ export default function BillMerchantRules({ billId, billName, onRulesChanged })
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -119,12 +119,12 @@ function BillCard({ bill, prefs = ALL_ON, onEdit, onToggle, onDelete, onHistory,
|
||||||
S
|
S
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!!bill.has_merchant_rule && (
|
{(!!bill.has_merchant_rule || !!bill.has_linked_transactions) && (
|
||||||
<span
|
<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"
|
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>
|
</span>
|
||||||
)}
|
)}
|
||||||
{hasHistory && (
|
{hasHistory && (
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,9 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
|
||||||
{bill.has_2fa && (
|
{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>
|
<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 && (
|
{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>
|
<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
|
AP
|
||||||
</span>
|
</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 && (
|
{row.is_subscription && (
|
||||||
<span
|
<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"
|
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
|
AP
|
||||||
</span>
|
</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 && (
|
{row.is_subscription && (
|
||||||
<span
|
<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"
|
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 {
|
import {
|
||||||
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { useTheme } from '@/contexts/ThemeContext';
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
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 ─────────────────────────────────────────────────────────────
|
// ─── Card wrapper ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function SectionCard({ title, children }) {
|
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 ─────────────────────────────────────────────────────────────
|
// ─── SettingsPage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
|
|
@ -323,6 +352,7 @@ export default function SettingsPage() {
|
||||||
|
|
||||||
{/* Billing Behavior */}
|
{/* Billing Behavior */}
|
||||||
<SectionCard title="Billing Behavior">
|
<SectionCard title="Billing Behavior">
|
||||||
|
<LinkImportToggle />
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Grace period"
|
label="Grace period"
|
||||||
description="Days after the due date before a bill is marked overdue."
|
description="Days after the due date before a bill is marked overdue."
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ import {
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import BillModal from '@/components/BillModal';
|
import BillModal from '@/components/BillModal';
|
||||||
|
import BillHistoricalImportDialog from '@/components/BillHistoricalImportDialog';
|
||||||
|
import { getLinkImportPref } from '@/pages/SettingsPage';
|
||||||
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
import { moveInArray, movedItemId, reorderPayload } from '@/lib/reorder';
|
||||||
|
|
||||||
const TYPE_LABELS = {
|
const TYPE_LABELS = {
|
||||||
|
|
@ -850,6 +852,7 @@ export default function SubscriptionsPage() {
|
||||||
const [txQuery, setTxQuery] = useState('');
|
const [txQuery, setTxQuery] = useState('');
|
||||||
const [txResults, setTxResults] = useState([]);
|
const [txResults, setTxResults] = useState([]);
|
||||||
const [txSearching, setTxSearching] = useState(false);
|
const [txSearching, setTxSearching] = useState(false);
|
||||||
|
const [importDialog, setImportDialog] = useState(null); // { billId, billName }
|
||||||
const txDebounce = useRef(null);
|
const txDebounce = useRef(null);
|
||||||
|
|
||||||
const subscriptionCategoryId = useMemo(() => {
|
const subscriptionCategoryId = useMemo(() => {
|
||||||
|
|
@ -938,9 +941,12 @@ export default function SubscriptionsPage() {
|
||||||
async function acceptRecommendation(recommendation) {
|
async function acceptRecommendation(recommendation) {
|
||||||
setBusyId(`rec-${recommendation.id}`);
|
setBusyId(`rec-${recommendation.id}`);
|
||||||
try {
|
try {
|
||||||
await api.createSubscriptionFromRecommendation(recommendation);
|
const created = await api.createSubscriptionFromRecommendation(recommendation);
|
||||||
toast.success(`${recommendation.name} is now tracked.`);
|
toast.success(`${recommendation.name} is now tracked.`);
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
|
if (getLinkImportPref() && recommendation.merchant && created?.id) {
|
||||||
|
setImportDialog({ billId: created.id, billName: created.name || recommendation.name });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Could not create subscription.');
|
toast.error(err.message || 'Could not create subscription.');
|
||||||
} finally {
|
} 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}".`);
|
toast.success(`Linked ${result.matched_count} transaction${result.matched_count !== 1 ? 's' : ''} to "${result.bill_name}".`);
|
||||||
setMatchTarget(null);
|
setMatchTarget(null);
|
||||||
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
setRecommendations(prev => prev.filter(r => r.id !== recommendation.id));
|
||||||
|
if (getLinkImportPref() && recommendation.merchant) {
|
||||||
|
setImportDialog({ billId, billName: result.bill_name || recommendation.name });
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Could not link recommendation to bill.');
|
toast.error(err.message || 'Could not link recommendation to bill.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1474,6 +1483,14 @@ export default function SubscriptionsPage() {
|
||||||
onConfirm={matchRecommendationToBill}
|
onConfirm={matchRecommendationToBill}
|
||||||
busy={!!busyId?.startsWith('match-')}
|
busy={!!busyId?.startsWith('match-')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BillHistoricalImportDialog
|
||||||
|
billId={importDialog?.billId}
|
||||||
|
billName={importDialog?.billName}
|
||||||
|
open={!!importDialog}
|
||||||
|
onClose={() => setImportDialog(null)}
|
||||||
|
onImported={() => { setImportDialog(null); refreshAll(); }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,17 @@ router.get('/', (req, res) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
ensureUserDefaultCategories(req.user.id);
|
ensureUserDefaultCategories(req.user.id);
|
||||||
const includeInactive = req.query.inactive === 'true';
|
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(`
|
const bills = db.prepare(`
|
||||||
SELECT b.*, c.name AS category_name,
|
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
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
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_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 = ?
|
WHERE b.user_id = ?
|
||||||
AND b.deleted_at IS NULL
|
AND b.deleted_at IS NULL
|
||||||
${includeInactive ? '' : 'AND b.active = 1'}
|
${includeInactive ? '' : 'AND b.active = 1'}
|
||||||
|
|
@ -333,7 +337,10 @@ router.get('/:id', (req, res) => {
|
||||||
) THEN 1 ELSE 0 END AS has_history_ranges,
|
) THEN 1 ELSE 0 END AS has_history_ranges,
|
||||||
CASE WHEN EXISTS(
|
CASE WHEN EXISTS(
|
||||||
SELECT 1 FROM bill_merchant_rules WHERE bill_id = b.id AND user_id = b.user_id
|
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
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
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
|
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') {
|
function fetchActiveBills(db, userId, orderKey = 'due_day') {
|
||||||
const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day;
|
const orderBy = FETCH_BILLS_ORDER[orderKey] ?? FETCH_BILLS_ORDER.due_day;
|
||||||
return db.prepare(`
|
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
|
FROM bills b
|
||||||
LEFT JOIN categories c ON b.category_id = c.id AND c.deleted_at IS NULL
|
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
|
WHERE b.active = 1 AND b.user_id = ? AND b.deleted_at IS NULL
|
||||||
ORDER BY ${orderBy}
|
ORDER BY ${orderBy}
|
||||||
`).all(userId);
|
`).all(userId);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue