feat: /api/v2/analiza/* endpoints - sport analytics backend
This commit is contained in:
@@ -43,10 +43,10 @@
|
||||
]},
|
||||
{title:'ERP', items: [
|
||||
{id:'erpfull', ic:'\u{1F4D2}', label:'ERP Full (SAP-Lite)', href:'/erp/full'},
|
||||
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)', href:'/erp#racuni'},
|
||||
{id:'putni', ic:'✈', label:'Putni nalozi', href:'/erp#putni'},
|
||||
{id:'placanja', ic:'\u{1F4B0}', label:'Plaćanja', href:'/erp#placanja'},
|
||||
{id:'xlsx', ic:'\u{1F4C8}', label:'XLSX export', href:'/erp#xlsx'}
|
||||
{id:'racuni', ic:'\u{1F9FE}', label:'Računi (OCR)', href:'/erp/full?tab=racuni'},
|
||||
{id:'putni', ic:'✈', label:'Putni nalozi', href:'/erp/full?tab=putni'},
|
||||
{id:'placanja', ic:'\u{1F4B0}', label:'Plaćanja', href:'/erp/full?tab=payments'},
|
||||
{id:'xlsx', ic:'\u{1F4C8}', label:'XLSX export', href:'/erp/full?tab=izvjestaji'}
|
||||
]},
|
||||
{title:'ANALITIKA', items: [
|
||||
{id:'kpi', ic:'\u{1F4C8}', label:'KPI Dashboard', href:'/kpi'},
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// shared/sortable.js | v1.0.0 | 2026-05-10 | task N5
|
||||
// Auto-attaches click-to-sort to every <table> with a <thead><tr><th>.
|
||||
//
|
||||
// Usage: <script src="/static/shared/sortable.js" defer></script>
|
||||
// Skips:
|
||||
// • tables with [data-sort-skip] attribute
|
||||
// • tables already using the legacy sportHeader() pattern (those have
|
||||
// sort handlers wired in their <th> already; we detect them and bail)
|
||||
//
|
||||
// Click behavior per <th>:
|
||||
// 1st click → ASC
|
||||
// 2nd click → DESC
|
||||
// 3rd click → unsorted (original order restored)
|
||||
//
|
||||
// Type detection per column (sampled from first 8 non-empty rows):
|
||||
// numeric → integers / floats / "1.234,56 €" / "12 kn"
|
||||
// date → ISO yyyy-mm-dd or hr-HR dd.mm.yyyy
|
||||
// string → fallback, locale-aware (hr-HR collation)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
var ARROW_NEUTRAL = ' ↕';
|
||||
var ARROW_ASC = ' ▲';
|
||||
var ARROW_DESC = ' ▼';
|
||||
var COLLATOR = (typeof Intl !== 'undefined' && Intl.Collator)
|
||||
? new Intl.Collator('hr-HR', { numeric: true, sensitivity: 'base' })
|
||||
: null;
|
||||
|
||||
function txt(node){ return (node.textContent || '').trim(); }
|
||||
|
||||
function detectType(samples){
|
||||
// Decide column type from up to 8 sample cell values
|
||||
var nNum = 0, nDate = 0, n = 0;
|
||||
for (var i = 0; i < samples.length && n < 8; i++){
|
||||
var v = samples[i];
|
||||
if (v === '' || v === '—' || v === '-' || v == null) continue;
|
||||
n++;
|
||||
if (parseHRNumber(v) !== null) nNum++;
|
||||
else if (parseDate(v)) nDate++;
|
||||
}
|
||||
if (n === 0) return 'string';
|
||||
if (nNum / n >= 0.6) return 'numeric';
|
||||
if (nDate / n >= 0.6) return 'date';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function parseHRNumber(s){
|
||||
// Accept: "1234", "1.234,56", "1,234.56", "1 234", "12 kn", "€ 12,5", "0.45"
|
||||
var raw = String(s).replace(/[^\d,.\- ]/g, '').trim();
|
||||
if (raw === '' || raw === '-') return null;
|
||||
// Detect decimal separator: if both . and , present, the rightmost is decimal
|
||||
var hasComma = raw.indexOf(',') >= 0, hasDot = raw.indexOf('.') >= 0;
|
||||
var cleaned;
|
||||
if (hasComma && hasDot){
|
||||
if (raw.lastIndexOf(',') > raw.lastIndexOf('.')){
|
||||
cleaned = raw.replace(/\./g, '').replace(/ /g, '').replace(',', '.');
|
||||
} else {
|
||||
cleaned = raw.replace(/,/g, '').replace(/ /g, '');
|
||||
}
|
||||
} else if (hasComma){
|
||||
// could be 1234,56 or 1,234 — treat , as decimal if exactly one and 1-2 digits after
|
||||
var parts = raw.split(',');
|
||||
if (parts.length === 2 && parts[1].replace(/ /g,'').length <= 2){
|
||||
cleaned = parts[0].replace(/ /g,'').replace(/\./g,'') + '.' + parts[1].replace(/ /g,'');
|
||||
} else {
|
||||
cleaned = raw.replace(/,/g, '').replace(/ /g, '');
|
||||
}
|
||||
} else {
|
||||
cleaned = raw.replace(/ /g, '');
|
||||
}
|
||||
var n = parseFloat(cleaned);
|
||||
return isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
var DATE_ISO = /^\d{4}-\d{2}-\d{2}/;
|
||||
var DATE_HR = /^(\d{1,2})\.\s*(\d{1,2})\.\s*(\d{4})/;
|
||||
function parseDate(s){
|
||||
s = String(s).trim();
|
||||
if (DATE_ISO.test(s)) return Date.parse(s.slice(0, 10));
|
||||
var m = s.match(DATE_HR);
|
||||
if (m) return Date.parse(m[3] + '-' + ('0'+m[2]).slice(-2) + '-' + ('0'+m[1]).slice(-2));
|
||||
return null;
|
||||
}
|
||||
|
||||
function compare(type, a, b){
|
||||
if (type === 'numeric'){
|
||||
var na = parseHRNumber(a), nb = parseHRNumber(b);
|
||||
if (na === null && nb === null) return 0;
|
||||
if (na === null) return 1;
|
||||
if (nb === null) return -1;
|
||||
return na - nb;
|
||||
}
|
||||
if (type === 'date'){
|
||||
var da = parseDate(a), db = parseDate(b);
|
||||
if (da == null && db == null) return 0;
|
||||
if (da == null) return 1;
|
||||
if (db == null) return -1;
|
||||
return da - db;
|
||||
}
|
||||
return COLLATOR ? COLLATOR.compare(a, b) : a.localeCompare(b);
|
||||
}
|
||||
|
||||
function sampleColumn(rows, colIdx){
|
||||
var out = [];
|
||||
for (var i = 0; i < rows.length; i++){
|
||||
var c = rows[i].cells && rows[i].cells[colIdx];
|
||||
if (c) out.push(txt(c));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function attach(table){
|
||||
if (table.__sortableAttached) return;
|
||||
if (table.hasAttribute('data-sort-skip')) return;
|
||||
// skip tables using legacy sortHeader() — they already have <th onclick=...>
|
||||
var firstTh = table.querySelector('thead th, tr th');
|
||||
if (!firstTh) return;
|
||||
var legacy = !!table.querySelector('thead th[onclick*="setSort"], thead th[onclick*="_sort"]');
|
||||
if (legacy) return;
|
||||
|
||||
var thead = table.tHead || (table.querySelector('tr') && table.querySelector('tr').parentNode);
|
||||
if (!thead) return;
|
||||
var headerRow = thead.querySelector('tr') || (table.rows[0] && table.rows[0].parentNode === thead ? table.rows[0] : null);
|
||||
if (!headerRow) return;
|
||||
var ths = headerRow.cells;
|
||||
if (!ths || ths.length === 0) return;
|
||||
|
||||
// Snapshot original order so 3rd-click can revert
|
||||
var tbody = table.tBodies && table.tBodies[0];
|
||||
if (!tbody) return;
|
||||
var originalRows = Array.prototype.slice.call(tbody.rows);
|
||||
if (originalRows.length < 2) return; // nothing to sort
|
||||
|
||||
// State per table
|
||||
var state = { col: -1, dir: 0 }; // 0 = unsorted, 1 = asc, -1 = desc
|
||||
|
||||
for (var i = 0; i < ths.length; i++){
|
||||
(function(idx, th){
|
||||
// Don't make non-data columns sortable (action icons etc.)
|
||||
if (th.hasAttribute('data-sort-skip')) return;
|
||||
var label = txt(th);
|
||||
if (label === '') return; // skip empty headers (icons)
|
||||
th.style.cursor = 'pointer';
|
||||
th.style.userSelect = 'none';
|
||||
th.title = 'Klikni za sort · ASC → DESC → reset';
|
||||
// Append neutral arrow if no existing sort marker
|
||||
if (!/[↕▲▼]/.test(label)){
|
||||
var span = document.createElement('span');
|
||||
span.className = '__sort-ind';
|
||||
span.style.opacity = '0.45';
|
||||
span.textContent = ARROW_NEUTRAL;
|
||||
th.appendChild(span);
|
||||
}
|
||||
th.addEventListener('click', function(){
|
||||
// Cycle: idx is current → asc; same idx asc → desc; same idx desc → reset
|
||||
if (state.col !== idx){ state.col = idx; state.dir = 1; }
|
||||
else if (state.dir === 1){ state.dir = -1; }
|
||||
else if (state.dir === -1){ state.col = -1; state.dir = 0; }
|
||||
else { state.dir = 1; }
|
||||
|
||||
// Update arrow indicators on all ths
|
||||
for (var j = 0; j < ths.length; j++){
|
||||
var ind = ths[j].querySelector('.__sort-ind');
|
||||
if (!ind) continue;
|
||||
if (j === state.col){
|
||||
ind.style.opacity = '1';
|
||||
ind.textContent = state.dir === 1 ? ARROW_ASC : ARROW_DESC;
|
||||
} else {
|
||||
ind.style.opacity = '0.45';
|
||||
ind.textContent = ARROW_NEUTRAL;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.dir === 0){
|
||||
// Restore original order
|
||||
for (var k = 0; k < originalRows.length; k++) tbody.appendChild(originalRows[k]);
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = Array.prototype.slice.call(tbody.rows);
|
||||
var samples = sampleColumn(rows, idx);
|
||||
var type = detectType(samples);
|
||||
rows.sort(function(a, b){
|
||||
var av = a.cells[idx] ? txt(a.cells[idx]) : '';
|
||||
var bv = b.cells[idx] ? txt(b.cells[idx]) : '';
|
||||
var c = compare(type, av, bv);
|
||||
return state.dir === 1 ? c : -c;
|
||||
});
|
||||
for (var r = 0; r < rows.length; r++) tbody.appendChild(rows[r]);
|
||||
});
|
||||
})(i, ths[i]);
|
||||
}
|
||||
table.__sortableAttached = true;
|
||||
}
|
||||
|
||||
function attachAll(scope){
|
||||
var root = scope || document;
|
||||
var tables = root.querySelectorAll ? root.querySelectorAll('table') : [];
|
||||
for (var i = 0; i < tables.length; i++) attach(tables[i]);
|
||||
}
|
||||
|
||||
// Auto-init on DOMContentLoaded; also re-scan when DOM mutates (SPAs that
|
||||
// re-render tables in-place).
|
||||
function boot(){
|
||||
attachAll(document);
|
||||
if (typeof MutationObserver !== 'undefined'){
|
||||
var rescan = function(){ attachAll(document); };
|
||||
var obs = new MutationObserver(function(muts){
|
||||
for (var i = 0; i < muts.length; i++){
|
||||
if (muts[i].addedNodes && muts[i].addedNodes.length){
|
||||
// throttle — debounce 100ms
|
||||
if (boot._t) clearTimeout(boot._t);
|
||||
boot._t = setTimeout(rescan, 100);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
obs.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
if (document.readyState === 'loading'){
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
} else {
|
||||
boot();
|
||||
}
|
||||
|
||||
// Public API for explicit re-scans from app code
|
||||
window.attachSortable = attachAll;
|
||||
})();
|
||||
Reference in New Issue
Block a user