// ==UserScript==
// @name SBI YONO VIEW PAGE 24062026
// @namespace http://tampermonkey.net/
// @version 5.1
// @description Safely add Export as PDF beside SBI Download button
// @match https://yonoretail.sbi.bank.in/*
// @grant none
// @require https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.4.0/jspdf.umd.min.js
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ─────────────────────────────────────────────────────────────────────────
// CONFIGURATION
// ─────────────────────────────────────────────────────────────────────────
const rowsPerPage = 10;
const pagesPerGroup = 5;
// ─────────────────────────────────────────────────────────────────────────
// STATE VARIABLES
// These live in the IIFE closure — they persist across re-renders
// because they are JS variables, NOT DOM attributes.
// ─────────────────────────────────────────────────────────────────────────
let currentPage = 1;
let currentGroup = 1;
let searchTerm = ''; // ← always holds the latest typed value
let showDebit = true;
let showCredit = true;
let activeDateRange = null;
let minAmount = null;
let maxAmount = null;
// ─────────────────────────────────────────────────────────────────────────
// DATA (replace this array with your real data or fetch call)
// ─────────────────────────────────────────────────────────────────────────
const myData = [];
let filteredData = [...myData];
// ─────────────────────────────────────────────────────────────────────────
// DATE UTILITIES
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: parseDate
* SBI stores dates as "DD/MM/YYYY" but JS Date() expects "YYYY-MM-DD".
* We split the string and manually pass (year, month-1, day) to new Date().
* Month is 0-indexed in JS: Jan=0, Dec=11.
*
* Example: "01/05/2020" → new Date(2020, 4, 1) → May 1 2020
*/
function parseDate(str) {
if (!str) return null;
const parts = str.split('/');
if (parts.length !== 3) return null;
const [d, m, y] = parts;
return new Date(Number(y), Number(m) - 1, Number(d));
}
// ─────────────────────────────────────────────────────────────────────────
// DATE RANGE CAPTURE FROM SBI PAGE
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: captureDateRange
* SBI's Angular app renders a text like:
* "Showing transactions from 01/05/2020 To 31/05/2020"
* We use a regex to extract both dates from that rendered text.
* We cannot read SBI's internal Angular component state directly —
* only the DOM text is accessible to us.
*/
function captureDateRange() {
const el = document.querySelector('.result-div .result');
if (!el) { activeDateRange = null; return; }
const text = el.innerText || el.textContent || '';
const match = text.match(/from\s+(\d{2}\/\d{2}\/\d{4})\s+To\s+(\d{2}\/\d{2}\/\d{4})/i);
if (!match) { activeDateRange = null; return; }
const fromDate = parseDate(match[1]);
const toDate = parseDate(match[2]);
if (!fromDate || !toDate) { activeDateRange = null; return; }
toDate.setHours(23, 59, 59, 999); // include the entire last day
activeDateRange = { from: fromDate, to: toDate };
console.log('[SBI Script] Date range captured:', match[1], '→', match[2]);
}
// ─────────────────────────────────────────────────────────────────────────
// MASTER FILTER — single source of truth for all filtering
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: applyAllFilters
* We always start from the FULL dataset (myData) and chain filters one by one.
* Each filter returns a smaller subset. The final subset becomes filteredData.
*
* Why start from myData every time?
* If we filtered filteredData, we could never "undo" a filter —
* data removed in step 1 would be gone forever for step 2.
*
* Filter order (cheapest → most expensive):
* 1. Date range — simple date comparison
* 2. Debit/Credit — simple boolean
* 3. Amount range — simple number comparison
* 4. Search term — string.includes() on multiple fields (most expensive)
*
* REAL-TIME USE CASE:
* User types "DARA" → term = "dara"
* item["Details"] = "TO TRANSFER-UPI/DR/.../DARA SAM/..."
* "to transfer-upi/dr/.../dara sam/...".includes("dara") → TRUE → row shown
*/
function applyAllFilters() {
let result = myData; // ← always reset to full data before filtering
// ── FILTER 1: Date Range ────────────────────────────────────────────
if (activeDateRange) {
result = result.filter(item => {
const itemDate = parseDate(item["Value Date"]);
if (!itemDate) return false;
return itemDate >= activeDateRange.from && itemDate <= activeDateRange.to;
});
}
// ── FILTER 2: Debit / Credit Toggle ────────────────────────────────
result = result.filter(item => {
const hasDebit = Number(item.Debit || 0) > 0;
const hasCredit = Number(item.Credit || 0) > 0;
if (showDebit && showCredit) return true;
if (showDebit && !showCredit) return hasDebit;
if (showCredit && !showDebit) return hasCredit;
return false;
});
// ── FILTER 3: Amount Range ──────────────────────────────────────────
if (minAmount !== null || maxAmount !== null) {
result = result.filter(item => {
const amount = Number(item.Debit || 0) > 0
? Number(item.Debit)
: Number(item.Credit || 0);
if (minAmount !== null && amount < minAmount) return false;
if (maxAmount !== null && amount > maxAmount) return false;
return true;
});
}
// ── FILTER 4: Search Term ───────────────────────────────────────────
/*
* ROOT CAUSE FIX — why search wasn't working:
*
* The filter logic here was always CORRECT.
* The problem was that addSearchBar() depended on finding SBI's input:
* document.querySelector('input.inp[placeholder*="Search by name"]')
* If that element didn't exist (wrong selector, or Angular hadn't rendered it),
* the function returned early → our <input> was NEVER injected into the page →
* user was typing into nothing → searchTerm stayed '' forever.
*
* FIX: addSearchBar() now injects our search bar ABOVE the transaction table
* using a guaranteed anchor (.transaction-table), completely independent
* of whether SBI's own search input exists or not.
*
* Now when user types → input event fires → searchTerm updates → this
* filter runs correctly.
*
* Search covers: Details, Debit, Credit, Cheq No, Balance
* All lowercased for case-insensitive matching.
*/
if (searchTerm.trim() !== '') {
const term = searchTerm.toLowerCase();
result = result.filter(item => {
/*
* THEORY — why String() is required here:
*
* item["Cheq No"] can be a NUMBER like 4897691162095
* item["Debit"] is always a number like 450
* item["Credit"] is "" (empty string) OR a number
* item["Balance"] is always a number like 12500
*
* The bug: (item["Cheq No"] || "").toLowerCase()
* → item["Cheq No"] = 4897691162095 (truthy number)
* → || "" never triggers (because number is truthy)
* → 4897691162095.toLowerCase() ← TypeError! numbers have no toLowerCase
*
* The fix: String(item["Cheq No"] ?? '')
* → String(4897691162095) = "4897691162095" (always a string)
* → "4897691162095".toLowerCase() ← works fine
*
* We use ?? (nullish coalescing) instead of || so that
* 0 (a valid amount) is not treated as falsy and replaced with ''.
* || would convert 0 → '' and miss searching for "0".
* ?? only replaces null / undefined, not 0 or false.
*/
const details = String(item["Details"] ?? '').toLowerCase();
const cheqno = String(item["Cheq No"] ?? '').toLowerCase();
const debit = String(item["Debit"] ?? '');
const credit = String(item["Credit"] ?? '');
const balance = String(item["Balance"] ?? '');
return (
details.includes(term) ||
cheqno.includes(term) ||
debit.includes(term) ||
credit.includes(term) ||
balance.includes(term)
);
});
}
filteredData = result;
currentPage = 1;
currentGroup = 1;
renderTable();
}
function applyFilter() {
captureDateRange();
applyAllFilters();
}
// ─────────────────────────────────────────────────────────────────────────
// STYLES
// ─────────────────────────────────────────────────────────────────────────
function addStyles() {
if (document.getElementById('tm-style')) return;
const style = document.createElement('style');
style.id = 'tm-style';
style.innerHTML = `
.transaction-table td:nth-child(3),
.transaction-table td:nth-child(4) { text-align: right; }
.transaction-table td { padding: 8px 15px; }
.tm-amount-cell {
display: inline-flex;
align-items: center;
gap: 5px;
justify-content: flex-end;
width: 100%;
}
.tm-arrow-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* ── Search bar container ── */
#tm-search-container {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 0;
padding: 6px 0;
}
#tm-search-label {
font-size: 13px;
font-weight: 600;
color: rgb(88,83,171);
white-space: nowrap;
}
#tm-search-input {
padding: 7px 12px;
border: 1.5px solid #ccc;
border-radius: 4px;
font-size: 14px;
width: 320px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
#tm-search-input:focus {
border-color: rgb(88,83,171);
box-shadow: 0 0 0 2px rgba(88,83,171,0.18);
}
#tm-search-clear {
cursor: pointer;
color: #fff;
font-size: 14px;
background: #aaa;
border: none;
border-radius: 50%;
width: 20px;
height: 20px;
line-height: 18px;
text-align: center;
padding: 0;
display: none;
flex-shrink: 0;
}
#tm-search-clear:hover { background: #888; }
#tm-search-count {
font-size: 12px;
color: #777;
white-space: nowrap;
}
`;
document.head.appendChild(style);
}
// ─────────────────────────────────────────────────────────────────────────
// ARROW ICON HELPER
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: createArrowIcon
* We reuse SBI's own SVG assets (already loaded on the page).
* CSS filter is used to tint the grayscale SVG:
* - Credit (money in) → green (hue-rotate ~120deg = green)
* - Debit (money out) → red (hue-rotate ~330deg = red)
* This avoids loading any external icon library.
*/
function createArrowIcon(type) {
const img = document.createElement('img');
img.classList.add('tm-arrow-icon');
img.setAttribute('loading', 'lazy');
if (type === 'credit') {
img.src = '../../../../../../assets/images/landing-page/down_arrow_new.svg';
img.alt = 'Credit';
img.title = 'Credit (Money In)';
img.style.filter = 'invert(40%) sepia(100%) saturate(400%) hue-rotate(90deg)';
} else {
img.src = '../../../../../../assets/images/landing-page/up_arrow_new.svg';
img.alt = 'Debit';
img.title = 'Debit (Money Out)';
img.style.filter = 'invert(20%) sepia(100%) saturate(500%) hue-rotate(330deg)';
}
return img;
}
// ─────────────────────────────────────────────────────────────────────────
// TABLE RENDERER
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: renderTable
* We wipe tbody.innerHTML and redraw only the rows for the current page.
* This is called after EVERY filter change or page change.
*
* FIX: forEach + return → for..of + continue
* return inside forEach only exits the arrow function callback for that
* iteration — the outer forEach loop keeps going AND the row still gets
* appended because we hadn't returned BEFORE appendChild().
* for..of + continue correctly jumps to the next iteration.
*/
function renderTable() {
const tbody = document.querySelector('.transaction-table tbody');
if (!tbody) return;
tbody.innerHTML = '';
const start = (currentPage - 1) * rowsPerPage;
const pageData = filteredData.slice(start, start + rowsPerPage);
// Update result count label in search bar
const countEl = document.getElementById('tm-search-count');
if (countEl) {
countEl.textContent = searchTerm.trim()
? `${filteredData.length} result${filteredData.length !== 1 ? 's' : ''} found`
: '';
}
if (pageData.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="4" style="text-align:center;color:#999;padding:20px;">
No Transactions Found
</td>
</tr>`;
renderPagination();
return;
}
// ✅ for..of so 'continue' works correctly (return inside forEach does NOT skip rows)
for (const item of pageData) {
if (item["Details"] && item["Details"].includes("Balance As On")) continue;
const tr = document.createElement('tr');
const debit = Number(item.Debit || 0);
const credit = Number(item.Credit || 0);
// Date
const tdDate = document.createElement('td');
tdDate.textContent = item["Value Date"];
// Details
const tdDetails = document.createElement('td');
tdDetails.textContent = item["Details"];
// Amount with arrow icon
const tdAmount = document.createElement('td');
if (debit > 0 || credit > 0) {
const wrapper = document.createElement('div');
wrapper.classList.add('tm-amount-cell');
const isDebit = debit > 0;
const amountVal = isDebit ? debit.toFixed(2) : credit.toFixed(2);
const arrowImg = createArrowIcon(isDebit ? 'debit' : 'credit');
const amountText = document.createElement('span');
amountText.textContent = amountVal;
amountText.style.color = isDebit ? '#d32f2f' : '#2e7d32';
amountText.style.fontWeight = '500';
wrapper.appendChild(arrowImg);
wrapper.appendChild(amountText);
tdAmount.appendChild(wrapper);
}
// Balance
const tdBalance = document.createElement('td');
tdBalance.textContent = Number(item["Balance"]).toFixed(2);
tr.appendChild(tdDate);
tr.appendChild(tdDetails);
tr.appendChild(tdAmount);
tr.appendChild(tdBalance);
tbody.appendChild(tr);
}
renderPagination();
}
// ─────────────────────────────────────────────────────────────────────────
// PAGINATION RENDERER
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: Grouped pagination
* We show pages in groups of pagesPerGroup (5).
* Page group 1 → pages 1-5, group 2 → pages 6-10, etc.
* Prev/Next buttons jump between groups.
* Active page is highlighted in purple.
*/
function renderPagination() {
let container = document.getElementById('custom-pagination');
if (!container) {
container = document.createElement('div');
container.id = 'custom-pagination';
container.style.cssText = 'margin:15px; text-align:center;';
document.querySelector('.transaction-table').after(container);
}
container.innerHTML = '';
const totalPages = Math.ceil(filteredData.length / rowsPerPage);
if (totalPages <= 1) return;
const startPage = (currentGroup - 1) * pagesPerGroup + 1;
const endPage = Math.min(startPage + pagesPerGroup - 1, totalPages);
if (currentGroup > 1) {
const prevBtn = document.createElement('button');
prevBtn.innerText = 'Prev';
prevBtn.style.cssText = 'margin:5px; padding:4px 10px; cursor:pointer;';
prevBtn.onclick = () => {
currentGroup--;
currentPage = startPage - pagesPerGroup;
renderTable();
};
container.appendChild(prevBtn);
}
for (let i = startPage; i <= endPage; i++) {
const btn = document.createElement('button');
btn.innerText = i;
btn.style.cssText = 'margin:5px; padding:4px 10px; cursor:pointer;';
if (i === currentPage) {
btn.style.background = 'rgb(88,83,171)';
btn.style.color = '#fff';
}
btn.onclick = () => { currentPage = i; renderTable(); };
container.appendChild(btn);
}
if (endPage < totalPages) {
const nextBtn = document.createElement('button');
nextBtn.innerText = 'Next';
nextBtn.style.cssText = 'margin:5px; padding:4px 10px; cursor:pointer;';
nextBtn.onclick = () => {
currentGroup++;
currentPage = endPage + 1;
renderTable();
};
container.appendChild(nextBtn);
}
}
// ─────────────────────────────────────────────────────────────────────────
// SEARCH BAR — ROOT CAUSE FIX
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: addSearchBar — why the old approach failed
*
* OLD approach:
* const sbiInput = document.querySelector('input.inp[placeholder*="Search by name"]');
* if (!sbiInput) return; ← PROBLEM: silently exits if SBI's input not found
* sbiInput.parentNode.replaceChild(wrapper, sbiInput);
*
* Problems with old approach:
* 1. If SBI's input selector doesn't match (different placeholder, class change,
* Angular hasn't rendered it yet) → sbiInput = null → function returns immediately
* → our search bar is NEVER injected → user types into nothing.
*
* 2. Even if it was injected once, when Angular re-renders the component,
* it recreates its own input, but our wrapper was placed INSIDE SBI's
* component tree — Angular's re-render wipes it.
*
* NEW approach (FIXED):
* - We inject our search bar BEFORE the transaction table using
* table.parentNode.insertBefore(..., table)
* - This anchor (.transaction-table) is guaranteed to exist at init time
* because we only call init() AFTER the table is detected.
* - Our search bar is OUTSIDE Angular's component tree so re-renders
* don't affect it.
* - We still try to hide SBI's original search input to avoid duplicates,
* but we don't DEPEND on finding it.
*
* REAL-TIME USE CASE:
* User types "DARA" → input event fires → searchTerm = "dara"
* → applyAllFilters() runs → Details field "...DARA SAM..."
* → "...dara sam...".includes("dara") = true → row appears in results.
*/
function addSearchBar() {
// Don't duplicate if already injected
if (document.getElementById('tm-search-container')) return;
const table = document.querySelector('.transaction-table');
if (!table) return;
// ── Build search container ─────────────────────────────────────────
const container = document.createElement('div');
container.id = 'tm-search-container';
// Label
const label = document.createElement('span');
label.id = 'tm-search-label';
label.textContent = '🔍 Search:';
// Input
const searchInput = document.createElement('input');
searchInput.id = 'tm-search-input';
searchInput.type = 'text';
searchInput.placeholder = 'Type name, amount, UPI ID, cheque no...';
searchInput.value = searchTerm; // restore previous value if re-attaching
// Clear (×) button
const clearBtn = document.createElement('button');
clearBtn.id = 'tm-search-clear';
clearBtn.textContent = '×';
clearBtn.title = 'Clear search';
clearBtn.style.display = searchTerm ? 'inline' : 'none';
// Result count label e.g. "2 results found"
const countEl = document.createElement('span');
countEl.id = 'tm-search-count';
// ── Event Listeners ────────────────────────────────────────────────
/*
* 'input' fires on every single keystroke (not just on blur or Enter).
* This gives live/instant filtering as the user types.
* searchTerm is updated first, then applyAllFilters() rerenders the table.
*/
searchInput.addEventListener('input', function () {
searchTerm = this.value; // update state
clearBtn.style.display = searchTerm ? 'inline' : 'none'; // show/hide ×
applyAllFilters(); // refilter + rerender
});
clearBtn.addEventListener('click', function () {
searchInput.value = '';
searchTerm = '';
clearBtn.style.display = 'none';
countEl.textContent = '';
applyAllFilters();
searchInput.focus();
});
// Also allow Enter key to trigger filter explicitly
searchInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') applyAllFilters();
});
container.appendChild(label);
container.appendChild(searchInput);
container.appendChild(clearBtn);
container.appendChild(countEl);
// ── Inject BEFORE the table (outside Angular's component tree) ─────
table.parentNode.insertBefore(container, table);
// ── Optionally hide SBI's own search input to avoid confusion ──────
// We don't DEPEND on finding it — just hide it if it exists
const sbiInput = document.querySelector('input.inp[placeholder*="Search by name"]');
if (sbiInput) {
const sbiWrapper = sbiInput.closest('.search-wrapper, .input-wrapper, mat-form-field') || sbiInput.parentNode;
if (sbiWrapper) sbiWrapper.style.display = 'none';
}
console.log('[SBI Script] Search bar injected successfully');
}
// ─────────────────────────────────────────────────────────────────────────
// AMOUNT RANGE INPUTS HOOK
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: hookAmountInputs — polling for Angular Material inputs
* Angular Material inputs (mat-input) are created dynamically by Angular.
* They don't exist at page load — they appear after Angular bootstraps.
* We use setInterval to poll every 500ms. Once both inputs appear,
* we attach our listeners and clear the interval.
*/
function hookAmountInputs() {
const amountInterval = setInterval(() => {
const minInput = document.getElementById('mat-input-0');
const maxInput = document.getElementById('mat-input-1');
if (!minInput || !maxInput) return;
clearInterval(amountInterval);
minInput.addEventListener('input', function () {
const val = this.value.trim();
minAmount = val === '' ? null : Number(val);
applyAllFilters();
});
maxInput.addEventListener('input', function () {
const val = this.value.trim();
maxAmount = val === '' ? null : Number(val);
applyAllFilters();
});
[minInput, maxInput].forEach(input => {
input.addEventListener('keydown', e => {
if (e.key === 'Enter') applyAllFilters();
});
});
console.log('[SBI Script] Amount inputs hooked');
}, 500);
}
// ─────────────────────────────────────────────────────────────────────────
// CHECKBOX HOOKS
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: hookCheckboxes — same polling pattern as hookAmountInputs
* Angular renders the debit/credit checkboxes after bootstrap.
* We poll until they exist, read their initial state, then listen for changes.
*/
function hookCheckboxes() {
const checkInterval = setInterval(() => {
// BUG FIX 1: Dynamic ID — Angular generates IDs like "1302926099Debit0"
// The prefix number changes every session. The SUFFIX is always stable.
// Use attribute ends-with selector ($=) instead of getElementById.
const debitCheckbox = document.querySelector('[id$="Debit0"]');
const creditCheckbox = document.querySelector('[id$="Credit1"]');
// BUG FIX 2: Also find SBI's Apply button by its class
// Filter should trigger when user clicks Apply, not on checkbox change
const applyBtn = document.querySelector('button.Apply');
if (!debitCheckbox || !creditCheckbox || !applyBtn) return;
clearInterval(checkInterval);
// BUG FIX 3: Angular checkboxes use aria-checked, not just .checked
// Read both and combine — whichever is true wins
function isChecked(el) {
return el.checked || el.getAttribute('aria-checked') === 'true';
}
// Read initial state on hook
showDebit = isChecked(debitCheckbox);
showCredit = isChecked(creditCheckbox);
// Hook the Apply button click — this is when SBI applies the filter
// We read checkbox states AT THAT MOMENT so we always get latest values
applyBtn.addEventListener('click', function () {
// Wait 100ms — Angular updates aria-checked AFTER the click event
setTimeout(() => {
showDebit = isChecked(debitCheckbox);
showCredit = isChecked(creditCheckbox);
// If neither checked (both unchecked) = show everything
if (!showDebit && !showCredit) {
showDebit = true;
showCredit = true;
}
console.log('[SBI Script] Apply clicked | debit:', showDebit, '| credit:', showCredit);
captureDateRange();
applyAllFilters();
}, 100);
});
console.log('[SBI Script] Checkboxes hooked | IDs:', debitCheckbox.id, creditCheckbox.id);
}, 500);
}
// ─────────────────────────────────────────────────────────────────────────
// FILTER BUTTON
// ─────────────────────────────────────────────────────────────────────────
function addFilterButton() {
if (document.getElementById('tm-filter-btn')) return;
const btn = document.createElement('button');
btn.id = 'tm-filter-btn';
btn.innerText = 'Apply Duration Filter';
btn.style.cssText = `
margin: 10px 0;
padding: 6px 14px;
background: rgb(88,83,171);
color: white;
border: none;
cursor: pointer;
border-radius: 4px;
font-size: 14px;
`;
btn.onclick = applyFilter;
const table = document.querySelector('.transaction-table');
table.parentNode.insertBefore(btn, table);
}
// ─────────────────────────────────────────────────────────────────────────
// MUTATION OBSERVER — keeps our injected UI alive after Angular re-renders
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: MutationObserver
* SBI YONO is an Angular SPA. Navigation and filter actions cause Angular
* to destroy and recreate its components. This removes elements we injected
* INSIDE Angular's component tree.
*
* MutationObserver watches the entire document body for any DOM changes
* (childList: true, subtree: true). When we detect our search container
* is missing (removed by Angular), we re-inject it.
*
* Our NEW approach places the search bar BEFORE the table (outside Angular's
* component), so it survives most re-renders. The observer is a safety net
* for cases where the entire table area gets replaced.
*
* childList: true → fires when direct children are added/removed
* subtree: true → also fires for all descendant changes
*/
function watchForDOMChanges() {
const observer = new MutationObserver(() => {
const table = document.querySelector('.transaction-table');
const myContainer = document.getElementById('tm-search-container');
const myBtn = document.getElementById('tm-filter-btn');
if (table && !myContainer) {
console.log('[SBI Script] Re-injecting search bar after DOM change');
addSearchBar();
}
if (table && !myBtn) {
addFilterButton();
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// ─────────────────────────────────────────────────────────────────────────
// INIT
// ─────────────────────────────────────────────────────────────────────────
function init() {
const table = document.querySelector('.transaction-table');
if (!table) return;
addStyles();
addSearchBar(); // ← inject search bar FIRST (before renderTable so count works)
addFilterButton();
renderTable();
hookCheckboxes();
hookAmountInputs();
watchForDOMChanges();
console.log('[SBI Script] Initialized successfully');
}
// ─────────────────────────────────────────────────────────────────────────
// ENTRY POINT — poll until transaction table exists
// ─────────────────────────────────────────────────────────────────────────
/*
* THEORY: Why polling instead of DOMContentLoaded?
* DOMContentLoaded fires when the HTML is parsed, but Angular hasn't
* rendered its components yet. The .transaction-table element only exists
* after Angular's change detection cycle completes.
* Polling every 1 second is simple and reliable for this use case.
*/
const interval = setInterval(() => {
if (document.querySelector('.transaction-table')) {
init();
clearInterval(interval);
}
}, 1000);
})();2 views