Skip to content

Instantly share code, notes, and snippets.

@manfred-hinsch
Last active January 1, 2026 09:52
Show Gist options
  • Select an option

  • Save manfred-hinsch/0f49c882928fe985af4f9ccc78dd09c3 to your computer and use it in GitHub Desktop.

Select an option

Save manfred-hinsch/0f49c882928fe985af4f9ccc78dd09c3 to your computer and use it in GitHub Desktop.
smart-trantypes
/* smart-trantype.css - visual styles */
#main.col620 {
width: 100% !important;
}
/* table */
#transaction-table {
border-collapse: collapse;
width: 100%;
background: #fff;
font-family: inherit;
}
#transaction-table th:not(.numeric),
#transaction-table td:not(.numeric) {
border: 1px solid #ccc;
padding: 8px;
text-align: left;
font-size: 1.3rem;
}
#transaction-table th.numeric,
#transaction-table td.numeric {
border: 1px solid #ccc;
padding: 8px;
text-align: right;
font-size: 1.3rem;
}
/* row states */
#transaction-table tbody tr.blank-row {
background: #f5f5f5;
transition: background-color .2s ease;
}
#transaction-table tbody tr.assigned-row {
background: #e8f8ee;
transition: background-color .25s ease;
}
#transaction-table tbody tr.changed-row {
background: #fff6e4;
transition: background-color .25s ease;
}
#transaction-table tbody tr.pre-assigned {
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.03);
}
/* small icons/buttons */
.save-tran-type-btn {
border: none;
background: transparent;
cursor: pointer;
font-size: 1rem;
}
.save-tran-type-btn[disabled] {
opacity: 0.6;
cursor: default;
}
.undo-tran-type-btn {
border: none;
background: transparent;
cursor: pointer;
font-size: 1rem;
margin-left: 6px;
}
#save-all-btn {
padding: 0px 0px;
background: transparent;
border: none;
cursor: pointer;
font-size: 17px;
transition: background-color 0.3s ease;
}
/* ============================================================
SMARTTRANTYPE POPUPS — CLEAN + ISOLATED
============================================================ */
/* ---------- OVERLAYS ---------- */
#pattern-popup-overlay,
#stt-tran-type-popup-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 99990;
display: none;
}
/* ---------- BASE POPUP CONTAINER ---------- */
#pattern-popup,
#stt-tran-type-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 99991;
background: #fff;
border: 1px solid #888;
border-radius: 8px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.35);
padding: 12px;
/* width: 25%; */
max-width: 760px;
max-height: 80vh;
display: none;
overflow: hidden;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial;
}
/* ---------- HEADER ---------- */
#pattern-popup .popup-header,
#stt-tran-type-popup .popup-header {
padding: 12px 16px;
background: #f5f5f5;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
.popup-title {
font-size: 18px;
font-weight: 600;
}
/* Close button */
.popup-close {
background: none;
border: none;
font-size: 22px;
cursor: pointer;
}
/* ---------- BODY ---------- */
#pattern-popup .popup-body,
#stt-tran-type-popup .popup-body {
padding: 14px;
overflow-y: auto;
flex: 1;
}
/* Highlighted pattern text */
#popup-description .highlighted {
background: yellow;
padding: 2px 4px;
border-radius: 3px;
font-weight: 600;
}
/* ---------- FOOTER ---------- */
/* #pattern-popup .popup-footer, */
#stt-tran-type-popup .popup-footer {
padding: 12px 14px;
border-top: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Buttons */
.btn-primary {
background: #0073aa;
border: none;
padding: 7px 12px;
color: white;
border-radius: 4px;
cursor: pointer;
}
.btn-secondary {
background: #e0e0e0;
border: none;
padding: 7px 12px;
border-radius: 4px;
cursor: pointer;
color: #222;
}
.btn-danger {
background: #c0392b;
border: none;
padding: 7px 12px;
border-radius: 4px;
cursor: pointer;
color: white;
}
/* Pattern popup footer: two-column layout */
/* .pattern-popup-footer {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
} */
/* Left side (font size control) */
/* .pattern-popup-footer .font-size-box {
flex: 1;
} */
/* Right side (Accept / Cancel / Clear buttons) */
/* .pattern-popup-footer .popup-buttons { */
.stt-popup-buttons {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Pattern popup should use vertical stacking layout */
/* #pattern-popup .popup-body {
display: flex;
flex-direction: column;
gap: 1.2rem;
} */
/* Each row in the pattern popup */
#pattern-popup .pattern-row {
width: 100%;
}
/* Pattern Popup Buttons - horizontal layout */
.pattern-button-row {
display: inline-flex;
justify-content: flex-start;
/* or center if you prefer */
gap: 0.5rem;
/* spacing between buttons */
margin-top: 1rem;
}
/* pattern-row pattern-button-row */
.pattern-button-row button {
display: flex;
/* ensure buttons do not take full width */
margin: 0;
/* reset any previous margin */
}
/* Ensure pattern popup buttons appear horizontally */
#pattern-popup .popup-footer .popup-buttons,
#pattern-popup .pattern-popup-footer .popup-buttons,
.pattern-popup .popup-buttons {
display: flex !important;
flex-direction: row !important;
gap: 8px;
align-items: center;
justify-content: flex-end;
}
/* Make sure the font-size control is on its own line and smaller */
#pattern-popup .font-size-box {
display: block;
margin-right: 12px;
}
/* Slight spacing cleanup for the footer */
#pattern-popup .popup-footer {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
}
@media (max-width: 500px) {
#pattern-popup,
#stt-tran-type-popup {
width: 95%;
max-width: 95%;
}
}
/* smart-trantype.js
Single namespace architecture: SmartTrantype
Advanced pattern_sets engine (matches description -> pattern_sets)
Popup Type 1 for tran-type management
Popup Type 2 for pattern management
*/
var SmartTrantype = SmartTrantype || {};
// ------------------------------
// SmartTrantype.Cache — central cache store
// ------------------------------
SmartTrantype.Cache = {
rows: [],
map: {},
get: function (rowId) {
return this.map[rowId] || null;
},
set: function (rowId, data) {
if (!this.map[rowId]) {
this.map[rowId] = data;
this.rows.push(data);
} else {
Object.assign(this.map[rowId], data);
}
}
};
(function (window, $) {
'use strict';
// Row states (single source of truth)
const STT_STATE = {
BLANK: 'blank',
CHANGED: 'changed',
ASSIGNED: 'assigned'
};
// internal locks
let _refreshLockActive = false;
let _initTranTypeInputsLock = false;
// small session cache (client-side)
window.sttClientCache = window.sttClientCache || {
tran_types: [],
pattern_sets: []
};
if (!window._stt_inputs_listener_attached) {
window._stt_inputs_listener_attached = true;
$(document).on('change', '#transaction-table .tran-type-input', function () {
SmartTrantype.handleInputChange(this);
});
}
// ----------------------------
// Utils
// ----------------------------
SmartTrantype.Utils = {
escapeRegExp: function (s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
},
highlightPatterns: function (container, patterns, color) {
if (!container) return;
const text = container.innerText || '';
if (!patterns || patterns.length === 0) {
container.innerHTML = $('<div/>').text(text).html();
return;
}
const sorted = [...patterns].filter(Boolean).sort((a, b) => b.length - a.length);
const regex = new RegExp("(" + sorted.map(this.escapeRegExp).join("|") + ")", "gi");
const html = text.replace(regex, match => `<span class="stt-highlighted" style="background-color:${color};">${match}</span>`);
container.innerHTML = html;
},
highlightDuplicates: function (container, patterns) {
if (!container || !container.parentNode) return;
const parent = container.parentNode;
// remove any previous warning
Array.from(parent.querySelectorAll(".duplicate-warning")).forEach(n => n.remove());
if (!patterns || patterns.length === 0) return;
// Normalize and compute counts
const normalized = patterns.map(p => (p || '').toString().trim()).filter(Boolean);
const counts = normalized.reduce((acc, p) => {
const k = p.toLowerCase();
acc[k] = (acc[k] || 0) + 1;
return acc;
}, {});
// exact duplicates
const exactRemoved = Object.keys(counts).filter(k => counts[k] > 1);
// subset/contained duplicates (present in normalized but omitted by dedupe)
const lowerClean = [...new Set(normalized.map(p => p.toLowerCase()))];
const subsetRemoved = normalized
.map(p => p.toLowerCase())
.filter(p => lowerClean.indexOf(p) === -1 && exactRemoved.indexOf(p) === -1);
if (exactRemoved.length || subsetRemoved.length) {
const warn = document.createElement("div");
warn.className = "duplicate-warning";
let parts = [];
if (exactRemoved.length) parts.push("duplicates: " + exactRemoved.join(", "));
if (subsetRemoved.length) parts.push("contained within longer ones: " + subsetRemoved.join(", "));
warn.innerText = "⚠ These patterns will be ignored — " + parts.join("; ");
parent.insertBefore(warn, container.nextSibling);
}
},
getHighlightedSelections: function (container) {
if (!container) return [];
return Array.from(container.querySelectorAll(".stt-highlighted")).map(el => el.textContent.trim()).filter(Boolean);
},
clearSelections: function (container) {
if (!container) return;
container.textContent = container.textContent; // removes spans
},
dedupeAndClean: function (arr) {
return [...new Set((arr || []).map(s => (s || '').toString().trim()).filter(Boolean))];
},
// ------------------------------------------------------
// Highlight one or more patterns inside an element
// ------------------------------------------------------
highlightPatterns(container, patterns, color = 'yellow') {
if (!container || !patterns || !patterns.length) return;
const text = container.innerText;
let html = text;
patterns.forEach(p => {
if (!p) return;
const esc = p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(esc, 'gi');
html = html.replace(re, match => `<span class="stt-highlighted" style="background:${color};">${match}</span>`);
});
container.innerHTML = html;
},
// ------------------------------------------------------
// Remove all highlights (reset to plain text)
// ------------------------------------------------------
clearSelections(container) {
if (!container) return;
const text = container.innerText;
container.innerHTML = text;
},
// ------------------------------------------------------
// Extract highlighted selections as array of strings
// ------------------------------------------------------
getHighlightedSelections(container) {
if (!container) return [];
const nodes = container.querySelectorAll('.stt-highlighted');
return Array.from(nodes).map(n => n.innerText.trim());
},
// ------------------------------------------------------
// Remove duplicates + trim + normalize
// ------------------------------------------------------
dedupeAndClean(arr) {
if (!Array.isArray(arr)) return [];
return [...new Set(arr.map(a => a.trim()).filter(a => a.length))];
},
// ------------------------------------------------------
// Highlight duplicates differently (orange)
// ------------------------------------------------------
highlightDuplicates(container, arr) {
if (!container || !arr || !arr.length) return;
// Calculate duplicates
const counts = arr.reduce((a, c) => {
a[c] = (a[c] || 0) + 1;
return a;
}, {});
const duplicates = Object.keys(counts).filter(k => counts[k] > 1);
if (!duplicates.length) return;
duplicates.forEach(d => {
const esc = d.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(esc, 'gi');
container.innerHTML = container.innerHTML.replace(
re,
match => `<span class="stt-highlighted" style="background:orange;">${match}</span>`
);
});
},
};
SmartTrantype.Row = {
applyStateToRow: function ($row, state) {
$row.removeClass('blank-row changed-row assigned-row');
switch (state) {
case STT_STATE.BLANK:
$row.addClass('blank-row');
$row.find('.save-tran-type-btn, .undo-tran-type-btn').hide();
break;
case STT_STATE.CHANGED:
$row.addClass('changed-row');
$row.find('.save-tran-type-btn').show();
$row.find('.undo-tran-type-btn').show();
break;
case STT_STATE.ASSIGNED:
$row.addClass('assigned-row');
$row.find('.save-tran-type-btn, .undo-tran-type-btn').hide();
break;
}
},
markAsChanged: function ($row) {
if (!$row || !$row.length) return;
const rowId = $row.data('txn-id');
const entry = window.sttCache.transactions.find(r => r.id === rowId);
if (!entry) return;
// Cache flags only
entry.changed = true;
entry.assigned = false;
entry.blank = false;
// DOM only
$row.removeClass('assigned-row blank-row').addClass('changed-row');
$row.find('.save-tran-type-btn').show();
$row.find('.undo-tran-type-btn').show();
},
markAsAssigned: function ($row) {
if (!$row || !$row.length) return;
const rowId = $row.data('txn-id');
const entry = window.sttCache.transactions.find(r => r.id === rowId);
if (!entry) return;
entry.changed = false;
entry.assigned = true;
entry.blank = false;
$row.removeClass('changed-row blank-row').addClass('assigned-row');
$row.find('.save-tran-type-btn').hide();
$row.find('.undo-tran-type-btn').hide();
},
markAsBlank: function ($row) {
if (!$row || !$row.length) return;
const rowId = $row.data('txn-id');
const entry = window.sttCache.transactions.find(r => r.id === rowId);
if (!entry) return;
// Update cache flags
entry.changed = false;
entry.assigned = false;
entry.blank = true;
// Update DOM
$row.removeClass('changed-row assigned-row').addClass('blank-row');
$row.find('.save-tran-type-btn').hide();
$row.find('.undo-tran-type-btn').hide();
}
};
// ------------------------------
// Handle tran-type input changes
// ------------------------------
SmartTrantype.handleInputChange = function (inputEl) {
const $input = $(inputEl);
if (!$input.length) return;
const $row = $input.closest('tr');
const rowId = $row.data('txn-id');
if (!rowId) return;
const entry = window.sttCache.transactions.find(r => r.id === rowId);
if (!entry) return;
// New values
const newText = $input.val();
const newId = $input.data('tran-type-id') || null;
// Update cache
entry.tran_type = newText;
entry.tran_type_id = newId;
// Determine state
const origText = entry.original_tran_type;
const origId = entry.original_tran_type_id;
const isBlank = (!newText || newText.trim() === '');
const isChanged = (newText !== origText) || (newId !== origId);
if (isBlank) {
SmartTrantype.Row.markAsBlank($row);
} else if (isChanged) {
SmartTrantype.Row.markAsChanged($row);
} else {
SmartTrantype.Row.markAsAssigned($row);
}
};
// ------------------------------
// Attach input listeners (blur only)
// ------------------------------
if (!window._stt_inputs_listener_attached) {
window._stt_inputs_listener_attached = true;
// Trigger only on blur (user leaves input)
$(document).on('blur', '#transaction-table .tran-type-input', function () {
const $input = $(this);
const $row = $input.closest('tr');
const rowId = $row.data('txn-id');
if (!rowId) return;
const entry = window.sttCache.transactions.find(r => r.id === rowId);
if (!entry) return;
SmartTrantype.handleInputChange($input[0]);
// Show popup only if meaningful change occurred
if (entry.tran_type !== entry.original_tran_type ||
entry.tran_type_id !== entry.original_tran_type_id) {
if (SmartTrantype.Popup?.showTranTypePopupForRow) {
SmartTrantype.Popup.showTranTypePopupForRow($row, $input);
}
}
});
}
// ------------------------------
// Popup management
// ------------------------------
SmartTrantype.Popup = {
activeRow: null,
activeEntry: null,
showTranTypePopupForRow: function ($row, $input) {
if (!($row instanceof jQuery)) $row = $($row);
if (!($input instanceof jQuery)) $input = $($input);
// Remove any existing popup/overlay
$('.tran-popup-overlay, #tran-type-popup').remove();
const overlay = $('<div class="tran-popup-overlay"></div>');
const popup = $(`
<div id="tran-type-popup" role="dialog" aria-modal="true" class="pattern-popup" style="display:none;">
<div class="popup-header">
<strong>Transaction Type Options</strong>
<button class="popup-close" aria-label="Close">&times;</button>
</div>
<p class="popup-body" style="margin-bottom:8px;">Choose what to do with this transaction:</p>
<div style="display:flex;flex-direction:column;gap:8px;">
<button class="popup-btn btn-secondary" data-action="create">Create New Transaction Type</button>
<button class="popup-btn btn-secondary" data-action="keep">Keep / Adjust This Transaction Type</button>
<button class="popup-btn btn-secondary" data-action="remove">Clear Type For This Transaction</button>
</div>
<div style="text-align:center; margin-top:8px">
<button class="popup-close btn-primary">Cancel</button>
</div>
</div>
`);
// Store references
SmartTrantype.Popup.activeRow = $row;
SmartTrantype.Popup.activeEntry = window.sttCache.transactions.find(r => r.id === $row.data('txn-id'));
$('body').append(overlay, popup);
// Show popup
overlay.fadeIn(120);
popup.fadeIn(120);
// Bind popup buttons
popup.find('.popup-btn').off('click').on('click', function () {
const action = $(this).data('action');
if (action === 'create') SmartTrantype.Popup.applyCreate();
else if (action === 'keep') SmartTrantype.Popup.applyKeep();
else if (action === 'remove') SmartTrantype.Popup.applyRemove();
});
// Bind close
popup.find('.popup-close, .tran-popup-overlay').off('click').on('click', function () {
SmartTrantype.Popup.close();
});
},
close: function () {
$('#tran-type-popup, .tran-popup-overlay').fadeOut(120, function () { $(this).remove(); });
SmartTrantype.Popup.activeRow = null;
SmartTrantype.Popup.activeEntry = null;
},
applyCreate: function () {
SmartTrantype.Popup.close();
},
applyKeep: function () {
const entry = SmartTrantype.Popup.activeEntry;
const $row = SmartTrantype.Popup.activeRow;
if (!entry || !$row) return;
entry.tran_type = entry.original_tran_type;
entry.tran_type_id = entry.original_tran_type_id ?? null;
const $input = $row.find('.tran-type-input');
$input.val(entry.tran_type).data('tran-type-id', entry.tran_type_id);
if (entry.tran_type) SmartTrantype.Row.markAsAssigned($row);
else SmartTrantype.Row.markAsBlank($row);
SmartTrantype.Popup.close();
if (SmartTrantype.Autocomplete?.refresh) SmartTrantype.Autocomplete.refresh($row, entry.tran_type);
},
applyRemove: function () {
const entry = SmartTrantype.Popup.activeEntry;
const $row = SmartTrantype.Popup.activeRow;
if (!entry || !$row) return;
entry.tran_type = '';
entry.tran_type_id = null;
const $input = $row.find('.tran-type-input');
$input.val('').removeData('tran-type-id');
SmartTrantype.Row.markAsBlank($row);
SmartTrantype.Popup.close();
if (SmartTrantype.Autocomplete?.refresh) SmartTrantype.Autocomplete.refresh($row, '');
}
};
/* ----------------------------
* SmartTrantype.Pattern
* ---------------------------- */
SmartTrantype.Pattern = (function () {
// Local safe fallbacks if SmartTrantype.Utils is missing
const Utils = (SmartTrantype && SmartTrantype.Utils) ? SmartTrantype.Utils : {
// dedupe and clean: trim, remove empties, unique
dedupeAndClean(arr) {
if (!Array.isArray(arr)) return [];
return [...new Set(arr.map(a => (a || '').toString().trim()).filter(Boolean))];
},
// highlightPatterns: replace text with spans (orange/yellow)
highlightPatterns(container, patterns = [], color = 'yellow') {
if (!container) return;
const text = container.innerText || '';
if (!patterns || patterns.length === 0) { container.textContent = text; return; }
const sorted = [...patterns].filter(Boolean).sort((a, b) => b.length - a.length);
const esc = s => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp("(" + sorted.map(esc).join("|") + ")", "gi");
const html = (text || '').replace(regex, match =>
`<span class="stt-highlighted highlighted" style="background-color:${color};">${match}</span>`
);
container.innerHTML = html;
},
// highlightDuplicates: shows a brief warning about duplicates (keeps simple)
highlightDuplicates(container, patterns = []) {
if (!container || !container.parentNode) return;
const parent = container.parentNode;
Array.from(parent.querySelectorAll(".duplicate-warning")).forEach(n => n.remove());
if (!patterns || patterns.length === 0) return;
const normalized = patterns.map(p => (p || '').toString().trim()).filter(Boolean);
const cleaned = [...new Set(normalized.map(p => p.toLowerCase()))];
const exactRemoved = [];
const counts = {};
normalized.forEach(p => { const k = p.toLowerCase(); counts[k] = (counts[k] || 0) + 1; });
Object.keys(counts).forEach(k => { if (counts[k] > 1) exactRemoved.push(k); });
const subsetRemoved = normalized
.map(p => p.toLowerCase())
.filter(p => cleaned.indexOf(p) === -1 && exactRemoved.indexOf(p) === -1);
if (exactRemoved.length || subsetRemoved.length) {
const warn = document.createElement("div");
warn.className = "duplicate-warning";
let parts = [];
if (exactRemoved.length) parts.push("duplicates: " + exactRemoved.join(", "));
if (subsetRemoved.length) parts.push("contained within longer ones: " + subsetRemoved.join(", "));
warn.innerText = "⚠ These patterns will be ignored — " + parts.join("; ");
parent.insertBefore(warn, container.nextSibling);
}
},
clearSelections(container) {
if (!container) return;
container.textContent = container.textContent; // remove spans
},
getHighlightedSelections(container) {
if (!container) return [];
return Array.from(container.querySelectorAll(".stt-highlighted, .highlighted"))
.map(s => s.textContent.trim())
.filter(Boolean);
}
};
// Helper to create popup HTML (keeps ids/classes used by your CSS)
function ensurePopupHtml() {
let $overlay = $("#pattern-popup-overlay");
if (!$overlay.length) $overlay = $('<div id="pattern-popup-overlay" style="display:none;"></div>').appendTo('body');
let $popup = $("#pattern-popup");
if (!$popup.length) {
$popup = $(`
<div id="pattern-popup" role="dialog" aria-modal="true" class="pattern-popup" style="display:none;">
<div class="popup-header"><strong>Pattern selector</strong><button class="popup-close" aria-label="Close">&times;</button></div>
<div class="popup-body">
<div id="popup-description" style="white-space:pre-wrap; line-height:1.4; cursor:text; font-size: 28px";"></div>
<div style="margin-top:10px;">
<label>Amount sign:
<select id="popup-amount-sign">
<option value="any">Any</option>
<option value="positive">Positive</option>
<option value="negative">Negative</option>
</select>
</label>
</div>
</div>
<div class="popup-footer">
<div>Font size: <input type="number" id="popup-font-size" value="20" min="10" max="36" style="width:64px;"></div>
<div style="display:flex; gap:8px; margin-top:15px;">
<button id="popup-accept" class="btn-primary">Accept</button>
<button id="popup-clear" class="btn-secondary">Clear</button>
<button id="popup-cancel" class="btn-secondary">Cancel</button>
</div>
</div>
</div>
`).appendTo('body');
}
return { $overlay: $("#pattern-popup-overlay"), $popup: $("#pattern-popup") };
}
// enable interactive selection inside popup body
function enablePopupHighlighting(containerEl, $row, txnId) {
if (!containerEl) return;
if (!Array.isArray(containerEl._highlightedSelections)) containerEl._highlightedSelections = [];
function persist(merged) {
containerEl._highlightedSelections = merged;
try { $row.data('current-patterns', merged); } catch (e) { /* ignore */ }
}
// mouseup -> capture text selection
containerEl.onmouseup = function () {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return;
const text = sel.toString().trim();
if (!text) { sel.removeAllRanges(); return; }
const existing = Utils.getHighlightedSelections(containerEl);
const merged = Utils.dedupeAndClean(existing.concat([text]));
Utils.highlightPatterns(containerEl, merged, 'yellow');
Utils.highlightDuplicates(containerEl, merged);
persist(merged);
sel.removeAllRanges();
};
// dblclick behavior
containerEl.addEventListener('dblclick', function () {
const sel = window.getSelection();
if (!sel) return;
const text = sel.toString().trim();
if (!text) return;
const existing = Utils.getHighlightedSelections(containerEl);
const merged = Utils.dedupeAndClean(existing.concat([text]));
Utils.highlightPatterns(containerEl, merged, 'yellow');
Utils.highlightDuplicates(containerEl, merged);
persist(merged);
sel.removeAllRanges();
});
// click on existing highlighted span removes it
containerEl.addEventListener('click', function (e) {
const targ = e.target;
if (targ && targ.classList && (targ.classList.contains('stt-highlighted') || targ.classList.contains('highlighted'))) {
const text = targ.innerText;
targ.replaceWith(document.createTextNode(text));
const remaining = Utils.getHighlightedSelections(containerEl);
const cleaned = Utils.dedupeAndClean(remaining);
Utils.highlightPatterns(containerEl, cleaned, 'yellow');
Utils.highlightDuplicates(containerEl, cleaned);
persist(cleaned);
}
}, true);
}
// Fetch server patterns for a given tranTypeId (visual only)
function fetchServerPatterns(accountId, tranTypeId) {
return new Promise((resolve) => {
if (!tranTypeId || !accountId) { resolve([]); return; }
$.post(stt_ajax_obj.ajaxurl, {
action: 'stt_get_patterns_for_tran_type',
account_id: accountId,
tran_type_id: tranTypeId,
nonce: stt_ajax_obj.nonce
}).done(function (res) {
if (res && res.success && Array.isArray(res.data.patterns)) {
// server returns rows with patterns column (JSON array) maybe
const serverPatterns = res.data.patterns.map(p => {
try {
// patterns might already be array or JSON
return (typeof p.patterns === 'string') ? JSON.parse(p.patterns) : p.patterns;
} catch (e) {
// fallback: if p is a stringified array
try { return JSON.parse(p); } catch (ee) { return []; }
}
}).flat(1).filter(Boolean);
resolve(Utils.dedupeAndClean(serverPatterns));
} else {
resolve([]);
}
}).fail(function () { resolve([]); });
});
}
// Main show popup
async function openPatternPopup($row) {
// remove existing popup/overlay if present
$("#pattern-popup, #pattern-popup-overlay").remove();
const desc = $row.find('td:nth-child(2)').text().trim();
const txnId = $row.data('txn-id') || '';
const accountId = $('#stt_account_id').val();
const tranTypeId = $row.find('.tran-type-input').data('tran-type-id') || 0;
// create DOM
const { $overlay, $popup } = ensurePopupHtml();
// Fill description
const descEl = document.getElementById('popup-description');
descEl.innerText = desc;
// restore font size if user saved
const savedSize = localStorage.getItem('patternPopupFontSize') || 28;
$('#popup-font-size').val(savedSize).trigger('input');
$('#popup-font-size').on('input', function () {
$(descEl).css('font-size', $(this).val() + 'px');
localStorage.setItem('patternPopupFontSize', $(this).val());
});
// hydrate selections from row (client-side)
const initialArr = $row.data('current-patterns') || [];
if (initialArr && Array.isArray(initialArr) && initialArr.length) {
Utils.highlightPatterns(descEl, initialArr, 'yellow');
Utils.highlightDuplicates(descEl, initialArr);
descEl._highlightedSelections = initialArr.slice();
} else {
descEl._highlightedSelections = [];
Utils.clearSelections(descEl);
}
// show server patterns visually (orange) but keep user yellow selections
const serverPatterns = await fetchServerPatterns(accountId, tranTypeId);
if (serverPatterns && serverPatterns.length) {
// render server-provided orange highlights first, then overlay user yellow selections
Utils.highlightPatterns(descEl, serverPatterns, 'orange');
if (descEl._highlightedSelections && descEl._highlightedSelections.length) {
Utils.highlightPatterns(descEl, descEl._highlightedSelections, 'yellow');
}
}
// enable user interactions for selecting / removing patterns
enablePopupHighlighting(descEl, $row, txnId);
// amount_sign prefill
const existingSign = ($row.data('current-amount-sign') || 'any');
$('#popup-amount-sign').val(existingSign);
// position + show
$popup.css({ position: 'fixed', left: '50%', top: '50%', transform: 'translate(-50%,-50%)', zIndex: 99999 });
$overlay.fadeIn(120); $popup.fadeIn(180);
// cleanup helper
function closePatternPopup() {
$popup.fadeOut(120, function () { $(this).remove(); });
$overlay.fadeOut(120, function () { $(this).remove(); });
$('#popup-font-size').off('input');
$('#popup-accept').off('click');
$('#popup-clear').off('click');
$('#popup-cancel').off('click');
$popup.find('.popup-close').off('click');
}
// handlers
$popup.find('.popup-close').on('click', closePatternPopup);
$overlay.on('click', closePatternPopup);
$('#popup-cancel').on('click', closePatternPopup);
$('#popup-clear').on('click', function () {
Utils.clearSelections(descEl);
descEl._highlightedSelections = [];
$row.data('current-patterns', []);
Utils.highlightDuplicates(descEl, []);
});
$('#popup-accept').on('click', function () {
const selections = Utils.getHighlightedSelections(descEl);
const cleaned = Utils.dedupeAndClean(selections);
$row.data('current-patterns', cleaned);
// record amount_sign
const chosenSign = $('#popup-amount-sign').val() || 'any';
$row.data('current-amount-sign', chosenSign);
// visually update the table cell to show persistent (orange) patterns
const descCell = $row.find('td:nth-child(2)')[0];
if (descCell) Utils.highlightPatterns(descCell, cleaned, 'orange');
// mark row changed so Save button is enabled
if (typeof SmartTrantype.Row !== 'undefined' && typeof SmartTrantype.Row.markAsChanged === 'function') {
SmartTrantype.Row.markAsChanged($row);
}
closePatternPopup();
});
}
// Public init function - binds to description cell (2nd td)
function init() {
$(document).on('click', '#transaction-table tbody tr td:nth-child(2)', function (e) {
e.preventDefault();
e.stopPropagation();
const $cell = $(this);
const $row = $cell.closest('tr');
if (!$row || !$row.length) return;
openPatternPopup($row);
});
}
// Return public interface
return {
init,
openPatternPopup, // backward-compat (old name)
showPatternPopup: openPatternPopup,
enablePopupHighlighting, // exported if needed elsewhere
ensurePopupHtml
};
}());
// ----------------------------
// Matching algorithm (scoring)
// ----------------------------
SmartTrantype.Matcher = {
scorePatternSet: function (description, patternSet) {
// description: string
// patternSet: { patterns: [p1,p2], amount_sign: 'any'|'positive'|'negative' }
if (!patternSet || !Array.isArray(patternSet.patterns)) return { score: 0, matchedCount: 0, totalLen: 0 };
const descLower = (description || '').toLowerCase();
let matchedCount = 0;
let totalLen = 0;
for (const p of patternSet.patterns) {
if (!p) continue;
const pl = p.toString().toLowerCase();
if (descLower.includes(pl)) {
matchedCount++;
totalLen += pl.length;
}
}
// Complexity heuristics:
// - A single pattern that equals or contains almost all description gets priority (long totalLen)
// - Higher matchedCount preferred
// - Then totalLen
const score = (matchedCount * 1000) + totalLen;
return { score: score, matchedCount: matchedCount, totalLen: totalLen };
},
findBestPatternSetForDescription: function (description, patternSets, amount) {
// patternSets: array of pattern_set objects
if (!Array.isArray(patternSets) || patternSets.length === 0) return null;
let best = null;
let bestScore = -1;
for (const ps of patternSets) {
const { score, matchedCount, totalLen } = this.scorePatternSet(description, ps);
if (matchedCount === 0) continue;
// amount_sign constraint check
if (ps.amount_sign === 'positive' && Number(amount) < 0) continue;
if (ps.amount_sign === 'negative' && Number(amount) > 0) continue;
// prefer higher score
if (score > bestScore) {
bestScore = score;
best = ps;
} else if (score === bestScore) {
// tie-break: prefer higher usage_count
if ((ps.usage_count || 0) > (best.usage_count || 0)) {
best = ps;
}
}
}
return best;
}
};
SmartTrantype.Autocomplete = {
initTranTypeInputs: function () {
const $inputs = $('.tran-type-input');
$inputs.each(function () {
const $input = $(this);
if ($input.data('ui-autocomplete')) return;
try {
$input.autocomplete({
source: function (request, response) {
const list = (sttCache && sttCache.tran_types) ? sttCache.tran_types : [];
const term = request.term.toLowerCase();
const matches = list
.filter(item =>
item.description &&
item.description.toLowerCase().includes(term)
)
.map(item => ({
label: item.description, // shown in dropdown
value: item.description, // inserted into input field
id: item.id // stored for reference
}));
response(matches);
},
minLength: 1,
// select: function (event, ui) {
// $input.val(ui.item.value); // the description
// $input.data('selected-id', ui.item.id); // store the id
// $input.trigger('change');
// }
select: function (event, ui) {
$input.val(ui.item.label);
$input.data('selected-id', ui.item.id);
$input.trigger('change');
}
});
} catch (e) { console.error('Autocomplete init failed', e); }
});
}
};
// ---------------------------------------------------------
// SmartTrantype.Cache — central cache + DOM initialization
// ---------------------------------------------------------
SmartTrantype.Cache = {
initCacheFromDOM: function () {
window.sttCache = window.sttCache || {};
// if (!window.sttCache.transactions) {
window.sttCache.transactions = [];
// }
$('#transaction-table tbody tr').each(function () {
const $row = $(this);
const rowId = $row.data('txn-id');
const tranType = $row.find('.tran-type-input').val() || '';
// const tranTypeId = $row.find('.tran-type-input').data('selected-id') || '';
const tranTypeId = $row.find('.tran-type-input').data('tran-type-id') || '';
// FIX
const entry = {
id: rowId,
tran_type: tranType,
tran_type_id: tranTypeId,
// assigned: !!tranType,
assigned: $row.hasClass('assigned-row'),
// changed: false,
changed: $row.hasClass('changed-row'),
blank: !tranType,
pre_assigned: $row.hasClass('pre-assigned'),
// saved: false
saved: $row.hasClass('assigned-row')
};
window.sttCache.transactions.push(entry);
});
},
get: function (rowId) {
return window.sttCache.transactions.find(e => e.id == rowId) || null;
},
setFlags: function (rowId, flags) {
const entry = this.get(rowId);
if (!entry) return;
Object.assign(entry, flags);
}
};
SmartTrantype.Save = {
getChangedRows: function () {
return window.sttCache.transactions.filter(entry => entry.changed);
},
saveSingleRow: function ($row, done) {
if (!$row || !$row.length) return;
const rowId = $row.data('txn-id');
const entry = window.sttCache.transactions.find(r => r.id == rowId);
if (!entry) { done?.(); return; }
// Cannot save if row is blank or unchanged
if (!entry.changed) { done?.(); return; }
const $saveBtn = $row.find('.save-tran-type-btn').first();
$saveBtn.prop('disabled', true);
const payload = {
action: 'save_tran_type',
nonce: stt_ajax_obj.nonce,
account_id: $('#stt_account_id').val(),
txn_id: entry.id,
tran_type: entry.tran_type,
tran_type_id: entry.tran_type_id,
current_patterns: JSON.stringify($row.data('current-patterns') || []),
original_patterns: JSON.stringify($row.data('original-patterns') || []),
pattern_text: $row.find('td:nth-child(2)').text().trim()
};
$.ajax({
url: stt_ajax_obj.ajaxurl,
method: 'POST',
data: payload
}).done((resp) => {
if (resp.success) {
// Update cache
entry.assigned = true;
entry.changed = false;
entry.blank = false;
entry.saved = true;
// Update row DOM
SmartTrantype.Row.markAsAssigned($row);
} else {
console.error('Save failed for row', entry, resp);
}
done?.();
}).fail((jqXHR, textStatus, errorThrown) => {
console.error('AJAX error saving row', entry, textStatus, errorThrown);
done?.();
});
}
}
// ----------------------------
// Refresh module
// ----------------------------
SmartTrantype.Refresh = {
refreshRowStatesAndAutocomplete: function () {
// const $rows = $('#transaction-table tbody tr');
// $rows.each(function () {
// const $row = $(this);
// const id = $row.data('txn-id');
// const entry = window.sttCache.transactions.find(r => r.id == id);
// if (!entry) return;
// // Determine current
// const hasValue = (entry.tran_type !== '' || entry.tran_type_id !== '');
// const hasChanged = (entry.tran_type !== entry.original_tran_type || entry.tran_type_id !== entry.original_tran_type_id);
// if (!hasValue) {
// entry.state = STT_STATE.BLANK;
// } else if (hasChanged) {
// entry.state = STT_STATE.CHANGED;
// } else {
// entry.state = STT_STATE.ASSIGNED;
// }
// // SmartTrantype.Row.applyStateToRow($row, entry.state);
// });
// Autocomplete stable init
SmartTrantype.Autocomplete.initTranTypeInputs();
}
};
/* ----------------------------
* Save module init
* ---------------------------- */
SmartTrantype.Save.init = function () {
// Save single row
$(document).on('click', '.save-tran-type-btn', function () {
const $row = $(this).closest('tr');
SmartTrantype.Save.saveSingleRow($row);
});
// Undo / reset row
$(document).on('click', '.undo-tran-type-btn', function () {
const $row = $(this).closest('tr');
SmartTrantype.Row.restoreOriginal($row);
});
// Save all rows
$(document).on('click', '#save-all-btn', function () {
const $rows = $('#transaction-table tbody tr');
let remaining = $rows.length;
$rows.each(function () {
const $r = $(this);
SmartTrantype.Save.saveSingleRow($r, function () {
remaining--;
if (remaining === 0) {
console.log('All rows saved.');
}
});
});
});
};
/* ----------------------------
* Row module: restore original
* ---------------------------- */
SmartTrantype.Row.restoreOriginal = function ($row) {
if (!$row || !$row.length) return;
const rowId = $row.data('txn-id');
const entry = window.sttCache.transactions.find(r => r.id === rowId);
if (!entry) return;
const $input = $row.find('.tran-type-input');
// Restore cache
entry.tran_type = entry.original_tran_type || '';
entry.tran_type_id = entry.original_tran_type_id ?? null;
// Restore input field
$input
.val(entry.tran_type)
.data('tran-type-id', entry.tran_type_id);
// Re-evaluate state centrally
SmartTrantype.handleInputChange($input[0]);
};
/* -----------------------------------------------------------------------------
SmartTrantype.Init — safe orchestrator (non-invasive)
Calls any module.init() that exists and sets minimal fallbacks.
Place this near the end of smart-trantype.js (after modules).
----------------------------------------------------------------------------- */
SmartTrantype.Init = (function () {
function safeCall(name, fn) {
try {
fn();
console.log("SmartTrantype.Init: initialized ->", name);
} catch (err) {
console.error("SmartTrantype.Init: error initializing", name, err);
}
}
function run() {
const candidates = [
'Autocomplete',
'Pattern',
'Popup',
'Events',
'Save',
'Row',
'Refresh',
'Ajax',
'Utils'
];
const initialized = [];
candidates.forEach(name => {
const module = SmartTrantype[name];
if (module && typeof module.init === 'function') {
safeCall(name, () => module.init());
initialized.push(name);
}
});
// Fallback: ensure basic save/undo event binding exists
// If an Events.init already handled this, the duplicate handler won't do harm
$(document).off('click.smarttrantype.save').on('click.smarttrantype.save', '.save-tran-type-btn', function (e) {
e.preventDefault();
const $row = $(this).closest('tr');
if (SmartTrantype.Save && typeof SmartTrantype.Save.saveSingleRow === 'function') {
SmartTrantype.Save.saveSingleRow($row);
} else if (SmartTrantype.Save && typeof SmartTrantype.Save.saveRow === 'function') {
SmartTrantype.Save.saveRow($row);
} else {
console.warn("SmartTrantype.Init: no Save.saveSingleRow found for .save-tran-type-btn");
}
});
$(document).off('click.smarttrantype.undo').on('click.smarttrantype.undo', '.undo-tran-type-btn', function (e) {
e.preventDefault();
const $row = $(this).closest('tr');
if (SmartTrantype.Row && typeof SmartTrantype.Row.undo === 'function') {
SmartTrantype.Row.undo($row);
} else if (SmartTrantype.Row && typeof SmartTrantype.Row.reset === 'function') {
SmartTrantype.Row.reset($row);
} else {
// Best-effort DOM restore from data-* attributes
const origType = $row.data('original-tran-type') || '';
const origTypeId = $row.data('original-tran-type-id') || '';
const origPatterns = $row.data('original-patterns') || [];
$row.find('.tran-type-input').val(origType).data('tran-type-id', origTypeId);
$row.data('current-patterns', Array.isArray(origPatterns) ? origPatterns : (typeof origPatterns === 'string' ? JSON.parse(origPatterns || '[]') : []));
$row.removeClass('changed-row').addClass(origType ? 'assigned-row' : 'blank-row');
console.warn("SmartTrantype.Init: Undo fallback applied (restored data-* values).");
}
});
// Attach pattern popup opener to description cell (works with existing rows)
$(document).off('click.smarttrantype.pattern').on('click.smarttrantype.pattern', '#transaction-table tbody tr td:nth-child(2)', function (e) {
const $cell = $(this);
const $row = $cell.closest('tr');
// This expects SmartTrantype.Pattern.openPatternPopup($row)
if (SmartTrantype.Pattern && typeof SmartTrantype.Pattern.openPatternPopup === 'function') {
SmartTrantype.Pattern.openPatternPopup($row);
} else {
console.warn('Pattern module not found');
}
});
console.log("SmartTrantype.Init: modules auto-initialized:", initialized);
return initialized;
}
return {
run
};
}());
SmartTrantype.Init.run = function () {
SmartTrantype.Save.init();
SmartTrantype.Pattern.init();
SmartTrantype.Autocomplete.initTranTypeInputs();
SmartTrantype.Refresh.refreshRowStatesAndAutocomplete();
};
// Initialize on page load
$(document).ready(function () {
// 1️⃣ Initialize history stack for each transaction
// window.sttCache.transactions.forEach(entry => {
// entry.history = entry.history || []; // create empty undo stack
// });
// 1️⃣ Initialize cache flags
window.sttCache.transactions.forEach(entry => {
if (entry.pre_assigned) {
if (entry.saved) {
entry.assigned = true;
entry.changed = false;
entry.blank = false;
} else {
entry.assigned = false;
entry.changed = true;
entry.blank = false;
}
} else {
if (!entry.tran_type && !entry.tran_type_id) {
entry.assigned = false;
entry.changed = false;
entry.blank = true;
} else {
entry.assigned = true;
entry.changed = false;
entry.blank = false;
}
}
});
SmartTrantype.Cache.initCacheFromDOM();
// 2️⃣ Run standard initialization
SmartTrantype.Init.run();
// 3️⃣ Refresh DOM based on cache
SmartTrantype.Refresh.refreshRowStatesAndAutocomplete();
});
})(window, jQuery);
<?php
/*
* Plugin Name: Smart TranType
* Description: Frontend transaction type manager with autocomplete and suggestions
*/
if (!defined('ABSPATH'))
exit; // Exit if accessed directly
class SmartTrantype
{
private static $instance = null;
public static function instance()
{
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
public function __construct()
{
add_shortcode('financial_year_transactions', [$this, 'financial_year_transactions']);
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
add_action('wp_ajax_save_tran_type', [$this, 'save_tran_type']);
add_action('wp_ajax_nopriv_save_tran_type', [$this, 'save_tran_type']);
add_action('wp_ajax_stt_get_patterns_for_tran_type', [$this, 'stt_get_patterns_for_tran_type']);
add_action('wp_ajax_nopriv_stt_get_patterns_for_tran_type', [$this, 'stt_get_patterns_for_tran_type']);
}
// -----------------------------------------------
// Enqueue CSS and JS
// -----------------------------------------------
public function enqueue_assets()
{
global $post;
if (!isset($post->post_content) || strpos($post->post_content, '[financial_year_transactions]') === false)
return;
wp_enqueue_style('smart-trantype-css', plugin_dir_url(__FILE__) . 'smart-trantype.css', [], '1.0');
wp_enqueue_script('smart-trantype-js', plugin_dir_url(__FILE__) . 'smart-trantype.js', ['jquery', 'jquery-ui-autocomplete'], '1.0', true);
wp_localize_script('smart-trantype-js', 'stt_ajax_obj', [
'ajaxurl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('stt_nonce'),
]);
}
// -----------------------------------------------
// Build financial year transactions table
// -----------------------------------------------
public function financial_year_transactions()
{
global $wpdb;
$account_id = isset($_POST['account_id']) ? intval($_POST['account_id']) : 0;
$fin_year = isset($_POST['fin_year']) ? intval($_POST['fin_year']) : date('Y');
// Show account selection if none chosen
if ($account_id === 0) {
return $this->render_account_selection_form($fin_year);
}
$tblAccount = $wpdb->prefix . "smart_account_{$account_id}";
list($start_date, $end_date) = $this->get_financial_year_dates($fin_year);
// Build JS cache
$cache = $this->refresh_transaction_cache($account_id, $fin_year);
wp_localize_script('smart-trantype-js', 'sttCache', $cache);
ob_start();
?>
<h3>Transactions for Account <?= esc_html($account_id); ?> (FY <?= esc_html($fin_year); ?>)</h3>
<input type="hidden" id="stt_account_id" value="<?= esc_attr($account_id); ?>">
<input type="hidden" name="stt_fin_year" id="stt_fin_year" value="<?= esc_attr($fin_year); ?>">
<table id="transaction-table">
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Amount</th>
<th>Transaction Type</th>
<th style="text-align: center; vertical-align: middle;"><button id="save-all-btn"
title="Save All">💾</button></th>
<th style="font-size: 0.8em;">Stmnt<br>_Line</th>
</tr>
</thead>
<tbody>
<?= $this->build_transaction_rows_by_account_and_year($account_id, $fin_year); ?>
</tbody>
</table>
<?php
return ob_get_clean();
}
/**
* Build HTML table rows for transactions with pattern matching.
*
* @param int $account_id
* @param int $fin_year
* @return string HTML table rows
*/
public function build_transaction_rows_by_account_and_year($account_id, $fin_year)
{
global $wpdb;
$tblAccount = $wpdb->prefix . "smart_account_{$account_id}";
$tblTypes = $wpdb->prefix . "smart_tran_types_{$account_id}";
$tblPatterns = $wpdb->prefix . "smart_tran_type_patterns_{$account_id}";
list($start_date, $end_date) = $this->get_financial_year_dates($fin_year);
// Fetch transactions for financial year
$transactions = $wpdb->get_results($wpdb->prepare(
"SELECT id, date, description, amount, tran_type_id
FROM {$tblAccount}
WHERE date BETWEEN %s AND %s
ORDER BY date ASC",
$start_date,
$end_date
), ARRAY_A);
// Fetch transaction types
$types = $wpdb->get_results("SELECT id, tran_type FROM {$tblTypes}", OBJECT_K);
// Fetch and normalize pattern sets
$patterns = $wpdb->get_results("SELECT tran_type_id, patterns FROM {$tblPatterns}", ARRAY_A);
$pattern_sets = [];
foreach ($patterns as $p) {
$list = json_decode($p['patterns'], true);
if (is_array($list) && count($list)) {
$tranTypeId = isset($p['tran_type_id']) && $p['tran_type_id'] !== '' ? $p['tran_type_id'] : '';
$pattern_sets[] = [
'tran_type_id' => $tranTypeId,
'patterns' => array_map('trim', $list)
];
}
}
// Sort pattern sets by complexity (total alphanumeric chars including spaces)
usort($pattern_sets, function ($a, $b) {
$count_a = strlen(preg_replace('/[^a-zA-Z0-9 ]/', '', implode(' ', $a['patterns'])));
$count_b = strlen(preg_replace('/[^a-zA-Z0-9 ]/', '', implode(' ', $b['patterns'])));
return $count_b - $count_a; // descending
});
$out = '';
foreach ($transactions as $txn) {
$tran_type_id = isset($txn['tran_type_id']) && $txn['tran_type_id'] !== '' ? $txn['tran_type_id'] : '';
$tran_type_label = $tran_type_id ? ($types[$tran_type_id]->tran_type ?? '') : '';
// Pattern matching only if tran_type_id not set
$matched_patterns = [];
$row_classes = [];
$pre_assigned = false;
$json_patterns = '[]';
if (!$tran_type_id) {
foreach ($pattern_sets as $ps) {
$all_match = true;
foreach ($ps['patterns'] as $pat) {
if (stripos($txn['description'], $pat) === false) {
$all_match = false;
break;
}
}
if ($all_match) {
$tran_type_id = $ps['tran_type_id'];
$tran_type_label = $types[$tran_type_id]->tran_type ?? '';
$matched_patterns = $ps['patterns'];
$pre_assigned = true;
break;
}
}
}
// Determine row classes
if ($pre_assigned) {
$row_classes[] = 'pre-assigned changed-row';
$json_patterns = wp_json_encode($matched_patterns);
} else {
if (!empty($tran_type_id)) {
$row_classes[] = 'assigned-row';
} else {
$row_classes[] = 'blank-row';
}
}
$amount_sign = isset($txn['amount_sign']) ? $txn['amount_sign'] : 'any';
$out .= '<tr class="' . esc_attr(implode(' ', $row_classes)) . '"'
. ' data-txn-id="' . esc_attr($txn['id']) . '"'
. ' data-original-tran-type-id="' . esc_attr($tran_type_id) . '"'
. ' data-original-tran-type="' . esc_attr($tran_type_label) . '"'
. ' data-original-patterns=\'' . esc_attr($json_patterns) . '\''
. ' data-current-patterns=\'' . esc_attr($json_patterns) . '\''
. ' data-current-amount-sign="' . esc_attr($amount_sign) . '">'
. '<td>' . esc_html($txn['date']) . '</td>'
. '<td>' . esc_html($txn['description']) . '</td>'
. '<td class="numeric">' . number_format_i18n($txn['amount'], 2) . '</td>'
. '<td>'
. '<input type="text" class="tran-type-input" '
. 'data-tran-type-id="' . esc_attr($tran_type_id) . '" '
. 'value="' . esc_attr($tran_type_label) . '" '
. 'placeholder="Assign transaction type" autocomplete="off" />'
. '</td>'
. '<td class="action-buttons">'
. '<button class="save-tran-type-btn" title="Save">&#128190;</button>'
. '<button class="undo-tran-type-btn" title="Reset">&#128257;</button>'
. '</td>'
. '<td class="txn-id-cell">' . esc_html($txn['id']) . '</td>'
. '</tr>';
}
return $out;
}
public function save_tran_type()
{
// error_log(__FUNCTION__ . ' - ' . __LINE__ . ' - called');
global $wpdb;
check_ajax_referer('stt_nonce', 'nonce');
$txnId = sanitize_text_field($_POST['txn_id']);
$accountId = intval($_POST['account_id']);
$tranType = sanitize_text_field($_POST['tran_type']);
$selectedPatternsArray = isset($_POST['current_patterns']) ? json_decode(stripslashes($_POST['current_patterns']), true) : [];
// error_log(print_r($_POST, true));
$patternText = sanitize_text_field($_POST['pattern_text']);
// error_log(__FUNCTION__ . ' ' . __LINE__ . ' ' . ' called for txnId=' . $txnId . ', accountId=' . $accountId . ', tranType=' . $tranType);
$result = $this->_save_single_transaction($txnId, $accountId, $tranType, $selectedPatternsArray, $patternText);
// error_log(__FUNCTION__ . ' ' . __LINE__ . ' ' . ' result=' . print_r($result, true));
wp_send_json($result);
}
private function _save_single_transaction($txnId, $accountId, $tranType, $patternsArray, $patternText)
{
// error_log(__FUNCTION__ . ' ' . __LINE__ . ' ' . ' called for txnId=' . $txnId . ', accountId=' . $accountId . ', tranType=' . $tranType);
global $wpdb;
// Table names (account-specific)
$tblAccount = $wpdb->prefix . "smart_account_{$accountId}";
$tblTypes = $wpdb->prefix . "smart_tran_types_{$accountId}";
$tblPatterns = $wpdb->prefix . "smart_tran_type_patterns_{$accountId}";
// Step 1: Lookup or create tran_type
$tranTypeId = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$tblTypes} WHERE tran_type = %s LIMIT 1",
$tranType
)
);
if (!$tranTypeId) {
// error_log('Inserting new tran_type=' . $tranType);
$wpdb->insert(
$tblTypes,
[
'tran_type' => $tranType,
'account_id' => $accountId
],
['%s', '%d']
);
$tranTypeId = $wpdb->insert_id;
} else {
// Maybe existing tran_type or account_id have been changed
// error_log('Updating existing tran_type_id=' . $tranTypeId);
$wpdb->update(
$tblTypes,
[
'tran_type' => $tranType,
'account_id' => $accountId
],
['id' => $tranTypeId],
['%s', '%d'],
['%d']
);
}
// Step 2: Normalize patterns
$patternsArray = array_values(array_filter(array_map('trim', $patternsArray)));
$patternsJson = wp_json_encode($patternsArray);
// Step 3: Check for existing pattern row
$existingPatternRow = $wpdb->get_row(
$wpdb->prepare(
// "SELECT id, patterns FROM {$tblPatterns} WHERE tran_type_id = %d LIMIT 1",
"SELECT id, patterns FROM {$tblPatterns} WHERE tran_type_id = %d AND patterns = %s",
$tranTypeId,
$patternsJson
)
);
if ($existingPatternRow) {
// error_log('Updating existing pattern row for tran_type_id & pattern = ' . $tranTypeId . ' ' . $patternsJson);
// patterns & tranType_id exist → increment usage
$wpdb->query($wpdb->prepare(
"UPDATE $tblPatterns SET usage_count = usage_count + 1 WHERE tran_type_id = %d AND patterns = %s",
$tranTypeId,
$patternsJson
));
} else {
// error_log('Inserting new pattern row for tran_type_id=' . $tranTypeId);
// New pattern row
$wpdb->insert(
$tblPatterns,
[
'account_id' => $accountId,
'tran_type_id' => $tranTypeId,
'patterns' => $patternsJson,
'pattern_text' => $patternText,
'usage_count' => 1
],
['%d', '%d', '%s', '%s', '%d']
);
}
// Step 4: Update transaction row (only tran_type_id!)
$wpdb->update(
$tblAccount,
['tran_type_id' => $tranTypeId],
['id' => $txnId],
['%d'],
['%s']
);
// Optional: clear or update cache here if caching exists
$this->refresh_transaction_cache($accountId);
return [
'success' => true,
'data' => [
'msg' => 'Saved',
'tran_type_id' => $tranTypeId,
'patterns' => $patternsArray
]
];
}
// -----------------------------------------------
// Refresh JS cache
// -----------------------------------------------
private function refresh_transaction_cache($accountId, $fin_year = null)
{
global $wpdb;
$accountId = intval($accountId);
if ($accountId <= 0) {
return [
'tran_types' => [],
'pattern_sets' => [],
'transactions' => []
];
}
$tblTypes = $wpdb->prefix . "smart_tran_types_{$accountId}";
$tblPatterns = $wpdb->prefix . "smart_tran_type_patterns_{$accountId}";
$tblAccount = $wpdb->prefix . "smart_account_{$accountId}";
/* -----------------------------------------
* 1) TRANSACTION TYPES (normalised)
* ----------------------------------------- */
$rawTypes = $wpdb->get_results("
SELECT id, tran_type
FROM {$tblTypes}
", ARRAY_A);
$tranTypes = array_map(function ($t) {
return [
'id' => intval($t['id']),
'description' => $t['tran_type']
];
}, $rawTypes);
/* -----------------------------------------
* 2) PATTERN SETS
* ----------------------------------------- */
$patternRows = $wpdb->get_results("
SELECT id, tran_type_id, patterns, pattern_text, amount_sign, usage_count
FROM {$tblPatterns}
ORDER BY usage_count DESC
", ARRAY_A);
$normalizedPatterns = [];
foreach ($patternRows as $row) {
$patterns = json_decode($row['patterns'], true);
if (!is_array($patterns)) {
$patterns = [];
}
$normalizedPatterns[] = [
'id' => intval($row['id']),
'tran_type_id' => isset($row['tran_type_id']) && $row['tran_type_id'] !== '' ? $row['tran_type_id'] : '',
'patterns' => $patterns,
'pattern_text' => $row['pattern_text'],
'amount_sign' => $row['amount_sign'],
'usage_count' => intval($row['usage_count'])
];
}
/* -----------------------------------------
* 3) TRANSACTIONS (limit to financial year)
* ----------------------------------------- */
if ($fin_year !== null) {
list($start_date, $end_date) = $this->get_financial_year_dates($fin_year);
$transactions = $wpdb->get_results($wpdb->prepare("
SELECT id, tran_type_id, description, amount, date
FROM {$tblAccount}
WHERE date BETWEEN %s AND %s
ORDER BY id ASC
", $start_date, $end_date), ARRAY_A);
} else {
// fallback to all rows
$transactions = $wpdb->get_results("
SELECT id, tran_type_id, description, amount, date
FROM {$tblAccount}
ORDER BY id ASC
", ARRAY_A);
}
return [
'tran_types' => $tranTypes,
'pattern_sets' => $normalizedPatterns,
'transactions' => $transactions
];
}
/**
* Get the start and end dates for a financial year.
*
* @param int $fin_year The financial year (e.g., 2025).
* @return array An array containing the start and end dates in 'Y-m-d' format.
*/
public function get_financial_year_dates($fin_year)
{
global $bus_year_end; // must be set globally; default fiscal year end month
if (empty($bus_year_end)) {
$bus_year_end = 2; // default to February
}
// Financial year starts on 1 March of previous calendar year
$start_year = $fin_year - 1;
$start_date = sprintf("%04d-%02d-01", $start_year, $bus_year_end + 1); // March 1
$end_date = sprintf(
"%04d-%02d-%02d",
$fin_year,
$bus_year_end,
cal_days_in_month(CAL_GREGORIAN, $bus_year_end, $fin_year)
);
return [$start_date, $end_date];
}
// -----------------------------------------------
// Render account selection form
// -----------------------------------------------
private function render_account_selection_form($fin_year)
{
global $wpdb;
$accounts = $wpdb->get_results("SELECT id, account_name FROM {$wpdb->prefix}smart_accounts ORDER BY account_name");
ob_start(); ?>
<form method="post">
<label>Account:</label>
<select name="account_id" required>
<option value="">-- Choose an Account --</option>
<?php foreach ($accounts as $acc): ?>
<option value="<?= esc_attr($acc->id); ?>"><?= esc_html($acc->account_name); ?></option>
<?php endforeach; ?>
</select>
<label>Financial Year:</label>
<input type="number" name="fin_year" value="<?= esc_attr($fin_year); ?>" required>
<button type="submit">View</button>
</form>
<?php
return ob_get_clean();
}
public function ajax_create_tran_type()
{
global $wpdb;
$account_id = intval($_POST['account_id'] ?? 0);
$name = trim($_POST['name'] ?? '');
if (!$account_id || $name === '') {
wp_send_json([
'success' => false,
'message' => 'Invalid parameters.'
]);
}
$tblTypes = $wpdb->prefix . "smart_tran_types_{$account_id}";
// Check if already exists (case-insensitive)
$existing = $wpdb->get_var(
$wpdb->prepare("SELECT id FROM {$tblTypes} WHERE LOWER(tran_type) = LOWER(%s)", $name)
);
if ($existing) {
wp_send_json([
'success' => true,
'id' => intval($existing),
'label' => $name
]);
}
// Insert new type (but DO NOT assign to any transaction)
$wpdb->insert(
$tblTypes,
['tran_type' => $name],
['%s']
);
$new_id = $wpdb->insert_id;
wp_send_json([
'success' => true,
'id' => intval($new_id),
'label' => $name
]);
}
// public function stt_get_patterns_for_tran_type()
// {
// global $wpdb;
// $account_id = intval($_POST['account_id'] ?? 0);
// $tran_type_id = $_POST['tran_type_id'] ?? '';
// $nonce = $_POST['nonce'] ?? '';
// if (!wp_verify_nonce($nonce, 'stt_nonce')) {
// wp_send_json([
// 'success' => false,
// 'message' => 'Invalid nonce'
// ]);
// }
// if ($account_id == 0 || !$tran_type_id) {
// wp_send_json([
// 'success' => false,
// 'message' => 'Invalid parameters'
// ]);
// }
// $tblPatterns = $wpdb->prefix . "smart_tran_patterns_{$account_id}";
// // Get all pattern groups that belong to this tran_type_id
// $rows = $wpdb->get_results(
// $wpdb->prepare("
// SELECT id, patterns, amount_sign
// FROM {$tblPatterns}
// WHERE tran_type_id = %d
// ORDER BY id ASC
// ", $tran_type_id),
// ARRAY_A
// );
// // Normalise pattern text: convert comma string → array
// foreach ($rows as &$r) {
// $r['patterns'] = array_filter(array_map('trim', explode(',', $r['patterns'])));
// }
// wp_send_json([
// 'success' => true,
// 'data' => [
// 'patterns' => $rows
// ]
// ]);
// }
public function stt_get_patterns_for_tran_type()
{
global $wpdb;
// nonce check
$nonce = $_POST['nonce'] ?? '';
if (!wp_verify_nonce($nonce, 'stt_nonce')) {
wp_send_json_error(['message' => 'Invalid nonce'], 400);
}
$account_id = intval($_POST['account_id'] ?? 0);
$tran_type_id = intval($_POST['tran_type_id'] ?? '');
if (!$account_id || !$tran_type_id) {
wp_send_json_error(['message' => 'Missing params'], 400);
}
$tblPatterns = $wpdb->prefix . "smart_tran_type_patterns_{$account_id}";
// Fetch rows that match this tran_type_id
$rows = $wpdb->get_results(
$wpdb->prepare("SELECT id, patterns, pattern_text, amount_sign, usage_count FROM {$tblPatterns} WHERE tran_type_id = %d ORDER BY usage_count DESC", $tran_type_id),
ARRAY_A
);
// decode patterns JSON to arrays
$out = [];
if (!empty($rows)) {
foreach ($rows as $r) {
$decoded = json_decode($r['patterns'], true);
if (!is_array($decoded))
$decoded = [];
$out[] = [
'id' => intval($r['id']),
'patterns' => $decoded,
'pattern_text' => $r['pattern_text'],
'amount_sign' => $r['amount_sign'],
'usage_count' => intval($r['usage_count']),
];
}
}
wp_send_json_success(['patterns' => $out]);
}
}
SmartTrantype::instance();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment