Task 4: Universal Export ▾ — CSV/XLSX/PDF dropdown across all screens

- routers/export_router.py: /api/v2/export?format=...&endpoint=...&filters=...
- static/js/export_dropdown.js: shared attachExportDropdown helper
- sport2/app/crm_v2/erp_full: Export ▾ button wired to representative tables
- pgz_sport_api.py: mount export_router with try/except

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Damir Radulić
2026-05-05 18:33:36 +02:00
parent 8127e2ef22
commit 38383d07c5
7 changed files with 1272 additions and 157 deletions
+175 -47
View File
@@ -113,6 +113,12 @@ button,input,select{font-family:inherit;font-size:inherit;outline:none}
.player-card .badge{font-size:9px;padding:2px 5px;border-radius:3px;background:var(--bg4);color:var(--t1);text-transform:uppercase;font-weight:600}
.player-card .badge.repr{background:var(--pgz-gold);color:var(--bg0)}
.player-card .badge.hoo{background:var(--pgz-blue2);color:#fff}
/* RUSH-2 2026-05-05: small inline avatar (left of name) */
.player-card .pn-row{display:flex;align-items:center;gap:8px}
.player-card .pn-row .pn{flex:1;min-width:0}
.rush2-avatar{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;overflow:hidden;background:var(--bg3);border:1px solid var(--rim);flex-shrink:0;color:var(--pgz-gold);font-weight:800;letter-spacing:.5px}
.rush2-avatar img{width:100%;height:100%;object-fit:cover;display:block}
.rush2-avatar.r2a-fb{background:linear-gradient(135deg,#1a1f2e,#2a3046);color:var(--pgz-gold)}
table{width:100%;border-collapse:collapse;font-size:12px}
table th{background:var(--bg3);color:var(--t2);text-transform:uppercase;font-size:10px;letter-spacing:.5px;padding:8px 10px;text-align:left;border-bottom:1px solid var(--rim);font-weight:700}
@@ -1403,6 +1409,7 @@ function renderSaveziShell(){
</div>
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('savezi') : ''}
<span class="tb-s" id="sav-cnt"></span>
<button id="sav-export-btn" class="export-btn" type="button">Export ▾</button>
</div>
<div id="sav-out"></div>
`;
@@ -1410,6 +1417,20 @@ function renderSaveziShell(){
$('#sav-sport').addEventListener('change', applySaveziFilter);
$('#sav-kat').addEventListener('change', applySaveziFilter);
$('#sav-pgz').addEventListener('change', applySaveziFilter);
// Export ▾ — uses same /v2/savezi/priority-sort URL as the table loader.
if (window.attachExportDropdown) {
window.attachExportDropdown(
document.getElementById('sav-export-btn'),
function(){
const f = _filters.savezi || {};
const useOnly = f.financirani || window._pgz_filter_priority;
return '/sport/api' + (useOnly
? '/v2/savezi/priority-sort?only=true&limit=500'
: '/v2/savezi/priority-sort?only=false&limit=500');
},
'savezi'
);
}
}
function setSaveziView(v){
_state.viewSavezi = v;
@@ -1529,13 +1550,18 @@ async function loadKlubovi(){
const root = $('#pg-klubovi');
if(!_cache.klubovi){
root.innerHTML = '<div class="loading">Učitavanje klubova…</div>';
// BUG-E (2026-05-05): build /api/klubovi URL from explicit _filters.klubovi state.
// Defaults: financirani=true + godisnjak=true. When BOTH off → load all.
// RUSH-1 (2026-05-05): /api/klubovi URL built from _filters.klubovi state.
// Spec (CC_FINAL_RUSH slika 4) — 3 checkboxes:
// ☑ Samo financirani (PGŽ + RSS + Grad Rijeka) — single combined
// ☑ U godišnjaku
// ☐ Ima HNS roster
// Backend `financiran=true` is OR of all 3 davateljs (single source of truth
// = v_klubovi_financiranje view). Default = priority (fin OR godišnjak).
// Sort: ukupno_potpora DESC.
const f = _filters.klubovi;
const qs = new URLSearchParams();
qs.set('limit','2500');
qs.set('sort','financiran'); qs.set('order','desc'); // sort by potpore DESC (financiran flag)
// financirani + godisnjak combined with kategorija=priority logic:
qs.set('sort','potpora'); qs.set('order','desc'); // ukupno_potpora DESC NULLS LAST
if(f.financirani && f.godisnjak){
qs.set('kategorija','priority'); // OR semantics → priority = financiran OR godišnjak
} else if(f.financirani){
@@ -1575,11 +1601,10 @@ function renderKluboviShell(){
<button id="kl-card" class="${_state.viewKlubovi==='card'?'active':''}" onclick="setKluboviView('card')">Kartice</button>
<button id="kl-table" class="${_state.viewKlubovi==='table'?'active':''}" onclick="setKluboviView('table')">Tablica</button>
</div>
<button class="btn" onclick="exportKlubovi('xlsx')">⬇ XLSX</button>
<button class="btn" onclick="exportKlubovi('csv')">⬇ CSV</button>
<button class="btn" onclick="enrichBulk('klub', 50, 70)">✨ Obogati (50)</button>
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('klubovi') : ''}
<span class="tb-s" id="kl-cnt"></span>
<button id="kl-export-btn" class="export-btn" type="button">Export ▾</button>
</div>
<div id="kl-out"></div>
`;
@@ -1588,6 +1613,25 @@ function renderKluboviShell(){
$('#kl-grad').addEventListener('change', applyKluboviFilter);
$('#kl-kat').addEventListener('change', applyKluboviFilter);
$('#kl-nk').addEventListener('change', applyKluboviFilter);
// Export ▾ — rebuilds the same querystring that loadKlubovi uses.
if (window.attachExportDropdown) {
window.attachExportDropdown(
document.getElementById('kl-export-btn'),
function(){
const f = _filters.klubovi || {};
const qs = new URLSearchParams();
qs.set('limit','2500');
qs.set('sort','potpora'); qs.set('order','desc');
if(f.financirani && f.godisnjak) qs.set('kategorija','priority');
else if(f.financirani) qs.set('financiran','true');
else if(f.godisnjak) qs.set('godisnjak','true');
if(f.hns_roster) qs.set('samo_hns_roster','true');
if(window._pgz_filter_priority && !qs.has('kategorija')) qs.set('kategorija','priority');
return '/sport/api/klubovi?'+qs.toString();
},
'klubovi'
);
}
}
function setKluboviView(v){
_state.viewKlubovi = v;
@@ -1628,25 +1672,31 @@ function applyKluboviFilter(){
}
function renderKluboviGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return '<div class="grid-club">'+rows.map(k => `
return '<div class="grid-club">'+rows.map(k => {
const finTitle = [k.prima_pgz?'PGŽ':null, k.prima_rss?'RSS':null, k.prima_grad_rijeka?'Grad Rijeka':null].filter(Boolean).join(' + ') || 'financiran';
const potpora = (k.ukupno_potpora!=null) ? ' <b style="color:var(--pgz-gold)" title="ukupno potpora">'+fmtEur(k.ukupno_potpora)+'</b>' : '';
return `
<div class="entity" onclick="openKlub(${k.id})">
${k.priority?'<div class="et-tag" style="background:#ffd700;color:#1a1a1a">★ PRIO</div>':(k.nositelj_kvalitete?'<div class="et-tag">N.K.</div>':'')}
<div class="et">${(window.pgzBadgePrefix?window.pgzBadgePrefix(k,'klub'):'')}${esc(k.klub||k.sport||'(bez naziva)')}</div>
<div class="es">${txt(k.razina,'')} · ${txt(k.grad,'—')}</div>
<div class="em">
${k.financiran?'<span class="tag gd" title="PGŽ sufinanciran">€</span>':''}
${k.financiran?'<span class="tag gd" title="'+esc(finTitle)+'">€</span>':''}
${k.godisnjak?'<span class="tag b" title="U godišnjaku">G</span>':''}
${potpora}
<span><b>${fmtNum(k.registriranih)}</b> reg.</span>
<span><b>${fmtNum(k.trenera)}</b> trenera</span>
<span><b>${fmtNum(k.reprezentativaca)}</b> repr.</span>
</div>
</div>`).join('')+'</div>';
</div>`;
}).join('')+'</div>';
}
function renderKluboviTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr><th style="width:34px"><input type="checkbox" id="kl-all" title="Označi sve"></th><th title="PGŽ priority">★</th>${sortHeader('klubovi','klub','Klub','')}${sortHeader('klubovi','sport','Sport','')}${sortHeader('klubovi','razina','Razina','')}${sortHeader('klubovi','grad','Grad','')}${sortHeader('klubovi','registriranih','Reg.','num')}${sortHeader('klubovi','trenera','Trenera','num')}${sortHeader('klubovi','nositelj_kvalitete','Status','')}</tr></thead>
<tbody>${rows.map(k => `
<thead><tr><th style="width:34px"><input type="checkbox" id="kl-all" title="Označi sve"></th><th title="PGŽ priority">★</th>${sortHeader('klubovi','klub','Klub','')}${sortHeader('klubovi','sport','Sport','')}${sortHeader('klubovi','razina','Razina','')}${sortHeader('klubovi','grad','Grad','')}${sortHeader('klubovi','ukupno_potpora','Potpora','num')}${sortHeader('klubovi','registriranih','Reg.','num')}${sortHeader('klubovi','nositelj_kvalitete','Status','')}</tr></thead>
<tbody>${rows.map(k => {
const finTitle = [k.prima_pgz?'PGŽ':null, k.prima_rss?'RSS':null, k.prima_grad_rijeka?'Grad Rijeka':null].filter(Boolean).join(' + ') || 'financiran';
return `
<tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="kl-pick" data-id="${k.id}"></td>
<td onclick="openKlub(${k.id})">${k.priority?'<span class="tag gd" title="financiran ili u godišnjaku">★</span>':''}</td>
@@ -1654,10 +1704,11 @@ function renderKluboviTable(rows){
<td onclick="openKlub(${k.id})">${txt(k.sport)}</td>
<td onclick="openKlub(${k.id})">${txt(k.razina)}</td>
<td onclick="openKlub(${k.id})">${txt(k.grad)}</td>
<td onclick="openKlub(${k.id})" class="num"><b style="color:var(--pgz-gold)">${k.ukupno_potpora!=null?fmtEur(k.ukupno_potpora):'—'}</b></td>
<td onclick="openKlub(${k.id})" class="num">${fmtNum(k.registriranih)}</td>
<td onclick="openKlub(${k.id})" class="num">${fmtNum(k.trenera)}</td>
<td onclick="openKlub(${k.id})">${k.financiran?'<span class="tag gd" title="financiran">€</span>':''}${k.godisnjak?'<span class="tag b" title="godišnjak">G</span>':''}${k.nositelj_kvalitete?'<span class="tag gd">N.K.</span>':''}${k.aktivan?'<span class="tag gr">AKT</span>':'<span class="tag rd">NK</span>'}</td>
</tr>`).join('')}</tbody>
<td onclick="openKlub(${k.id})">${k.financiran?'<span class="tag gd" title="'+esc(finTitle)+'">€</span>':''}${k.godisnjak?'<span class="tag b" title="godišnjak">G</span>':''}${k.nositelj_kvalitete?'<span class="tag gd">N.K.</span>':''}${k.aktivan?'<span class="tag gr">AKT</span>':'<span class="tag rd">NK</span>'}</td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
}
@@ -2112,17 +2163,39 @@ function renderSportasiGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return '<div class="grid-player">'+rows.map(c => buildPlayerCard(c)).join('')+'</div>';
}
// RUSH-2 (2026-05-05): avatarUrl + avatarHTML helpers. Small circular avatar
// to the left of the name in player cards (per Damir slika 6 spec).
// Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one)
function avatarUrl(c){
if(!c) return null;
const u = c.slika_url || c.avatar || c.photo_url;
if(!u) return null;
if(/^https?:/i.test(u)) return u;
if(u.startsWith('/')) return u;
return '/sport/uploads/avatars/'+u;
}
function avatarHTML(c, sizePx){
const sz = sizePx || 36;
const initials = (((c.ime||'?')[0]||'?')+((c.prezime||'?')[0]||'?')).toUpperCase();
const url = avatarUrl(c);
if(url){
return '<span class="rush2-avatar" style="width:'+sz+'px;height:'+sz+'px;font-size:'+Math.round(sz*0.4)+'px"><img src="'+esc(url)+'" alt="" onerror="this.style.display=\'none\';this.parentElement.classList.add(\'r2a-fb\');this.parentElement.innerHTML=\''+initials+'\'"></span>';
}
return '<span class="rush2-avatar r2a-fb" style="width:'+sz+'px;height:'+sz+'px;font-size:'+Math.round(sz*0.4)+'px">'+initials+'</span>';
}
function buildPlayerCard(c){
const initials = (((c.ime||'?')[0]||'?')+((c.prezime||'?')[0]||'?')).toUpperCase();
const photo = c.slika_url ? '<img src="'+esc(c.slika_url)+'" alt="" onerror="this.style.display=\'none\';if(this.parentElement)this.parentElement.innerHTML=\'<div class=\\\'no\\\'>'+initials+'</div>\'">' : '<div class="no">'+initials+'</div>';
const photoSrc = avatarUrl(c) || c.slika_url;
const photo = photoSrc ? '<img src="'+esc(photoSrc)+'" alt="" onerror="this.style.display=\'none\';if(this.parentElement)this.parentElement.innerHTML=\'<div class=\\\'no\\\'>'+initials+'</div>\'">' : '<div class="no">'+initials+'</div>';
const hooCat = c.hoo_kategorija || c.kategorija_hoo;
const smallAv = avatarHTML(c, 32);
return `
<div class="player-card" onclick="openSportas(${c.id})">
<div class="ph">${photo}</div>
<div class="pb">
<div class="pn">${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.ime||'')} ${esc(c.prezime||'')}</div>
<div class="pn-row">${smallAv}<div class="pn">${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.ime||'')} ${esc(c.prezime||'')}</div></div>
<div class="pp">${txt(c.sport,'—')} · ${txt(c.pozicija,'')}</div>
<div class="pk">${txt(c.klub_naziv_godisnjak,'')}</div>
<div class="pk">${txt(c.klub_naziv_godisnjak||c.klub_naziv,'')}</div>
<div class="badges">
${c.reprezentativac?'<span class="badge repr">REPR</span>':''}
${hooCat?'<span class="badge hoo">HOO '+esc(hooCat)+'</span>':''}
@@ -2670,77 +2743,131 @@ function openObjekt(id){
}
//=========== MANIFESTACIJE ===========
// View mode persisted in localStorage as `_manifViewMode` ('card'|'table')
const _manifFilter = {mjesto:'', razina:'', organizator:'', q:''};
let _manifMeta = null;
let _manifLoadSeq = 0;
async function loadManifestacije(){
const root = $('#pg-manifestacije');
if(!_cache.manifestacije){
// Restore view mode from localStorage
const saved = localStorage.getItem('_manifViewMode');
if(saved==='card' || saved==='table') _state.viewManif = saved;
if(!_manifMeta){
root.innerHTML = '<div class="loading">Učitavanje manifestacija…</div>';
const d = await api('/manifestacije-full');
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.manifestacije = d.rows || (Array.isArray(d) ? d : []);
_manifMeta = await api('/v2/manifestacije/meta') || {mjesta:[], razine:[], organizatori:[]};
}
renderManifShell();
applyManifFilter();
await reloadManifestacije();
}
async function reloadManifestacije(){
const seq = ++_manifLoadSeq;
const out = $('#mn-out');
if(out) out.innerHTML = '<div class="loading">Učitavanje…</div>';
const cnt = $('#mn-cnt');
if(cnt) cnt.textContent = '…';
const params = new URLSearchParams();
if(_manifFilter.mjesto) params.set('mjesto', _manifFilter.mjesto);
if(_manifFilter.razina) params.set('razina', _manifFilter.razina);
if(_manifFilter.organizator) params.set('organizator', _manifFilter.organizator);
if(_manifFilter.q) params.set('q', _manifFilter.q);
params.set('limit', '500');
const qs = params.toString();
const d = await api('/v2/manifestacije'+(qs?'?'+qs:''));
if(seq !== _manifLoadSeq) return; // newer request superseded this one
if(!d){
if(out) out.innerHTML = '<div class="empty">Greška pri dohvatu</div>';
return;
}
_cache.manifestacije = d.rows || [];
renderManifBody();
}
function renderManifShell(){
const root = $('#pg-manifestacije');
const razine = Array.from(new Set((_cache.manifestacije||[]).map(m=>m.razina).filter(Boolean))).sort();
const meta = _manifMeta || {mjesta:[], razine:[], organizatori:[]};
const optList = (arr) => (arr||[]).filter(x=>x!==null && x!==undefined && x!=='').map(v=>'<option value="'+esc(v)+'">'+esc(v)+'</option>').join('');
root.innerHTML = `
<div class="toolbar">
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…">
<select id="mn-raz"><option value="">Sve razine</option>${razine.map(r=>'<option value="'+esc(r)+'">'+esc(r)+'</option>').join('')}</select>
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…" value="${esc(_manifFilter.q)}">
<select id="mn-mjesto" title="Mjesto"><option value="">Sva mjesta</option>${optList(meta.mjesta)}</select>
<select id="mn-raz" title="Razina"><option value="">Sve razine</option>${optList(meta.razine)}</select>
<select id="mn-org" title="Organizator"><option value="">Svi organizatori</option>${optList(meta.organizatori)}</select>
<button id="mn-reset" class="btn" type="button" title="Poništi filtere">↺ Reset</button>
<div class="toggle">
<button id="mn-card" class="${_state.viewManif==='card'?'active':''}" onclick="setManifView('card')">Kartice</button>
<button id="mn-table" class="${_state.viewManif==='table'?'active':''}" onclick="setManifView('table')">Tablica</button>
<button id="mn-card" class="${_state.viewManif==='card'?'active':''}" onclick="setManifView('card')" title="Kartice">🃏 Kartice</button>
<button id="mn-table" class="${_state.viewManif==='table'?'active':''}" onclick="setManifView('table')" title="Tablica">📋 Tablica</button>
</div>
<span class="tb-s" id="mn-cnt"></span>
</div>
<div id="mn-out"></div>
`;
$('#mn-q').addEventListener('input', debounce(applyManifFilter, 200));
$('#mn-raz').addEventListener('change', applyManifFilter);
// Restore selections after re-render
if($('#mn-mjesto')) $('#mn-mjesto').value = _manifFilter.mjesto;
if($('#mn-raz')) $('#mn-raz').value = _manifFilter.razina;
if($('#mn-org')) $('#mn-org').value = _manifFilter.organizator;
$('#mn-q').addEventListener('input', debounce(()=>{ _manifFilter.q = $('#mn-q').value.trim(); reloadManifestacije(); }, 250));
$('#mn-mjesto').addEventListener('change', ()=>{ _manifFilter.mjesto = $('#mn-mjesto').value; reloadManifestacije(); });
$('#mn-raz').addEventListener('change', ()=>{ _manifFilter.razina = $('#mn-raz').value; reloadManifestacije(); });
$('#mn-org').addEventListener('change', ()=>{ _manifFilter.organizator = $('#mn-org').value; reloadManifestacije(); });
$('#mn-reset').addEventListener('click', ()=>{
_manifFilter.mjesto=''; _manifFilter.razina=''; _manifFilter.organizator=''; _manifFilter.q='';
$('#mn-q').value=''; $('#mn-mjesto').value=''; $('#mn-raz').value=''; $('#mn-org').value='';
reloadManifestacije();
});
}
function setManifView(v){
_state.viewManif = v;
$('#mn-card').classList.toggle('active', v==='card');
$('#mn-table').classList.toggle('active', v==='table');
applyManifFilter();
try{ localStorage.setItem('_manifViewMode', v); }catch(_){}
if($('#mn-card')) $('#mn-card').classList.toggle('active', v==='card');
if($('#mn-table')) $('#mn-table').classList.toggle('active', v==='table');
renderManifBody();
}
function applyManifFilter(){
const q = (($('#mn-q')?$('#mn-q').value:'') || '').toLowerCase().trim();
const raz = $('#mn-raz') ? $('#mn-raz').value : '';
function renderManifBody(){
let rows = _cache.manifestacije || [];
if(q) rows = rows.filter(m => (m.naziv||'').toLowerCase().includes(q) || (m.organizator||'').toLowerCase().includes(q) || (m.mjesto||'').toLowerCase().includes(q));
if(raz) rows = rows.filter(m => m.razina===raz);
if(_sort.manifestacije) rows = sortRows(rows, _sort.manifestacije.key, _sort.manifestacije.dir);
$('#mn-cnt').textContent = rows.length+' manifestacija';
$('#mn-out').innerHTML = _state.viewManif==='card' ? renderManifGrid(rows) : renderManifTable(rows);
}
// Backwards-compat: existing handlers (e.g. sortHeader) call applyManifFilter()
function applyManifFilter(){ renderManifBody(); }
function manifLinkFor(m){
if(m && m.source_url) return m.source_url;
const gq = encodeURIComponent(((m&&m.naziv)||'')+' '+((m&&m.mjesto)||'')+' sport');
return 'https://www.google.com/search?q='+gq;
}
function renderManifGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return '<div class="grid">'+rows.map(m => `
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
return '<div class="grid">'+rows.map(m => {
const url = manifLinkFor(m);
const linkIcon = '<a class="et-link" href="'+esc(url)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="'+(m.source_url?'Otvori izvor':'Pretraži online')+'">🔗</a>';
return `
<div class="entity" onclick="openManif(${m.id})">
${m.razina?'<div class="et-tag">'+esc(m.razina)+'</div>':''}
<div class="et">${esc(m.naziv)}</div>
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}</div>
<div class="et">${esc(m.naziv)} ${linkIcon}</div>
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}${m.godina_od?' · od '+esc(m.godina_od):''}</div>
<div class="em">
${m.broj_ucesnika?'<span><b>'+esc(m.broj_ucesnika)+'</b> sudionika</span>':''}
${m.organizator?'<span>'+esc((m.organizator||'').slice(0,40))+'</span>':''}
</div>
</div>`).join('')+'</div>';
</div>`;
}).join('')+'</div>';
}
function renderManifTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr>${sortHeader('manifestacije','naziv','Naziv','')}${sortHeader('manifestacije','mjesto','Mjesto','')}${sortHeader('manifestacije','razina','Razina','')}${sortHeader('manifestacije','organizator','Organizator','')}${sortHeader('manifestacije','broj_ucesnika','Sudionici','')}<th>Link</th></tr></thead>
<tbody>${rows.map(m => `
<tbody>${rows.map(m => {
const url = manifLinkFor(m);
return `
<tr onclick="openManif(${m.id})">
<td><b>${esc(m.naziv)}</b></td>
<td>${txt(m.mjesto)}</td>
<td>${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</td>
<td>${txt(m.organizator)}</td>
<td>${txt(m.broj_ucesnika)}</td>
<td>${m.source_url?'<a href="'+esc(m.source_url)+'" target="_blank">↗</a>':'—'}</td>
</tr>`).join('')}</tbody>
<td><a href="${esc(url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="${m.source_url?'Otvori izvor':'Pretraži online'}">🔗</a></td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
}
function openManif(id){
@@ -3787,5 +3914,6 @@ window.closePanel = function(){
if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); }
};
</script>
<script src="/static/js/export_dropdown.js"></script>
</body>
</html>