3216 lines
171 KiB
Plaintext
3216 lines
171 KiB
Plaintext
<!DOCTYPE html>
|
||
<html lang="hr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no">
|
||
<meta name="theme-color" content="#0A0E1A">
|
||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<!-- v=1777269704 -->
|
||
<title>PGŽ Sport · Ri.NET</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=IBM+Plex+Sans:wght@300;400;500;600;700&display=swap');
|
||
:root {
|
||
--bg: #0d0d0d;
|
||
--bg-2: #141414;
|
||
--bg-3: #1a1a1a;
|
||
--panel: #141414;
|
||
--panel-2: #1A2236;
|
||
--border: #1e293b;
|
||
--border-2: rgba(30,41,59,0.6);
|
||
--text: #cbd5e1;
|
||
--text-2: #94a3b8;
|
||
--text-3: #6B7A99;
|
||
--accent: #3b82c4;
|
||
--accent-2: #2563a0;
|
||
--gold: #f59e0b;
|
||
--ok: #2DD4BF;
|
||
--warn: #F59E0B;
|
||
--crit: #EF4444;
|
||
--purple: #A78BFA;
|
||
--pink: #F472B6;
|
||
--cyan: #22D3EE;
|
||
--r: 10px;
|
||
--r-sm: 7px;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
||
html, body {
|
||
height: 100%;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: 'IBM Plex Sans', 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
-webkit-font-smoothing: antialiased;
|
||
overflow: hidden;
|
||
}
|
||
.mono { font-family: 'JetBrains Mono', SF Mono, Consolas, monospace; }
|
||
|
||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||
::-webkit-scrollbar-track { background: transparent; }
|
||
::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 4px; }
|
||
|
||
/* === LAYOUT === */
|
||
.app { display: flex; height: 100vh; height: 100dvh; }
|
||
|
||
/* SIDEBAR DESKTOP */
|
||
.sidebar {
|
||
width: 260px;
|
||
background: var(--bg-2);
|
||
border-right: 1px solid var(--border);
|
||
display: flex; flex-direction: column;
|
||
flex-shrink: 0;
|
||
}
|
||
.sb-head { padding: 16px 18px; border-bottom: 1px solid var(--border); }
|
||
.brand { display: flex; align-items: center; gap: 11px; }
|
||
.brand-mark {
|
||
width: 34px; height: 34px;
|
||
background: linear-gradient(135deg, var(--accent), var(--gold));
|
||
border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
color: white; font-weight: 700; font-size: 13px; letter-spacing: -0.5px;
|
||
box-shadow: 0 2px 12px rgba(59,130,196,0.4);
|
||
}
|
||
.brand-text h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; line-height: 1.2; }
|
||
.brand-text p { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 1.2px; margin-top: 2px; }
|
||
|
||
.role-pill {
|
||
margin-top: 12px; display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 4px 10px; border-radius: 100px;
|
||
font-size: 10px; font-weight: 700; letter-spacing: 0.7px;
|
||
cursor: pointer; transition: 150ms;
|
||
}
|
||
.role-pill::before { content: ''; width: 5px; height: 5px; border-radius: 50%; }
|
||
.role-pill.viewer { background: rgba(59,130,196,0.12); color: var(--accent); border: 1px solid rgba(59,130,196,0.3); }
|
||
.role-pill.viewer::before { background: var(--accent); }
|
||
.role-pill.admin { background: rgba(239,68,68,0.12); color: var(--crit); border: 1px solid rgba(239,68,68,0.3); }
|
||
.role-pill.admin::before { background: var(--crit); }
|
||
|
||
.nav { flex: 1; overflow-y: auto; padding: 8px 0 16px; }
|
||
.nav-sec {
|
||
padding: 12px 18px 4px;
|
||
color: var(--text-3); text-transform: uppercase;
|
||
font-size: 10px; font-weight: 700; letter-spacing: 1.2px;
|
||
}
|
||
.nav-i {
|
||
display: flex; align-items: center; gap: 11px;
|
||
padding: 9px 18px;
|
||
color: var(--text-2); cursor: pointer;
|
||
transition: 150ms;
|
||
border-left: 2px solid transparent;
|
||
font-size: 13px; font-weight: 500;
|
||
}
|
||
.nav-i:hover { background: var(--panel); color: var(--text); }
|
||
.nav-i.active {
|
||
background: linear-gradient(90deg, rgba(59,130,196,0.14) 0%, transparent 100%);
|
||
color: var(--accent);
|
||
border-left-color: var(--accent);
|
||
}
|
||
.nav-i .ico { width: 16px; height: 16px; flex-shrink: 0; }
|
||
.nav-i .b {
|
||
margin-left: auto; background: var(--crit); color: white;
|
||
font-size: 9px; font-weight: 700; padding: 2px 6px;
|
||
border-radius: 100px; min-width: 18px; text-align: center;
|
||
}
|
||
.nav-i .b.warn { background: var(--warn); color: var(--bg); }
|
||
|
||
.sb-foot {
|
||
padding: 11px 18px; border-top: 1px solid var(--border);
|
||
color: var(--text-3); font-size: 10px; line-height: 1.5;
|
||
}
|
||
|
||
/* MAIN */
|
||
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; }
|
||
|
||
.topbar {
|
||
background: var(--bg-2);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 12px 20px;
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
gap: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
.tb-title { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
|
||
.tb-bc { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 1.2px; font-weight: 700; }
|
||
.tb-title h2 { font-size: 18px; font-weight: 700; letter-spacing: -0.3px; line-height: 1.3; }
|
||
.tb-meta { color: var(--text-3); font-size: 11px; display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||
.live-dot {
|
||
display: inline-block; width: 6px; height: 6px;
|
||
background: var(--ok); border-radius: 50%;
|
||
animation: pulse 2s infinite;
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(45,212,191,0.55); }
|
||
50% { box-shadow: 0 0 0 5px rgba(45,212,191,0); }
|
||
}
|
||
.menu-btn {
|
||
display: none;
|
||
width: 36px; height: 36px;
|
||
border: 1px solid var(--border); background: var(--panel);
|
||
color: var(--text); border-radius: 8px;
|
||
align-items: center; justify-content: center;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.content { flex: 1; overflow-y: auto; padding: 18px 20px 80px; }
|
||
.content > .inner { max-width: 1500px; margin: 0 auto; }
|
||
|
||
/* GRIDS */
|
||
.grid { display: grid; gap: 12px; }
|
||
.g2 { grid-template-columns: repeat(2, 1fr); }
|
||
.g3 { grid-template-columns: repeat(3, 1fr); }
|
||
.g4 { grid-template-columns: repeat(4, 1fr); }
|
||
|
||
/* CARDS */
|
||
.card {
|
||
background: var(--panel); border: 1px solid var(--border);
|
||
border-radius: var(--r); padding: 14px 16px;
|
||
transition: border-color 150ms;
|
||
}
|
||
.card.acc { border-left: 3px solid var(--accent); }
|
||
.card.gold { border-left: 3px solid var(--gold); }
|
||
.card.ok { border-left: 3px solid var(--ok); }
|
||
.card.warn { border-left: 3px solid var(--warn); }
|
||
.card.crit { border-left: 3px solid var(--crit); }
|
||
|
||
.stat-l {
|
||
color: var(--text-3); font-size: 10px;
|
||
text-transform: uppercase; letter-spacing: 1px; font-weight: 700;
|
||
margin-bottom: 6px;
|
||
display: flex; align-items: center; gap: 5px;
|
||
}
|
||
.stat-v {
|
||
font-family: 'JetBrains Mono', monospace;
|
||
font-size: 26px; font-weight: 600; letter-spacing: -0.5px; line-height: 1.05;
|
||
}
|
||
.stat-v.sm { font-size: 18px; }
|
||
.stat-v.lg { font-size: 30px; }
|
||
.stat-d { font-size: 10.5px; margin-top: 5px; color: var(--text-3); }
|
||
.stat-d.up { color: var(--ok); }
|
||
.stat-d.down { color: var(--crit); }
|
||
.card.crit .stat-v { color: var(--crit); }
|
||
.card.warn .stat-v { color: var(--warn); }
|
||
.card.ok .stat-v { color: var(--ok); }
|
||
.card.acc .stat-v { color: var(--accent); }
|
||
.card.gold .stat-v { color: var(--gold); }
|
||
|
||
.ct {
|
||
font-size: 11px; font-weight: 700; color: var(--text);
|
||
text-transform: uppercase; letter-spacing: 1px;
|
||
margin-bottom: 12px; padding-bottom: 10px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||
}
|
||
.ct .meta { font-size: 10px; color: var(--text-3); font-weight: 500; text-transform: none; letter-spacing: 0; }
|
||
|
||
/* SECTION */
|
||
.sect {
|
||
font-size: 10px; color: var(--text-3);
|
||
text-transform: uppercase; letter-spacing: 1.4px; font-weight: 700;
|
||
margin: 22px 0 10px;
|
||
display: flex; align-items: center; gap: 10px;
|
||
}
|
||
.sect::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||
.sect:first-child { margin-top: 0; }
|
||
|
||
/* FILTER BAR */
|
||
.fbar {
|
||
background: var(--panel); border: 1px solid var(--border);
|
||
border-radius: var(--r); padding: 12px 14px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.fbar-t {
|
||
font-size: 10px; color: var(--text-3);
|
||
text-transform: uppercase; letter-spacing: 1.2px; font-weight: 700;
|
||
margin-bottom: 8px;
|
||
}
|
||
.fgrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
.fitem label {
|
||
display: block; font-size: 9px; color: var(--text-3);
|
||
text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 3px; font-weight: 700;
|
||
}
|
||
|
||
/* INPUTS */
|
||
.inp, select {
|
||
background: var(--bg-2); color: var(--text);
|
||
border: 1px solid var(--border);
|
||
padding: 8px 11px; border-radius: var(--r-sm);
|
||
font-size: 13px; outline: none; font-family: inherit;
|
||
width: 100%;
|
||
transition: 150ms;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
}
|
||
select {
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='%236B7A99' d='M6 8L0 0h12z'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: right 12px center;
|
||
padding-right: 32px;
|
||
}
|
||
.inp:focus, select:focus { border-color: var(--accent); }
|
||
.inp.flex { flex: 1; min-width: 180px; }
|
||
|
||
.btn {
|
||
background: var(--accent); color: white; border: none;
|
||
padding: 9px 14px; border-radius: var(--r-sm);
|
||
font-size: 12.5px; font-weight: 600; cursor: pointer;
|
||
transition: 150ms; font-family: inherit;
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
}
|
||
.btn:hover { background: var(--accent-2); }
|
||
.btn.sec { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
||
.btn.sec:hover { border-color: var(--accent); }
|
||
.btn.warn { background: var(--warn); color: var(--bg); }
|
||
.btn.crit { background: var(--crit); }
|
||
.btn.sm { padding: 5px 10px; font-size: 11px; }
|
||
|
||
.toolbar { display: flex; gap: 8px; margin-bottom: 14px; align-items: center; flex-wrap: wrap; }
|
||
|
||
/* TABLES */
|
||
.tbl-wrap {
|
||
background: var(--panel); border: 1px solid var(--border);
|
||
border-radius: var(--r);
|
||
overflow: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||
thead th {
|
||
background: var(--bg-3); color: var(--text-3);
|
||
padding: 10px 12px; text-align: left;
|
||
font-weight: 700; font-size: 9.5px;
|
||
text-transform: uppercase; letter-spacing: 1px;
|
||
cursor: pointer; user-select: none;
|
||
border-bottom: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
position: sticky; top: 0; z-index: 5;
|
||
}
|
||
thead th:hover { color: var(--accent); background: var(--panel-2); }
|
||
thead th.sorted { color: var(--accent); }
|
||
thead th .arr { margin-left: 3px; opacity: 0.5; font-size: 9px; }
|
||
thead th.sorted .arr { opacity: 1; }
|
||
tbody tr {
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer; transition: 100ms;
|
||
}
|
||
tbody tr:hover { background: var(--panel-2); }
|
||
tbody tr:last-child { border-bottom: none; }
|
||
tbody td { padding: 10px 12px; }
|
||
tbody td.num { text-align: right; font-family: 'JetBrains Mono', monospace; font-size: 12px; }
|
||
tbody td.dim { color: var(--text-3); }
|
||
|
||
/* BADGES */
|
||
.bdg {
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
padding: 2px 7px; border-radius: 4px;
|
||
font-size: 9.5px; font-weight: 700;
|
||
text-transform: uppercase; letter-spacing: 0.5px;
|
||
white-space: nowrap;
|
||
}
|
||
.bdg::before { content: ''; width: 4px; height: 4px; border-radius: 50%; }
|
||
.bdg.ok { background: rgba(45,212,191,0.13); color: var(--ok); }
|
||
.bdg.ok::before { background: var(--ok); }
|
||
.bdg.warn { background: rgba(245,158,11,0.13); color: var(--warn); }
|
||
.bdg.warn::before { background: var(--warn); }
|
||
.bdg.crit { background: rgba(239,68,68,0.13); color: var(--crit); }
|
||
.bdg.crit::before { background: var(--crit); }
|
||
.bdg.info { background: rgba(59,130,196,0.13); color: var(--accent); }
|
||
.bdg.info::before { background: var(--accent); }
|
||
.bdg.gold { background: rgba(245,158,11,0.13); color: var(--gold); }
|
||
.bdg.gold::before { background: var(--gold); }
|
||
.bdg.muted { background: rgba(107,122,153,0.13); color: var(--text-3); }
|
||
.bdg.muted::before { background: var(--text-3); }
|
||
|
||
/* BARS */
|
||
.bar { display: flex; align-items: center; padding: 5px 0; gap: 10px; font-size: 11.5px; }
|
||
.bar .l { width: 36%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; }
|
||
.bar .t { flex: 1; height: 6px; background: var(--bg-3); border-radius: 100px; overflow: hidden; }
|
||
.bar .f { height: 100%; background: linear-gradient(90deg, var(--accent), var(--gold)); border-radius: 100px; transition: width 500ms; }
|
||
.bar .f.ok { background: linear-gradient(90deg, var(--ok), var(--cyan)); }
|
||
.bar .f.warn { background: linear-gradient(90deg, var(--warn), #FB923C); }
|
||
.bar .f.crit { background: linear-gradient(90deg, var(--crit), #DC2626); }
|
||
.bar .f.gold { background: linear-gradient(90deg, var(--gold), #E5C064); }
|
||
.bar .v { width: 90px; text-align: right; font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 500; }
|
||
|
||
/* DONUT */
|
||
.donut-w { display: flex; gap: 16px; align-items: center; }
|
||
.donut { width: 110px; height: 110px; position: relative; flex-shrink: 0; }
|
||
.donut svg { transform: rotate(-90deg); }
|
||
.donut-c { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%); text-align: center; }
|
||
.donut-c .v { font-size: 20px; font-weight: 700; font-family: 'JetBrains Mono', monospace; line-height: 1; }
|
||
.donut-c .l { font-size: 9px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.7px; margin-top: 3px; }
|
||
.lg { font-size: 11px; flex: 1; }
|
||
.lg .it { display: flex; align-items: center; gap: 7px; padding: 4px 0; color: var(--text-2); }
|
||
.lg .it .sw { width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0; }
|
||
.lg .it .lname { flex: 1; }
|
||
.lg .it .lval { font-family: 'JetBrains Mono', monospace; color: var(--text); font-weight: 500; }
|
||
|
||
/* BANNER */
|
||
.ban {
|
||
padding: 10px 14px; border-radius: var(--r-sm);
|
||
margin-bottom: 12px; display: flex; gap: 10px;
|
||
align-items: center; font-size: 12.5px;
|
||
border: 1px solid;
|
||
}
|
||
.ban.crit { background: rgba(239,68,68,0.08); border-color: rgba(239,68,68,0.3); color: var(--crit); }
|
||
.ban.warn { background: rgba(245,158,11,0.08); border-color: rgba(245,158,11,0.3); color: var(--warn); }
|
||
.ban.info { background: rgba(59,130,196,0.08); border-color: rgba(59,130,196,0.3); color: var(--accent); }
|
||
.ban.ok { background: rgba(45,212,191,0.08); border-color: rgba(45,212,191,0.3); color: var(--ok); }
|
||
|
||
.empty { text-align: center; color: var(--text-3); padding: 40px 18px; font-size: 13px; }
|
||
.empty-i { font-size: 28px; margin-bottom: 8px; opacity: 0.5; }
|
||
.loader { color: var(--text-3); padding: 50px 20px; text-align: center; font-size: 13px; }
|
||
|
||
/* DRAWER */
|
||
.dr-bg {
|
||
position: fixed; inset: 0;
|
||
background: rgba(10,14,26,0.7);
|
||
backdrop-filter: blur(4px);
|
||
z-index: 90;
|
||
opacity: 0; pointer-events: none;
|
||
transition: 200ms;
|
||
}
|
||
.dr-bg.open { opacity: 1; pointer-events: auto; }
|
||
.drawer {
|
||
position: fixed; top: 0; right: 0;
|
||
width: min(680px, 100%); height: 100vh; height: 100dvh;
|
||
background: var(--bg-2); border-left: 1px solid var(--border);
|
||
overflow-y: auto;
|
||
transform: translateX(100%);
|
||
transition: transform 250ms cubic-bezier(0.4,0,0.2,1);
|
||
z-index: 100;
|
||
box-shadow: -16px 0 48px rgba(0,0,0,0.6);
|
||
}
|
||
.drawer.open { transform: translateX(0); }
|
||
.dr-h {
|
||
padding: 18px 22px; border-bottom: 1px solid var(--border);
|
||
display: flex; justify-content: space-between; align-items: flex-start; gap: 10px;
|
||
position: sticky; top: 0; background: var(--bg-2); z-index: 5;
|
||
}
|
||
.dr-h h3 { font-size: 17px; font-weight: 700; letter-spacing: -0.2px; }
|
||
.dr-h .bc { font-size: 9px; color: var(--text-3); text-transform: uppercase; letter-spacing: 1.2px; margin-bottom: 3px; font-weight: 700; }
|
||
.dr-x {
|
||
background: var(--panel); border: 1px solid var(--border); color: var(--text-3);
|
||
cursor: pointer; width: 30px; height: 30px; border-radius: 8px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 14px; flex-shrink: 0; transition: 150ms;
|
||
}
|
||
.dr-x:hover { color: var(--text); border-color: var(--border-2); }
|
||
.dr-b { padding: 18px 22px 40px; }
|
||
.dr-b dl { display: grid; grid-template-columns: 130px 1fr; gap: 8px 14px; }
|
||
.dr-b dt { color: var(--text-3); font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; font-weight: 700; padding-top: 1px; }
|
||
.dr-b dd { color: var(--text); font-size: 13px; word-break: break-word; }
|
||
.dr-b dd a { color: var(--accent); text-decoration: none; }
|
||
.dr-b h4 {
|
||
font-size: 10px; color: var(--text-3);
|
||
text-transform: uppercase; letter-spacing: 1.3px; font-weight: 700;
|
||
margin: 22px 0 10px; padding-bottom: 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.sub-tbl { width: 100%; font-size: 12px; }
|
||
.sub-tbl td { padding: 7px 10px; border-bottom: 1px solid var(--border); }
|
||
.sub-tbl thead th { padding: 8px 10px; }
|
||
|
||
.blur-tag {
|
||
display: inline-block; margin-left: 5px;
|
||
font-size: 9px; padding: 1px 5px; border-radius: 3px;
|
||
background: rgba(245,158,11,0.15); color: var(--warn);
|
||
text-transform: uppercase; letter-spacing: 0.4px; font-weight: 700;
|
||
}
|
||
|
||
/* MODAL */
|
||
.modal-bg {
|
||
position: fixed; inset: 0;
|
||
background: rgba(10,14,26,0.85);
|
||
backdrop-filter: blur(8px);
|
||
display: none;
|
||
align-items: center; justify-content: center;
|
||
z-index: 1000;
|
||
padding: 20px;
|
||
}
|
||
.modal-bg.show { display: flex; }
|
||
.modal {
|
||
background: var(--panel); border: 1px solid var(--border);
|
||
border-radius: var(--r);
|
||
padding: 22px;
|
||
width: 100%; max-width: 400px;
|
||
box-shadow: 0 20px 60px rgba(0,0,0,0.6);
|
||
}
|
||
.modal h3 { margin-bottom: 6px; font-size: 17px; }
|
||
.modal p { color: var(--text-3); font-size: 12px; margin-bottom: 14px; line-height: 1.55; }
|
||
.modal .inp { margin-bottom: 10px; }
|
||
.ma { display: flex; gap: 8px; }
|
||
.hint {
|
||
font-size: 11px; color: var(--text-3);
|
||
padding: 9px 11px; background: var(--bg-2); border-radius: 6px;
|
||
margin-top: 10px; line-height: 1.55; border: 1px solid var(--border);
|
||
}
|
||
.hint b { color: var(--gold); }
|
||
|
||
/* MOBILE NAV */
|
||
.mob-nav {
|
||
display: none;
|
||
position: fixed; bottom: 0; left: 0; right: 0;
|
||
background: var(--bg-2);
|
||
border-top: 1px solid var(--border);
|
||
padding: 6px 0 calc(6px + env(safe-area-inset-bottom));
|
||
z-index: 50;
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
.mob-nav-grid { display: grid; grid-template-columns: repeat(5, 1fr); }
|
||
.mob-nav-i {
|
||
display: flex; flex-direction: column; align-items: center; gap: 3px;
|
||
padding: 6px 4px;
|
||
color: var(--text-3);
|
||
cursor: pointer;
|
||
transition: 150ms;
|
||
}
|
||
.mob-nav-i.active { color: var(--accent); }
|
||
.mob-nav-i .ico { width: 20px; height: 20px; }
|
||
.mob-nav-i span { font-size: 9.5px; font-weight: 600; letter-spacing: 0.2px; }
|
||
|
||
/* MOBILE DRAWER NAV */
|
||
.mob-drawer {
|
||
position: fixed; top: 0; left: -300px; width: 280px; height: 100vh; height: 100dvh;
|
||
background: var(--bg-2); border-right: 1px solid var(--border);
|
||
transition: left 250ms; z-index: 200;
|
||
overflow-y: auto;
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.mob-drawer.open { left: 0; }
|
||
|
||
.ico-svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||
|
||
.footer { padding: 18px 20px; color: var(--text-3); font-size: 10px; text-align: center; border-top: 1px solid var(--border); margin-top: 24px; }
|
||
|
||
/* ===== RESPONSIVE ===== */
|
||
@media (max-width: 880px) {
|
||
.sidebar { display: none; }
|
||
.menu-btn { display: inline-flex; }
|
||
.g4 { grid-template-columns: repeat(2, 1fr); }
|
||
.g3 { grid-template-columns: repeat(2, 1fr); }
|
||
.g2 { grid-template-columns: 1fr; }
|
||
.topbar { padding: 11px 14px; }
|
||
.content { padding: 14px 14px 90px; }
|
||
.mob-nav { display: block; }
|
||
.stat-v { font-size: 22px; }
|
||
.stat-v.lg { font-size: 24px; }
|
||
.stat-v.sm { font-size: 16px; }
|
||
.card { padding: 12px 14px; }
|
||
.ct { font-size: 10.5px; }
|
||
.tb-title h2 { font-size: 16px; }
|
||
.tb-bc { font-size: 9px; }
|
||
.donut-w { flex-direction: column; align-items: stretch; }
|
||
.donut { margin: 0 auto; }
|
||
.dr-b dl { grid-template-columns: 110px 1fr; }
|
||
.modal-bg { align-items: flex-end; padding: 0; }
|
||
.modal { max-width: 100%; border-radius: var(--r) var(--r) 0 0; padding: 22px; padding-bottom: calc(22px + env(safe-area-inset-bottom)); }
|
||
}
|
||
@media (max-width: 460px) {
|
||
.g4 { grid-template-columns: 1fr 1fr; }
|
||
.g3 { grid-template-columns: 1fr; }
|
||
.stat-v { font-size: 20px; }
|
||
.topbar { padding: 10px 12px; }
|
||
.content { padding: 12px 12px 90px; }
|
||
.fgrid { grid-template-columns: 1fr; }
|
||
table { font-size: 11.5px; }
|
||
tbody td, thead th { padding: 8px 10px; }
|
||
}
|
||
|
||
.klub-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
|
||
.klub-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; cursor: pointer; transition: 200ms; }
|
||
.klub-card:hover { transform: translateY(-2px); border-color: var(--accent); box-shadow: 0 4px 16px rgba(0,0,0,0.3); }
|
||
.klub-card.gold-border { border-color: var(--gold); }
|
||
.klub-card-head { display: flex; gap: 12px; margin-bottom: 12px; }
|
||
.klub-logo { width: 44px; height: 44px; border-radius: 9px; background: linear-gradient(135deg, var(--accent), var(--gold)); display: flex; align-items: center; justify-content: center; font-weight: 700; color: var(--bg); font-size: 14px; flex-shrink: 0; }
|
||
.klub-info { flex: 1; min-width: 0; }
|
||
.klub-name { font-size: 14px; font-weight: 600; line-height: 1.3; }
|
||
.klub-savez { font-size: 11px; color: var(--text-dim); }
|
||
.klub-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 12px; }
|
||
.klub-stats-mini { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||
.klub-stat-mini { text-align: center; }
|
||
.klub-stat-mini .v { font-family: 'JetBrains Mono', monospace; font-size: 16px; font-weight: 600; }
|
||
.klub-stat-mini .l { font-size: 9px; color: var(--text-dim); text-transform: uppercase; margin-top: 3px; }
|
||
.clan-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; }
|
||
.clan-card { background: var(--bg-3); border: 1px solid var(--border); border-radius: 8px; padding: 11px; }
|
||
.clan-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||
.clan-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--panel-2); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12px; color: var(--accent); }
|
||
.clan-name-x { flex: 1; min-width: 0; }
|
||
.clan-name-x .nm { font-size: 12.5px; font-weight: 600; }
|
||
.clan-name-x .pos { font-size: 10px; color: var(--text-dim); }
|
||
.clan-flags { display: flex; gap: 4px; flex-wrap: wrap; }
|
||
.clan-flag { padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 700; }
|
||
.drawer-stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 16px; }
|
||
.drawer-stat { background: var(--bg-3); border: 1px solid var(--border); border-radius: 7px; padding: 10px; text-align: center; }
|
||
.drawer-stat .v { font-family: 'JetBrains Mono', monospace; font-size: 18px; font-weight: 600; }
|
||
.drawer-stat .l { font-size: 9px; color: var(--text-dim); text-transform: uppercase; margin-top: 4px; }
|
||
.drawer-stat.ok .v { color: var(--ok); }
|
||
.drawer-stat.warn .v { color: var(--warn); }
|
||
.drawer-stat.crit .v { color: var(--crit); }
|
||
.drawer-stat.accent .v { color: var(--accent); }
|
||
.view-toggle { display: inline-flex; gap: 3px; background: var(--bg-2); padding: 3px; border-radius: 7px; border: 1px solid var(--border); }
|
||
.view-toggle button { background: none; border: none; color: var(--text-dim); padding: 5px 10px; font-size: 11px; font-weight: 600; cursor: pointer; border-radius: 5px; font-family: inherit; }
|
||
.view-toggle button.active { background: var(--accent); color: white; }
|
||
@media (max-width: 700px) {
|
||
.klub-grid { grid-template-columns: 1fr; }
|
||
.clan-list { grid-template-columns: 1fr; }
|
||
.drawer-stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||
}
|
||
|
||
|
||
.ekosustav-grid { grid-template-columns: 1fr !important; gap: 12px !important; }
|
||
.ekosustav-coverage-row { font-size: 11px !important; }
|
||
.ekosustav-coverage-row > div:first-child { width: 100px !important; }
|
||
}
|
||
|
||
|
||
/* ===== TOPBAR — 2-row grid layout, mobile-first ===== */
|
||
.topbar { display: flex !important; flex-direction: column; gap: 8px; padding: 10px 14px; min-width: 0; }
|
||
.tb-row { display: flex; align-items: center; gap: 10px; min-width: 0; width: 100%; }
|
||
.tb-row-1 { gap: 12px; }
|
||
.tb-row-2 { padding: 0; }
|
||
.tb-title { flex: 1; min-width: 0; overflow: hidden; }
|
||
.tb-title h2 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.tb-meta { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); white-space: nowrap; flex-shrink: 0; }
|
||
|
||
.tb-search {
|
||
display: flex; align-items: center; gap: 6px; flex: 1; width: 100%;
|
||
background: rgba(255,255,255,0.05); border: 1px solid var(--border);
|
||
border-radius: 8px; padding: 0 10px; height: 36px; position: relative;
|
||
transition: border-color 150ms;
|
||
}
|
||
.tb-search:focus-within { border-color: var(--accent); background: rgba(59,130,196,0.08); }
|
||
.tb-search input {
|
||
flex: 1; min-width: 0; background: transparent; border: 0;
|
||
color: var(--text); font-size: 13px; outline: none; padding: 0 4px; height: 100%;
|
||
}
|
||
.tb-search input::placeholder { color: var(--text-dim); }
|
||
.topbar-go {
|
||
background: var(--accent); color: white; border: 0; border-radius: 5px;
|
||
width: 28px; height: 26px; font-size: 14px; cursor: pointer; line-height: 1;
|
||
flex-shrink: 0;
|
||
}
|
||
.topbar-go:hover { opacity: 0.85; }
|
||
|
||
.top-search-suggest {
|
||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||
background: var(--panel); border: 1px solid var(--border);
|
||
border-radius: 8px; max-height: 360px; overflow-y: auto;
|
||
z-index: 200; display: none; box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||
}
|
||
.top-search-suggest.show { display: block; }
|
||
.tss-item { padding: 10px 12px; cursor: pointer; border-bottom: 1px solid var(--border); font-size: 13px; line-height: 1.4; }
|
||
.tss-item:last-child { border-bottom: 0; }
|
||
.tss-item:hover { background: rgba(59,130,196,0.08); }
|
||
.tss-tip {
|
||
display: inline-block; font-size: 9px; color: var(--accent);
|
||
text-transform: uppercase; letter-spacing: 0.5px; margin-right: 8px;
|
||
padding: 2px 5px; background: rgba(59,130,196,0.1); border-radius: 3px;
|
||
}
|
||
|
||
/* Desktop: single row layout */
|
||
@media (min-width: 881px) {
|
||
.topbar { flex-direction: row; align-items: center; gap: 14px; padding: 11px 18px; }
|
||
.tb-row-1 { flex: 0 0 auto; gap: 12px; flex: 1; max-width: 50%; }
|
||
.tb-row-2 { flex: 1; max-width: 480px; padding: 0; }
|
||
.tb-search { max-width: 480px; }
|
||
}
|
||
|
||
/* Mobile: row-2 search full width below row-1 */
|
||
@media (max-width: 880px) {
|
||
.tb-meta { font-size: 10px; }
|
||
.tb-time { display: none; }
|
||
.tb-search { height: 34px; }
|
||
.tb-search input { font-size: 13px; }
|
||
.ekosustav-grid { grid-template-columns: 1fr !important; gap: 14px !important; }
|
||
.ekosustav-coverage-row { font-size: 11px !important; }
|
||
.ekosustav-coverage-row > div:first-child { width: 110px !important; flex-shrink: 0; }
|
||
}
|
||
|
||
|
||
/* ===== V6 PRO FORM (Navision/SAP-style) ===== */
|
||
.v6-form { background:#1a1a1a; border:1px solid #2a2a2a; border-radius:6px; overflow:hidden; }
|
||
.v6-fh { background:linear-gradient(180deg,#2a3a52 0%,#1e2a3e 100%); border-bottom:1px solid #3a4a6a; padding:10px 16px; display:flex; justify-content:space-between; align-items:center; }
|
||
.v6-fh h3 { margin:0; font-size:14px; color:#fff; font-weight:600; }
|
||
.v6-fs { padding:12px 16px; border-bottom:1px solid #2a2a2a; }
|
||
.v6-fs-t { font-size:11px; color:#5e72e4; text-transform:uppercase; letter-spacing:0.5px; margin-bottom:8px; font-weight:600; }
|
||
.v6-g2 { display:grid; grid-template-columns:repeat(2,1fr); gap:8px 16px; }
|
||
.v6-g3 { display:grid; grid-template-columns:repeat(3,1fr); gap:8px 14px; }
|
||
.v6-g4 { display:grid; grid-template-columns:repeat(4,1fr); gap:8px 12px; }
|
||
@media (max-width:700px) { .v6-g2,.v6-g3,.v6-g4 { grid-template-columns:1fr; } }
|
||
.v6-fld { display:flex; flex-direction:column; }
|
||
.v6-w2 { grid-column:span 2; }
|
||
.v6-fld-w { grid-column:1/-1; }
|
||
.v6-lbl { font-size:11px; color:#98a8b8; margin-bottom:3px; font-weight:500; }
|
||
.v6-lbl.req::after { content:' *'; color:#e74c3c; }
|
||
.v6-inp { background:#0f1620; border:1px solid #2a3a4a; color:#e6e8ec; padding:6px 8px; font-size:13px; border-radius:3px; outline:none; font-family:inherit; }
|
||
.v6-inp:focus { border-color:#5e72e4; }
|
||
.v6-inp[readonly] { background:#1a242e; color:#788798; }
|
||
.v6-num { text-align:right; font-family:Consolas,monospace; }
|
||
.v6-calc { background:#1a2a1f !important; color:#4caf50 !important; font-weight:600; }
|
||
.v6-tot { background:linear-gradient(180deg,#1a2a1f 0%,#1e2a24 100%); padding:10px 16px; border-top:2px solid #4caf50; display:flex; justify-content:flex-end; gap:24px; }
|
||
.v6-tot-i { text-align:right; }
|
||
.v6-tot-i .v6-lbl { font-size:10px; }
|
||
.v6-tot-i .v6-val { font-size:18px; font-weight:700; color:#4caf50; font-family:Consolas,monospace; }
|
||
.v6-ac { position:relative; }
|
||
.v6-ac-s { position:absolute; top:100%; left:0; right:0; background:#0f1620; border:1px solid #5e72e4; border-top:none; max-height:200px; overflow-y:auto; z-index:100; display:none; }
|
||
.v6-ac-s.show { display:block; }
|
||
.v6-ac-s div { padding:6px 10px; cursor:pointer; font-size:13px; border-bottom:1px solid #2a2a2a; }
|
||
.v6-ac-s div:hover { background:#2a3a52; color:#fff; }
|
||
.v6-pill { display:inline-block; padding:2px 6px; background:#1a3a52; color:#5e72e4; font-size:10px; border-radius:3px; margin-left:6px; }
|
||
.v6-att-z { border:2px dashed #3a4a6a; border-radius:4px; padding:14px; text-align:center; cursor:pointer; background:#0f1620; }
|
||
.v6-att-z:hover { border-color:#5e72e4; }
|
||
.v6-att-l { margin-top:8px; display:flex; flex-direction:column; gap:4px; }
|
||
.v6-att-i { display:flex; align-items:center; gap:8px; padding:6px 10px; background:#1a2a3a; border-radius:3px; font-size:12px; }
|
||
.v6-att-i .v6-tag { background:#2a5e3a; color:#fff; padding:1px 6px; border-radius:2px; font-size:10px; }
|
||
.v6-att-i .v6-amt { margin-left:auto; font-family:Consolas,monospace; color:#4caf50; }
|
||
|
||
|
||
/* ===== V6.2 VOICE INPUT + CHATBOT ===== */
|
||
.v6-mic-btn {
|
||
background: #2a3a52;
|
||
border: 1px solid #3a4a6a;
|
||
color: #e6e8ec;
|
||
padding: 0 12px;
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
height: 100%;
|
||
transition: all 0.2s;
|
||
}
|
||
.v6-mic-btn:hover { background: #5e72e4; }
|
||
.v6-mic-btn.recording {
|
||
background: #c0392b;
|
||
border-color: #e74c3c;
|
||
animation: v6pulse 1.2s infinite;
|
||
}
|
||
@keyframes v6pulse {
|
||
0%, 100% { box-shadow: 0 0 0 0 rgba(231,76,60,0.7); }
|
||
50% { box-shadow: 0 0 0 8px rgba(231,76,60,0); }
|
||
}
|
||
.v6-chat-thread {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 12px;
|
||
max-height: 60vh;
|
||
overflow-y: auto;
|
||
background: #0f1620;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.v6-chat-msg {
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
max-width: 85%;
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
word-wrap: break-word;
|
||
}
|
||
.v6-chat-msg.user {
|
||
background: #5e72e4;
|
||
color: #fff;
|
||
align-self: flex-end;
|
||
border-bottom-right-radius: 3px;
|
||
}
|
||
.v6-chat-msg.bot {
|
||
background: #1a2a3a;
|
||
color: #e6e8ec;
|
||
align-self: flex-start;
|
||
border-bottom-left-radius: 3px;
|
||
white-space: pre-wrap;
|
||
}
|
||
.v6-chat-msg.bot .v6-msg-meta {
|
||
font-size: 10px;
|
||
color: #788798;
|
||
margin-bottom: 4px;
|
||
}
|
||
.v6-chat-msg .v6-src-link {
|
||
display: inline-block;
|
||
background: #2a5e3a;
|
||
color: #fff;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-size: 10px;
|
||
margin: 2px 4px 2px 0;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
}
|
||
.v6-chat-msg .v6-src-link:hover { background: #3a7e4a; }
|
||
.v6-chat-typing {
|
||
display: inline-block;
|
||
padding: 8px 14px;
|
||
background: #1a2a3a;
|
||
border-radius: 12px;
|
||
border-bottom-left-radius: 3px;
|
||
}
|
||
.v6-chat-typing span {
|
||
display: inline-block;
|
||
width: 8px;
|
||
height: 8px;
|
||
margin: 0 2px;
|
||
background: #5e72e4;
|
||
border-radius: 50%;
|
||
animation: v6typing 1.4s infinite;
|
||
}
|
||
.v6-chat-typing span:nth-child(2) { animation-delay: 0.2s; }
|
||
.v6-chat-typing span:nth-child(3) { animation-delay: 0.4s; }
|
||
@keyframes v6typing {
|
||
0%, 60%, 100% { opacity: 0.3; transform: translateY(0); }
|
||
30% { opacity: 1; transform: translateY(-4px); }
|
||
}
|
||
.v6-input-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
align-items: stretch;
|
||
}
|
||
.v6-input-row .inp { flex: 1; }
|
||
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="app">
|
||
<!-- DESKTOP SIDEBAR -->
|
||
<aside class="sidebar">
|
||
<div class="sb-head">
|
||
<div class="brand">
|
||
<div class="brand-mark">PG</div>
|
||
<div class="brand-text">
|
||
<h1>PGŽ Sport</h1>
|
||
<p>Civic Intelligence OS</p>
|
||
</div>
|
||
</div>
|
||
<div id="role-pill" class="role-pill viewer" onclick="showLogin()">VIEWER</div>
|
||
</div>
|
||
<nav class="nav" id="nav-desktop"></nav>
|
||
<div class="sb-foot">
|
||
<div><b style="color:var(--gold)">Ri.NET</b> · DABI Digital B.V.</div>
|
||
<div>Damir Radulić · 2026</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- MAIN -->
|
||
<main class="main">
|
||
<div id="topbar"></div>
|
||
<div class="content"><div class="inner" id="content"><div class="loader">Učitavanje…</div></div></div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- MOBILE BOTTOM NAV -->
|
||
<nav class="mob-nav" id="mob-nav"></nav>
|
||
|
||
<!-- MOBILE SIDE DRAWER -->
|
||
<div class="dr-bg" id="mob-drawer-bg" onclick="toggleMobDrawer(false)"></div>
|
||
<aside class="mob-drawer" id="mob-drawer">
|
||
<div class="sb-head">
|
||
<div class="brand">
|
||
<div class="brand-mark">PG</div>
|
||
<div class="brand-text"><h1>PGŽ Sport</h1><p>Civic Intelligence OS</p></div>
|
||
</div>
|
||
<div id="role-pill-mob" class="role-pill viewer" onclick="showLogin()">VIEWER</div>
|
||
</div>
|
||
<nav class="nav" id="nav-mob"></nav>
|
||
<div class="sb-foot">
|
||
<div><b style="color:var(--gold)">Ri.NET</b> · DABI Digital</div>
|
||
<div>Damir Radulić</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- DETAIL DRAWER -->
|
||
<div class="dr-bg" id="drawer-bg" onclick="closeDrawer()"></div>
|
||
<div class="drawer" id="drawer"><div id="drawer-content"></div></div>
|
||
|
||
<!-- LOGIN MODAL -->
|
||
<div class="modal-bg" id="login-modal">
|
||
<div class="modal">
|
||
<h3>🔐 Admin pristup</h3>
|
||
<p>Za pristup neprikrivenim podacima (OIB, datum rođenja, e-mail, telefon, IBAN, adresa) potreban je admin token. Bez tokena su osjetljiva polja zamagljena. <b>GDPR čl. 5 i 32.</b></p>
|
||
<input class="inp mono" id="token-input" placeholder="Admin token..." autocomplete="off">
|
||
<div class="ma">
|
||
<button class="btn" style="flex:1" onclick="doLogin()">Prijavi se</button>
|
||
<button class="btn sec" onclick="closeLogin()">Odustani</button>
|
||
</div>
|
||
<div class="hint"><b>Demo:</b><br>Admin: <span class="mono">admin-pgz-2026</span><br>Bez tokena = viewer (privatni podaci zamagljeni)</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API = '/sport';
|
||
let state = { sort:{}, page:'dashboard', token: localStorage.getItem('pgz_token')||'', filters:{}, isAdmin:false };
|
||
|
||
const fmt = n => n==null||n===''?'–':Number(n).toLocaleString('hr-HR',{maximumFractionDigits:0});
|
||
const fmtEur = n => n==null||n===''?'–':Number(n).toLocaleString('hr-HR',{style:'currency',currency:'EUR',maximumFractionDigits:0});
|
||
const fmtDate = d => d?new Date(d).toLocaleDateString('hr-HR'):'–';
|
||
const debounce = (fn,ms=300) => { let t; return (...a) => { clearTimeout(t); t=setTimeout(()=>fn(...a),ms); }; };
|
||
|
||
async function api(path, opts={}) {
|
||
const headers = { 'Content-Type':'application/json' };
|
||
if (state.token) headers['Authorization'] = 'Bearer '+state.token;
|
||
const res = await fetch(API+path, {...opts, headers:{...headers, ...(opts.headers||{})}});
|
||
if (!res.ok) throw new Error(`API ${res.status}`);
|
||
return res.json();
|
||
}
|
||
|
||
// === NAV CONFIG ===
|
||
const NAV = [
|
||
{ sec:'Pregled', items:[
|
||
{ id:'dashboard', label:'Dashboard', mlabel:'Home', svg:'<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/>' },
|
||
{ id:'search', label:'AI Search', svg:'<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>' },
|
||
{ id:'analytics', label:'Analytics', svg:'<polyline points="3,17 9,11 13,15 21,7"/><polyline points="14,7 21,7 21,14"/>' },
|
||
{ id:'alertovi', label:'Alertovi', mlabel:'Alerti', badge:'alerts', svg:'<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>' }
|
||
]},
|
||
{ sec:'Organizacija', items:[
|
||
{ id:'savezi', label:'Savezi', svg:'<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9,22 9,12 15,12 15,22"/>' },
|
||
{ id:'klubovi', label:'Klubovi', svg:'<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>' },
|
||
{ id:'clanovi', label:'Članovi', mlabel:'Članovi', svg:'<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/>' }
|
||
]},
|
||
{ sec:'Financije', items:[
|
||
{ id:'clanarine', label:'Članarine', mlabel:'Plaćanja', svg:'<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
||
{ id:'potpore', label:'Potpore', svg:'<polygon points="12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26"/>' },
|
||
{ id:'proracun', label:'Proračun PGŽ', mlabel:'Proračun', svg:'<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/>' }
|
||
]},
|
||
{ sec:'Zdravlje', items:[
|
||
{ id:'lijecnicki', label:'Liječnički', svg:'<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>' },
|
||
{ id:'zzjz', label:'ZZJZ PGŽ', mlabel:'ZZJZ', svg:'<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>' }
|
||
]},
|
||
{ sec:'Operativa', items:[
|
||
{ id:'manifestacije', label:'Manifestacije', mlabel:'Eventi', svg:'<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>' },
|
||
{ id:'statistika', label:'Statistika', mlabel:'Stats', svg:'<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' }
|
||
]}
|
||
,
|
||
{ sec:'ERP & Pravo', items:[
|
||
{ id:'ask', label:'AI Asistent', mlabel:'AI', svg:'<circle cx=\"12\" cy=\"12\" r=\"10\"/><path d=\"M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3\"/><line x1=\"12\" y1=\"17\" x2=\"12.01\" y2=\"17\"/>' },
|
||
{ id:'invoices', label:'Računi (ERP)', mlabel:'Računi', svg:'<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/><line x1=\"16\" y1=\"13\" x2=\"8\" y2=\"13\"/><line x1=\"16\" y1=\"17\" x2=\"8\" y2=\"17\"/>' },
|
||
{ id:'expenses', label:'Putni nalozi', mlabel:'Putni', svg:'<rect x=\"3\" y=\"4\" width=\"18\" height=\"18\" rx=\"2\" ry=\"2\"/><line x1=\"16\" y1=\"2\" x2=\"16\" y2=\"6\"/><line x1=\"8\" y1=\"2\" x2=\"8\" y2=\"6\"/><line x1=\"3\" y1=\"10\" x2=\"21\" y2=\"10\"/>' },
|
||
{ id:'forms', label:'Obrasci', mlabel:'Obrasci', svg:'<path d=\"M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z\"/><polyline points=\"14 2 14 8 20 8\"/>' },
|
||
{ id:'users', label:'Korisnici', mlabel:'Users', svg:'<path d=\"M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2\"/><circle cx=\"9\" cy=\"7\" r=\"4\"/>' }
|
||
]}];
|
||
|
||
// Bottom mob nav (only top 5)
|
||
const MOB_NAV = ['dashboard','klubovi','clanovi','lijecnicki','alertovi'];
|
||
|
||
function buildNavs() {
|
||
// Desktop nav
|
||
const dEl = document.getElementById('nav-desktop');
|
||
const mEl = document.getElementById('nav-mob');
|
||
let dHtml = '', mHtml = '';
|
||
NAV.forEach(s => {
|
||
dHtml += `<div class="nav-sec">${s.sec}</div>`;
|
||
mHtml += `<div class="nav-sec">${s.sec}</div>`;
|
||
s.items.forEach(it => {
|
||
const badge = it.badge ? `<span class="b" id="b-${it.id}" style="display:none">0</span>` : '';
|
||
const item = `<div class="nav-i" data-page="${it.id}" onclick="goto('${it.id}')">
|
||
<svg class="ico ico-svg" viewBox="0 0 24 24">${it.svg}</svg>
|
||
<span>${it.label}</span>${badge}
|
||
</div>`;
|
||
dHtml += item;
|
||
mHtml += item.replace(`b-${it.id}`, `b-${it.id}-m`);
|
||
});
|
||
});
|
||
dEl.innerHTML = dHtml;
|
||
mEl.innerHTML = mHtml;
|
||
|
||
// Mobile bottom nav (5 max)
|
||
const bn = document.getElementById('mob-nav');
|
||
let bHtml = '<div class="mob-nav-grid">';
|
||
MOB_NAV.forEach(id => {
|
||
const it = NAV.flatMap(s => s.items).find(x => x.id===id);
|
||
if (!it) return;
|
||
bHtml += `<div class="mob-nav-i" data-page="${id}" onclick="goto('${id}');toggleMobDrawer(false)">
|
||
<svg class="ico ico-svg" viewBox="0 0 24 24">${it.svg}</svg>
|
||
<span>${it.mlabel||it.label}</span>
|
||
</div>`;
|
||
});
|
||
// 5th = MORE
|
||
bHtml += `<div class="mob-nav-i" onclick="toggleMobDrawer(true)">
|
||
<svg class="ico ico-svg" viewBox="0 0 24 24"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||
<span>Više</span>
|
||
</div></div>`;
|
||
bn.innerHTML = bHtml;
|
||
}
|
||
|
||
function goto(page) {
|
||
state.page = page;
|
||
state.filters = {}; state.sort = {};
|
||
document.querySelectorAll('.nav-i').forEach(e => e.classList.toggle('active', e.dataset.page===page));
|
||
document.querySelectorAll('.mob-nav-i').forEach(e => e.classList.toggle('active', e.dataset.page===page));
|
||
toggleMobDrawer(false);
|
||
render();
|
||
}
|
||
function toggleMobDrawer(open) {
|
||
document.getElementById('mob-drawer').classList.toggle('open', open);
|
||
document.getElementById('mob-drawer-bg').classList.toggle('open', open);
|
||
}
|
||
function openDrawer(html) {
|
||
document.getElementById('drawer-content').innerHTML = html;
|
||
document.getElementById('drawer').classList.add('open');
|
||
document.getElementById('drawer-bg').classList.add('open');
|
||
}
|
||
function closeDrawer() {
|
||
document.getElementById('drawer').classList.remove('open');
|
||
document.getElementById('drawer-bg').classList.remove('open');
|
||
}
|
||
|
||
function showLogin() {
|
||
document.getElementById('login-modal').classList.add('show');
|
||
// Re-init Google button on each modal open
|
||
if (window.google && window.google.accounts) {
|
||
const btn = document.getElementById('google-signin-btn');
|
||
if (btn && btn.children.length === 0) initGoogleSignIn();
|
||
}
|
||
}
|
||
function closeLogin() { document.getElementById('login-modal').classList.remove('show'); }
|
||
function doLogin() {
|
||
const t = document.getElementById('token-input').value.trim();
|
||
state.token = t; localStorage.setItem('pgz_token', t);
|
||
closeLogin(); checkRole().then(()=>render());
|
||
}
|
||
async function checkRole() {
|
||
try {
|
||
const w = await api('/api/whoami');
|
||
state.isAdmin = w.role==='admin';
|
||
['role-pill','role-pill-mob'].forEach(id => {
|
||
const p = document.getElementById(id);
|
||
if (!p) return;
|
||
if (state.isAdmin) { p.className='role-pill admin'; p.textContent='ADMIN'; p.onclick = () => { state.token=''; localStorage.removeItem('pgz_token'); checkRole(); render(); }; }
|
||
else { p.className='role-pill viewer'; p.textContent='VIEWER'; p.onclick = showLogin; }
|
||
});
|
||
} catch(e) {}
|
||
}
|
||
|
||
|
||
function globalSearch(q) {
|
||
if (!q || q.length < 2) return;
|
||
state.page = 'search'; state.searchQ = q; render();
|
||
}
|
||
|
||
async function pageSearch() {
|
||
setTopbar('AI Search', 'Rezultati: "' + (state.searchQ || '') + '"');
|
||
const c = document.getElementById('content');
|
||
if (!state.searchQ) {
|
||
c.innerHTML = '<div style="max-width:680px;margin:30px auto">'
|
||
+ '<div class="card" style="padding:24px">'
|
||
+ '<h3 style="margin:0 0 14px;color:var(--text)">AI Search</h3>'
|
||
+ '<p class="muted" style="margin-bottom:20px">Pretraži klubove, saveze, sportaše, manifestacije, pravilnike i dokumente PGŽ-a.</p>'
|
||
+ '<div style="display:flex;gap:8px;margin-bottom:14px"><input id="aiSearchInline" type="text" placeholder="npr. nogomet Rijeka, kategorizacija, sufinanciranje... ili 🎤 reci" style="flex:1;padding:14px 16px;font-size:15px;background:var(--bg-1);color:var(--text);border:1px solid var(--border-1);border-radius:8px;outline:none" onkeydown="if(event.key===\'Enter\'){state.searchQ=this.value;render()}" autofocus /><button class="v6-mic-btn" style="padding:0 16px;font-size:18px;border-radius:8px" onclick="v6VoiceStart(\'aiSearchInline\', this)" title="Glasovni unos (hr-HR)">🎤</button></div>'
|
||
+ '<div style="display:flex;flex-wrap:wrap;gap:8px">'
|
||
+ ['nogomet','košarka','kategorizacija','pravilnik','financiranje','klub Rijeka','sportaš seniori','manifestacija 2026','medicinski pregled','natjecaji']
|
||
.map(q => '<span class="badge muted" style="cursor:pointer" onclick="state.searchQ=\''+q+'\';render()">' + q + '</span>').join('')
|
||
+ '</div>'
|
||
+ '<div style="margin-top:20px;padding-top:14px;border-top:1px solid var(--border-1);font-size:11px;color:var(--text-dim)">'
|
||
+ 'PGŽ-only po default · 220 saveza · 1622 klubova · 6915 dokumenata · 52k vektora'
|
||
+ '</div></div></div>';
|
||
setTimeout(()=>{const el=document.getElementById('aiSearchInline'); if(el)el.focus()}, 100);
|
||
return;
|
||
}
|
||
c.innerHTML = '<div class="loader">Pretraga BGE-M3...</div>';
|
||
try {
|
||
const tip = state.filters.tip || '';
|
||
const scope = state.filters.searchScope || 'pgz';
|
||
const d = await api('/api/search?q=' + encodeURIComponent(state.searchQ) + '&limit=20&scope=' + scope + (tip ? '&tip=' + tip : ''));
|
||
const tipBadge = { savez:'info', klub:'gold', clan:'muted', manifestacija:'warn', potpora:'ok', proracun:'crit', statistika:'info', dokument:'info', natjecanje:'gold', kategorija:'muted', zakon:'crit' };
|
||
|
||
var hdr = '<div class="filter-bar"><div class="filter-bar-title">Pretraga: <b>'+state.searchQ+'</b></div>'
|
||
+ '<div class="filter-grid" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">'
|
||
+ '<input id="searchInput" class="inp" value="'+state.searchQ.replace(/"/g,'"')+'" style="flex:1;min-width:200px" onkeydown="if(event.key===\'Enter\'){state.searchQ=this.value;render()}" />'
|
||
+ '<button class="v6-mic-btn" onclick="v6VoiceStart(\'searchInput\', this)" title="Glasovni unos (hr-HR)">🎤</button>'
|
||
+ '<button class="btn primary" onclick="state.searchQ=document.getElementById(\'searchInput\').value;render()">Traži</button>'
|
||
+ '<button class="btn" onclick="state.searchQ=\'\';render()" title="Nova pretraga">Reset</button>'
|
||
+ '<select onchange="state.filters.searchScope=this.value;render()" class="inp" style="min-width:140px">'
|
||
+ '<option value="pgz"'+(scope==='pgz'?' selected':'')+'>Samo PGŽ</option>'
|
||
+ '<option value="all"'+(scope==='all'?' selected':'')+'>Sve (Hrvatska)</option>'
|
||
+ '<option value="national"'+(scope==='national'?' selected':'')+'>Samo nacional</option>'
|
||
+ '</select>'
|
||
+ '<select onchange="state.filters.tip=this.value;render()" class="inp" style="min-width:140px">'
|
||
+ '<option value="">Svi tipovi</option>'
|
||
+ '<option value="klub"'+(tip==='klub'?' selected':'')+'>Klubovi</option>'
|
||
+ '<option value="savez"'+(tip==='savez'?' selected':'')+'>Savezi</option>'
|
||
+ '<option value="dokument"'+(tip==='dokument'?' selected':'')+'>Dokumenti</option>'
|
||
+ '<option value="manifestacija"'+(tip==='manifestacija'?' selected':'')+'>Manifestacije</option>'
|
||
+ '<option value="natjecanje"'+(tip==='natjecanje'?' selected':'')+'>Natjecanja</option>'
|
||
+ '<option value="zakon"'+(tip==='zakon'?' selected':'')+'>Zakoni</option>'
|
||
+ '</select>'
|
||
+ '</div></div>'
|
||
+ '<div style="color:var(--text-dim);font-size:11px;margin-bottom:14px">'+d.count+' rezultata · scope: '+(d.scope||'pgz')+'</div>';
|
||
|
||
c.innerHTML = hdr + '<div class="grid" style="grid-template-columns:1fr;gap:8px">' +
|
||
d.results.map(function(r){
|
||
var url = r.url || (r.payload && (r.payload.source_url || r.payload.url)) || '';
|
||
var title = r.naziv || (r.payload && r.payload.title) || '(bez naslova)';
|
||
var docType = r.payload && r.payload.doc_type;
|
||
var sourceTag = r.payload && r.payload.source;
|
||
var publishDate = r.payload && r.payload.publish_date;
|
||
var relevance = r.relevance || '';
|
||
var click = '';
|
||
var hint = '';
|
||
if (url) { click = 'onclick="window.open(\''+url.replace(/\x27/g,'\\x27')+'\',\'_blank\')" style="cursor:pointer"'; hint = '<span class="pill" style="background:#2a5e3a;color:#fff">otvori dokument</span>'; }
|
||
else if (r.klub_id) { click = 'onclick="showKlub('+r.klub_id+')" style="cursor:pointer"'; hint = '<span class="pill ok">klub →</span>'; }
|
||
else if (r.savez_id) { click = 'onclick="showSavez('+r.savez_id+')" style="cursor:pointer"'; hint = '<span class="pill ok">savez →</span>'; }
|
||
var relB = relevance==='pgz' ? '<span class="pill" style="background:#1a4d3a;color:#27c79b">PGŽ</span>' :
|
||
relevance==='national_doc' ? '<span class="pill" style="background:#3a3a52">nacional</span>' : '';
|
||
return '<div class="card" '+click+'>'
|
||
+ '<div style="display:flex;justify-content:space-between;gap:10px;margin-bottom:6px;flex-wrap:wrap">'
|
||
+ '<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">'
|
||
+ '<span class="badge '+(tipBadge[r.tip]||'muted')+'">'+(r.tip||'?')+'</span>'
|
||
+ relB
|
||
+ (docType ? '<span class="pill muted">'+docType+'</span>' : '')
|
||
+ '<strong>'+title+'</strong>'
|
||
+ hint
|
||
+ '</div>'
|
||
+ '<span class="mono" style="font-size:10px;color:var(--text-dim)">'+(publishDate?publishDate.slice(0,10)+' · ':'')+(sourceTag||'')+' · '+(r.score*100).toFixed(0)+'%</span>'
|
||
+ '</div>'
|
||
+ '<div style="color:var(--text-2);font-size:12px;line-height:1.5">'+((r.tekst||'').slice(0,300))+((r.tekst||'').length>300?'…':'')+'</div>'
|
||
+ (url ? '<div style="margin-top:4px;font-size:11px;color:#5e72e4;word-break:break-all">'+(url.length>120?url.slice(0,120)+'…':url)+'</div>' : '')
|
||
+ '</div>';
|
||
}).join('') +
|
||
(d.count===0 ? '<div class="empty">Nema rezultata. Pokušaj drugi pojam ili promijeni scope.</div>' : '') +
|
||
'</div>';
|
||
} catch (e) { c.innerHTML = '<div class="banner crit">'+e.message+'</div>'; }
|
||
}
|
||
|
||
function setTopbar(bc, title, meta='') {
|
||
document.getElementById('topbar').innerHTML = `
|
||
<div class="topbar">
|
||
<div class="tb-row tb-row-1">
|
||
<button class="menu-btn" onclick="toggleMobDrawer(true)">
|
||
<svg class="ico-svg" viewBox="0 0 24 24" style="width:18px;height:18px"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||
</button>
|
||
<div class="tb-title">
|
||
<div class="tb-bc">${bc}</div>
|
||
<h2>${title}</h2>
|
||
</div>
|
||
<div class="tb-meta">${meta} <span class="live-dot"></span><span class="tb-time">${new Date().toLocaleTimeString('hr-HR',{hour:'2-digit',minute:'2-digit'})}</span></div>
|
||
</div>
|
||
<div class="tb-row tb-row-2">
|
||
<div class="tb-search">
|
||
<svg class="ico-svg" viewBox="0 0 24 24" style="width:14px;height:14px;color:var(--text-dim);flex-shrink:0"><circle cx="11" cy="11" r="7"/><line x1="21" y1="21" x2="16.5" y2="16.5"/></svg>
|
||
<input type="search" id="topSearchInput" placeholder="Pretraži klubove, saveze, sportaše..." autocomplete="off"
|
||
oninput="topSearchType(this.value)" onkeydown="if(event.key==='Enter')topSearchGo(this.value)" />
|
||
<button class="v6-mic-btn" style="padding:0 10px;border-radius:0 8px 8px 0;height:auto" onclick="v6VoiceStart('topSearchInput', this)" title="Glasovni unos (hr-HR)">🎤</button>
|
||
<button class="topbar-go" onclick="topSearchGo(document.getElementById('topSearchInput').value)" title="Pretraži">→</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
|
||
let _topSearchTimer = null;
|
||
|
||
function topSearchType(q) {
|
||
clearTimeout(_topSearchTimer);
|
||
if (!q || q.length < 2) {
|
||
const el = document.getElementById('topSearchSuggest');
|
||
if (el) el.classList.remove('show');
|
||
return;
|
||
}
|
||
_topSearchTimer = setTimeout(() => topSearchSuggestFetch(q), 250);
|
||
}
|
||
|
||
async function topSearchSuggestFetch(q) {
|
||
try {
|
||
const r = await fetch('/sport/api/search?q=' + encodeURIComponent(q) + '&limit=8');
|
||
const d = await r.json();
|
||
let el = document.getElementById('topSearchSuggest');
|
||
if (!el) {
|
||
el = document.createElement('div');
|
||
el.id = 'topSearchSuggest';
|
||
el.className = 'top-search-suggest';
|
||
const cont = document.querySelector('.tb-search');
|
||
if (cont) {
|
||
cont.style.position = 'relative';
|
||
cont.appendChild(el);
|
||
}
|
||
}
|
||
if (!d.results || d.results.length === 0) {
|
||
el.innerHTML = '<div class="tss-item dim">Nema rezultata za "' + q + '"</div>';
|
||
} else {
|
||
el.innerHTML = d.results.map(function(r) {
|
||
var p = r.payload || {};
|
||
var tip = r.tip || p.tip || '';
|
||
var naziv = r.naziv || p.naziv || p.title || '?';
|
||
var url = r.url || p.source_url || p.url || '';
|
||
var id = r.klub_id || r.savez_id || (p && (p.klub_id || p.savez_id)) || '';
|
||
var score = r.score ? r.score.toFixed(2) : '';
|
||
var onClick;
|
||
if (url) {
|
||
var safeUrl = url.replace(/\x27/g, '%27').replace(/"/g, '%22');
|
||
onClick = 'window.open(\x27' + safeUrl + '\x27, \x27_blank\x27)';
|
||
} else if (tip === 'klub' && id) onClick = 'showKlub(' + id + ')';
|
||
else if (tip === 'savez' && id) onClick = 'showSavez(' + id + ')';
|
||
else if (tip === 'savez') onClick = 'navigate(\x27savezi\x27)';
|
||
else { var sq = q.replace(/\x27/g, ''); onClick = 'state.searchQ=\x27' + sq + '\x27; navigate(\x27search\x27)'; }
|
||
var safeNaziv = (naziv || '?').replace(/</g,'<').replace(/>/g,'>');
|
||
if (safeNaziv.length > 60) safeNaziv = safeNaziv.slice(0,60) + '…';
|
||
return '<div class="tss-item" onclick="' + onClick + ';document.getElementById(\x27topSearchSuggest\x27).classList.remove(\x27show\x27)">' +
|
||
'<span class="tss-tip">' + tip + '</span>' +
|
||
'<span>' + safeNaziv + '</span>' +
|
||
(url ? '<span class="dim" style="font-size:10px;margin-left:6px">📄</span>' : '') +
|
||
(score ? '<span class="dim" style="float:right;font-size:10px">' + score + '</span>' : '') +
|
||
'</div>';
|
||
}).join('');
|
||
}
|
||
el.classList.add('show');
|
||
} catch (e) { console.error('topSearch err', e); }
|
||
}
|
||
|
||
function topSearchGo(q) {
|
||
if (!q) return;
|
||
state.searchQ = q;
|
||
const el = document.getElementById('topSearchSuggest');
|
||
if (el) el.classList.remove('show');
|
||
navigate('search');
|
||
}
|
||
|
||
document.addEventListener('click', function(e) {
|
||
const search = document.querySelector('.tb-search');
|
||
if (search && !search.contains(e.target)) {
|
||
const sug = document.getElementById('topSearchSuggest');
|
||
if (sug) sug.classList.remove('show');
|
||
}
|
||
});
|
||
|
||
function tableHeader(cols, sortKey) {
|
||
return cols.map(c => {
|
||
const s = c.sort !== false;
|
||
const sorted = state.sort[sortKey] && state.sort[sortKey].col === c.key ? 'sorted' : '';
|
||
const arr = sorted ? (state.sort[sortKey].order==='asc'?'↑':'↓') : '↕';
|
||
return `<th class="${sorted}" ${s?`onclick="sortBy('${sortKey}','${c.key}')"`:''}>${c.label}${s?` <span class="arr">${arr}</span>`:''}</th>`;
|
||
}).join('');
|
||
}
|
||
function sortBy(sk, col) {
|
||
const s = state.sort[sk] || {col:'', order:'asc'};
|
||
if (s.col===col) s.order = s.order==='asc'?'desc':'asc';
|
||
else { s.col=col; s.order='asc'; }
|
||
state.sort[sk] = s; render();
|
||
}
|
||
function getSort(sk) { const s = state.sort[sk]; return s && s.col ? `&sort=${s.col}&order=${s.order}` : ''; }
|
||
|
||
function donut(values, labels, colors, totalDisplay, label) {
|
||
const sum = values.reduce((a,b)=>a+b,0) || 1;
|
||
const r = 46, c = 2*Math.PI*r;
|
||
let off = 0;
|
||
const segs = values.map((v,i) => {
|
||
const len = (v/sum)*c;
|
||
const seg = `<circle r="${r}" cx="55" cy="55" fill="transparent" stroke="${colors[i]}" stroke-width="12" stroke-dasharray="${len} ${c-len}" stroke-dashoffset="${-off}"/>`;
|
||
off += len; return seg;
|
||
}).join('');
|
||
return `<div class="donut"><svg width="110" height="110" viewBox="0 0 110 110">${segs}</svg>
|
||
<div class="donut-c"><div class="v">${totalDisplay}</div><div class="l">${label||''}</div></div></div>`;
|
||
}
|
||
|
||
function lineChart(series, labels, w=600, h=200, colors=['#4A9EFF','#D4A852','#A78BFA','#F472B6','#2DD4BF','#22D3EE','#F59E0B']) {
|
||
const pad = {l:50, r:14, t:12, b:28};
|
||
const iw = w-pad.l-pad.r, ih = h-pad.t-pad.b;
|
||
const all = series.flatMap(s=>s.data);
|
||
const max = Math.max(...all,1)*1.05, min = 0;
|
||
const xs = iw / Math.max(labels.length-1, 1);
|
||
const lines = series.map((s,si) => {
|
||
const pts = s.data.map((v,i) => `${pad.l+i*xs},${pad.t+ih-(v-min)/(max-min)*ih}`).join(' ');
|
||
return `<polyline fill="none" stroke="${colors[si%colors.length]}" stroke-width="2.5" points="${pts}" stroke-linejoin="round"/>` +
|
||
s.data.map((v,i) => `<circle cx="${pad.l+i*xs}" cy="${pad.t+ih-(v-min)/(max-min)*ih}" r="3" fill="${colors[si%colors.length]}" stroke="var(--bg)" stroke-width="1.5"/>`).join('');
|
||
}).join('');
|
||
const xax = labels.map((l,i) => `<text x="${pad.l+i*xs}" y="${h-10}" fill="var(--text-3)" font-size="10" font-family="JetBrains Mono" text-anchor="middle">${l}</text>`).join('');
|
||
const yt = [0,0.25,0.5,0.75,1].map(p => {
|
||
const y = pad.t+ih*(1-p);
|
||
return `<line x1="${pad.l}" y1="${y}" x2="${w-pad.r}" y2="${y}" stroke="var(--border)" stroke-dasharray="3,3" opacity="0.5"/>
|
||
<text x="${pad.l-6}" y="${y+3}" fill="var(--text-3)" font-size="9" font-family="JetBrains Mono" text-anchor="end">${fmt(min+p*(max-min))}</text>`;
|
||
}).join('');
|
||
const lg = series.map((s,i) => `<div class="it"><div class="sw" style="background:${colors[i%colors.length]}"></div><span class="lname">${s.label}</span></div>`).join('');
|
||
return `<svg viewBox="0 0 ${w} ${h}" style="width:100%;height:auto" preserveAspectRatio="xMidYMid meet">${yt}${lines}${xax}</svg>
|
||
<div class="lg" style="display:flex;gap:14px;flex-wrap:wrap;margin-top:8px">${lg}</div>`;
|
||
}
|
||
|
||
function barChart(items, getLbl, getVal, fillClass='', formatter=fmt) {
|
||
const max = Math.max(...items.map(getVal),1);
|
||
return items.map(it => `<div class="bar">
|
||
<div class="l" title="${getLbl(it)}">${getLbl(it)}</div>
|
||
<div class="t"><div class="f ${fillClass}" style="width:${(getVal(it)/max*100).toFixed(1)}%"></div></div>
|
||
<div class="v">${formatter(getVal(it))}</div></div>`).join('');
|
||
}
|
||
|
||
// ========== PAGES ==========
|
||
|
||
async function fetchEkosustav() {
|
||
try {
|
||
const e = await api('/api/dashboard/ekosustav');
|
||
const c = e.coverage || {};
|
||
const rows = [
|
||
['🆔 OIB', c.oib_pct, e.s_oib, e.klubova_total],
|
||
['👤 Predsjednik', c.predsjednik_pct, e.s_predsjednik, e.klubova_total],
|
||
['🎯 Ciljevi', c.ciljevi_pct, e.s_ciljevi, e.klubova_total],
|
||
['📋 Djelatnosti', c.opis_pct, e.s_opis, e.klubova_total],
|
||
['🏛️ Savez', c.savez_pct, e.s_savez, e.klubova_total],
|
||
['📍 Sjedište', c.sjediste_pct, e.s_sjediste, e.klubova_total],
|
||
['👥 Tajnik', c.tajnik_pct, e.s_tajnik, e.klubova_total],
|
||
['📧 Email', c.email_pct, e.s_email, e.klubova_total],
|
||
];
|
||
const barColor = (pct) => pct >= 80 ? 'var(--ok)' : (pct >= 50 ? 'var(--gold)' : 'var(--accent)');
|
||
const coverageHTML = rows.map(([label, pct, count, total]) => `
|
||
<div class="ekosustav-coverage-row" style="display:flex;align-items:center;gap:10px;font-size:12px;padding:6px 0;border-bottom:1px solid var(--border)">
|
||
<div style="width:120px;color:var(--text)">${label}</div>
|
||
<div style="flex:1;background:rgba(255,255,255,0.04);border-radius:3px;height:18px;position:relative;overflow:hidden">
|
||
<div style="position:absolute;top:0;left:0;height:100%;width:${pct}%;background:${barColor(pct)};opacity:0.4"></div>
|
||
<div style="position:absolute;top:0;left:0;height:100%;width:100%;display:flex;align-items:center;justify-content:space-between;padding:0 8px;font-size:11px">
|
||
<span class="mono" style="color:var(--text)">${pct}%</span>
|
||
<span class="dim" style="font-size:10px">${count}/${total}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).join('');
|
||
|
||
const sportTopHTML = (e.by_sport || []).slice(0, 8).map(s =>
|
||
`<div style="display:flex;justify-content:space-between;font-size:12px;padding:3px 0">
|
||
<span class="dim">${s.sport}</span><span class="mono">${s.broj}</span>
|
||
</div>`).join('');
|
||
|
||
const regionHTML = (e.by_region || []).map(r => {
|
||
const pctR = ((r.broj / e.klubova_total) * 100).toFixed(0);
|
||
return `<div style="display:flex;justify-content:space-between;font-size:12px;padding:3px 0">
|
||
<span>${r.region}</span><span class="mono dim">${r.broj} (${pctR}%)</span></div>`;
|
||
}).join('');
|
||
|
||
return `<div style="background:linear-gradient(135deg,rgba(59,130,196,0.08),rgba(245,158,11,0.05));border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:16px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
||
<h3 style="margin:0;font-size:14px;color:var(--text)">🌐 Sport Ekosustav PGŽ — FINA Registar Coverage</h3>
|
||
<span class="bdg gold" style="font-size:11px">${e.klubova_total} sport klubova</span>
|
||
</div>
|
||
<div class="ekosustav-grid" style="display:grid;grid-template-columns:1fr 280px;gap:20px">
|
||
<div>${coverageHTML}</div>
|
||
<div>
|
||
<div style="margin-bottom:10px">
|
||
<div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">Po regiji</div>
|
||
${regionHTML}
|
||
</div>
|
||
<div>
|
||
<div style="font-size:11px;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.5px;margin:10px 0 6px">Top sportovi</div>
|
||
${sportTopHTML}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
} catch(e) { return ''; }
|
||
}
|
||
|
||
async function pageDashboard() {
|
||
setTopbar('PGŽ Sportski savez', 'Operativni pregled');
|
||
const c = document.getElementById('content');
|
||
c.innerHTML = '<div class="loader">Učitavanje…</div>';
|
||
const ekoHTML = await fetchEkosustav();
|
||
try {
|
||
const godina = state.filters.godina || 2026;
|
||
const savez = state.filters.savez_id || '';
|
||
const region = state.filters.region || '';
|
||
const [d, savezi] = await Promise.all([
|
||
api(`/api/dashboard?godina=${godina}` + (savez?`&savez_id=${savez}`:'') + (region?`®ion=${region}`:'')),
|
||
api('/api/savezi')
|
||
]);
|
||
|
||
// Update alert badges
|
||
const totalA = (d.critical_alerts||0) + (d.warning_alerts||0);
|
||
['b-alertovi','b-alertovi-m'].forEach(id => {
|
||
const b = document.getElementById(id);
|
||
if (b) {
|
||
if (totalA>0) { b.textContent = totalA; b.style.display='inline-block'; b.className = d.critical_alerts>0?'b':'b warn'; }
|
||
else b.style.display='none';
|
||
}
|
||
});
|
||
|
||
const proracun = d.proracun_trend || [];
|
||
const procY = proracun.map(p => p.godina);
|
||
const procV = proracun.map(p => parseFloat(p.ukupno||0));
|
||
|
||
const ts = d.trend_savezi || [];
|
||
const trBy = {};
|
||
ts.forEach(r => { (trBy[r.naziv]=trBy[r.naziv]||[]).push({godina:r.godina, val:r.registriranih}); });
|
||
const allG = [...new Set(ts.map(r=>r.godina))].sort();
|
||
const trSer = Object.entries(trBy).slice(0,5).map(([n,da]) => ({label:n, data: allG.map(g=>(da.find(x=>x.godina===g)||{}).val||0)}));
|
||
|
||
const topSv = (d.top_savezi||[]).slice(0,8);
|
||
const noslist = d.nositelji||[];
|
||
const ls = d.lijec_status||{};
|
||
const lsV = [parseInt(ls.validni||0), parseInt(ls.uskoro_isteka||0), parseInt(ls.istekli||0), parseInt(ls.bez_termina||0)];
|
||
const zzjz = d.zzjz||{};
|
||
const kat = d.kategorije||[];
|
||
const katC = ['#4A9EFF','#D4A852','#A78BFA','#F472B6','#2DD4BF','#22D3EE'];
|
||
const totK = kat.reduce((s,k)=>s+parseInt(k.cnt||0),0);
|
||
const cG = d.clanarine_godine||[];
|
||
|
||
c.innerHTML = `
|
||
${ekoHTML || ''}
|
||
<div class="fbar">
|
||
<div class="fbar-t">⚙ Parametri</div>
|
||
<div class="fgrid">
|
||
<div class="fitem"><label>Godina</label>
|
||
<select onchange="state.filters.godina=this.value;render()">
|
||
${[2026,2025,2024,2023,2022,2021,2020].map(y=>`<option value="${y}" ${y==godina?'selected':''}>${y}.</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fitem"><label>Savez</label>
|
||
<select onchange="state.filters.savez_id=this.value;render()">
|
||
<option value="">— Svi savezi —</option>
|
||
${(savezi.rows||[]).map(s=>`<option value="${s.id}" ${s.id==savez?'selected':''}>${s.naziv}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fitem"><label>Regija</label>
|
||
<select onchange="state.filters.region=this.value;render()">
|
||
<option value="">— Sve —</option>
|
||
${['Rijeka','Zaleđe','Primorje','Gorski kotar','Otoci'].map(r=>`<option value="${r}" ${r==region?'selected':''}>${r}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fitem"><label> </label>
|
||
<button class="btn sec" onclick="state.filters={};render()">↻ Reset</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${d.critical_alerts>0?`<div class="ban crit"><b>⚠</b> ${d.critical_alerts} kritičnih alerta</div>`:''}
|
||
${d.warning_alerts>0?`<div class="ban warn"><b>⚠</b> ${d.warning_alerts} upozorenja</div>`:''}
|
||
|
||
<div class="sect">Ključni indikatori</div>
|
||
<div class="grid g4">
|
||
<div class="card acc"><div class="stat-l">Aktivni savezi</div><div class="stat-v">${d.aktivnih_saveza}</div><div class="stat-d">županijska razina</div></div>
|
||
<div class="card acc"><div class="stat-l">Aktivni klubovi</div><div class="stat-v">${d.aktivnih_klubova}</div><div class="stat-d">u 28 saveza</div></div>
|
||
<div class="card gold"><div class="stat-l">Nositelji kvalitete</div><div class="stat-v">${d.nositelja_kvalitete}</div><div class="stat-d">elitni klubovi</div></div>
|
||
<div class="card ok"><div class="stat-l">Proračun ${godina}</div><div class="stat-v sm">${fmtEur(d.proracun_aktualni)}</div><div class="stat-d up">↗ +38% YoY</div></div>
|
||
</div>
|
||
|
||
<div class="grid g4" style="margin-top:12px">
|
||
<div class="card"><div class="stat-l">Registr. sportaši</div><div class="stat-v">${fmt(d.registriranih_sportasa)}</div></div>
|
||
<div class="card"><div class="stat-l">Treneri</div><div class="stat-v">${fmt(d.trenera)}</div></div>
|
||
<div class="card"><div class="stat-l">Reprezentativci</div><div class="stat-v">${fmt(d.reprezentativaca)}</div></div>
|
||
<div class="card"><div class="stat-l">Aktivni članovi</div><div class="stat-v">${fmt(d.aktivnih_clanova)}</div></div>
|
||
</div>
|
||
|
||
<div class="sect">Financije ${godina}.</div>
|
||
<div class="grid g3">
|
||
<div class="card ok"><div class="stat-l">Naplaćeno</div><div class="stat-v sm">${fmtEur(d.naplaceno_clanarine_god)}</div></div>
|
||
<div class="card warn"><div class="stat-l">Dug članarine</div><div class="stat-v sm">${fmtEur(d.dug_clanarine_god)}</div></div>
|
||
<div class="card acc"><div class="stat-l">ZZJZ isplata</div><div class="stat-v sm">${fmtEur(d.zzjz_isplata_god)}</div></div>
|
||
</div>
|
||
|
||
<div class="sect">Liječnički pregledi</div>
|
||
<div class="grid g3">
|
||
<div class="card crit"><div class="stat-l">Istekli</div><div class="stat-v">${fmt(d.isteki_lijecnicki)}</div></div>
|
||
<div class="card warn"><div class="stat-l">Ističu uskoro</div><div class="stat-v">${fmt(d.lijecnicki_uskoro_istek)}</div></div>
|
||
<div class="card crit"><div class="stat-l">Critical alertovi</div><div class="stat-v">${fmt(d.critical_alerts)}</div></div>
|
||
</div>
|
||
|
||
<div class="sect">Vizualne analize</div>
|
||
|
||
<div class="grid g2">
|
||
<div class="card">
|
||
<div class="ct">📈 Proračun PGŽ za sport <span class="meta">2016—2026</span></div>
|
||
${lineChart([{label:'Ukupno', data:procV}], procY, 600, 220)}
|
||
</div>
|
||
<div class="card">
|
||
<div class="ct">📊 Top 5 saveza · trend reg. <span class="meta">${allG[0]||''}—${allG[allG.length-1]||''}</span></div>
|
||
${trSer.length?lineChart(trSer, allG, 600, 220):'<div class="empty">Bez podataka</div>'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid g2" style="margin-top:12px">
|
||
<div class="card">
|
||
<div class="ct">🏆 Top saveza · reg. ${godina>2024?2024:godina}.</div>
|
||
${barChart(topSv, s=>s.naziv, s=>s.registriranih||0)}
|
||
</div>
|
||
<div class="card">
|
||
<div class="ct">⭐ Nositelji kvalitete ${godina>2025?2025:godina}.</div>
|
||
${noslist.length?barChart(noslist, n=>n.naziv_kluba, n=>parseFloat(n.iznos||0), 'gold', fmtEur):'<div class="empty">Bez podataka</div>'}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid g3" style="margin-top:12px">
|
||
<div class="card">
|
||
<div class="ct">🏥 Status pregleda</div>
|
||
<div class="donut-w">
|
||
${donut(lsV, ['Validni','Uskoro','Istekli','Bez termina'], ['#2DD4BF','#F59E0B','#EF4444','#6B7A99'], lsV.reduce((a,b)=>a+b,0), 'pregleda')}
|
||
<div class="lg">
|
||
<div class="it"><div class="sw" style="background:#2DD4BF"></div><span class="lname">Validni</span><span class="lval">${lsV[0]}</span></div>
|
||
<div class="it"><div class="sw" style="background:#F59E0B"></div><span class="lname">Uskoro</span><span class="lval">${lsV[1]}</span></div>
|
||
<div class="it"><div class="sw" style="background:#EF4444"></div><span class="lname">Istekli</span><span class="lval">${lsV[2]}</span></div>
|
||
<div class="it"><div class="sw" style="background:#6B7A99"></div><span class="lname">Bez termina</span><span class="lval">${lsV[3]}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="ct">⚕ ZZJZ podjela</div>
|
||
<div class="donut-w">
|
||
${donut([parseFloat(zzjz.zzjz_udio||0), parseFloat(zzjz.klub_udio||0), parseFloat(zzjz.clan_udio||0)],
|
||
['ZZJZ','Klub','Član'], ['#2DD4BF','#4A9EFF','#D4A852'],
|
||
fmt(parseFloat(zzjz.total||0)), 'EUR')}
|
||
<div class="lg">
|
||
<div class="it"><div class="sw" style="background:#2DD4BF"></div><span class="lname">ZZJZ</span><span class="lval">${fmtEur(zzjz.zzjz_udio)}</span></div>
|
||
<div class="it"><div class="sw" style="background:#4A9EFF"></div><span class="lname">Klub</span><span class="lval">${fmtEur(zzjz.klub_udio)}</span></div>
|
||
<div class="it"><div class="sw" style="background:#D4A852"></div><span class="lname">Član</span><span class="lval">${fmtEur(zzjz.clan_udio)}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="ct">👥 Kategorije</div>
|
||
<div class="donut-w">
|
||
${donut(kat.map(k=>parseInt(k.cnt)), kat.map(k=>k.kategorija), katC, totK, 'članova')}
|
||
<div class="lg">${kat.map((k,i)=>`<div class="it"><div class="sw" style="background:${katC[i%katC.length]}"></div><span class="lname">${k.kategorija||'–'}</span><span class="lval">${k.cnt}</span></div>`).join('')}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
${cG.length?`<div class="card" style="margin-top:12px">
|
||
<div class="ct">💰 Članarine kroz godine · propisano vs naplaćeno vs dug</div>
|
||
${lineChart([
|
||
{label:'Propisano', data:cG.map(c=>parseFloat(c.propisano||0))},
|
||
{label:'Naplaćeno', data:cG.map(c=>parseFloat(c.placeno||0))},
|
||
{label:'Dug', data:cG.map(c=>parseFloat(c.dug||0))}
|
||
], cG.map(c=>c.godina), 1200, 220)}</div>`:''}
|
||
`;
|
||
} catch (e) { c.innerHTML = `<div class="ban crit"><b>Greška:</b> ${e.message}</div>`; }
|
||
}
|
||
|
||
async function pageAnalytics() {
|
||
setTopbar('Analytics', 'Detaljne analize');
|
||
const c = document.getElementById('content');
|
||
const metric = state.filters.metric || 'registriranih';
|
||
const godine = state.filters.godine || '2020,2021,2022,2023,2024';
|
||
try {
|
||
const [data, proracun] = await Promise.all([
|
||
api(`/api/analytics/savezi-trend?metric=${metric}&godine=${godine}`),
|
||
api('/api/analytics/proracun-detaljno')
|
||
]);
|
||
const allG = data.godine;
|
||
const series = Object.entries(data.data).map(([n,v]) => ({label:n, data:allG.map(g=>parseInt(v[g]||0))}));
|
||
series.sort((a,b)=>b.data.reduce((x,y)=>x+y,0) - a.data.reduce((x,y)=>x+y,0));
|
||
const top = series.slice(0,8);
|
||
const ML = {registriranih:'Registrirani', neregistriranih:'Neregistr.', rekreativaca:'Rekreativci', trenera:'Treneri', reprezentativaca:'Reprezent.', kategoriziranih:'Kategoriz.', stipendiranih:'Stipend.', klubova_clanica:'Klubovi-čl.'};
|
||
c.innerHTML = `
|
||
<div class="fbar">
|
||
<div class="fbar-t">⚙ Parametri analize</div>
|
||
<div class="fgrid">
|
||
<div class="fitem"><label>Metrika</label>
|
||
<select onchange="state.filters.metric=this.value;render()">
|
||
${Object.entries(ML).map(([k,v])=>`<option value="${k}" ${metric==k?'selected':''}>${v}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="fitem"><label>Godine (CSV)</label>
|
||
<input class="inp mono" value="${godine}" onchange="state.filters.godine=this.value;render()">
|
||
</div>
|
||
<div class="fitem"><label> </label>
|
||
<button class="btn sec" onclick="state.filters={};render()">↻ Reset</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="sect">Trend · ${ML[metric]}</div>
|
||
<div class="card" style="margin-bottom:14px">
|
||
<div class="ct">📊 Top 8 saveza · ${allG[0]} — ${allG[allG.length-1]}.</div>
|
||
${top.length?lineChart(top, allG, 1200, 280):'<div class="empty">Bez podataka</div>'}
|
||
</div>
|
||
<div class="sect">Tabelarni prikaz</div>
|
||
<div class="tbl-wrap">
|
||
<table>
|
||
<thead><tr><th>Savez</th>${allG.map(g=>`<th style="text-align:right">${g}.</th>`).join('')}<th style="text-align:right">Σ</th><th style="text-align:right">Trend</th></tr></thead>
|
||
<tbody>${Object.entries(data.data).sort((a,b)=>Object.values(b[1]).reduce((x,y)=>x+(y||0),0)-Object.values(a[1]).reduce((x,y)=>x+(y||0),0)).map(([n,v])=>{
|
||
const arr = allG.map(g=>v[g]||0);
|
||
const sum = arr.reduce((a,b)=>a+b,0);
|
||
const tr = arr[arr.length-1]-arr[0];
|
||
const trC = tr>0?'var(--ok)':tr<0?'var(--crit)':'var(--text-3)';
|
||
return `<tr><td><b>${n}</b></td>${arr.map(x=>`<td class="num">${fmt(x)}</td>`).join('')}<td class="num"><b>${fmt(sum)}</b></td><td class="num" style="color:${trC};font-weight:600">${tr>0?'↗ +':''}${tr}</td></tr>`;
|
||
}).join('')}</tbody>
|
||
</table>
|
||
</div>
|
||
<div class="sect">Proračun PGŽ · detaljno</div>
|
||
<div class="grid g3">
|
||
<div class="card ok"><div class="stat-l">${proracun.current_year}.</div><div class="stat-v sm">${fmtEur(proracun.current_total)}</div></div>
|
||
<div class="card acc"><div class="stat-l">Rast 10g</div><div class="stat-v">${proracun.rast_dekada_pct}%</div></div>
|
||
<div class="card"><div class="stat-l">Godina podataka</div><div class="stat-v">${proracun.proracun?.length||0}</div></div>
|
||
</div>
|
||
<div class="card" style="margin-top:12px">
|
||
<div class="ct">📈 Godišnji rast YoY</div>
|
||
${(proracun.rast_godisnji||[]).map(r=>{
|
||
const cl = r.rast_postotak>0?'ok':r.rast_postotak<0?'crit':'';
|
||
return `<div class="bar"><div class="l">${r.godina}.</div><div class="t"><div class="f ${cl}" style="width:${Math.min(Math.abs(r.rast_postotak)*1.5,100).toFixed(1)}%"></div></div><div class="v">${r.rast_postotak>0?'+':''}${r.rast_postotak}%</div></div>`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
} catch (e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
|
||
async function pageSavezi() {
|
||
setTopbar('Organizacija', 'Županijski savezi');
|
||
const c = document.getElementById('content');
|
||
const q = document.getElementById('q-savezi')?.value || '';
|
||
try {
|
||
const razina = state.filters.savez_razina !== undefined ? state.filters.savez_razina : 'zupanijski'; const d = await api('/api/savezi?'+(q?`q=${encodeURIComponent(q)}&`:'')+(razina?`razina=${encodeURIComponent(razina)}`:'')+getSort('savezi'));
|
||
c.innerHTML = `
|
||
<div class="toolbar">
|
||
<input class="inp flex" id="q-savezi" placeholder="🔍 Pretraga..." value="${q}" oninput="dbS()">
|
||
<select onchange="state.filters.savez_razina=this.value;render()">
|
||
<option value="zupanijski" ${(state.filters.savez_razina||'zupanijski')==='zupanijski'?'selected':''}>Županijski (PGŽ)</option>
|
||
<option value="gradski" ${state.filters.savez_razina==='gradski'?'selected':''}>Gradski</option>
|
||
<option value="opcinski" ${state.filters.savez_razina==='opcinski'?'selected':''}>Općinski</option>
|
||
<option value="strukovni" ${state.filters.savez_razina==='strukovni'?'selected':''}>Strukovni</option>
|
||
<option value="nacional" ${state.filters.savez_razina==='nacional'?'selected':''}>Nacionalni</option>
|
||
<option value="" ${state.filters.savez_razina===''?'selected':''}>Sve razine</option>
|
||
</select>
|
||
<span style="color:var(--text-3);font-size:11px">${d.count} saveza</span>
|
||
</div>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr>${tableHeader([{key:'naziv',label:'Naziv'},{key:'sport',label:'Sport'},{key:'godina',label:'Osn.'},{key:'klubova',label:'Klub.'},{key:'klubova',label:'Reg.',sort:false},{key:'klubova',label:'Tren.',sort:false},{key:'klubova',label:'Repr.',sort:false}], 'savezi')}</tr></thead>
|
||
<tbody>${d.rows.map(r=>`<tr onclick="showSavez(${r.id})">
|
||
<td><b>${r.naziv}</b></td>
|
||
<td class="dim">${r.sport||'–'}</td>
|
||
<td class="num">${r.godina_osnutka||'–'}</td>
|
||
<td class="num">${r.broj_klubova||0}</td>
|
||
<td class="num">${fmt(r.reg_2024)}</td>
|
||
<td class="num">${fmt(r.treneri_2024)}</td>
|
||
<td class="num">${fmt(r.repr_2024)}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
const dbS = debounce(pageSavezi, 300);
|
||
const dbStat = debounce(pageStatistika, 300);
|
||
async function showSavez(id) {
|
||
try {
|
||
const d = await api('/api/savezi/'+id);
|
||
openDrawer(`<div class="dr-h">
|
||
<div><div class="bc">SAVEZ · #${d.id}</div><h3>${d.naziv}</h3>
|
||
<div style="color:var(--text-3);font-size:12px;margin-top:3px">${d.sport||'–'} · osn. ${d.godina_osnutka||'–'}</div></div>
|
||
<button class="dr-x" onclick="closeDrawer()">✕</button></div>
|
||
<div class="dr-b"><dl>
|
||
<dt>Email</dt><dd>${d.email||'–'}</dd>
|
||
<dt>Web</dt><dd>${d.web?`<a href="${d.web}" target="_blank">${d.web}</a>`:'–'}</dd>
|
||
<dt>Klubova</dt><dd>${d.klubovi.length}</dd>
|
||
<dt>Manifestacija</dt><dd>${d.manifestacije.length}</dd>
|
||
</dl>
|
||
<h4>Statistika kroz godine</h4>
|
||
<table class="sub-tbl"><thead><tr><th>God</th><th style="text-align:right">Klub.</th><th style="text-align:right">Reg.</th><th style="text-align:right">Tren.</th><th style="text-align:right">Repr.</th></tr></thead>
|
||
<tbody>${d.statistika.map(s=>`<tr><td><b>${s.godina}</b></td><td class="num">${s.klubova_clanica}</td><td class="num">${s.registriranih}</td><td class="num">${s.trenera}</td><td class="num">${s.reprezentativaca}</td></tr>`).join('')}</tbody></table>
|
||
${d.klubovi.length?`<h4>Klubovi-članice (${d.klubovi.length})</h4>
|
||
<table class="sub-tbl"><tbody>${d.klubovi.slice(0,15).map(k=>`<tr><td>${k.naziv}</td><td><span class="bdg muted">${k.razina||'–'}</span></td><td class="dim" style="text-align:right">${k.grad||'–'}</td></tr>`).join('')}</tbody></table>`:''}
|
||
</div>`);
|
||
} catch(e) { alert(e.message); }
|
||
}
|
||
|
||
async function pageKlubovi() {
|
||
setTopbar('Organizacija', 'Klubovi');
|
||
const c = document.getElementById('content');
|
||
const q = document.getElementById('q-klub')?.value||'';
|
||
const fnos = state.filters.nositelj||'';
|
||
const freg = state.filters.region||'';
|
||
try {
|
||
const d = await api('/api/klubovi?'+(q?`q=${encodeURIComponent(q)}`:'')+(fnos?`&nositelj=${fnos}`:'')+(freg?`®ion=${freg}`:'')+getSort('klubovi'));
|
||
c.innerHTML = `
|
||
<div class="toolbar">
|
||
<input class="inp flex" id="q-klub" placeholder="🔍 Pretraga..." value="${q}" oninput="dbK()">
|
||
<select onchange="state.filters.nositelj=this.value;render()">
|
||
<option value="">Svi klubovi</option>
|
||
<option value="true" ${fnos==='true'?'selected':''}>⭐ Nositelji</option>
|
||
<option value="false" ${fnos==='false'?'selected':''}>Bez nositelja</option>
|
||
</select>
|
||
<select onchange="state.filters.region=this.value;render()">
|
||
<option value="">Sve regije</option>
|
||
${['Rijeka','Liburnija','Primorje','Gorski kotar','Otoci','Zaleđe'].map(r=>`<option value="${r}" ${freg===r?'selected':''}>${r}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr>${tableHeader([{key:'klub',label:'Klub'},{key:'sport',label:'Sport'},{key:'predsjednik',label:'Predsjednik',sort:false},{key:'oib',label:'OIB',sort:false},{key:'savez',label:'Savez'},{key:'broj_clanova',label:'Čl.'},{key:'enrich',label:'Status',sort:false}], 'klubovi')}</tr></thead>
|
||
<tbody>${d.rows.map(r=>{
|
||
const enrichDots = [
|
||
r.ima_oib ? '<span title="OIB" style="color:var(--ok)">●</span>' : '<span title="Nema OIB" style="color:var(--text-dim);opacity:0.3">●</span>',
|
||
r.ima_predsjednika ? '<span title="Predsjednik" style="color:var(--ok)">●</span>' : '<span title="Nema predsjednika" style="color:var(--text-dim);opacity:0.3">●</span>',
|
||
r.ima_ciljeve ? '<span title="Ciljevi" style="color:var(--accent)">●</span>' : '<span title="Bez ciljeva" style="color:var(--text-dim);opacity:0.3">●</span>',
|
||
r.ima_sjediste ? '<span title="Sjedište" style="color:var(--accent)">●</span>' : '<span title="Bez sjedišta" style="color:var(--text-dim);opacity:0.3">●</span>',
|
||
].join(' ');
|
||
return `<tr onclick="showKlub(${r.id})">
|
||
<td><b>${r.klub}</b>${r.razina?` <span class="bdg muted" style="font-size:9px">${r.razina}</span>`:''}</td>
|
||
<td class="dim">${r.sport||'–'}</td>
|
||
<td>${r.predsjednik||'<span class="dim">–</span>'}</td>
|
||
<td class="mono" style="font-size:11px">${r.oib||'<span class="dim">–</span>'}</td>
|
||
<td class="dim" style="font-size:11px">${r.savez||'–'}</td>
|
||
<td class="num">${r.broj_clanova||0}</td>
|
||
<td style="white-space:nowrap;font-size:14px">${enrichDots}${r.nositelj_kvalitete?' <span class="bdg gold" style="font-size:9px">⭐</span>':''}</td>
|
||
</tr>`;
|
||
}).join('')}</tbody>
|
||
</table></div>
|
||
<div style="font-size:10px;color:var(--text-dim);margin-top:8px;padding:0 8px">
|
||
Status: <span style="color:var(--ok)">●</span> OIB · <span style="color:var(--ok)">●</span> predsjednik · <span style="color:var(--accent)">●</span> ciljevi · <span style="color:var(--accent)">●</span> sjedište
|
||
</div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
const dbK = debounce(pageKlubovi, 300);
|
||
async function showKlub(id) {
|
||
try {
|
||
const d = await api('/api/klubovi/' + id);
|
||
const initial = (d.naziv||'?').replace(/[^A-Za-zŠŽČĆĐšžčćđ]/g,'').slice(0,2).toUpperCase() || '?';
|
||
const stats = d.stats || {};
|
||
|
||
const clanoviHTML = (d.clanovi||[]).map(c => {
|
||
const ini = ((c.ime||'?')[0]+(c.prezime||'?')[0]).toUpperCase();
|
||
const flags = [];
|
||
if (c.reprezentativac) flags.push('<span class="clan-flag" style="background:rgba(245,158,11,0.15);color:var(--gold)">🇭🇷 REPR.</span>');
|
||
if (c.kategoriziran) flags.push('<span class="clan-flag" style="background:rgba(59,130,196,0.15);color:var(--accent)">⭐ KAT.</span>');
|
||
return `<div class="clan-card">
|
||
<div class="clan-head">
|
||
<div class="clan-avatar">${ini}</div>
|
||
<div class="clan-name-x"><div class="nm">${c.prezime} ${c.ime}</div><div class="pos">${c.pozicija || c.kategorija || '–'}</div></div>
|
||
</div>
|
||
<div class="clan-flags">${flags.join('')}</div>
|
||
<div style="font-size:10px;color:var(--text-dim);margin-top:5px">${c.spol||'–'} · rođ. ${fmtDate(c.datum_rodenja)}</div>
|
||
<div class="mono" style="font-size:10px;color:var(--text-dim);margin-top:3px">OIB: ${c.oib||'–'}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
openDrawer(`
|
||
<div class="drawer-head">
|
||
<div style="display:flex;gap:12px;align-items:flex-start;flex:1">
|
||
<div class="klub-logo" style="width:48px;height:48px;font-size:16px">${initial}</div>
|
||
<div>
|
||
<div class="breadcrumb">KLUB · #${d.id}</div>
|
||
<h3>${d.naziv}</h3>
|
||
<div style="margin-top:6px;display:flex;gap:5px;flex-wrap:wrap">
|
||
${d.sport ? `<span class="badge muted">${d.sport}</span>` : ''}
|
||
${d.razina ? `<span class="badge info">${d.razina}</span>` : ''}
|
||
${d.nositelj_kvalitete ? '<span class="badge gold">⭐ Nositelj kvalitete</span>' : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<button class="drawer-close" onclick="closeDrawer()">✕</button>
|
||
</div>
|
||
<div class="drawer-body">
|
||
<div class="drawer-stats-grid">
|
||
<div class="drawer-stat accent"><div class="v">${stats.broj_clanova||0}</div><div class="l">Članova</div></div>
|
||
<div class="drawer-stat"><div class="v">${stats.broj_registriranih||0}</div><div class="l">Reg.</div></div>
|
||
<div class="drawer-stat"><div class="v">${stats.broj_trenera||0}</div><div class="l">Treneri</div></div>
|
||
<div class="drawer-stat ok"><div class="v">${stats.broj_reprezentativaca||0}</div><div class="l">Reprez.</div></div>
|
||
</div>
|
||
<div class="drawer-stats-grid">
|
||
<div class="drawer-stat ok"><div class="v">${stats.lijecnicki_validni||0}</div><div class="l">Lij. ✓</div></div>
|
||
<div class="drawer-stat warn"><div class="v">${stats.lijecnicki_uskoro||0}</div><div class="l">Lij. ~</div></div>
|
||
<div class="drawer-stat crit"><div class="v">${stats.lijecnicki_istekli||0}</div><div class="l">Lij. ✗</div></div>
|
||
<div class="drawer-stat ok"><div class="v" style="font-size:13px">${fmtEur(stats.potpore_2025).replace(/\s.*/,'')}</div><div class="l">Potpore</div></div>
|
||
</div>
|
||
|
||
<h4>Osnovni podaci</h4>
|
||
<dl>
|
||
<dt>OIB ${!state.isAdmin?'<span class="blur-tag">priv.</span>':''}</dt><dd class="mono">${d.oib || '–'}</dd>
|
||
<dt>Adresa</dt><dd>${d.adresa ? d.adresa + ', ' : ''}${d.grad || '–'}${d.region ? ` (${d.region})` : ''}</dd>
|
||
<dt>Predsjednik</dt><dd>${d.predsjednik || '–'}</dd>
|
||
<dt>Tajnik</dt><dd>${d.tajnik || '–'}</dd>
|
||
<dt>Glavni trener</dt><dd>${d.trener_glavni || '–'}</dd>
|
||
<dt>Email</dt><dd>${d.email || '–'}</dd>
|
||
<dt>Telefon</dt><dd>${d.telefon || '–'}</dd>
|
||
<dt>IBAN</dt><dd class="mono">${d.iban || '–'}</dd>
|
||
<dt>Web</dt><dd>${d.web ? `<a href="${d.web}" target="_blank">${d.web}</a>` : '–'}</dd>
|
||
<dt>Osnovan</dt><dd>${d.godina_osnutka || (d.datum_osnivanja_full ? d.datum_osnivanja_full.substring(0,10) : '–')}</dd>
|
||
${d.reg_broj ? `<dt>Reg. broj</dt><dd class="mono">${d.reg_broj}</dd>` : ''}
|
||
${d.udruga_status ? `<dt>Status u registru</dt><dd><span class="badge ${d.udruga_status==='AKTIVAN'?'ok':'warn'}">${d.udruga_status}</span></dd>` : ''}
|
||
</dl>
|
||
|
||
${d.sjediste ? `<h4>📍 Sjedište (FINA registar)</h4>
|
||
<div class="banner info" style="font-size:13px">${d.sjediste}</div>` : ''}
|
||
|
||
${(d.ciljevi || d.opis_djelatnosti) ? `<h4>🎯 Ciljevi i djelatnost</h4>
|
||
<div style="background:rgba(59,130,196,0.05);padding:12px;border-left:3px solid var(--accent);font-size:12px;line-height:1.5">
|
||
${d.ciljevi ? `<div style="color:var(--text)"><strong>Ciljevi:</strong> ${d.ciljevi}</div>` : ''}
|
||
${d.opis_djelatnosti ? `<div style="color:var(--text-dim);margin-top:8px"><strong>Djelatnosti:</strong> ${d.opis_djelatnosti}</div>` : ''}
|
||
</div>` : ''}
|
||
|
||
${d.web_stranica ? `<h4>🌐 Web</h4>
|
||
<div><a href="${d.web_stranica}" target="_blank" style="color:var(--accent)">${d.web_stranica}</a></div>` : ''}
|
||
|
||
${d.napomena ? `<div class="banner info" style="margin-top:12px;font-size:12px">${d.napomena}</div>` : ''}
|
||
|
||
${(d.potpore||[]).length ? `<h4>💰 Potpore PGŽ</h4>
|
||
<table class="subtable">
|
||
<thead><tr><th>Godina</th><th style="text-align:right">Iznos</th></tr></thead>
|
||
<tbody>${d.potpore.map(p => `<tr><td><strong>${p.godina}</strong></td><td class="num">${fmtEur(p.iznos)}</td></tr>`).join('')}</tbody>
|
||
</table>` : ''}
|
||
|
||
${(d.clanovi||[]).length ? `<h4>👥 Članovi (${d.clanovi.length})</h4>
|
||
<div class="clan-list">${clanoviHTML}</div>` : '<h4>👥 Članovi (0)</h4><div class="empty" style="padding:20px">Bez članova</div>'}
|
||
|
||
${(d.lijecnicki||[]).length ? `<h4>🏥 Liječnički pregledi (${d.lijecnicki.length})</h4>
|
||
<table class="subtable">
|
||
<thead><tr><th>Sportaš</th><th>Datum</th><th>Vrijedi do</th><th>Status</th></tr></thead>
|
||
<tbody>${d.lijecnicki.slice(0,15).map(l => `<tr>
|
||
<td>${l.clan}</td><td class="dim">${fmtDate(l.datum_pregleda)}</td>
|
||
<td>${fmtDate(l.vrijedi_do)}</td>
|
||
<td><span class="badge ${l.status_pregled==='Validan'?'ok':l.status_pregled==='Ističe uskoro'?'warn':'crit'}">${l.status_pregled}</span></td>
|
||
</tr>`).join('')}</tbody></table>` : ''}
|
||
|
||
${(d.clanarine||[]).length ? `<h4>💳 Članarine (${d.clanarine.length})</h4>
|
||
<table class="subtable">
|
||
<thead><tr><th>God.</th><th>Član</th><th style="text-align:right">Propis.</th><th style="text-align:right">Plać.</th><th>Status</th></tr></thead>
|
||
<tbody>${d.clanarine.slice(0,20).map(cl => `<tr>
|
||
<td>${cl.godina}</td><td>${cl.clan}</td>
|
||
<td class="num">${fmtEur(cl.iznos_propisan)}</td><td class="num" style="color:var(--ok)">${fmtEur(cl.iznos_placen)}</td>
|
||
<td><span class="badge ${cl.status==='podmireno'?'ok':cl.status==='djelomicno'?'warn':'crit'}">${cl.status}</span></td>
|
||
</tr>`).join('')}</tbody></table>` : ''}
|
||
</div>
|
||
`);
|
||
} catch (e) { alert('Greška: '+e.message); }
|
||
}
|
||
|
||
|
||
async function pageClanovi() {
|
||
setTopbar('Organizacija', 'Članovi');
|
||
const c = document.getElementById('content');
|
||
const q = document.getElementById('q-clan')?.value||'';
|
||
const fkat = state.filters.kategorija||'';
|
||
const fspol = state.filters.spol||'';
|
||
const fr = state.filters.repr||'';
|
||
try {
|
||
const d = await api('/api/clanovi?'+(q?`q=${encodeURIComponent(q)}`:'')+(fkat?`&kategorija=${fkat}`:'')+(fspol?`&spol=${fspol}`:'')+(fr?`&reprezentativac=${fr}`:'')+getSort('clanovi'));
|
||
c.innerHTML = `
|
||
<div class="toolbar">
|
||
<input class="inp flex" id="q-clan" placeholder="🔍 Ime, OIB..." value="${q}" oninput="dbC()">
|
||
<select onchange="state.filters.kategorija=this.value;render()">
|
||
<option value="">Sve</option>
|
||
${['registrirani','neregistrirani','rekreativac','trener'].map(k=>`<option value="${k}" ${fkat===k?'selected':''}>${k}</option>`).join('')}
|
||
</select>
|
||
<select onchange="state.filters.spol=this.value;render()">
|
||
<option value="">Svi</option>
|
||
<option value="M" ${fspol==='M'?'selected':''}>M</option>
|
||
<option value="Ž" ${fspol==='Ž'?'selected':''}>Ž</option>
|
||
</select>
|
||
</div>
|
||
${!state.isAdmin?`<div class="ban warn"><b>🔒</b> Privatni podaci zamagljeni · klikni VIEWER za admin pristup</div>`:''}
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr>${tableHeader([{key:'prezime',label:'Prezime'},{key:'ime',label:'Ime'},{key:'oib',label:'OIB'},{key:'klub',label:'Klub'},{key:'kategorija',label:'Kat.'},{key:'datum_rodenja',label:'Rod.'},{key:'oib',label:'Liječ. do',sort:false},{key:'oib',label:'Dug',sort:false}], 'clanovi')}</tr></thead>
|
||
<tbody>${d.rows.length===0?'<tr><td colspan="8" class="empty">Nema članova</td></tr>':
|
||
d.rows.map(r=>`<tr>
|
||
<td><b>${r.prezime}</b></td>
|
||
<td>${r.ime}</td>
|
||
<td class="mono dim">${r.oib||'–'}</td>
|
||
<td>${r.klub_naziv||'–'}</td>
|
||
<td>${r.kategorija?`<span class="bdg info">${r.kategorija}</span>`:'–'}</td>
|
||
<td class="dim">${fmtDate(r.datum_rodenja)}</td>
|
||
<td>${r.lijecnicki_vrijedi_do?(new Date(r.lijecnicki_vrijedi_do)<new Date()?`<span class="bdg crit">${fmtDate(r.lijecnicki_vrijedi_do)}</span>`:`<span class="bdg ok">${fmtDate(r.lijecnicki_vrijedi_do)}</span>`):'–'}</td>
|
||
<td class="num mono">${r.dug_clanarine?`<span style="color:var(--crit)">${fmtEur(r.dug_clanarine)}</span>`:'–'}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
const dbC = debounce(pageClanovi, 300);
|
||
|
||
async function pageClanarine() {
|
||
setTopbar('Financije', 'Članarine');
|
||
const c = document.getElementById('content');
|
||
const fg = state.filters.godina||''; const fs = state.filters.status||'';
|
||
try {
|
||
const d = await api('/api/clanarine?'+(fg?`godina=${fg}`:'')+(fs?`&status=${fs}`:'')+getSort('clanarine'));
|
||
c.innerHTML = `
|
||
<div class="grid g4">
|
||
<div class="card"><div class="stat-l">Ukupno</div><div class="stat-v">${d.count}</div></div>
|
||
<div class="card acc"><div class="stat-l">Propisano</div><div class="stat-v sm">${fmtEur(d.summary.total_propisan)}</div></div>
|
||
<div class="card ok"><div class="stat-l">Plaćeno</div><div class="stat-v sm">${fmtEur(d.summary.total_placen)}</div></div>
|
||
<div class="card crit"><div class="stat-l">Dug</div><div class="stat-v sm">${fmtEur(d.summary.total_dug)}</div></div>
|
||
</div>
|
||
<div class="toolbar" style="margin-top:14px">
|
||
<select onchange="state.filters.godina=this.value;render()">
|
||
<option value="">Sve godine</option>
|
||
${[2026,2025,2024,2023,2022].map(y=>`<option value="${y}" ${fg==y?'selected':''}>${y}</option>`).join('')}
|
||
</select>
|
||
<select onchange="state.filters.status=this.value;render()">
|
||
<option value="">Svi statusi</option>
|
||
<option value="podmireno" ${fs==='podmireno'?'selected':''}>✓ Podmireno</option>
|
||
<option value="djelomicno" ${fs==='djelomicno'?'selected':''}>~ Djelomično</option>
|
||
<option value="nepodmireno" ${fs==='nepodmireno'?'selected':''}>✗ Nepodmireno</option>
|
||
</select>
|
||
</div>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr>${tableHeader([{key:'godina',label:'God.'},{key:'klub',label:'Klub'},{key:'iznos',label:'Propisano'},{key:'iznos',label:'Plaćeno',sort:false},{key:'iznos',label:'Dug',sort:false},{key:'datum_uplate',label:'Uplata'},{key:'status',label:'Status'}], 'clanarine')}</tr></thead>
|
||
<tbody>${d.rows.length===0?'<tr><td colspan="7" class="empty">Bez zapisa</td></tr>':
|
||
d.rows.map(r=>`<tr>
|
||
<td><b>${r.godina}</b></td>
|
||
<td>${r.klub||'–'}</td>
|
||
<td class="num mono">${fmtEur(r.iznos_propisan)}</td>
|
||
<td class="num mono" style="color:var(--ok)">${fmtEur(r.iznos_placen)}</td>
|
||
<td class="num mono" style="color:${r.dug>0?'var(--crit)':'var(--text-3)'}">${fmtEur(r.dug)}</td>
|
||
<td class="dim">${fmtDate(r.datum_uplate)}</td>
|
||
<td><span class="bdg ${r.status==='podmireno'?'ok':r.status==='djelomicno'?'warn':'crit'}">${r.status}</span></td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
|
||
async function pageLijecnicki() {
|
||
setTopbar('Zdravlje', 'Liječnički pregledi');
|
||
const c = document.getElementById('content');
|
||
const fs = state.filters.status||'';
|
||
try {
|
||
const d = await api('/api/lijecnicki?'+(fs?`status=${encodeURIComponent(fs)}`:'')+getSort('lijecnicki'));
|
||
c.innerHTML = `
|
||
<div class="grid g4">
|
||
<div class="card"><div class="stat-l">Ukupno</div><div class="stat-v">${d.count}</div></div>
|
||
<div class="card crit"><div class="stat-l">Istekli</div><div class="stat-v">${d.summary?.istekli||0}</div></div>
|
||
<div class="card warn"><div class="stat-l">Uskoro</div><div class="stat-v">${d.summary?.uskoro||0}</div></div>
|
||
<div class="card acc"><div class="stat-l">ZZJZ udio</div><div class="stat-v sm">${fmtEur(d.summary?.total_zzjz)}</div></div>
|
||
</div>
|
||
<div class="toolbar" style="margin-top:14px">
|
||
<select onchange="state.filters.status=this.value;render()">
|
||
<option value="">Svi</option>
|
||
<option value="Validan" ${fs==='Validan'?'selected':''}>✓ Validni</option>
|
||
<option value="Ističe uskoro" ${fs==='Ističe uskoro'?'selected':''}>~ Uskoro</option>
|
||
<option value="Istekao" ${fs==='Istekao'?'selected':''}>✗ Istekli</option>
|
||
</select>
|
||
</div>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr>${tableHeader([{key:'clan',label:'Sportaš'},{key:'klub',label:'Klub'},{key:'datum_pregleda',label:'Datum'},{key:'vrijedi_do',label:'Vrijedi do'},{key:'iznos',label:'Iznos'},{key:'iznos',label:'ZZJZ',sort:false},{key:'iznos',label:'Status',sort:false}], 'lijecnicki')}</tr></thead>
|
||
<tbody>${d.rows.length===0?'<tr><td colspan="7" class="empty">Bez zapisa</td></tr>':
|
||
d.rows.map(r=>`<tr>
|
||
<td><b>${r.clan}</b></td>
|
||
<td>${r.klub||'–'}</td>
|
||
<td class="dim">${fmtDate(r.datum_pregleda)}</td>
|
||
<td>${fmtDate(r.vrijedi_do)}</td>
|
||
<td class="num mono">${fmtEur(r.iznos)}</td>
|
||
<td class="num mono" style="color:var(--ok)">${fmtEur(r.iznos_zzjz)}</td>
|
||
<td><span class="bdg ${r.status_pregled==='Validan'?'ok':r.status_pregled==='Ističe uskoro'?'warn':'crit'}">${r.status_pregled}</span></td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
|
||
async function pagePotpore() {
|
||
setTopbar('Financije', 'Potpore nositeljima kvalitete');
|
||
const c = document.getElementById('content');
|
||
const fg = state.filters.godina||'';
|
||
try {
|
||
const d = await api('/api/potpore?'+(fg?`godina=${fg}`:'')+getSort('potpore'));
|
||
const total = d.rows.reduce((s,r)=>s+parseFloat(r.iznos||0),0);
|
||
c.innerHTML = `
|
||
<div class="grid g3">
|
||
<div class="card ok"><div class="stat-l">Ukupno</div><div class="stat-v sm">${fmtEur(total)}</div></div>
|
||
<div class="card"><div class="stat-l">Klubova</div><div class="stat-v">${new Set(d.rows.map(r=>r.naziv_kluba)).size}</div></div>
|
||
<div class="card"><div class="stat-l">Zapisa</div><div class="stat-v">${d.count}</div></div>
|
||
</div>
|
||
<div class="toolbar" style="margin-top:14px">
|
||
<select onchange="state.filters.godina=this.value;render()">
|
||
<option value="">Sve godine</option>
|
||
${[2025,2024,2023,2022,2021].map(y=>`<option value="${y}" ${fg==y?'selected':''}>${y}</option>`).join('')}
|
||
</select>
|
||
</div>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr>${tableHeader([{key:'klub',label:'Klub'},{key:'godina',label:'God.'},{key:'iznos',label:'Iznos'}], 'potpore')}</tr></thead>
|
||
<tbody>${d.rows.map(r=>`<tr>
|
||
<td><b>${r.naziv_kluba}</b></td>
|
||
<td>${r.godina}</td>
|
||
<td class="num mono">${fmtEur(r.iznos)}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
|
||
async function pageProracun() {
|
||
setTopbar('Financije', 'Proračun PGŽ za sport');
|
||
const c = document.getElementById('content');
|
||
try {
|
||
const d = await api('/api/proracun');
|
||
const max = Math.max(...d.rows.map(r=>parseFloat(r.ukupno||0)),1);
|
||
c.innerHTML = `
|
||
<div class="card">
|
||
<div class="ct">📈 Trend 2016—2026 <span class="meta">${d.count} godina</span></div>
|
||
${d.rows.map(r=>`<div class="bar">
|
||
<div class="l"><b>${r.godina}.</b></div>
|
||
<div class="t"><div class="f ok" style="width:${(parseFloat(r.ukupno)/max*100).toFixed(1)}%"></div></div>
|
||
<div class="v">${fmtEur(r.ukupno)}</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
<div class="sect">Detaljna tablica</div>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr><th>God.</th><th style="text-align:right">PGŽ</th><th style="text-align:right">Reb.1</th><th style="text-align:right">Reb.2</th><th style="text-align:right">PGŽ uk.</th><th style="text-align:right">Min.</th><th style="text-align:right">UKUPNO</th></tr></thead>
|
||
<tbody>${d.rows.map(r=>`<tr>
|
||
<td><b>${r.godina}</b></td>
|
||
<td class="num mono">${fmtEur(r.proracun_pgz)}</td>
|
||
<td class="num mono">${fmtEur(r.rebalans1)}</td>
|
||
<td class="num mono">${fmtEur(r.rebalans2)}</td>
|
||
<td class="num mono">${fmtEur(r.ukupno_pgz)}</td>
|
||
<td class="num mono">${fmtEur(r.ministarstvo)}</td>
|
||
<td class="num mono" style="color:var(--ok);font-weight:700">${fmtEur(r.ukupno)}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
|
||
async function pageStatistika() {
|
||
setTopbar('Operativa', 'Statistika saveza');
|
||
const c = document.getElementById('content');
|
||
const fg = state.filters.godina || '2026';
|
||
const fr = state.filters.stat_razina !== undefined ? state.filters.stat_razina : 'zupanijski';
|
||
const fq = state.filters.stat_q || '';
|
||
try {
|
||
const d = await api('/api/statistika?godina='+fg+(fr?`&razina=${encodeURIComponent(fr)}`:'')+(fq?`&q=${encodeURIComponent(fq)}`:'')+getSort('statistika'));
|
||
c.innerHTML = `
|
||
<div class="toolbar">
|
||
<input class="inp flex" id="q-stat" placeholder="Pretraga saveza..." value="${fq}" oninput="dbStat()">
|
||
<select onchange="state.filters.godina=this.value;render()" title="Godina">
|
||
${[2026,2024,2023,2022,2021,2020].map(y=>`<option value="${y}" ${fg==y?'selected':''}>${y}.</option>`).join('')}
|
||
</select>
|
||
<select onchange="state.filters.stat_razina=this.value;render()" title="Razina saveza">
|
||
<option value="zupanijski" ${fr==='zupanijski'?'selected':''}>Županijski (PGŽ)</option>
|
||
<option value="gradski" ${fr==='gradski'?'selected':''}>Gradski</option>
|
||
<option value="opcinski" ${fr==='opcinski'?'selected':''}>Općinski</option>
|
||
<option value="strukovni" ${fr==='strukovni'?'selected':''}>Strukovni</option>
|
||
<option value="nacional" ${fr==='nacional'?'selected':''}>Nacionalni</option>
|
||
<option value="" ${fr===''?'selected':''}>Sve</option>
|
||
</select>
|
||
<span style="color:var(--text-3);font-size:11px;margin-left:auto">${d.count} saveza</span>
|
||
</div>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr>${tableHeader([{key:'savez',label:'Savez'},{key:'klubova',label:'Klub.'},{key:'registriranih',label:'Reg.'},{key:'klubova',label:'Nereg.',sort:false},{key:'klubova',label:'Rekr.',sort:false},{key:'trenera',label:'Tren.'},{key:'reprezentativaca',label:'Repr.'}], 'statistika')}</tr></thead>
|
||
<tbody>${d.rows.map(r=>`<tr>
|
||
<td><b>${r.savez}</b></td>
|
||
<td class="num">${r.klubova_clanica}</td>
|
||
<td class="num">${r.registriranih}</td>
|
||
<td class="num dim">${r.neregistriranih}</td>
|
||
<td class="num dim">${r.rekreativaca}</td>
|
||
<td class="num">${r.trenera}</td>
|
||
<td class="num">${r.reprezentativaca}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
|
||
async function pageManifestacije() {
|
||
setTopbar('Operativa', 'Manifestacije');
|
||
const c = document.getElementById('content');
|
||
const fr = state.filters.razina||'';
|
||
try {
|
||
const d = await api('/api/manifestacije?'+(fr?`razina=${encodeURIComponent(fr)}`:'')+getSort('manifestacije'));
|
||
c.innerHTML = `
|
||
<div class="toolbar">
|
||
<select onchange="state.filters.razina=this.value;render()">
|
||
<option value="">Sve razine</option>
|
||
${['Klupska','Regionalna','Državna','Međunarodna'].map(r=>`<option value="${r}" ${fr===r?'selected':''}>${r}</option>`).join('')}
|
||
</select>
|
||
<span style="color:var(--text-3);font-size:11px;margin-left:auto">${d.count} manifestacija</span>
|
||
</div>
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr>${tableHeader([{key:'naziv',label:'Naziv'},{key:'mjesto',label:'Mjesto'},{key:'savez',label:'Savez',sort:false},{key:'razina',label:'Razina'},{key:'godina_od',label:'Od g.'},{key:'mjesto',label:'Učesnici',sort:false}], 'manifestacije')}</tr></thead>
|
||
<tbody>${d.rows.map(r=>`<tr>
|
||
<td><b>${r.naziv}</b></td>
|
||
<td>${r.mjesto||'–'}</td>
|
||
<td class="dim">${r.savez_naziv||'–'}</td>
|
||
<td><span class="bdg ${r.razina==='Međunarodna'?'gold':r.razina==='Državna'?'info':r.razina==='Regionalna'?'warn':'muted'}">${r.razina||'–'}</span></td>
|
||
<td class="num">${r.godina_od||'–'}</td>
|
||
<td class="dim">${r.broj_ucesnika||'–'}</td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
|
||
async function pageAlertovi() {
|
||
setTopbar('Pregled', 'Alertovi');
|
||
const c = document.getElementById('content');
|
||
try {
|
||
const d = await api('/api/alertovi?rijeseno=false');
|
||
c.innerHTML = `
|
||
<div class="toolbar">
|
||
<button class="btn warn" onclick="scanA()">↻ Skeniraj</button>
|
||
<span style="color:var(--text-3);font-size:11px;margin-left:auto">${d.count} aktivnih</span>
|
||
</div>
|
||
${d.count===0?'<div class="empty"><div class="empty-i">✓</div>Nema aktivnih alerta</div>':`
|
||
<div class="tbl-wrap"><table>
|
||
<thead><tr><th>Razina</th><th>Tip</th><th>Poruka</th><th>Datum</th><th style="text-align:right">Iznos</th><th>Akcija</th></tr></thead>
|
||
<tbody>${d.rows.map(r=>`<tr>
|
||
<td><span class="bdg ${r.razina==='CRITICAL'?'crit':r.razina==='WARNING'?'warn':'info'}">${r.razina}</span></td>
|
||
<td class="dim">${r.tip}</td>
|
||
<td>${r.poruka}</td>
|
||
<td class="dim">${fmtDate(r.datum)}</td>
|
||
<td class="num mono">${r.iznos?fmtEur(r.iznos):'–'}</td>
|
||
<td><button class="btn sec sm" onclick="event.stopPropagation();rijesi(${r.id})">Riješi</button></td>
|
||
</tr>`).join('')}</tbody>
|
||
</table></div>`}
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
async function scanA() { await fetch(API+'/api/alertovi/scan',{method:'POST',headers:{'Authorization':'Bearer '+state.token}}); pageAlertovi(); }
|
||
async function rijesi(id) { await fetch(API+'/api/alertovi/'+id+'/rijesi',{method:'PUT',headers:{'Authorization':'Bearer '+state.token}}); pageAlertovi(); }
|
||
|
||
async function pageZzjz() {
|
||
setTopbar('Zdravlje', 'ZZJZ PGŽ — Sufinanciranje');
|
||
const c = document.getElementById('content');
|
||
try {
|
||
const d = await api('/api/zzjz/dogovor');
|
||
const stv = d.stvarno_stanje||{};
|
||
c.innerHTML = `
|
||
<div class="ban info"><div><b>${d.info}</b><br><span style="opacity:0.85">${d.model}</span></div></div>
|
||
<div class="grid g3">
|
||
<div class="card"><div class="stat-l">Sportaša potencijalnih</div><div class="stat-v">${fmt(d.godisnji_potencijal?.sportasa_potencijalno)}</div></div>
|
||
<div class="card acc"><div class="stat-l">Procijenjeni godišnji</div><div class="stat-v sm">${fmtEur(d.godisnji_potencijal?.godisnji_trosak_eur)}</div></div>
|
||
<div class="card ok"><div class="stat-l">Pregleda u sustavu</div><div class="stat-v">${stv.pregleda||0}</div></div>
|
||
</div>
|
||
<div class="grid g2" style="margin-top:14px">
|
||
<div class="card">
|
||
<div class="ct">⚕ Stvarna podjela troškova</div>
|
||
<div class="donut-w">
|
||
${donut([parseFloat(stv.zzjz_isplata||0), parseFloat(stv.klub_isplata||0), parseFloat(stv.clan_isplata||0)],
|
||
['ZZJZ','Klub','Član'], ['#2DD4BF','#4A9EFF','#D4A852'],
|
||
fmt(parseFloat(stv.ukupan_trosak||0)), 'EUR')}
|
||
<div class="lg">
|
||
<div class="it"><div class="sw" style="background:#2DD4BF"></div><span class="lname">ZZJZ</span><span class="lval">${fmtEur(stv.zzjz_isplata)}</span></div>
|
||
<div class="it"><div class="sw" style="background:#4A9EFF"></div><span class="lname">Klub</span><span class="lval">${fmtEur(stv.klub_isplata)}</span></div>
|
||
<div class="it"><div class="sw" style="background:#D4A852"></div><span class="lname">Član</span><span class="lval">${fmtEur(stv.clan_isplata)}</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="ct">📋 Predviđeni tijek</div>
|
||
<ol style="padding-left:18px;line-height:1.9;font-size:12.5px;color:var(--text-2)">
|
||
<li><b>Klub registrira</b> sportaša u sustav</li>
|
||
<li><b>Sportaš odlazi</b> na liječnički u ZZJZ PGŽ</li>
|
||
<li><b>Liječnik unosi</b> nalaz, datum, vrijedi do</li>
|
||
<li><b>Sustav izračunava</b> udio: ZZJZ ⟷ klub ⟷ član</li>
|
||
<li><b>ZZJZ izdaje</b> račun klubu/PGŽ-u</li>
|
||
<li><b>Auto-alert</b> kada pregled ističe (60d/30d/0d)</li>
|
||
</ol>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} catch(e) { c.innerHTML = `<div class="ban crit">${e.message}</div>`; }
|
||
}
|
||
|
||
const routes = { search:pageSearch, dashboard:pageDashboard, analytics:pageAnalytics, alertovi:pageAlertovi, savezi:pageSavezi, klubovi:pageKlubovi, clanovi:pageClanovi, clanarine:pageClanarine, potpore:pagePotpore, proracun:pageProracun, lijecnicki:pageLijecnicki, zzjz:pageZzjz, manifestacije:pageManifestacije, statistika:pageStatistika , ask:pageAsk, invoices:pageInvoices, expenses:pageExpenses, forms:pageForms, users:pageUsers, pravnik:pagePravnik, natjecanja:pageNatjecanja, admin:pageAdmin };
|
||
|
||
|
||
// ===== V6.2 VOICE INPUT (hr-HR) =====
|
||
window._v6Recognition = null;
|
||
window._v6CurrentInput = null;
|
||
function v6VoiceInit() {
|
||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||
if (!SR) return null;
|
||
const r = new SR();
|
||
r.lang = 'hr-HR';
|
||
r.continuous = false;
|
||
r.interimResults = true;
|
||
r.maxAlternatives = 1;
|
||
return r;
|
||
}
|
||
function v6VoiceStart(inputId, btnEl) {
|
||
const inp = document.getElementById(inputId);
|
||
if (!inp) return;
|
||
if (!window._v6Recognition) window._v6Recognition = v6VoiceInit();
|
||
const rec = window._v6Recognition;
|
||
if (!rec) {
|
||
alert('Voice input nije podržan u ovom pregledniku. Koristi Chrome ili Edge.');
|
||
return;
|
||
}
|
||
// If already recording, stop
|
||
if (btnEl && btnEl.classList.contains('recording')) {
|
||
try { rec.stop(); } catch(e){}
|
||
btnEl.classList.remove('recording');
|
||
btnEl.innerHTML = '🎤';
|
||
return;
|
||
}
|
||
if (btnEl) {
|
||
btnEl.classList.add('recording');
|
||
btnEl.innerHTML = '■';
|
||
}
|
||
let finalTranscript = '';
|
||
rec.onresult = function(ev) {
|
||
let interim = '';
|
||
for (let i = ev.resultIndex; i < ev.results.length; i++) {
|
||
if (ev.results[i].isFinal) finalTranscript += ev.results[i][0].transcript;
|
||
else interim += ev.results[i][0].transcript;
|
||
}
|
||
inp.value = finalTranscript + interim;
|
||
};
|
||
rec.onerror = function(ev) {
|
||
console.warn('voice err', ev.error);
|
||
if (btnEl) { btnEl.classList.remove('recording'); btnEl.innerHTML = '🎤'; }
|
||
};
|
||
rec.onend = function() {
|
||
if (btnEl) { btnEl.classList.remove('recording'); btnEl.innerHTML = '🎤'; }
|
||
// If we got final transcript and inp is part of search/ask form, auto-submit
|
||
if (finalTranscript) {
|
||
inp.value = finalTranscript.trim();
|
||
// Auto-submit based on input id
|
||
if (inputId === 'askQ' && typeof askGo === 'function') askGo();
|
||
else if (inputId === 'lawQ' && typeof lawGo === 'function') lawGo();
|
||
else if (inputId === 'aiSearchInline' && finalTranscript.trim()) {
|
||
state.searchQ = finalTranscript.trim();
|
||
render();
|
||
}
|
||
else if (inputId === 'searchInput' && finalTranscript.trim()) {
|
||
state.searchQ = finalTranscript.trim();
|
||
render();
|
||
}
|
||
}
|
||
};
|
||
try { rec.start(); }
|
||
catch(e) {
|
||
console.warn('voice start err', e);
|
||
if (btnEl) { btnEl.classList.remove('recording'); btnEl.innerHTML = '🎤'; }
|
||
}
|
||
}
|
||
|
||
// ===== V6.2 CHATBOT for AI Asistent =====
|
||
window.chatHistory = []; // array of {role:'user'|'bot', content, sources?, llm?, hits?}
|
||
|
||
function chatRender() {
|
||
const t = document.getElementById('chatThread');
|
||
if (!t) return;
|
||
t.innerHTML = chatHistory.map(function(m, i){
|
||
if (m.role === 'user') {
|
||
return '<div class="v6-chat-msg user">' + (m.content||'').replace(/</g,'<') + '</div>';
|
||
} else if (m.role === 'typing') {
|
||
return '<div class="v6-chat-typing"><span></span><span></span><span></span></div>';
|
||
} else {
|
||
let body = (m.content||'').replace(/</g,'<');
|
||
let metaHtml = '';
|
||
if (m.llm) metaHtml = '<div class="v6-msg-meta">🤖 ' + m.llm + (m.hits ? ' · '+m.hits+' izvora' : '') + '</div>';
|
||
let srcHtml = '';
|
||
if (m.sources && m.sources.length) {
|
||
srcHtml = '<div style="margin-top:8px">' + m.sources.map(function(s){
|
||
const url = (s.payload && (s.payload.source_url || s.payload.url)) || s.url || '';
|
||
const title = s.title || (s.payload && s.payload.title) || '?';
|
||
if (url) {
|
||
return '<a class="v6-src-link" href="' + url.replace(/"/g,'"') + '" target="_blank">📄 ' + (title.length>40?title.slice(0,40)+'…':title) + '</a>';
|
||
}
|
||
return '<span class="v6-src-link" style="background:#2a3a52">📌 '+title+'</span>';
|
||
}).join('') + '</div>';
|
||
}
|
||
return '<div class="v6-chat-msg bot">' + metaHtml + body + srcHtml + '</div>';
|
||
}
|
||
}).join('');
|
||
t.scrollTop = t.scrollHeight;
|
||
}
|
||
|
||
async function chatSend(mode) {
|
||
// mode: 'rag' (askGo) or 'lawyer' (lawGo)
|
||
const inp = document.getElementById(mode === 'lawyer' ? 'lawQ' : 'askQ');
|
||
const q = inp.value.trim();
|
||
if (!q) return;
|
||
chatHistory.push({role:'user', content:q});
|
||
chatHistory.push({role:'typing'});
|
||
chatRender();
|
||
inp.value = '';
|
||
|
||
try {
|
||
let endpoint, payload;
|
||
if (mode === 'lawyer') {
|
||
endpoint = '/sport/api/v2/sport/lawyer';
|
||
// include conversation context (last 4 turns)
|
||
const ctx = chatHistory.slice(-8).filter(function(m){ return m.role !== 'typing'; })
|
||
.map(function(m){ return (m.role==='user'?'Q: ':'A: ') + (m.content||'').slice(0,300); }).join('\n');
|
||
payload = {query: q, context: ctx};
|
||
} else {
|
||
endpoint = '/sport/api/v2/sport/ask';
|
||
payload = {query: q, limit: 8};
|
||
}
|
||
const r = await fetch(endpoint, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
|
||
const d = await r.json();
|
||
|
||
// Remove typing indicator
|
||
chatHistory = chatHistory.filter(function(m){ return m.role !== 'typing'; });
|
||
|
||
if (mode === 'lawyer') {
|
||
chatHistory.push({
|
||
role: 'bot',
|
||
content: d.answer || d.detail || 'Nema odgovora.',
|
||
sources: d.sources,
|
||
llm: d.llm,
|
||
hits: d.hits_count
|
||
});
|
||
} else {
|
||
// RAG mode — synthesize a list-style answer
|
||
if (!d.results || !d.results.length) {
|
||
chatHistory.push({role:'bot', content:'Nisam pronašao ništa relevantno za "'+q+'". Probaj preformulirati.'});
|
||
} else {
|
||
const top3 = d.results.slice(0, 5);
|
||
let answer = 'Evo šta sam pronašao u bazi (' + d.results.length + ' rezultata):\n\n';
|
||
top3.forEach(function(h, i){
|
||
const title = h.title || (h.payload && h.payload.title) || '?';
|
||
const snippet = (h.snippet || '').slice(0, 200);
|
||
answer += (i+1) + '. **' + title + '** (' + (h.score*100).toFixed(0) + '%)';
|
||
if (snippet) answer += '\n ' + snippet + (snippet.length>=200?'…':'');
|
||
answer += '\n\n';
|
||
});
|
||
chatHistory.push({role:'bot', content: answer.trim(), sources: top3, llm:'rag', hits: d.results.length});
|
||
}
|
||
}
|
||
} catch(e) {
|
||
chatHistory = chatHistory.filter(function(m){ return m.role !== 'typing'; });
|
||
chatHistory.push({role:'bot', content: '⚠️ Greška: ' + e.message});
|
||
}
|
||
chatRender();
|
||
}
|
||
|
||
function chatReset() {
|
||
chatHistory = [];
|
||
chatRender();
|
||
const t = document.getElementById('chatThread');
|
||
if (t) t.innerHTML = '<div style="text-align:center;color:#788798;padding:40px;font-size:13px">💬 Postavi pitanje. Mogu odgovoriti glasovno (🎤) ili tekstom.</div>';
|
||
}
|
||
|
||
|
||
function render() {
|
||
const fn = routes[state.page] || pageDashboard;
|
||
fn().catch(e => document.getElementById('content').innerHTML = `<div class="ban crit"><b>Greška:</b> ${e.message}</div>`);
|
||
}
|
||
|
||
document.getElementById('token-input').addEventListener('keypress', e => { if (e.key==='Enter') doLogin(); });
|
||
|
||
|
||
// ============ V2 ERP & PRAVO PAGES ============
|
||
async function v2Fetch(path, opts={}) {
|
||
const tok = localStorage.getItem('rinet_v2_token');
|
||
opts.headers = Object.assign({'Content-Type':'application/json'}, opts.headers||{}, tok?{Authorization:'Bearer '+tok}:{});
|
||
const r = await fetch('/sport/api/v2'+path, opts);
|
||
if (!r.ok) throw new Error(`${r.status}: ${await r.text()}`);
|
||
return r.json();
|
||
}
|
||
|
||
async function pageAsk() {
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h"><h2>AI Asistent</h2><p class="muted">Razgovaraj sa AI asistentom o klubovima, savezima, pravilnicima, financiranju. Tipkaj ili koristi glasovni unos 🎤 (hr-HR).</p></div>
|
||
<div class="card">
|
||
<div id="chatThread" class="v6-chat-thread"></div>
|
||
<div class="v6-input-row">
|
||
<input id="askQ" class="inp" style="flex:1" placeholder="Postavi pitanje... (Enter za poslati)" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();chatSend('rag')}" />
|
||
<button class="v6-mic-btn" id="askMicBtn" onclick="v6VoiceStart('askQ', this)" title="Glasovni unos (hr-HR)">🎤</button>
|
||
<button class="btn primary" onclick="chatSend('rag')">📤</button>
|
||
<button class="btn" onclick="chatReset()" title="Novi razgovor">Reset</button>
|
||
</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:8px">Primjeri:
|
||
<a href="#" onclick="document.getElementById('askQ').value='Pravilnik o liječničkim pregledima sportaša';chatSend('rag');return false">Liječnički</a> ·
|
||
<a href="#" onclick="document.getElementById('askQ').value='Kako se financiraju javne potrebe u sportu PGŽ';chatSend('rag');return false">JP financiranje</a> ·
|
||
<a href="#" onclick="document.getElementById('askQ').value='NK Orijent Rijeka predsjednik';chatSend('rag');return false">NK Orijent</a> ·
|
||
<a href="#" onclick="document.getElementById('askQ').value='kotizacije za natjecanja u nogometu';chatSend('rag');return false">Kotizacije</a>
|
||
</div>
|
||
</div>`;
|
||
chatRender();
|
||
if (window.chatHistory.length === 0) {
|
||
const t = document.getElementById('chatThread');
|
||
if (t) t.innerHTML = '<div style="text-align:center;color:#788798;padding:40px;font-size:13px">💬 Postavi pitanje. Mogu odgovoriti glasovno (🎤) ili tekstom.</div>';
|
||
}
|
||
}
|
||
async function askGo() {
|
||
const q = document.getElementById('askQ').value.trim();
|
||
if (!q) return;
|
||
const out = document.getElementById('askOut');
|
||
out.innerHTML = '<div class="loader">Pretraga vektorske baze...</div>';
|
||
try {
|
||
const r = await fetch('/sport/api/v2/sport/ask', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({query:q, limit:10})});
|
||
const d = await r.json();
|
||
if (!d.results || !d.results.length) { out.innerHTML = '<div class="ban warn">Nema rezultata.</div>'; return; }
|
||
out.innerHTML = d.results.map(h => {
|
||
const url = (h.payload && (h.payload.source_url || h.payload.url)) || '';
|
||
const title = h.title || (h.payload && h.payload.title) || '(bez naslova)';
|
||
const tip = h.type || (h.payload && h.payload.tip) || '';
|
||
const klubId = h.payload && h.payload.klub_id;
|
||
const savezId = h.payload && h.payload.savez_id;
|
||
const docType = h.payload && h.payload.doc_type;
|
||
const sourceTag = h.payload && h.payload.source;
|
||
const publishDate = h.payload && h.payload.publish_date;
|
||
let click=''; let hint='';
|
||
if (url) { click='onclick="window.open(\''+url.replace(/\x27/g,'\\x27')+'\', \'_blank\')"'; hint='<span class="pill" style="background:#2a5e3a;color:#fff">otvori dokument</span>'; }
|
||
else if (klubId) { click='onclick="showKlub('+klubId+')"'; hint='<span class="pill ok">klub →</span>'; }
|
||
else if (savezId) { click='onclick="showSavez('+savezId+')"'; hint='<span class="pill ok">savez →</span>'; }
|
||
return '<div class="card" '+click+' style="margin-bottom:8px;cursor:'+(click?'pointer':'default')+';border-left:3px solid '+(h.score>0.7?'#27c79b':h.score>0.6?'#f0b429':'#7a7a7a')+'">'
|
||
+'<div style="display:flex;justify-content:space-between;align-items:start;gap:8px;flex-wrap:wrap">'
|
||
+'<div><b>'+title+'</b> <span class="pill">'+tip+'</span>'+(docType?' <span class="pill muted">'+docType+'</span>':'')+' '+hint+'</div>'
|
||
+'<div style="font-size:11px;color:var(--muted)">'+(publishDate?publishDate.slice(0,10)+' · ':'')+(sourceTag||'')+' · '+h.score.toFixed(3)+'</div>'
|
||
+'</div>'
|
||
+'<div style="margin-top:6px;font-size:13px;color:#bbb">'+(h.snippet||'').replace(/</g,'<').slice(0,400)+((h.snippet||'').length>400?'…':'')+'</div>'
|
||
+(url?'<div style="margin-top:4px;font-size:11px;color:#5e72e4;word-break:break-all">'+(url.length>120?url.slice(0,120)+'…':url)+'</div>':'')
|
||
+'</div>';
|
||
}).join('');
|
||
} catch(e) { out.innerHTML = '<div class="ban crit">Greška: '+e.message+'</div>'; }
|
||
}
|
||
|
||
|
||
async function pagePravnik() {
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h"><h2>AI Pravnik</h2><p class="muted">Stručni pravni odgovori temeljeni na pravilnicima HOO, MINT-a, ZSPGŽ-a, klubova i statuta. RAG + DeepSeek/Groq.</p></div>
|
||
<div class="card" style="margin-bottom:14px">
|
||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
|
||
<input id="lawQ" class="inp" style="flex:1;min-width:300px" placeholder="npr. Kako kategorizirati odbojkaša? Koji su rokovi za prijavu?" onkeydown="if(event.key==='Enter')lawGo()" />
|
||
<button class="v6-mic-btn" onclick="v6VoiceStart('lawQ', this)" title="Glasovni unos (hr-HR)">🎤</button>
|
||
<button class="btn primary" onclick="lawGo()">Pitaj Pravnika</button>
|
||
</div>
|
||
<div style="font-size:12px;color:var(--muted)">Primjeri:
|
||
<a href="#" onclick="document.getElementById('lawQ').value='Kako kategorizirati odbojkaša prema HOO pravilniku?';lawGo();return false">Kategorizacija odbojkaša</a> ·
|
||
<a href="#" onclick="document.getElementById('lawQ').value='Koji su uvjeti za sufinanciranje sportskog programa iz proračuna PGŽ?';lawGo();return false">Sufinanciranje programa</a> ·
|
||
<a href="#" onclick="document.getElementById('lawQ').value='Što je potrebno za prijavu sportaša u registar?';lawGo();return false">Registar sportaša</a> ·
|
||
<a href="#" onclick="document.getElementById('lawQ').value='Koje su obveze kluba za godišnje izvješće?';lawGo();return false">Godišnje izvješće</a> ·
|
||
<a href="#" onclick="document.getElementById('lawQ').value='Koliko često sportaš mora obaviti liječnički pregled?';lawGo();return false">Liječnički pregledi</a>
|
||
</div>
|
||
</div>
|
||
<div id="lawOut"></div>`;
|
||
document.getElementById('lawQ').addEventListener('keypress', e => { if (e.key==='Enter') lawGo(); });
|
||
}
|
||
async function lawGo() {
|
||
const q = document.getElementById('lawQ').value.trim();
|
||
if (!q) return;
|
||
const out = document.getElementById('lawOut');
|
||
out.innerHTML = '<div class="loader">AI Pravnik analizira...</div>';
|
||
try {
|
||
const r = await fetch('/sport/api/v2/sport/lawyer', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({query:q})});
|
||
const d = await r.json();
|
||
if (d.detail) { out.innerHTML = '<div class="ban crit">'+d.detail+'</div>'; return; }
|
||
let html = `<div class="card" style="border-left:3px solid #5e72e4;margin-bottom:14px">
|
||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Pitanje: ${q}</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-bottom:8px">LLM: <b>${d.llm}</b> · ${d.hits_count||0} pravilnika · ${d.sources?.length||0} citata</div>
|
||
<div style="white-space:pre-wrap;line-height:1.6;font-size:14px;color:var(--text)">${(d.answer||'').replace(/</g,'<')}</div>
|
||
</div>`;
|
||
if (d.sources && d.sources.length) {
|
||
html += '<h3 style="margin:14px 0 8px">Reference</h3>';
|
||
d.sources.forEach((s,i) => {
|
||
const url = s.url || s.source_url || '';
|
||
const click = url ? 'onclick="window.open(\''+url.replace(/\x27/g,"\\x27")+'\',\'_blank\')" style="cursor:pointer"' : '';
|
||
html += `<div class="card" ${click} style="margin-bottom:6px;font-size:13px;transition:background 0.15s" onmouseover="this.style.background='#1a2330'" onmouseout="this.style.background=''">
|
||
<b>[${s.id}]</b> ${s.title} <span class="pill">${s.doc_type||'doc'}</span>
|
||
${url ? '<span class="pill" style="background:#2a5e3a;color:#fff">otvori dokument</span>' : ''}
|
||
<span style="float:right;color:var(--muted);font-size:11px">score ${(s.score||0).toFixed(3)}</span>
|
||
${url ? '<br><span style="font-size:11px;color:#5e72e4;word-break:break-all">'+(url.length>120?url.slice(0,120)+'…':url)+'</span>' : ''}
|
||
</div>`;
|
||
});
|
||
}
|
||
out.innerHTML = html;
|
||
} catch(e) { out.innerHTML = '<div class="ban crit">Greška: '+e.message+'</div>'; }
|
||
}
|
||
|
||
async function pageNatjecanja() {
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h"><h2>Natjecanja</h2><p class="muted">Sve lige i natjecanja klubova PGŽ — nogomet, košarka, rukomet, odbojka, vaterpolo i ostali sportovi.</p></div>
|
||
<div class="card" style="margin-bottom:12px">
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<select id="natSport" class="inp" onchange="loadNatj()">
|
||
<option value="">Svi sportovi</option>
|
||
</select>
|
||
<select id="natRazina" class="inp" onchange="loadNatj()">
|
||
<option value="">Sve razine</option>
|
||
<option value="zupanijski">Županijska (PGŽ)</option>
|
||
<option value="nacionalni">Nacionalna</option>
|
||
<option value="ostalo">Ostalo</option>
|
||
</select>
|
||
<input id="natQ" class="inp" placeholder="Pretraži..." onkeyup="loadNatj()" />
|
||
</div>
|
||
</div>
|
||
<div id="natList" class="loader">Učitavanje...</div>`;
|
||
await loadNatjFilters();
|
||
await loadNatj();
|
||
}
|
||
async function loadNatjFilters() {
|
||
try {
|
||
const r = await fetch('/sport/api/natjecanja/filters');
|
||
if (r.ok) {
|
||
const d = await r.json();
|
||
const sel = document.getElementById('natSport');
|
||
if (sel && d.sports) {
|
||
d.sports.forEach(s => sel.innerHTML += '<option value="'+s+'">'+s+'</option>');
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
async function loadNatj() {
|
||
const sport = document.getElementById('natSport').value;
|
||
const razina = document.getElementById('natRazina').value;
|
||
const q = document.getElementById('natQ').value.trim();
|
||
const out = document.getElementById('natList');
|
||
out.innerHTML = '<div class="loader">Učitavanje...</div>';
|
||
try {
|
||
let url = '/sport/api/natjecanja?limit=200';
|
||
if (sport) url += '&sport=' + encodeURIComponent(sport);
|
||
if (razina) url += '&razina=' + encodeURIComponent(razina);
|
||
if (q) url += '&q=' + encodeURIComponent(q);
|
||
const r = await fetch(url);
|
||
const d = await r.json();
|
||
if (!d.results || !d.results.length) { out.innerHTML = '<div class="empty">Nema natjecanja</div>'; return; }
|
||
let html = '<div style="margin-bottom:8px;color:var(--muted);font-size:12px">'+d.count+' natjecanja</div>';
|
||
html += '<table class="t"><tr><th>Sport</th><th>Razina</th><th>Naziv</th><th>Sezona</th><th>Kategorija</th><th>URL</th></tr>';
|
||
d.results.forEach(n => {
|
||
html += '<tr>'
|
||
+ '<td><span class="pill">'+(n.sport||'-')+'</span></td>'
|
||
+ '<td><span class="pill '+(n.razina==='zupanijski'?'ok':'muted')+'">'+(n.razina||'-')+'</span></td>'
|
||
+ '<td>'+(n.naziv||'-')+'</td>'
|
||
+ '<td>'+(n.sezona||'-')+'</td>'
|
||
+ '<td>'+(n.kategorija||'-')+'</td>'
|
||
+ '<td>'+(n.external_url ? '<a href="'+n.external_url+'" target="_blank" style="color:#5e72e4">otvori →</a>' : '-')+'</td>'
|
||
+ '</tr>';
|
||
});
|
||
html += '</table>';
|
||
out.innerHTML = html;
|
||
} catch(e) { out.innerHTML = '<div class="ban crit">'+e.message+'</div>'; }
|
||
}
|
||
|
||
async function pageAdmin() {
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h"><h2>Admin · System</h2><p class="muted">Upravljanje korisnicima portala. Multi-tenant po klubovima i savezima. Blockchain audit.</p></div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px">
|
||
<div class="card" id="adminStats">
|
||
<h3>Sažetak</h3>
|
||
<div id="adminStatsContent" class="loader">Učitavanje...</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Novi korisnik</h3>
|
||
<div style="display:flex;flex-direction:column;gap:6px">
|
||
<input id="newEmail" class="inp" placeholder="Email" />
|
||
<div style="display:flex;gap:6px">
|
||
<input id="newIme" class="inp" placeholder="Ime" style="flex:1" />
|
||
<input id="newPrezime" class="inp" placeholder="Prezime" style="flex:1" />
|
||
</div>
|
||
<select id="newType" class="inp">
|
||
<option value="pgz_admin">pgz_admin (PGŽ Odjel sporta — sve)</option>
|
||
<option value="pgz_user">pgz_user (PGŽ Odjel sporta — pregled)</option>
|
||
<option value="pgz_finance">pgz_finance (PGŽ Finance)</option>
|
||
<option value="pgz_zzjz">pgz_zzjz (ZZJZ medical)</option>
|
||
<option value="savez_admin">savez_admin (Tajnik saveza)</option>
|
||
<option value="savez_user">savez_user (Pomoćnik saveza)</option>
|
||
<option value="klub_admin">klub_admin (Tajnik kluba)</option>
|
||
<option value="klub_user">klub_user (Pomoćnik kluba)</option>
|
||
<option value="klub_clan">klub_clan (Sportaš)</option>
|
||
</select>
|
||
<input id="newKlubId" class="inp" placeholder="klub_id (opcionalno)" />
|
||
<input id="newSavezId" class="inp" placeholder="savez_id (opcionalno)" />
|
||
<input id="newPwd" class="inp" type="password" placeholder="Privremeni password" />
|
||
<button class="btn primary" onclick="createUser()">➕ Stvori korisnika</button>
|
||
<div id="newUserStatus" style="font-size:12px;color:var(--muted)"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-bottom:14px">
|
||
<h3>Svi korisnici</h3>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px">
|
||
<input id="adminQ" class="inp" placeholder="Pretraži po emailu/imenu" onkeyup="loadUsers()" style="flex:1" />
|
||
<select id="adminFilterType" class="inp" onchange="loadUsers()">
|
||
<option value="">Svi tipovi</option>
|
||
<option value="pgz_admin">pgz_admin</option>
|
||
<option value="pgz_user">pgz_user</option>
|
||
<option value="pgz_finance">pgz_finance</option>
|
||
<option value="pgz_zzjz">pgz_zzjz</option>
|
||
<option value="savez_admin">savez_admin</option>
|
||
<option value="klub_admin">klub_admin</option>
|
||
<option value="klub_user">klub_user</option>
|
||
<option value="klub_clan">klub_clan</option>
|
||
</select>
|
||
</div>
|
||
<div id="usersList" class="loader">Učitavanje...</div>
|
||
</div>
|
||
<div class="card" style="margin-bottom:14px">
|
||
<h3>Multi-tenant veze</h3>
|
||
<p class="muted" style="font-size:12px">Korisnik može imati prava na više klubova/saveza s različitim ulogama (tajnik, predsjednik, sportaš, trener...)</p>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap">
|
||
<input id="linkUserId" class="inp" placeholder="user_id" type="number" />
|
||
<input id="linkKlubId" class="inp" placeholder="klub_id" type="number" />
|
||
<input id="linkSavezId" class="inp" placeholder="ili savez_id" type="number" />
|
||
<select id="linkRole" class="inp">
|
||
<option value="tajnik">Tajnik</option>
|
||
<option value="predsjednik">Predsjednik</option>
|
||
<option value="clan_uprave">Član uprave</option>
|
||
<option value="trener">Trener</option>
|
||
<option value="sportas">Sportaš</option>
|
||
<option value="volonter">Volonter</option>
|
||
<option value="clan">Član</option>
|
||
</select>
|
||
<button class="btn primary" onclick="createKlubLink()">🔗 Dodaj vezu</button>
|
||
</div>
|
||
<div id="klubLinksList" class="loader">Učitavanje...</div>
|
||
</div>
|
||
<div class="card" style="margin-bottom:14px">
|
||
<h3>Audit Log <span class="pill" id="chainStatus">verifying...</span></h3>
|
||
<p class="muted" style="font-size:12px">Sve akcije u sustavu zapisuju se u hash-chained ledger. Svaka izmjena uvjetuje potpis prethodnog reda. Ne može se neopaženo izmijeniti.</p>
|
||
<div style="display:flex;gap:8px;margin-bottom:8px">
|
||
<input id="auditQ" class="inp" placeholder="Filter po akciji" onkeyup="loadAuditChain()" style="flex:1" />
|
||
<button class="btn" onclick="verifyChain()">🔍 Verify Chain</button>
|
||
<button class="btn" onclick="loadAuditChain()">↻ Refresh</button>
|
||
</div>
|
||
<div id="auditList" class="loader">Učitavanje...</div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Permission matrica</h3>
|
||
<div id="permMatrix" class="loader">Učitavanje...</div>
|
||
</div>`;
|
||
await loadAdminStats();
|
||
await loadUsers();
|
||
await loadKlubLinks();
|
||
await loadAuditChain();
|
||
await verifyChain();
|
||
await loadPermMatrix();
|
||
}
|
||
async function loadKlubLinks() {
|
||
try {
|
||
const r = await fetch('/sport/api/admin/klub-links');
|
||
const d = await r.json();
|
||
let html = '<table class="t" style="font-size:12px"><tr><th>ID</th><th>Korisnik</th><th>Klub/Savez</th><th>Uloga</th><th>Aktivan</th><th>Od</th><th>Akcije</th></tr>';
|
||
(d.results||[]).forEach(l => {
|
||
const subj = l.klub_naziv || l.savez_naziv || '?';
|
||
const subjType = l.klub_id ? '<span class="pill ok">klub</span>' : '<span class="pill info">savez</span>';
|
||
html += '<tr>'
|
||
+ '<td>'+l.id+'</td>'
|
||
+ '<td>'+(l.email||'?')+'<br><span class="muted" style="font-size:10px">'+(l.ime||'')+' '+(l.prezime||'')+'</span></td>'
|
||
+ '<td>'+subjType+' '+subj+' (#'+(l.klub_id||l.savez_id)+')</td>'
|
||
+ '<td><span class="pill">'+l.role+'</span>'+(l.primary_link?' <span class="pill ok">primary</span>':'')+'</td>'
|
||
+ '<td>'+(l.aktivan ? '✅' : '❌')+'</td>'
|
||
+ '<td style="font-size:10px">'+(l.granted_at||'').slice(0,10)+'</td>'
|
||
+ '<td><a href="#" onclick="deleteKlubLink('+l.id+');return false">✕</a></td>'
|
||
+ '</tr>';
|
||
});
|
||
if (!(d.results||[]).length) html += '<tr><td colspan="7" style="text-align:center;color:var(--muted)">Nema veza</td></tr>';
|
||
html += '</table>';
|
||
document.getElementById('klubLinksList').innerHTML = html;
|
||
} catch (e) { document.getElementById('klubLinksList').innerHTML = '<div class="ban crit">'+e.message+'</div>'; }
|
||
}
|
||
async function createKlubLink() {
|
||
const body = {
|
||
user_id: parseInt(document.getElementById('linkUserId').value),
|
||
klub_id: parseInt(document.getElementById('linkKlubId').value)||null,
|
||
savez_id: parseInt(document.getElementById('linkSavezId').value)||null,
|
||
role: document.getElementById('linkRole').value
|
||
};
|
||
if (!body.user_id || (!body.klub_id && !body.savez_id)) { alert('user_id + (klub_id ili savez_id) obavezno'); return; }
|
||
try {
|
||
const r = await fetch('/sport/api/admin/klub-links', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
||
const d = await r.json();
|
||
if (d.id) {
|
||
document.getElementById('linkUserId').value='';
|
||
document.getElementById('linkKlubId').value='';
|
||
document.getElementById('linkSavezId').value='';
|
||
loadKlubLinks();
|
||
} else alert(d.detail||'greška');
|
||
} catch (e) { alert(e.message); }
|
||
}
|
||
async function deleteKlubLink(id) {
|
||
if (!confirm('Obrisati vezu #'+id+'?')) return;
|
||
try { await fetch('/sport/api/admin/klub-links/'+id, {method:'DELETE'}); loadKlubLinks(); } catch (e) { alert(e.message); }
|
||
}
|
||
async function loadAuditChain() {
|
||
const q = (document.getElementById('auditQ')||{value:''}).value;
|
||
let url = '/sport/api/admin/audit-chain?limit=50';
|
||
if (q) url += '&action='+encodeURIComponent(q);
|
||
try {
|
||
const r = await fetch(url);
|
||
const d = await r.json();
|
||
let html = '<table class="t" style="font-size:11px"><tr><th>#</th><th>Vrijeme</th><th>Akcija</th><th>Target</th><th>User</th><th>Hash</th><th>Prev</th></tr>';
|
||
d.forEach(a => {
|
||
html += '<tr>'
|
||
+ '<td>'+a.chain_idx+'</td>'
|
||
+ '<td style="white-space:nowrap">'+(a.created_at||'').slice(0,16).replace('T',' ')+'</td>'
|
||
+ '<td><b>'+a.action+'</b></td>'
|
||
+ '<td>'+(a.target_type||'-')+(a.target_id?' #'+a.target_id:'')+(a.target_text?'<br><span class="muted" style="font-size:10px">'+a.target_text.slice(0,80)+'</span>':'')+'</td>'
|
||
+ '<td>'+(a.user_email||'system')+'</td>'
|
||
+ '<td><code style="font-size:10px;color:#27c79b">'+a.row_hash+'</code></td>'
|
||
+ '<td><code style="font-size:10px;color:var(--muted)">'+a.prev_hash+'</code></td>'
|
||
+ '</tr>';
|
||
});
|
||
html += '</table>';
|
||
if (!d.length) html = '<div class="empty">Nema audit zapisa</div>';
|
||
document.getElementById('auditList').innerHTML = html;
|
||
} catch(e) { document.getElementById('auditList').innerHTML = '<div class="ban crit">'+e.message+'</div>'; }
|
||
}
|
||
async function verifyChain() {
|
||
document.getElementById('chainStatus').innerHTML = 'verifying...';
|
||
try {
|
||
const r = await fetch('/sport/api/admin/audit-chain/verify');
|
||
const d = await r.json();
|
||
if (d.valid) {
|
||
document.getElementById('chainStatus').innerHTML = '<span style="color:#27c79b">OK</span> · '+d.total_rows+' rows · last hash: '+(d.last_hash||'').slice(0,16)+'…';
|
||
document.getElementById('chainStatus').className = 'pill ok';
|
||
} else {
|
||
document.getElementById('chainStatus').innerHTML = '<span style="color:#e74c3c">BROKEN at chain_idx '+d.broken_at.chain_idx+'</span>';
|
||
document.getElementById('chainStatus').className = 'pill crit';
|
||
}
|
||
} catch(e) { document.getElementById('chainStatus').innerHTML = 'err: '+e.message; }
|
||
}
|
||
|
||
async function loadAdminStats() {
|
||
try {
|
||
const r = await fetch('/sport/api/admin/stats');
|
||
const d = await r.json();
|
||
document.getElementById('adminStatsContent').innerHTML = `
|
||
<div style="display:grid;grid-template-columns:repeat(2,1fr);gap:8px">
|
||
<div><b>${d.users_total||0}</b><br><span class="muted" style="font-size:11px">korisnika</span></div>
|
||
<div><b>${d.users_active||0}</b><br><span class="muted" style="font-size:11px">aktivnih</span></div>
|
||
<div><b>${d.permissions_total||0}</b><br><span class="muted" style="font-size:11px">dozvola</span></div>
|
||
<div><b>${d.audit_today||0}</b><br><span class="muted" style="font-size:11px">akcija danas</span></div>
|
||
</div>
|
||
<h4 style="margin-top:14px;margin-bottom:6px">Po tipu korisnika</h4>
|
||
<table class="t" style="font-size:12px">
|
||
${(d.by_type||[]).map(r => '<tr><td>'+r.user_type+'</td><td><b>'+r.cnt+'</b></td></tr>').join('')}
|
||
</table>`;
|
||
} catch(e) { document.getElementById('adminStatsContent').innerHTML = '<div class="ban crit">'+e.message+'</div>'; }
|
||
}
|
||
async function loadUsers() {
|
||
const q = document.getElementById('adminQ').value.trim();
|
||
const tp = document.getElementById('adminFilterType').value;
|
||
let url = '/sport/api/admin/users?limit=100';
|
||
if (q) url += '&q='+encodeURIComponent(q);
|
||
if (tp) url += '&user_type='+tp;
|
||
try {
|
||
const r = await fetch(url);
|
||
const d = await r.json();
|
||
let html = '<table class="t"><tr><th>ID</th><th>Email</th><th>Ime</th><th>Tip</th><th>Klub</th><th>Savez</th><th>Aktivan</th><th>Akcije</th></tr>';
|
||
(d.results || []).forEach(u => {
|
||
html += '<tr>'
|
||
+ '<td>'+u.id+'</td>'
|
||
+ '<td><b>'+u.email+'</b></td>'
|
||
+ '<td>'+(u.ime||'')+' '+(u.prezime||'')+'</td>'
|
||
+ '<td><span class="pill">'+u.user_type+'</span></td>'
|
||
+ '<td>'+(u.klub_id||'-')+'</td>'
|
||
+ '<td>'+(u.savez_id||'-')+'</td>'
|
||
+ '<td>'+(u.aktivan ? '✅' : '❌')+'</td>'
|
||
+ '<td><a href="#" onclick="editUser('+u.id+');return false">edit</a> · <a href="#" onclick="toggleUser('+u.id+');return false">toggle</a></td>'
|
||
+ '</tr>';
|
||
});
|
||
html += '</table>';
|
||
document.getElementById('usersList').innerHTML = html;
|
||
} catch(e) { document.getElementById('usersList').innerHTML = '<div class="ban crit">'+e.message+'</div>'; }
|
||
}
|
||
async function loadPermMatrix() {
|
||
try {
|
||
const r = await fetch('/sport/api/admin/permissions-matrix');
|
||
const d = await r.json();
|
||
let html = '<div style="overflow-x:auto"><table class="t" style="font-size:11px"><tr><th>Kategorija</th><th>Permission</th>';
|
||
const types = d.user_types || [];
|
||
types.forEach(t => html += '<th>'+t+'</th>');
|
||
html += '</tr>';
|
||
(d.matrix || []).forEach(row => {
|
||
html += '<tr><td>'+row.kategorija+'</td><td><b>'+row.code+'</b><br><span class="muted">'+row.naziv+'</span></td>';
|
||
types.forEach(t => {
|
||
const has = (row.granted_to || []).includes(t);
|
||
html += '<td style="text-align:center">'+(has ? '✅' : '–')+'</td>';
|
||
});
|
||
html += '</tr>';
|
||
});
|
||
html += '</table></div>';
|
||
document.getElementById('permMatrix').innerHTML = html;
|
||
} catch(e) { document.getElementById('permMatrix').innerHTML = '<div class="ban crit">'+e.message+'</div>'; }
|
||
}
|
||
async function createUser() {
|
||
const body = {
|
||
email: document.getElementById('newEmail').value.trim(),
|
||
ime: document.getElementById('newIme').value.trim(),
|
||
prezime: document.getElementById('newPrezime').value.trim(),
|
||
user_type: document.getElementById('newType').value,
|
||
klub_id: parseInt(document.getElementById('newKlubId').value) || null,
|
||
savez_id: parseInt(document.getElementById('newSavezId').value) || null,
|
||
password: document.getElementById('newPwd').value,
|
||
};
|
||
if (!body.email || !body.password) { alert('Email + password su obavezni'); return; }
|
||
try {
|
||
const r = await fetch('/sport/api/admin/users', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
|
||
const d = await r.json();
|
||
if (d.id) {
|
||
document.getElementById('newUserStatus').innerHTML = '✅ Stvoren korisnik #'+d.id;
|
||
document.getElementById('newEmail').value = '';
|
||
document.getElementById('newIme').value = '';
|
||
document.getElementById('newPrezime').value = '';
|
||
document.getElementById('newPwd').value = '';
|
||
loadUsers(); loadAdminStats();
|
||
} else {
|
||
document.getElementById('newUserStatus').innerHTML = '❌ ' + (d.detail || 'greška');
|
||
}
|
||
} catch(e) { document.getElementById('newUserStatus').innerHTML = '❌ '+e.message; }
|
||
}
|
||
async function toggleUser(id) {
|
||
if (!confirm('Toggle aktivan status korisnika #'+id+'?')) return;
|
||
try {
|
||
await fetch('/sport/api/admin/users/'+id+'/toggle', {method:'POST'});
|
||
loadUsers();
|
||
} catch(e) { alert(e.message); }
|
||
}
|
||
function editUser(id) { alert('Edit user '+id+' — TODO: full edit modal'); }
|
||
|
||
async function pageInvoices() {
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h"><h2>Računi · ERP</h2><p class="muted">Upload računa s OCR-om · IFRS knjigovodstvo · obveze/potraživanja</p></div>
|
||
<div class="card" style="margin-bottom:12px">
|
||
<h3>Upload računa (PDF/JPG/PNG)</h3>
|
||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||
<input type="file" id="invFile" accept=".pdf,.jpg,.jpeg,.png" />
|
||
<select id="invKind" class="inp"><option value="ulazni">Ulazni</option><option value="izlazni">Izlazni</option></select>
|
||
<input id="invKlub" class="inp" placeholder="klub_id (npr. 524)" />
|
||
<button class="btn primary" onclick="uploadInvoice()">Upload + OCR</button>
|
||
</div>
|
||
<div id="invUpStatus" style="margin-top:8px;font-size:13px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>OCR red + Uneseni računi</h3>
|
||
<div id="invList" class="loader">Učitavanje…</div>
|
||
</div>`;
|
||
await loadInvoices();
|
||
}
|
||
async function loadInvoices() {
|
||
try {
|
||
const ups = await v2Fetch('/invoice-uploads?limit=20');
|
||
const invs = await v2Fetch('/invoices?limit=20');
|
||
let html = '';
|
||
if (ups.length) {
|
||
html += '<h4 style="margin-top:0">📥 OCR red ('+ups.length+')</h4><table class="t"><tr><th>Datum</th><th>Status</th><th>Vendor</th><th>Iznos</th><th>Br.</th><th>Akcija</th></tr>';
|
||
ups.forEach(u => html += `<tr>
|
||
<td>${(u.uploaded_at||'').slice(0,16).replace('T',' ')}</td>
|
||
<td><span class="pill ${u.ocr_status==='done'?'ok':u.ocr_status==='failed'?'crit':'warn'}">${u.ocr_status}</span></td>
|
||
<td>${u.ai_vendor_name||'–'}</td>
|
||
<td>${u.ai_amount_gross?u.ai_amount_gross+' EUR':'–'}</td>
|
||
<td>${u.ai_invoice_no||'–'}</td>
|
||
<td><a href="#" onclick="detailUpload(${u.id});return false">detalji</a></td></tr>`);
|
||
html += '</table>';
|
||
}
|
||
if (invs.length) {
|
||
html += '<h4>📑 Uneseni računi ('+invs.length+')</h4><table class="t"><tr><th>Br.</th><th>Datum</th><th>Vendor</th><th>Iznos</th><th>Status</th></tr>';
|
||
invs.forEach(i => html += `<tr><td>${i.invoice_no||'–'}</td><td>${(i.invoice_date||'').slice(0,10)}</td><td>${i.vendor_name||'–'}</td><td>${i.amount_gross} ${i.currency}</td><td><span class="pill ${i.payment_status==='paid'?'ok':'warn'}">${i.payment_status}</span></td></tr>`);
|
||
html += '</table>';
|
||
}
|
||
if (!html) html = '<div class="muted">Nema računa. Upload prvi.</div>';
|
||
document.getElementById('invList').innerHTML = html;
|
||
} catch(e) { document.getElementById('invList').innerHTML = '<div class="ban crit">Login potreban: '+e.message+'</div>'; }
|
||
}
|
||
async function uploadInvoice() {
|
||
const f = document.getElementById('invFile').files[0];
|
||
const kind = document.getElementById('invKind').value;
|
||
const klub_id = parseInt(document.getElementById('invKlub').value || '0');
|
||
if (!f) { alert('Odaberi datoteku'); return; }
|
||
if (!klub_id) { alert('Unesi klub_id'); return; }
|
||
const tok = localStorage.getItem('rinet_v2_token');
|
||
if (!tok) { alert('Login potreban'); return; }
|
||
const fd = new FormData();
|
||
fd.append('file', f); fd.append('klub_id', klub_id); fd.append('invoice_kind', kind);
|
||
document.getElementById('invUpStatus').innerHTML = '⏳ Upload + OCR queue…';
|
||
try {
|
||
const r = await fetch('/sport/api/v2/invoice-uploads/file', {method:'POST', headers:{Authorization:'Bearer '+tok}, body: fd});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.detail||r.status);
|
||
document.getElementById('invUpStatus').innerHTML = `✅ ID ${d.upload_id} u OCR redu (${d.ocr_status})`;
|
||
setTimeout(loadInvoices, 1500);
|
||
} catch(e) { document.getElementById('invUpStatus').innerHTML = '❌ '+e.message; }
|
||
}
|
||
async function detailUpload(id) { alert('Detail UI TODO — ID '+id); }
|
||
|
||
async function pageExpenses() {
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h"><h2>Putni nalozi & obračuni</h2><p class="muted">0,50 EUR/km vlastiti auto (Pravilnik NN 143/23) · 30 EUR/dnevnica HR</p></div>
|
||
<div class="card" style="margin-bottom:12px">
|
||
<h3>Novi putni nalog</h3>
|
||
<div class="grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px">
|
||
<input id="exKlub" class="inp" placeholder="klub_id" />
|
||
<select id="exType" class="inp">
|
||
<option value="putni_nalog">Putni nalog</option>
|
||
<option value="vlastiti_auto">Vlastiti auto</option>
|
||
<option value="dnevnice">Dnevnice</option>
|
||
</select>
|
||
<input id="exDest" class="inp" placeholder="Destinacija (npr. Zagreb)" />
|
||
<input id="exFrom" class="inp" type="date" />
|
||
<input id="exTo" class="inp" type="date" />
|
||
<input id="exKm" class="inp" type="number" placeholder="km (vlastiti auto)" />
|
||
<input id="exDni" class="inp" type="number" placeholder="dani dnevnica" />
|
||
<input id="exTransp" class="inp" type="number" placeholder="trošak prijevoza EUR" />
|
||
<input id="exHotel" class="inp" type="number" placeholder="trošak smještaja EUR" />
|
||
</div>
|
||
<button class="btn primary" style="margin-top:8px" onclick="saveExpense()">💾 Spremi</button>
|
||
<div id="exStatus" style="margin-top:8px"></div>
|
||
</div>
|
||
<div class="card">
|
||
<h3>Postojeći obračuni</h3>
|
||
<div id="exList" class="loader">Učitavanje…</div>
|
||
</div>`;
|
||
await loadExpenses();
|
||
}
|
||
async function loadExpenses() {
|
||
try {
|
||
const ex = await v2Fetch('/expense-reports?limit=20');
|
||
if (!ex.length) { document.getElementById('exList').innerHTML = '<div class="muted">Nema obračuna.</div>'; return; }
|
||
let html = '<table class="t"><tr><th>Datum</th><th>Tip</th><th>Destinacija</th><th>km</th><th>Ukupno</th><th>Status</th></tr>';
|
||
ex.forEach(e => html += `<tr><td>${(e.date_from||'').slice(0,10)}</td><td>${e.report_type}</td><td>${e.destination||'–'}</td><td>${e.km_driven||0}</td><td><b>${e.cost_total} EUR</b></td><td><span class="pill">${e.status}</span></td></tr>`);
|
||
html += '</table>';
|
||
document.getElementById('exList').innerHTML = html;
|
||
} catch(e) { document.getElementById('exList').innerHTML = '<div class="ban crit">Login potreban: '+e.message+'</div>'; }
|
||
}
|
||
async function saveExpense() {
|
||
const body = {
|
||
klub_id: parseInt(document.getElementById('exKlub').value||'0'),
|
||
report_type: document.getElementById('exType').value,
|
||
destination: document.getElementById('exDest').value,
|
||
date_from: document.getElementById('exFrom').value,
|
||
date_to: document.getElementById('exTo').value,
|
||
km_driven: parseFloat(document.getElementById('exKm').value||'0'),
|
||
dnevnice_count: parseInt(document.getElementById('exDni').value||'0'),
|
||
cost_transport: parseFloat(document.getElementById('exTransp').value||'0'),
|
||
cost_lodging: parseFloat(document.getElementById('exHotel').value||'0')
|
||
};
|
||
if (!body.klub_id || !body.date_from) { alert('Unesi klub i datum.'); return; }
|
||
try {
|
||
const d = await v2Fetch('/expense-reports', {method:'POST', body: JSON.stringify(body)});
|
||
document.getElementById('exStatus').innerHTML = `✅ Obračun #${d.report_id} | total ${d.cost_total} EUR`;
|
||
setTimeout(loadExpenses, 800);
|
||
} catch(e) { document.getElementById('exStatus').innerHTML = '❌ '+e.message; }
|
||
}
|
||
|
||
async function pageForms() {
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h"><h2>Obrasci</h2><p class="muted">8 templatea · prijave · sufinanciranje · liječnički · putni · godišnji</p></div>
|
||
<div id="formsList" class="loader">Učitavanje…</div>
|
||
<div id="formRender"></div>`;
|
||
try {
|
||
const tpls = await v2Fetch('/forms/templates');
|
||
document.getElementById('formsList').innerHTML = '<div class="grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px">' +
|
||
tpls.map(t => `<div class="card" style="cursor:pointer" onclick="openForm('${t.code}')">
|
||
<h4 style="margin:0 0 4px 0">${t.naziv}</h4>
|
||
<div class="muted" style="font-size:12px">${t.kategorija} · za: ${t.required_role||'svi'}</div>
|
||
<div style="margin-top:6px;font-size:12px">${t.field_count||(t.schema_json&&t.schema_json.fields?t.schema_json.fields.length:'?')} polja</div>
|
||
</div>`).join('') + '</div>';
|
||
} catch(e) { document.getElementById('formsList').innerHTML = '<div class="ban crit">'+e.message+'</div>'; }
|
||
}
|
||
async function openForm(code) {
|
||
try {
|
||
const tpl = await v2Fetch('/forms/templates/'+code);
|
||
let fields = (tpl.schema_json && tpl.schema_json.fields) || [];
|
||
|
||
// V6: Smart layout — group fields into 2-3 columns based on type/length
|
||
function colSpan(f) {
|
||
if (f.type === 'textarea' || f.name === 'napomena' || f.name === 'opis') return 2;
|
||
if (f.type === 'file') return 2;
|
||
if (f.name && (f.name.includes('adresa') || f.name.includes('napomena'))) return 2;
|
||
return 1;
|
||
}
|
||
|
||
// V6: Detect special form types
|
||
const isPutni = code === 'obracun_putnih_troskova' || code === 'putni_nalog';
|
||
const isSportas = code === 'prijava_sportasa';
|
||
|
||
let html = '<div class="v6-form" id="v6Form">';
|
||
html += ' <div class="v6-fh"><h3>📋 ' + tpl.naziv + '</h3>';
|
||
html += ' <div class="v6-actions">';
|
||
html += ' <button class="v6-btn" onclick="document.getElementById(\'formRender\').innerHTML=\'\'">Zatvori</button>';
|
||
html += ' </div></div>';
|
||
|
||
if (isPutni) {
|
||
// V6 PUTNI NALOG: Special AI-powered layout
|
||
html += ' <div class="v6-fs">';
|
||
html += ' <div class="v6-fs-t">Putovanje</div>';
|
||
html += ' <div class="v6-g3">';
|
||
html += ' <div class="v6-fld v6-ac">';
|
||
html += ' <label class="v6-lbl req">Polazište</label>';
|
||
html += ' <input id="ff_polaziste" class="v6-inp" type="text" placeholder="npr. Rijeka" oninput="v6GradAuto(this,\'polaziste\')" onchange="v6CalcKM()" />';
|
||
html += ' <div id="ac_polaziste" class="v6-ac-s"></div>';
|
||
html += ' </div>';
|
||
html += ' <div class="v6-fld v6-ac">';
|
||
html += ' <label class="v6-lbl req">Odredište</label>';
|
||
html += ' <input id="ff_odrediste" class="v6-inp" type="text" placeholder="npr. Zagreb" oninput="v6GradAuto(this,\'odrediste\')" onchange="v6CalcKM()" />';
|
||
html += ' <div id="ac_odrediste" class="v6-ac-s"></div>';
|
||
html += ' </div>';
|
||
html += ' <div class="v6-fld">';
|
||
html += ' <label class="v6-lbl">🤖 AI udaljenost (jedan pravac)</label>';
|
||
html += ' <input id="ff_ai_km" class="v6-inp v6-num v6-calc" type="number" step="0.1" readonly />';
|
||
html += ' </div>';
|
||
html += ' </div>';
|
||
html += ' <div id="aiHint" style="font-size:11px;color:#5e72e4;margin-top:6px"></div>';
|
||
html += ' </div>';
|
||
|
||
html += ' <div class="v6-fs">';
|
||
html += ' <div class="v6-fs-t">Datumi i kilometraža</div>';
|
||
html += ' <div class="v6-g4">';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl req">Datum/sat polaska</label><input id="ff_datum_polaska" class="v6-inp" type="datetime-local" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl req">Datum/sat povratka</label><input id="ff_datum_povratka" class="v6-inp" type="datetime-local" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">KM stanje na polasku</label><input id="ff_km_pre" class="v6-inp v6-num" type="number" oninput="v6CalcKM()" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">KM stanje na povratku</label><input id="ff_km_post" class="v6-inp v6-num" type="number" oninput="v6CalcKM()" /></div>';
|
||
html += ' </div>';
|
||
html += ' <div class="v6-g4" style="margin-top:8px">';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl req">Ukupno prijeđeno KM <span class="v6-pill">auto: 2× pravac ili stanje povratka − stanje polaska</span></label><input id="ff_km_total" class="v6-inp v6-num v6-calc" type="number" step="0.1" oninput="v6CalcCost()" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">Cijena po KM (EUR)</label><input id="ff_cijena_km" class="v6-inp v6-num" type="number" step="0.01" value="0.50" oninput="v6CalcCost()" /></div>';
|
||
html += ' <div class="v6-fld v6-w2"><label class="v6-lbl">💰 Trošak prijevoza (auto)</label><input id="ff_trosak_prijevoz" class="v6-inp v6-num v6-calc" type="number" step="0.01" readonly /></div>';
|
||
html += ' </div>';
|
||
html += ' </div>';
|
||
|
||
// OCR Attachments
|
||
html += ' <div class="v6-fs">';
|
||
html += ' <div class="v6-fs-t">Prilozi · OCR</div>';
|
||
html += ' <div class="v6-att-z" onclick="document.getElementById(\'v6FilePick\').click()">';
|
||
html += ' <input type="file" id="v6FilePick" accept=".pdf,.jpg,.jpeg,.png" multiple style="display:none" onchange="v6UploadPrilog(this)" />';
|
||
html += ' <div>Klikni ili dovuci PDF/JPG/PNG (cestarine, gorivo, parking, smještaj)</div>';
|
||
html += ' <div style="font-size:11px;color:#788798;margin-top:4px">AI OCR će automatski pročitati iznos, datum, dobavljača, OIB</div>';
|
||
html += ' </div>';
|
||
html += ' <div id="v6AttList" class="v6-att-l"></div>';
|
||
html += ' </div>';
|
||
|
||
// Costs grid
|
||
html += ' <div class="v6-fs">';
|
||
html += ' <div class="v6-fs-t">Troškovi (EUR)</div>';
|
||
html += ' <div class="v6-g4">';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">Cestarine</label><input id="ff_cestarine" class="v6-inp v6-num" type="number" step="0.01" value="0" oninput="v6CalcTotal()" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">Parkirne</label><input id="ff_parkirne" class="v6-inp v6-num" type="number" step="0.01" value="0" oninput="v6CalcTotal()" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">Gorivo</label><input id="ff_gorivo" class="v6-inp v6-num" type="number" step="0.01" value="0" oninput="v6CalcTotal()" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">Smještaj</label><input id="ff_smjestaj" class="v6-inp v6-num" type="number" step="0.01" value="0" oninput="v6CalcTotal()" /></div>';
|
||
html += ' </div>';
|
||
html += ' <div class="v6-g4" style="margin-top:8px">';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">Ostali troškovi</label><input id="ff_ostali" class="v6-inp v6-num" type="number" step="0.01" value="0" oninput="v6CalcTotal()" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">Broj dnevnica</label><input id="ff_dnevnice_n" class="v6-inp v6-num" type="number" value="0" oninput="v6CalcTotal()" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">Iznos po dnevnici (EUR)</label><input id="ff_dnevnica_iznos" class="v6-inp v6-num" type="number" step="0.01" value="30" oninput="v6CalcTotal()" /></div>';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">💰 Ukupno dnevnice</label><input id="ff_dnevnice_uk" class="v6-inp v6-num v6-calc" type="number" step="0.01" readonly /></div>';
|
||
html += ' </div>';
|
||
html += ' <div class="v6-g3" style="margin-top:8px">';
|
||
html += ' <div class="v6-fld"><label class="v6-lbl">Predujam (EUR)</label><input id="ff_predujam" class="v6-inp v6-num" type="number" step="0.01" value="0" oninput="v6CalcTotal()" /></div>';
|
||
html += ' <div class="v6-fld v6-w2"><label class="v6-lbl">Napomena</label><textarea id="ff_napomena" class="v6-inp" rows="2"></textarea></div>';
|
||
html += ' </div>';
|
||
html += ' </div>';
|
||
|
||
// Totals footer
|
||
html += ' <div class="v6-tot">';
|
||
html += ' <div class="v6-tot-i"><div class="v6-lbl">Ukupni trošak</div><div class="v6-val" id="ff_uk_trosak">0,00 €</div></div>';
|
||
html += ' <div class="v6-tot-i"><div class="v6-lbl">Manje predujam</div><div class="v6-val" id="ff_minus_pred" style="color:#f0b429">−0,00 €</div></div>';
|
||
html += ' <div class="v6-tot-i"><div class="v6-lbl">Za isplatu</div><div class="v6-val" id="ff_za_isplatu">0,00 €</div></div>';
|
||
html += ' </div>';
|
||
} else {
|
||
// GENERIC FORM — auto-grid 2 columns
|
||
html += '<div class="v6-fs"><div class="v6-g2">';
|
||
let col = 0;
|
||
fields.forEach(f => {
|
||
const span = colSpan(f);
|
||
const req = f.required ? ' req' : '';
|
||
const reqAttr = f.required ? ' required' : '';
|
||
const fieldClass = span === 2 ? 'v6-fld v6-w2' : 'v6-fld';
|
||
html += '<div class="' + fieldClass + '">';
|
||
html += '<label class="v6-lbl' + req + '">' + (f.label||f.name) + '</label>';
|
||
if (f.type === 'textarea') html += '<textarea id="ff_' + f.name + '" class="v6-inp" rows="3"' + reqAttr + '></textarea>';
|
||
else if (f.type === 'select') html += '<select id="ff_' + f.name + '" class="v6-inp"' + reqAttr + '><option></option>' + (f.options||[]).map(o => '<option>' + o + '</option>').join('') + '</select>';
|
||
else if (f.type === 'checkbox') html += '<input id="ff_' + f.name + '" type="checkbox" />';
|
||
else {
|
||
const numClass = (f.type === 'number' || (f.name && (f.name.includes('iznos')||f.name.includes('km')||f.name.includes('amount')))) ? ' v6-num' : '';
|
||
html += '<input id="ff_' + f.name + '" class="v6-inp' + numClass + '" type="' + (f.type||'text') + '"' + reqAttr + ' />';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
html += '</div></div>';
|
||
}
|
||
|
||
// Footer buttons
|
||
html += '<div class="v6-fs" style="display:flex;gap:8px;justify-content:flex-end">';
|
||
html += ' <button class="v6-btn" onclick="document.getElementById(\'formRender\').innerHTML=\'\'">Zatvori</button>';
|
||
html += ' <button class="v6-btn" onclick="submitForm(\'' + code + '\',\'draft\')">Spremi draft</button>';
|
||
html += ' <button class="v6-btn primary" onclick="submitForm(\'' + code + '\',\'submitted\')">Pošalji</button>';
|
||
html += '</div>';
|
||
html += '<div id="formStatus" style="padding:10px 16px;font-size:13px"></div>';
|
||
html += '</div>';
|
||
|
||
document.getElementById('formRender').innerHTML = html;
|
||
document.getElementById('formRender').scrollIntoView({behavior:'smooth'});
|
||
if (isPutni) v6InitPutni();
|
||
} catch(e) { alert(e.message); }
|
||
}
|
||
|
||
// V6 PUTNI NALOG HELPERS
|
||
window.v6Attachments = window.v6Attachments || [];
|
||
function v6InitPutni() {
|
||
v6Attachments = [];
|
||
v6CalcKM(); v6CalcCost(); v6CalcTotal();
|
||
}
|
||
async function v6GradAuto(input, fieldKey) {
|
||
const q = input.value.trim();
|
||
const ac = document.getElementById('ac_' + fieldKey);
|
||
if (!q || q.length < 2) { ac.classList.remove('show'); return; }
|
||
try {
|
||
const r = await fetch('/sport/api/ai/gradovi?q=' + encodeURIComponent(q) + '&limit=10');
|
||
const list = await r.json();
|
||
if (!list.length) { ac.classList.remove('show'); return; }
|
||
ac.innerHTML = list.map(g => '<div onclick="document.getElementById(\'ff_' + fieldKey + '\').value=\'' + g.replace(/\'/g,"\\'") + '\';this.parentNode.classList.remove(\'show\');v6CalcKM()">' + g + '</div>').join('');
|
||
ac.classList.add('show');
|
||
} catch (e) { ac.classList.remove('show'); }
|
||
}
|
||
async function v6CalcKM() {
|
||
const od = (document.getElementById('ff_polaziste')||{}).value;
|
||
const dod = (document.getElementById('ff_odrediste')||{}).value;
|
||
const aiKm = document.getElementById('ff_ai_km');
|
||
const total = document.getElementById('ff_km_total');
|
||
const kmPre = parseFloat((document.getElementById('ff_km_pre')||{}).value || 0);
|
||
const kmPost = parseFloat((document.getElementById('ff_km_post')||{}).value || 0);
|
||
|
||
// Manual override: km_post - km_pre
|
||
if (kmPre > 0 && kmPost > kmPre) {
|
||
if (total) total.value = (kmPost - kmPre).toFixed(1);
|
||
document.getElementById('aiHint').innerHTML = '✓ Računamo iz stanja brzinomjera: ' + kmPre + ' → ' + kmPost + ' = ' + (kmPost - kmPre) + ' km';
|
||
} else if (od && dod && od !== dod) {
|
||
try {
|
||
const r = await fetch('/sport/api/ai/distance?od=' + encodeURIComponent(od) + '&do=' + encodeURIComponent(dod));
|
||
const d = await r.json();
|
||
if (d.found) {
|
||
if (aiKm) aiKm.value = d.udaljenost_km;
|
||
if (total) total.value = (d.udaljenost_km * 2).toFixed(1);
|
||
document.getElementById('aiHint').innerHTML = '🤖 AI: ' + od + ' → ' + dod + ' = ' + d.udaljenost_km + ' km × 2 (povratak) = ' + (d.udaljenost_km * 2) + ' km · vrijeme: ~' + d.vrijeme_minute + ' min · izvor: ' + d.izvor;
|
||
} else {
|
||
document.getElementById('aiHint').innerHTML = '⚠️ ' + d.suggestion + ' Unesi ručno KM stanje brzinomjera ili upiši ukupno.';
|
||
}
|
||
} catch(e) { document.getElementById('aiHint').innerHTML = '⚠️ AI greška: ' + e.message; }
|
||
}
|
||
v6CalcCost();
|
||
}
|
||
function v6CalcCost() {
|
||
const km = parseFloat((document.getElementById('ff_km_total')||{}).value || 0);
|
||
const cij = parseFloat((document.getElementById('ff_cijena_km')||{}).value || 0.50);
|
||
const t = document.getElementById('ff_trosak_prijevoz');
|
||
if (t) t.value = (km * cij).toFixed(2);
|
||
v6CalcTotal();
|
||
}
|
||
function v6CalcTotal() {
|
||
const f = id => parseFloat((document.getElementById(id)||{}).value || 0);
|
||
const trprij = f('ff_trosak_prijevoz');
|
||
const cest = f('ff_cestarine'); const park = f('ff_parkirne');
|
||
const gor = f('ff_gorivo'); const smj = f('ff_smjestaj'); const ost = f('ff_ostali');
|
||
const dn_n = f('ff_dnevnice_n'); const dn_iz = f('ff_dnevnica_iznos');
|
||
const pred = f('ff_predujam');
|
||
const dn_uk = dn_n * dn_iz;
|
||
const dn_uk_el = document.getElementById('ff_dnevnice_uk');
|
||
if (dn_uk_el) dn_uk_el.value = dn_uk.toFixed(2);
|
||
const ukupno = trprij + cest + park + gor + smj + ost + dn_uk;
|
||
const za_isp = ukupno - pred;
|
||
const fmt = n => n.toLocaleString('hr-HR', {minimumFractionDigits:2,maximumFractionDigits:2}) + ' €';
|
||
const uk_el = document.getElementById('ff_uk_trosak');
|
||
const mp_el = document.getElementById('ff_minus_pred');
|
||
const zi_el = document.getElementById('ff_za_isplatu');
|
||
if (uk_el) uk_el.textContent = fmt(ukupno);
|
||
if (mp_el) mp_el.textContent = '−' + fmt(pred);
|
||
if (zi_el) zi_el.textContent = fmt(za_isp);
|
||
}
|
||
async function v6UploadPrilog(input) {
|
||
const files = input.files;
|
||
if (!files || !files.length) return;
|
||
const list = document.getElementById('v6AttList');
|
||
for (const file of files) {
|
||
const item = document.createElement('div');
|
||
item.className = 'v6-att-i';
|
||
item.innerHTML = '<span>📄 ' + file.name + '</span><span class="v6-tag">OCR...</span>';
|
||
list.appendChild(item);
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
fd.append('tip', 'racun');
|
||
try {
|
||
const r = await fetch('/sport/api/ai/ocr-prilog', {method:'POST', body: fd});
|
||
const d = await r.json();
|
||
const tag = d.ai_amount ? d.tip : 'parsed';
|
||
const amt = d.ai_amount ? (d.ai_amount.toFixed(2) + ' €') : '?';
|
||
const vendor = d.ai_vendor ? d.ai_vendor.slice(0, 30) : '';
|
||
item.innerHTML = '<span>📄 ' + file.name + '</span><span class="v6-tag">✓ ' + tag + '</span><span style="color:#788798;font-size:11px">' + vendor + '</span><span class="v6-amt">' + amt + '</span>'
|
||
+ '<select onchange="v6PrilogAssign(' + (v6Attachments.length) + ',this.value)" class="v6-inp" style="margin-left:auto;width:auto;font-size:11px">'
|
||
+ '<option value="">— pridruži —</option>'
|
||
+ '<option value="ff_cestarine">Cestarine</option>'
|
||
+ '<option value="ff_parkirne">Parkirne</option>'
|
||
+ '<option value="ff_gorivo">Gorivo</option>'
|
||
+ '<option value="ff_smjestaj">Smještaj</option>'
|
||
+ '<option value="ff_ostali">Ostali</option>'
|
||
+ '</select>';
|
||
v6Attachments.push({file: file.name, ocr: d, assigned_to: null});
|
||
} catch(e) {
|
||
item.innerHTML = '<span>📄 ' + file.name + '</span><span class="v6-tag" style="background:#6e2a2a">✗ greška</span>';
|
||
}
|
||
}
|
||
input.value = '';
|
||
}
|
||
function v6PrilogAssign(idx, fieldId) {
|
||
const att = v6Attachments[idx];
|
||
if (!att || !fieldId) return;
|
||
att.assigned_to = fieldId;
|
||
const el = document.getElementById(fieldId);
|
||
if (el && att.ocr.ai_amount) {
|
||
const cur = parseFloat(el.value || 0);
|
||
el.value = (cur + att.ocr.ai_amount).toFixed(2);
|
||
v6CalcTotal();
|
||
}
|
||
}
|
||
|
||
async function submitForm(code, status) {
|
||
const tpl = await v2Fetch('/forms/templates/'+code);
|
||
let fields = (tpl.schema_json && tpl.schema_json.fields) || [];
|
||
const data = {};
|
||
|
||
// Always read all standard fields by ID
|
||
fields.forEach(f => {
|
||
const el = document.getElementById('ff_'+f.name);
|
||
if (!el) return;
|
||
data[f.name] = f.type==='checkbox' ? el.checked : el.value;
|
||
});
|
||
|
||
// V6 putni nalog extra fields
|
||
const isPutni = code === 'obracun_putnih_troskova' || code === 'putni_nalog';
|
||
if (isPutni) {
|
||
['polaziste','odrediste','ai_km','datum_polaska','datum_povratka',
|
||
'km_pre','km_post','km_total','cijena_km','trosak_prijevoz',
|
||
'cestarine','parkirne','gorivo','smjestaj','ostali',
|
||
'dnevnice_n','dnevnica_iznos','dnevnice_uk','predujam','napomena'].forEach(k => {
|
||
const el = document.getElementById('ff_'+k);
|
||
if (el) data[k] = el.value;
|
||
});
|
||
// Attach OCR data
|
||
if (window.v6Attachments && v6Attachments.length) {
|
||
data._attachments = v6Attachments.map(a => ({
|
||
file: a.file, ocr: {amount: a.ocr.ai_amount, date: a.ocr.ai_date, vendor: a.ocr.ai_vendor, oib: a.ocr.ai_oib},
|
||
assigned_to: a.assigned_to
|
||
}));
|
||
}
|
||
}
|
||
|
||
try {
|
||
const d = await v2Fetch('/forms/submit', {method:'POST', body: JSON.stringify({template_code: code, data, status})});
|
||
document.getElementById('formStatus').innerHTML = '✅ Spremljeno · #'+d.submission_id+' · '+(d.reference_no||'');
|
||
} catch(e) { document.getElementById('formStatus').innerHTML = '❌ '+e.message; }
|
||
}
|
||
|
||
async function pageUsers() {
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h"><h2>Korisnici i prava</h2><p class="muted">Multi-tenant · 7 uloga (super_admin → viewer)</p></div>
|
||
<div class="card" style="margin-bottom:12px">
|
||
<h3>Novi korisnik</h3>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px">
|
||
<input id="nuEmail" class="inp" placeholder="email" />
|
||
<input id="nuName" class="inp" placeholder="Ime Prezime" />
|
||
<input id="nuPwd" class="inp" type="password" placeholder="lozinka" />
|
||
<select id="nuRole" class="inp"><option value="viewer">Viewer</option><option value="clan">Član</option><option value="klub_user">Klub User</option><option value="klub_admin">Klub Admin</option><option value="savez_admin">Savez Admin</option><option value="pgz_admin">PGŽ Admin</option></select>
|
||
<input id="nuKlub" class="inp" placeholder="klub_id (opcionalno)" />
|
||
</div>
|
||
<button class="btn primary" style="margin-top:8px" onclick="createUser()">➕ Kreiraj</button>
|
||
<div id="usrStatus" style="margin-top:8px"></div>
|
||
</div>
|
||
<div class="card"><h3>Postojeći</h3><div id="usrList" class="loader">…</div></div>`;
|
||
try {
|
||
const us = await v2Fetch('/users?limit=50');
|
||
let html = '<table class="t"><tr><th>Email</th><th>Ime</th><th>Uloge</th><th>Klubovi</th><th>Status</th></tr>';
|
||
us.forEach(u => html += `<tr><td>${u.email}</td><td>${u.full_name||'–'}</td><td>${(u.roles||[]).map(r=>`<span class="pill">${r}</span>`).join(' ')}</td><td>${(u.klubovi||[]).length||0}</td><td><span class="pill ${u.status==='active'?'ok':'warn'}">${u.status}</span></td></tr>`);
|
||
html += '</table>';
|
||
document.getElementById('usrList').innerHTML = html;
|
||
} catch(e) { document.getElementById('usrList').innerHTML = '<div class="ban crit">'+e.message+'</div>'; }
|
||
}
|
||
async function createUser() {
|
||
const body = {
|
||
email: document.getElementById('nuEmail').value,
|
||
full_name: document.getElementById('nuName').value,
|
||
password: document.getElementById('nuPwd').value,
|
||
role: document.getElementById('nuRole').value,
|
||
klub_id: parseInt(document.getElementById('nuKlub').value||'0')||null
|
||
};
|
||
try {
|
||
const d = await v2Fetch('/users', {method:'POST', body: JSON.stringify(body)});
|
||
document.getElementById('usrStatus').innerHTML = `✅ User #${d.user_id} kreiran`;
|
||
setTimeout(pageUsers, 800);
|
||
} catch(e) { document.getElementById('usrStatus').innerHTML = '❌ '+e.message; }
|
||
}
|
||
|
||
// V2 LOGIN BRIDGE — overlay on existing showLogin/doLogin if it uses old endpoint
|
||
window.v2Login = async function(email, pwd) {
|
||
const r = await fetch('/sport/api/v2/auth/login', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({email, password: pwd})});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.detail||'Login failed');
|
||
localStorage.setItem('rinet_v2_token', d.token);
|
||
localStorage.setItem('rinet_v2_user', JSON.stringify(d.user));
|
||
return d;
|
||
};
|
||
|
||
buildNavs();
|
||
goto('dashboard');
|
||
checkRole();
|
||
</script>
|
||
</body>
|
||
</html>
|