Sidebar: +ERP +CRM +Dokumenti, godišnjaci import (18 PDFs), filter helpers

- pgz nav now includes /erp/full, /crm/v2, /admin/users, /dokumenti
- 4 dokumenti endpoints: list, godišnjaci/list, godišnjak/{godina} PDF, detail
- 18 godišnjaka u pgz_sport.dokumenti (2006-2024) with savez_id=333
- PGŽ filter helpers (window._pgz_filter_priority, togglePGZFilter)
- navItemClick handler for nav items with href
This commit is contained in:
2026-05-05 13:08:11 +02:00
parent 9fb512932a
commit 1d02c0897d
970 changed files with 268354 additions and 434 deletions
+751
View File
@@ -0,0 +1,751 @@
<!DOCTYPE html>
<!--
PGŽ Sport — Dokumenti UI v1.0
dradulic@outlook.com / damir@rinet.one — 2026-05-05
Library svih dokumenata: godišnjaci, publikacije, pravilnici, javni pozivi.
Tabovi + filteri + drill-down modal s RAG citatima + XLSX export + globalna pretraga.
-->
<html lang="hr">
<head>
<meta charset="UTF-8">
<title>Dokumenti — PGŽ Sport</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<style>
:root { --bg0:#08090e; --bg1:#11141d; --bg2:#1a1f2c; --bg3:#232a3d;
--txt:#e6e9ef; --muted:#7a8294; --line:#1a1f2c;
--pgz-blue:#003087; --pgz-blue-l:#0040b8; --pgz-gold:#F4C430;
--green:#1a8754; --red:#dc3545; --orange:#fd7e14; --cyan:#5fa8d3; }
* { box-sizing:border-box; margin:0; padding:0; }
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg0); color:var(--txt);
padding:20px; line-height:1.5; min-height:100vh; }
a { color:var(--cyan); }
h1 { color:var(--pgz-gold); margin-bottom:4px; font-size:1.6rem; }
h2 { color:var(--pgz-gold); margin:0 0 10px 0; font-size:1.1rem; }
h3 { color:var(--txt); margin:0 0 6px 0; font-size:0.98rem; }
.sub { color:var(--muted); margin-bottom:18px; font-size:0.92rem; }
/* Top toolbar — global search + export */
.top-toolbar { display:flex; gap:10px; flex-wrap:wrap; align-items:center; margin-bottom:14px; }
.top-toolbar .global-search { flex:1; min-width:280px; position:relative; }
.top-toolbar .global-search input {
width:100%; padding:11px 14px 11px 38px; border-radius:8px; border:1px solid #2a3144;
background:var(--bg1); color:var(--txt); font-size:0.95rem;
}
.top-toolbar .global-search::before {
content:"🔎"; position:absolute; left:12px; top:9px; opacity:0.7;
}
.btn { background:var(--pgz-blue); color:white; border:none; padding:10px 16px;
border-radius:6px; cursor:pointer; font-weight:500; font-size:0.92rem;
transition:background 0.15s; }
.btn:hover { background:var(--pgz-blue-l); }
.btn.secondary { background:var(--bg2); color:var(--txt); border:1px solid #2a3144; }
.btn.secondary:hover { background:var(--bg3); }
.btn.gold { background:var(--pgz-gold); color:#000; }
.btn.gold:hover { background:#ffe066; }
.btn.sm { padding:6px 11px; font-size:0.84rem; }
.btn:disabled { opacity:0.4; cursor:not-allowed; }
/* Tabbar */
.tabbar { display:flex; gap:2px; border-bottom:2px solid #2a3144; margin-bottom:14px;
overflow-x:auto; -webkit-overflow-scrolling:touch; }
.tab { padding:11px 18px; cursor:pointer; color:var(--muted); border:none; background:none;
font-size:0.95rem; font-weight:500; border-bottom:3px solid transparent;
margin-bottom:-2px; white-space:nowrap; transition:all 0.15s; }
.tab:hover { color:var(--txt); }
.tab.active { color:var(--pgz-gold); border-bottom-color:var(--pgz-gold); }
.tab .count { color:var(--muted); font-size:0.78rem; margin-left:6px;
background:var(--bg2); padding:1px 7px; border-radius:9px; }
.tab.active .count { color:var(--pgz-gold); }
/* Filters bar */
.filters { display:flex; gap:12px; flex-wrap:wrap; align-items:center;
background:var(--bg1); padding:12px 14px; border-radius:8px; margin-bottom:14px; }
.filter-grp { display:flex; gap:8px; align-items:center; font-size:0.88rem; }
.filter-grp label { color:var(--muted); }
.filter-grp input[type=number], .filter-grp input[type=text], .filter-grp select {
background:var(--bg2); color:var(--txt); border:1px solid #2a3144; padding:7px 9px;
border-radius:5px; font-size:0.88rem; min-width:90px;
}
.filter-grp input[type=text] { min-width:200px; }
.multiselect { position:relative; }
.multiselect-btn {
background:var(--bg2); border:1px solid #2a3144; padding:7px 10px; border-radius:5px;
color:var(--txt); cursor:pointer; min-width:160px; text-align:left; font-size:0.88rem;
}
.multiselect-pop {
position:absolute; top:100%; left:0; margin-top:4px; background:var(--bg2);
border:1px solid #2a3144; border-radius:6px; padding:6px; z-index:50;
max-height:240px; overflow-y:auto; min-width:200px; display:none;
box-shadow:0 4px 24px rgba(0,0,0,0.5);
}
.multiselect-pop.open { display:block; }
.multiselect-pop label {
display:flex; align-items:center; gap:8px; padding:5px 7px; border-radius:4px;
cursor:pointer; font-size:0.88rem;
}
.multiselect-pop label:hover { background:var(--bg3); }
/* Cards grid */
.grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(260px, 1fr));
gap:14px; }
.card {
background:var(--bg1); border:1px solid #2a3144; border-radius:10px; padding:14px;
cursor:pointer; transition:transform 0.15s, border-color 0.15s, box-shadow 0.15s;
display:flex; flex-direction:column; gap:8px; min-height:160px;
}
.card:hover {
transform:translateY(-2px); border-color:var(--pgz-gold);
box-shadow:0 6px 24px rgba(244,196,48,0.12);
}
.card .cardyear {
font-size:1.7rem; font-weight:700; color:var(--pgz-gold); line-height:1; letter-spacing:-1px;
}
.card .cardvrsta {
display:inline-block; font-size:0.72rem; padding:2px 8px; border-radius:9px;
background:rgba(0,48,135,0.5); color:#aac8ff; align-self:flex-start; text-transform:uppercase;
letter-spacing:0.5px; font-weight:600;
}
.card .cardtitle {
font-size:0.95rem; font-weight:600; color:var(--txt); flex:1; line-height:1.3;
}
.card .cardmeta { font-size:0.78rem; color:var(--muted); display:flex; gap:10px; }
.card .cardmeta span { display:flex; align-items:center; gap:3px; }
/* Loading & empty */
.empty { padding:60px 20px; text-align:center; color:var(--muted); font-size:0.95rem; }
.loading { padding:30px; text-align:center; color:var(--muted); }
.loading::before {
content:""; display:inline-block; width:18px; height:18px;
border:2px solid var(--bg3); border-top-color:var(--pgz-gold);
border-radius:50%; animation:spin 0.8s linear infinite; margin-right:8px;
vertical-align:middle;
}
@keyframes spin { to { transform:rotate(360deg); } }
/* Search results table */
.search-results { background:var(--bg1); border-radius:8px; overflow:hidden; }
.search-results .res-row {
padding:14px 18px; border-bottom:1px solid var(--line); cursor:pointer;
transition:background 0.15s;
}
.search-results .res-row:hover { background:var(--bg2); }
.search-results .res-row:last-child { border-bottom:none; }
.search-results .res-title { font-weight:600; color:var(--pgz-gold); }
.search-results .res-meta { font-size:0.8rem; color:var(--muted); margin:3px 0 6px 0; }
.search-results .res-excerpt {
font-size:0.88rem; color:#cdd2dc; background:var(--bg2); padding:8px 11px;
border-radius:5px; border-left:3px solid var(--cyan);
}
.search-results mark {
background:var(--pgz-gold); color:#000; padding:1px 3px; border-radius:3px;
}
/* Modal */
.modal-bg {
position:fixed; inset:0; background:rgba(0,0,0,0.7); backdrop-filter:blur(4px);
z-index:100; display:none; align-items:flex-start; justify-content:center;
padding:40px 20px; overflow-y:auto;
}
.modal-bg.open { display:flex; }
.modal {
background:var(--bg1); border:1px solid #2a3144; border-radius:12px;
width:100%; max-width:880px; padding:0; overflow:hidden;
box-shadow:0 12px 60px rgba(0,0,0,0.6);
}
.modal-h {
padding:18px 22px; border-bottom:1px solid var(--line); display:flex;
align-items:flex-start; justify-content:space-between; gap:12px;
}
.modal-h h2 { color:var(--pgz-gold); font-size:1.15rem; flex:1; }
.modal-h .close {
background:none; border:none; color:var(--muted); font-size:1.5rem; cursor:pointer;
padding:0 4px; line-height:1;
}
.modal-h .close:hover { color:var(--txt); }
.modal-tabs { display:flex; gap:0; padding:0 22px; border-bottom:1px solid var(--line); }
.modal-tab {
padding:9px 14px; cursor:pointer; color:var(--muted); border:none; background:none;
font-size:0.9rem; border-bottom:2px solid transparent; margin-bottom:-1px;
}
.modal-tab.active { color:var(--pgz-gold); border-bottom-color:var(--pgz-gold); }
.modal-body { padding:18px 22px; max-height:60vh; overflow-y:auto; }
.modal-actions {
padding:14px 22px; border-top:1px solid var(--line); display:flex; gap:10px; flex-wrap:wrap;
}
.info-row {
display:flex; gap:14px; padding:6px 0; border-bottom:1px dashed #1f2535; font-size:0.92rem;
}
.info-row:last-child { border-bottom:none; }
.info-row .lab { color:var(--muted); min-width:130px; }
.info-row .val { flex:1; word-break:break-word; }
.chip {
display:inline-block; background:var(--bg2); padding:2px 8px; border-radius:9px;
font-size:0.78rem; margin:0 4px 4px 0;
}
.chunk {
background:var(--bg2); padding:10px 14px; border-radius:6px; margin-bottom:10px;
border-left:3px solid var(--cyan); font-size:0.88rem; line-height:1.55;
}
.chunk .chunk-meta { font-size:0.75rem; color:var(--muted); margin-bottom:5px; }
/* Inline scoped search */
.inline-search {
background:var(--bg2); padding:12px 14px; border-radius:6px; margin-bottom:14px;
display:flex; gap:8px; align-items:center;
}
.inline-search input {
flex:1; background:var(--bg1); color:var(--txt); border:1px solid #2a3144;
padding:7px 11px; border-radius:5px; font-size:0.9rem;
}
/* Sidebar offset */
body.pgz-has-sb { padding-left:280px; }
body.pgz-has-sb.pgz-sb-col { padding-left:80px; }
@media (max-width:900px) {
body.pgz-has-sb { padding-left:20px; }
body.pgz-has-sb.pgz-sb-col { padding-left:20px; }
}
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="dokumentilib"></script>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js" defer></script>
</head>
<body>
<h1>📚 Dokumenti</h1>
<div class="sub">Knjižnica svih sportskih publikacija PGŽ — godišnjaci ZSPGZ, pravilnici, programi, izvještaji. Klik na dokument otvara PDF + RAG citate.</div>
<div class="top-toolbar">
<div class="global-search">
<input id="globalQ" type="search" placeholder="Pretraži kroz cjelokupni sadržaj svih dokumenata (npr. „Andrijaševic", mladi", proračun")">
</div>
<button class="btn" onclick="globalSearch()">Pretraži</button>
<button class="btn secondary" id="btnExport" onclick="exportXLSX()">📥 XLSX</button>
</div>
<div class="tabbar" id="tabbar"></div>
<div id="filters-wrap"></div>
<div id="content"></div>
<!-- Drill-down modal -->
<div class="modal-bg" id="modal" onclick="if(event.target===this) closeModal()">
<div class="modal" id="modalInner">
<div class="modal-h">
<h2 id="mTitle"></h2>
<button class="close" onclick="closeModal()">×</button>
</div>
<div class="modal-tabs">
<button class="modal-tab active" data-mtab="info" onclick="setModalTab('info')">️ Info</button>
<button class="modal-tab" data-mtab="search" onclick="setModalTab('search')">🔎 Pretraži sadržaj</button>
<button class="modal-tab" data-mtab="citati" onclick="setModalTab('citati')">📎 Citati</button>
</div>
<div class="modal-body" id="mBody"></div>
<div class="modal-actions" id="mActions"></div>
</div>
</div>
<script>
'use strict';
// ─────────────────────────────────────────
// Tab definitions (vrste mapping)
// ─────────────────────────────────────────
const TABS = [
{ id:'godisnjaci', label:'📅 Godišnjaci ZSPGZ',
vrste:['godisnjak'], view:'cards-year' },
{ id:'publikacije', label:'📰 Publikacije',
vrste:['program','plan','strategija','izvjestaj','raspodjela','erasmus'], view:'cards' },
{ id:'pravilnici', label:'📜 Pravilnici',
vrste:['pravilnik','statut','odluka','zakon'], view:'cards' },
{ id:'javni', label:'📢 Javni pozivi',
vrste:['manifestacija'], view:'cards', titleLike:['javni','natje','poziv','potpor'] },
{ id:'ostalo', label:'📂 Ostalo',
vrsteExclude:['godisnjak','program','plan','strategija','izvjestaj','raspodjela','erasmus',
'pravilnik','statut','odluka','zakon','manifestacija',
'corpus','corpus_v2','corpus_v3','novost_savez','audit','godisnjak_facts'],
view:'cards' },
];
const VRSTE_HIDDEN = ['corpus','corpus_v2','corpus_v3','novost_savez','audit','godisnjak_facts'];
const API = '/sport/api/v2'; // hosted under /sport/ via reverse proxy
// Fallback: when running directly on :8095, /sport prefix is stripped — try both
const API_DIRECT = '/api/v2';
// ─────────────────────────────────────────
// State
// ─────────────────────────────────────────
let activeTab = 'godisnjaci';
let allDocs = []; // all loaded docs (cached after first fetch)
let displayDocs = []; // currently filtered/visible
let lastSearchHits = null; // for export of search results
let currentDocId = null;
let currentDocFull = null;
const filterState = {}; // per-tab filter state {tab: {godinaMin, godinaMax, vrste:Set, q}}
// ─────────────────────────────────────────
// Utilities
// ─────────────────────────────────────────
const $ = (s, root) => (root||document).querySelector(s);
const $$ = (s, root) => Array.from((root||document).querySelectorAll(s));
const esc = s => String(s==null?'':s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
function fmtDate(d){
if(!d) return '—';
try { return new Date(d).toLocaleDateString('hr-HR'); } catch(e){ return d; }
}
async function api(path, opts){
// Try /sport/api/v2 first, fall back to /api/v2 (same-origin direct)
try {
const r = await fetch(API + path, opts);
if(r.ok || r.status === 404) return r;
} catch(e) {}
return await fetch(API_DIRECT + path, opts);
}
function highlightTerm(text, q){
if(!text || !q) return esc(text||'');
const safe = esc(text);
const re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return safe.replace(re, '<mark>$1</mark>');
}
// ─────────────────────────────────────────
// Initial render
// ─────────────────────────────────────────
function renderTabs(){
const html = TABS.map(t => `
<button class="tab ${t.id===activeTab?'active':''}" data-tab="${t.id}" onclick="setTab('${t.id}')">
${t.label}<span class="count" id="cnt-${t.id}">…</span>
</button>`).join('') +
`<button class="tab" data-tab="search" id="tab-search" style="display:none" onclick="setTab('search')">
🔎 Rezultati pretrage<span class="count" id="cnt-search">0</span>
</button>`;
$('#tabbar').innerHTML = html;
}
function setTab(id){
activeTab = id;
$$('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab===id));
if(id==='search'){
renderSearchView();
} else {
renderFilters();
applyFilters();
}
}
// ─────────────────────────────────────────
// Filters UI
// ─────────────────────────────────────────
function getTabConfig(){ return TABS.find(t => t.id===activeTab); }
function tabDocs(){
const cfg = getTabConfig();
if(!cfg) return [];
return allDocs.filter(d => {
if(VRSTE_HIDDEN.includes(d.vrsta)) return false;
if(cfg.vrste && !cfg.vrste.includes(d.vrsta)) return false;
if(cfg.vrsteExclude && cfg.vrsteExclude.includes(d.vrsta)) return false;
if(cfg.titleLike){
const t = (d.title||'').toLowerCase();
const desc = (d.kratak_opis||'').toLowerCase();
if(!cfg.titleLike.some(k => t.includes(k) || desc.includes(k))) return false;
}
return true;
});
}
function renderFilters(){
const docs = tabDocs();
const years = docs.map(d => d.godina).filter(Boolean).sort((a,b)=>a-b);
const minY = years[0] || 2000;
const maxY = years[years.length-1] || 2026;
const vrste = [...new Set(docs.map(d => d.vrsta).filter(Boolean))].sort();
const fs = filterState[activeTab] = filterState[activeTab] || {
godinaMin:minY, godinaMax:maxY, vrste:new Set(vrste), q:''
};
// re-clamp on first render
if(fs.godinaMin < minY) fs.godinaMin = minY;
if(fs.godinaMax > maxY) fs.godinaMax = maxY;
$('#filters-wrap').innerHTML = `
<div class="filters">
<div class="filter-grp">
<label>Godina:</label>
<input type="number" id="fyMin" value="${fs.godinaMin}" min="${minY}" max="${maxY}" onchange="onFilterChange()">
<span></span>
<input type="number" id="fyMax" value="${fs.godinaMax}" min="${minY}" max="${maxY}" onchange="onFilterChange()">
</div>
<div class="filter-grp">
<label>Vrsta:</label>
<div class="multiselect">
<button class="multiselect-btn" id="msBtn" onclick="toggleMS(event)">${msLabel(fs.vrste, vrste)}</button>
<div class="multiselect-pop" id="msPop">
${vrste.map(v => `
<label><input type="checkbox" data-vrsta="${esc(v)}" ${fs.vrste.has(v)?'checked':''} onchange="onMSChange()"> ${esc(v)}</label>
`).join('')}
</div>
</div>
</div>
<div class="filter-grp">
<label>U tabu:</label>
<input type="text" id="fq" value="${esc(fs.q)}" placeholder="filter po nazivu/opisu..." oninput="onFilterChange()">
</div>
<div style="margin-left:auto;color:var(--muted);font-size:0.85rem;" id="filterCount">…</div>
</div>`;
}
function msLabel(setVal, all){
if(setVal.size === 0) return 'Ništa odabrano';
if(setVal.size === all.length) return `Sve vrste (${all.length})`;
return `${setVal.size} odabrano`;
}
function toggleMS(ev){
ev.stopPropagation();
const pop = $('#msPop');
const isOpen = pop.classList.toggle('open');
if(isOpen){
setTimeout(() => {
const closer = (e) => {
if(!pop.contains(e.target) && e.target.id !== 'msBtn'){
pop.classList.remove('open');
document.removeEventListener('click', closer);
}
};
document.addEventListener('click', closer);
}, 50);
}
}
function onMSChange(){
const fs = filterState[activeTab];
fs.vrste = new Set($$('#msPop input:checked').map(i => i.dataset.vrsta));
const docs = tabDocs();
const allVrste = [...new Set(docs.map(d => d.vrsta))];
$('#msBtn').textContent = msLabel(fs.vrste, allVrste);
applyFilters();
}
function onFilterChange(){
const fs = filterState[activeTab];
fs.godinaMin = parseInt($('#fyMin').value) || fs.godinaMin;
fs.godinaMax = parseInt($('#fyMax').value) || fs.godinaMax;
fs.q = $('#fq').value || '';
applyFilters();
}
// ─────────────────────────────────────────
// Apply filters → render content
// ─────────────────────────────────────────
function applyFilters(){
const cfg = getTabConfig();
const fs = filterState[activeTab];
let docs = tabDocs();
if(fs){
docs = docs.filter(d => {
if(d.godina != null){
if(d.godina < fs.godinaMin || d.godina > fs.godinaMax) return false;
} else {
// no year — only show if range covers full span (i.e., user hasn't narrowed)
}
if(fs.vrste && fs.vrste.size > 0 && !fs.vrste.has(d.vrsta)) return false;
if(fs.q){
const ql = fs.q.toLowerCase();
const hit = (d.title||'').toLowerCase().includes(ql) ||
(d.kratak_opis||'').toLowerCase().includes(ql) ||
(d.organizacija||'').toLowerCase().includes(ql);
if(!hit) return false;
}
return true;
});
}
// Sort: godisnjaci by year DESC; ostalo by izdano_datum DESC, then godina DESC
if(cfg && cfg.view === 'cards-year'){
docs.sort((a,b)=> (b.godina||0) - (a.godina||0));
} else {
docs.sort((a,b) => {
const ay = a.godina || 0, by = b.godina || 0;
if(ay !== by) return by - ay;
return (a.title||'').localeCompare(b.title||'', 'hr');
});
}
displayDocs = docs;
$('#filterCount') && ($('#filterCount').textContent = `${docs.length} dokumenata`);
renderContent(docs);
}
function renderContent(docs){
if(docs.length === 0){
$('#content').innerHTML = `<div class="empty">Nema dokumenata za odabrane filtere.</div>`;
return;
}
const cfg = getTabConfig();
const html = docs.map(d => cardHTML(d, cfg && cfg.view === 'cards-year')).join('');
$('#content').innerHTML = `<div class="grid">${html}</div>`;
}
function cardHTML(d, yearStyle){
const year = d.godina ? `<div class="cardyear">${d.godina}</div>` : '';
const meta = [];
if(d.organizacija) meta.push(`<span>🏛️ ${esc(d.organizacija.slice(0,30))}${d.organizacija.length>30?'…':''}</span>`);
if(d.izdano_datum) meta.push(`<span>📅 ${fmtDate(d.izdano_datum)}</span>`);
if(d.chars) meta.push(`<span>📝 ${(d.chars/1000).toFixed(0)}K znakova</span>`);
return `
<div class="card" onclick="openModal(${d.id})">
${yearStyle ? year : ''}
<span class="cardvrsta">${esc(d.vrsta||'—')}</span>
<div class="cardtitle">${esc(d.title || '(bez naslova)')}</div>
<div class="cardmeta">${meta.join('')}</div>
</div>`;
}
// ─────────────────────────────────────────
// Data load
// ─────────────────────────────────────────
async function loadAll(){
$('#content').innerHTML = '<div class="loading">Učitavam dokumente…</div>';
try {
const r = await api('/dokumenti?limit=1000');
const j = await r.json();
allDocs = (j.dokumenti || []).filter(d => !VRSTE_HIDDEN.includes(d.vrsta));
updateTabCounts();
renderFilters();
applyFilters();
} catch(e){
$('#content').innerHTML = `<div class="empty">⚠️ Greška pri učitavanju: ${esc(e.message)}</div>`;
}
}
function updateTabCounts(){
for(const t of TABS){
const old = activeTab; activeTab = t.id;
const c = tabDocs().length;
activeTab = old;
const el = document.getElementById('cnt-'+t.id);
if(el) el.textContent = c;
}
}
// ─────────────────────────────────────────
// Global search
// ─────────────────────────────────────────
async function globalSearch(){
const q = $('#globalQ').value.trim();
if(!q || q.length < 2){
alert('Upiši minimalno 2 znaka za pretragu.');
return;
}
$('#tab-search').style.display = '';
setTab('search');
$('#content').innerHTML = '<div class="loading">Pretražujem cjelokupni sadržaj…</div>';
$('#filters-wrap').innerHTML = `
<div class="filters">
<div class="filter-grp">
<strong>🔎 Pretraga: </strong><span style="color:var(--pgz-gold)">"${esc(q)}"</span>
</div>
<div style="margin-left:auto;color:var(--muted);font-size:0.85rem;" id="searchInfo">…</div>
</div>`;
try {
const r = await api('/dokumenti/search/q?q=' + encodeURIComponent(q) + '&limit=80');
const j = await r.json();
lastSearchHits = j.rezultati || [];
$('#cnt-search').textContent = lastSearchHits.length;
$('#searchInfo').textContent = `${lastSearchHits.length} pogodaka`;
renderSearchResults(lastSearchHits, q);
} catch(e){
$('#content').innerHTML = `<div class="empty">⚠️ Pretraga neuspjela: ${esc(e.message)}</div>`;
}
}
function renderSearchView(){
if(!lastSearchHits){
$('#content').innerHTML = '<div class="empty">Upiši upit u tražilicu iznad i klikni „Pretraži".</div>';
$('#filters-wrap').innerHTML = '';
return;
}
const q = $('#globalQ').value.trim();
renderSearchResults(lastSearchHits, q);
}
function renderSearchResults(hits, q){
if(hits.length === 0){
$('#content').innerHTML = `<div class="empty">Nema pogodaka za "${esc(q)}".</div>`;
return;
}
const html = hits.map(h => `
<div class="res-row" onclick="openModal(${h.id})">
<div class="res-title">${esc(h.title || '(bez naslova)')}</div>
<div class="res-meta">
<span class="chip">${esc(h.vrsta||'—')}</span>
${h.godina ? '<span class="chip">'+h.godina+'</span>' : ''}
${h.izdano_datum ? '<span class="chip">'+fmtDate(h.izdano_datum)+'</span>' : ''}
</div>
<div class="res-excerpt">${highlightTerm(h.excerpt||'', q)}</div>
</div>`).join('');
$('#content').innerHTML = `<div class="search-results">${html}</div>`;
}
// ─────────────────────────────────────────
// Modal — drill-down
// ─────────────────────────────────────────
async function openModal(id){
currentDocId = id;
currentDocFull = null;
$('#modal').classList.add('open');
$('#mTitle').textContent = 'Učitavam…';
$('#mBody').innerHTML = '<div class="loading">Učitavam detalje…</div>';
$('#mActions').innerHTML = '';
setModalTab('info');
try {
const r = await api(`/dokumenti/${id}/full`);
if(!r.ok){
// Fallback to basic detail
const r2 = await api(`/dokumenti/${id}`);
const j2 = await r2.json();
currentDocFull = { dokument: { ...j2, naziv: j2.title }, chunks: [], chunks_count: 0 };
} else {
currentDocFull = await r.json();
}
const d = currentDocFull.dokument;
$('#mTitle').textContent = d.naziv || d.title || '(bez naslova)';
renderModalActions(d);
setModalTab('info');
} catch(e){
$('#mBody').innerHTML = `<div class="empty">⚠️ Greška: ${esc(e.message)}</div>`;
}
}
function closeModal(){
$('#modal').classList.remove('open');
currentDocId = null; currentDocFull = null;
}
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeModal(); });
function renderModalActions(d){
const pdfUrl = d.pdf_url || `${API}/dokumenti/${d.id}/pdf`;
const fallbackPdf = `${API_DIRECT}/dokumenti/${d.id}/pdf`;
const txtUrl = `${API}/dokumenti/${d.id}/text`;
$('#mActions').innerHTML = `
<a class="btn gold" target="_blank" rel="noopener" href="${esc(pdfUrl)}"
onerror="this.href='${esc(fallbackPdf)}'">📄 Otvori PDF</a>
<a class="btn secondary" target="_blank" rel="noopener" href="${esc(txtUrl)}">📝 Plain text</a>
${d.izvor_url ? `<a class="btn secondary" target="_blank" rel="noopener" href="${esc(d.izvor_url)}">🔗 Izvor</a>` : ''}
<button class="btn secondary" style="margin-left:auto" onclick="closeModal()">Zatvori</button>`;
}
function setModalTab(t){
$$('.modal-tab').forEach(b => b.classList.toggle('active', b.dataset.mtab === t));
if(!currentDocFull){ return; }
const d = currentDocFull.dokument;
if(t === 'info'){
const kr = Array.isArray(d.kljucne_rijeci) ? d.kljucne_rijeci :
(typeof d.kljucne_rijeci === 'string' ? d.kljucne_rijeci.split(/[,;]/).map(s=>s.trim()).filter(Boolean) : []);
$('#mBody').innerHTML = `
<div class="info-row"><div class="lab">Naslov</div><div class="val">${esc(d.naziv||d.title||'—')}</div></div>
${d.kratak_opis ? `<div class="info-row"><div class="lab">Opis</div><div class="val">${esc(d.kratak_opis)}</div></div>` : ''}
<div class="info-row"><div class="lab">Vrsta</div><div class="val"><span class="chip">${esc(d.vrsta||'—')}</span></div></div>
${d.razina ? `<div class="info-row"><div class="lab">Razina</div><div class="val">${esc(d.razina)}</div></div>` : ''}
${d.organizacija ? `<div class="info-row"><div class="lab">Organizacija</div><div class="val">${esc(d.organizacija)}</div></div>` : ''}
${d.sport ? `<div class="info-row"><div class="lab">Sport</div><div class="val">${esc(d.sport)}</div></div>` : ''}
${d.godina ? `<div class="info-row"><div class="lab">Godina</div><div class="val">${esc(d.godina)}</div></div>` : ''}
${d.izdano_datum ? `<div class="info-row"><div class="lab">Izdano</div><div class="val">${fmtDate(d.izdano_datum)}</div></div>` : ''}
${d.sluzbeni_glasnik ? `<div class="info-row"><div class="lab">Službeni glasnik</div><div class="val">${esc(d.sluzbeni_glasnik)}</div></div>` : ''}
${kr.length ? `<div class="info-row"><div class="lab">Ključne riječi</div><div class="val">${kr.map(k=>`<span class="chip">${esc(k)}</span>`).join('')}</div></div>` : ''}
${d.izvor_url ? `<div class="info-row"><div class="lab">Izvor URL</div><div class="val"><a href="${esc(d.izvor_url)}" target="_blank">${esc(d.izvor_url)}</a></div></div>` : ''}
<div class="info-row"><div class="lab">RAG chunks</div><div class="val">${currentDocFull.chunks_count || 0}</div></div>
`;
} else if(t === 'search'){
$('#mBody').innerHTML = `
<div class="inline-search">
<input id="docQ" type="search" placeholder="Pretraži samo ovaj dokument...">
<button class="btn sm" onclick="searchInDoc()">Traži</button>
</div>
<div id="docSearchOut" style="color:var(--muted);font-size:0.9rem;">Upiši pojam i klikni Traži.</div>
`;
setTimeout(()=>$('#docQ').focus(), 50);
} else if(t === 'citati'){
const chunks = currentDocFull.chunks || [];
if(chunks.length === 0){
$('#mBody').innerHTML = '<div class="empty">Ovaj dokument nema RAG chunks.</div>';
return;
}
const limit = 30;
const slice = chunks.slice(0, limit);
$('#mBody').innerHTML = slice.map(c => `
<div class="chunk">
<div class="chunk-meta">Chunk #${c.chunk_index} · ${c.chunk_tokens||'?'} tokens</div>
<div>${esc((c.chunk_text||'').slice(0, 1000))}${(c.chunk_text||'').length>1000?'…':''}</div>
</div>
`).join('') + (chunks.length>limit ?
`<div class="empty">+ još ${chunks.length-limit} chunkova (skip)…</div>` : '');
}
}
async function searchInDoc(){
const q = $('#docQ').value.trim();
if(!q || q.length < 2){ return; }
$('#docSearchOut').innerHTML = '<div class="loading">Pretražujem…</div>';
try {
// Reuse global search and filter klijentski na dokument_id
const r = await api('/dokumenti/search/q?q='+encodeURIComponent(q)+'&limit=80');
const j = await r.json();
const chunks = currentDocFull.chunks || [];
// Local in-doc match: highlight chunks koji sadrže pojam
const matchingChunks = chunks.filter(c => (c.chunk_text||'').toLowerCase().includes(q.toLowerCase()));
if(matchingChunks.length === 0){
$('#docSearchOut').innerHTML = `<div class="empty">Nema pogodaka unutar ovog dokumenta za "${esc(q)}".</div>`;
return;
}
$('#docSearchOut').innerHTML = matchingChunks.slice(0, 20).map(c => `
<div class="chunk">
<div class="chunk-meta">Chunk #${c.chunk_index}</div>
<div>${highlightTerm((c.chunk_text||'').slice(0,1200), q)}${(c.chunk_text||'').length>1200?'…':''}</div>
</div>
`).join('') + (matchingChunks.length > 20 ?
`<div class="empty">+ još ${matchingChunks.length-20} pogodaka…</div>` : '');
} catch(e){
$('#docSearchOut').innerHTML = `<div class="empty">⚠️ ${esc(e.message)}</div>`;
}
}
// ─────────────────────────────────────────
// XLSX export
// ─────────────────────────────────────────
function exportXLSX(){
if(typeof XLSX === 'undefined'){
alert('XLSX biblioteka još učitava — pričekaj sekundu i probaj opet.');
return;
}
const isSearch = activeTab === 'search';
const rows = isSearch ? (lastSearchHits || []).map(h => ({
id:h.id, title:h.title, vrsta:h.vrsta, godina:h.godina,
izdano_datum:h.izdano_datum, izvor_url:h.izvor_url,
excerpt:(h.excerpt||'').replace(/\s+/g,' ').slice(0,300)
})) : displayDocs.map(d => ({
id:d.id, title:d.title, vrsta:d.vrsta, godina:d.godina,
organizacija:d.organizacija, izdano_datum:d.izdano_datum,
izvor_url:d.izvor_url, chars:d.chars
}));
if(rows.length === 0){ alert('Nema redaka za export.'); return; }
const ws = XLSX.utils.json_to_sheet(rows);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Dokumenti');
const fname = `pgz-dokumenti-${activeTab}-${new Date().toISOString().slice(0,10)}.xlsx`;
XLSX.writeFile(wb, fname);
}
// ─────────────────────────────────────────
// Init
// ─────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
renderTabs();
loadAll();
$('#globalQ').addEventListener('keydown', e => { if(e.key === 'Enter') globalSearch(); });
});
</script>
</body>
</html>