5263 lines
284 KiB
Plaintext
5263 lines
284 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="mobile-web-app-capable" content="yes">
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='12' fill='%230d0d0d'/%3E%3Ctext x='32' y='42' font-family='IBM Plex Sans,sans-serif' font-size='28' font-weight='700' fill='%233b82c4' text-anchor='middle'%3EPG%3C/text%3E%3C/svg%3E">
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||
<!-- v=1777465915 -->
|
||
<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');
|
||
/* ═══════════════════════════════════════════
|
||
PGŽ SPORT — RI.NET UNIFIED TOKENS
|
||
Synced with /opt/rinet-v4/app/src/index.css
|
||
v2.0.0 | 29.04.2026
|
||
═══════════════════════════════════════════ */
|
||
:root {
|
||
/* === MASTER TOKENS (klasik-aligned) === */
|
||
--bg: #0d0d0d;
|
||
--bg2: #141414;
|
||
--bg3: #1a1a1a;
|
||
--bg4: #1a2332;
|
||
--bg5: #243044;
|
||
|
||
--border: #1e293b;
|
||
--border2: rgba(30,41,59,0.6);
|
||
--border3: rgba(56, 97, 150, 0.4);
|
||
|
||
--text: #94a3b8;
|
||
--text2: #cbd5e1;
|
||
--text3: #c0c0d0;
|
||
--text-bright: #f1f5f9;
|
||
--text-dim: #6B7A99;
|
||
|
||
--accent: #3b82c4;
|
||
--accent2: #2563a0;
|
||
--accent-glow: rgba(59, 130, 196, 0.12);
|
||
|
||
--green: #22c55e;
|
||
--red: #ef4444;
|
||
--amber: #f59e0b;
|
||
--cyan: #06b6d4;
|
||
|
||
--sans: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
--mono: 'JetBrains Mono', 'Fira Code', SF Mono, Consolas, monospace;
|
||
|
||
--radius: 4px;
|
||
--radius-lg: 6px;
|
||
|
||
--chart1: #3b82c4;
|
||
--chart2: #1d6fa8;
|
||
--chart3: #0f5a8e;
|
||
--chart4: #064572;
|
||
|
||
--gradient-main: linear-gradient(135deg, #1a3a5c, #0f2440);
|
||
--gradient-accent: linear-gradient(90deg, #2563a0, #1d6fa8);
|
||
|
||
/* === LEGACY ALIASES (sport-specific, dash variants) === */
|
||
--bg-2: var(--bg2);
|
||
--bg-3: var(--bg3);
|
||
--border-2: var(--border2);
|
||
--accent-2: var(--accent2);
|
||
--text-2: var(--text);
|
||
--text-3: var(--text-dim);
|
||
|
||
/* === SEMANTIC ALIASES === */
|
||
--ok: var(--green);
|
||
--warn: var(--amber);
|
||
--crit: var(--red);
|
||
|
||
/* === DOMAIN COLORS (kept for sport visual variety) === */
|
||
--gold: var(--amber);
|
||
--purple: #A78BFA;
|
||
--pink: #F472B6;
|
||
|
||
/* === LEGACY RADIUS === */
|
||
--r: var(--radius-lg);
|
||
--r-sm: var(--radius);
|
||
|
||
/* === PANEL ALIASES (sport had panel/panel-2) === */
|
||
--panel: var(--bg2);
|
||
--panel-2: var(--bg4);
|
||
}
|
||
/* ═══ MASTER UTILITY CLASSES — append, sport overrides may follow ═══ */
|
||
.ri-card {
|
||
background: var(--bg2);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-lg);
|
||
padding: 12px;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.ri-card:hover { border-color: var(--border2); }
|
||
|
||
.ri-glass {
|
||
background: rgba(8, 12, 20, 0.85);
|
||
backdrop-filter: blur(12px);
|
||
-webkit-backdrop-filter: blur(12px);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.ri-tbl { width:100%; border-collapse:collapse; font-family:var(--mono); font-size:11px; }
|
||
.ri-tbl th { text-align:left; padding:6px 8px; font-weight:500; font-size:10px; text-transform:uppercase; letter-spacing:0.5px; color:var(--text3); border-bottom:1px solid var(--border2); background:var(--bg3); }
|
||
.ri-tbl td { padding:4px 8px; border-bottom:1px solid var(--border); white-space:nowrap; color:var(--text); }
|
||
.ri-tbl tr:hover td { background: var(--accent-glow); }
|
||
|
||
.ri-btn { font-family:var(--sans); font-size:11px; font-weight:500; padding:6px 12px; border-radius:var(--radius); border:1px solid var(--border2); background:var(--bg3); color:var(--text); cursor:pointer; transition:all 0.15s; letter-spacing:0.02em; }
|
||
.ri-btn:hover { background:var(--bg4); border-color:var(--border3); }
|
||
.ri-btn-primary { background:var(--accent2); border-color:var(--accent); color:white; }
|
||
.ri-btn-primary:hover { background:var(--accent); }
|
||
.ri-btn-ghost { background:transparent; border-color:var(--border); color:var(--text2); }
|
||
|
||
.ri-kpi-value { font-family:var(--mono); font-weight:700; font-size:20px; color:var(--text-bright); letter-spacing:-0.02em; }
|
||
.ri-kpi-label { font-size:9px; text-transform:uppercase; letter-spacing:0.8px; color:var(--text3); margin-top:2px; }
|
||
|
||
.risk { font-family:var(--mono); font-size:9px; font-weight:700; padding:2px 6px; border-radius:2px; }
|
||
.risk-critical { background:rgba(239,68,68,0.12); color:var(--red); }
|
||
.risk-high { background:rgba(212,160,23,0.12); color:var(--amber); }
|
||
.risk-medium { background:rgba(6,182,212,0.12); color:var(--cyan); }
|
||
.risk-low { background:rgba(34,197,94,0.12); color:var(--green); }
|
||
|
||
/* ═══ ICON — Lucide stroke style ═══ */
|
||
.ico-svg, .ri-ico {
|
||
stroke: currentColor;
|
||
stroke-width: 1.5;
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
fill: none;
|
||
flex-shrink: 0;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
* { 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 — v2 (email+password + admin tab) -->
|
||
<div class="modal-bg" id="login-modal">
|
||
<div class="modal" style="min-width:340px;max-width:420px">
|
||
<div style="display:flex;gap:6px;margin-bottom:14px;border-bottom:1px solid var(--border);padding-bottom:10px">
|
||
<button id="loginTabUser" class="btn" style="flex:1;font-size:12px" onclick="loginSwitchTab('user')">👤 Korisnik</button>
|
||
<button id="loginTabAdmin" class="btn sec" style="flex:1;font-size:12px" onclick="loginSwitchTab('admin')">🔓 Admin token</button>
|
||
</div>
|
||
<div id="loginPanelUser">
|
||
<h3 style="margin-bottom:6px">Prijava korisnika</h3>
|
||
<p class="muted" style="margin-bottom:12px">Email i lozinka. Default lozinka novim korisnicima: <span class="mono">PgzSport2026!</span> (mora se promijeniti)</p>
|
||
<input class="inp" id="loginEmail" type="email" placeholder="email@pgz.hr" autocomplete="username" style="margin-bottom:8px;width:100%">
|
||
<input class="inp" id="loginPwd" type="password" placeholder="Lozinka" autocomplete="current-password" style="margin-bottom:12px;width:100%" onkeydown="if(event.key==='Enter')doUserLogin()">
|
||
<div id="loginError" style="display:none;color:var(--red);font-size:11px;margin-bottom:8px;padding:6px 10px;background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.3);border-radius:4px"></div>
|
||
<div class="ma">
|
||
<button class="btn" style="flex:1" onclick="doUserLogin()">Prijavi se</button>
|
||
<button class="btn sec" onclick="closeLogin()">Odustani</button>
|
||
</div>
|
||
</div>
|
||
<div id="loginPanelAdmin" style="display:none">
|
||
<h3 style="margin-bottom:6px">Admin token</h3>
|
||
<p class="muted" style="margin-bottom:12px">Za PII unmask (OIB, IBAN, telefon). <b>GDPR čl. 5 i 32.</b></p>
|
||
<input class="inp mono" id="token-input" placeholder="Admin token..." autocomplete="off" style="margin-bottom:12px;width:100%">
|
||
<div class="ma">
|
||
<button class="btn" style="flex:1" onclick="doLogin()">Aktiviraj</button>
|
||
<button class="btn sec" onclick="closeLogin()">Odustani</button>
|
||
</div>
|
||
<div class="hint" style="margin-top:10px"><b>Demo:</b> <span class="mono">admin-pgz-2026</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- MUST CHANGE PASSWORD MODAL -->
|
||
<div class="modal-bg" id="pwd-change-modal">
|
||
<div class="modal" style="min-width:340px;max-width:420px">
|
||
<h3>🔒 Promjena lozinke</h3>
|
||
<p class="muted" style="margin-bottom:12px">Vaša početna lozinka mora se promijeniti prije pristupa sustavu.</p>
|
||
<input class="inp" id="pwdNew1" type="password" placeholder="Nova lozinka (min 8 znakova)" autocomplete="new-password" style="margin-bottom:8px;width:100%">
|
||
<input class="inp" id="pwdNew2" type="password" placeholder="Potvrdi novu lozinku" autocomplete="new-password" style="margin-bottom:12px;width:100%" onkeydown="if(event.key==='Enter')doPwdChange()">
|
||
<div id="pwdError" style="display:none;color:var(--red);font-size:11px;margin-bottom:8px"></div>
|
||
<div class="ma">
|
||
<button class="btn" style="flex:1" onclick="doPwdChange()">Promijeni i nastavi</button>
|
||
<button class="btn sec" onclick="doLogout()">Odjavi se</button>
|
||
</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:'baza', label:'PGŽ baza', mlabel:'PGŽ baza', svg:'<rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/>' },
|
||
{ id:'dokumenti', label:'Pravilnici i zakoni', mlabel:'Pravilnici', 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"/><polyline points="10 9 9 9 8 9"/>' },
|
||
{ id:'kategorije', label:'Dobne kategorije', mlabel:'Kategorije', 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:'funkcionari', label:'Funkcionari', mlabel:'Funkc', svg:'<path d="M16 21v-2a4 4 0 0 0-3-4H8a4 4 0 0 0-4 4v2"/><circle cx="10" cy="7" r="3"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||
{ id:'sportasi', label:'Igrači · Foto', mlabel:'Igrači', svg:'<circle cx="12" cy="7" r="4"/><path d="M5 21v-2a4 4 0 0 1 4-4h6a4 4 0 0 1 4 4v2"/>' },
|
||
{ id:'audit', label:'Audit · Kvaliteta', mlabel:'Audit', svg:'<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>', roles:['super_admin','pgz_admin','pgz_user'] },
|
||
{ 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:'sportStats', label:'⚽ Sport Stats', mlabel:'Stats', 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');
|
||
}
|
||
|
||
// ═══ AUTH v3 ═══
|
||
function loginSwitchTab(which) {
|
||
const u = document.getElementById('loginPanelUser'), a = document.getElementById('loginPanelAdmin');
|
||
const tu = document.getElementById('loginTabUser'), ta = document.getElementById('loginTabAdmin');
|
||
if (which === 'user') { u.style.display=''; a.style.display='none'; tu.classList.remove('sec'); ta.classList.add('sec'); }
|
||
else { u.style.display='none'; a.style.display=''; ta.classList.remove('sec'); tu.classList.add('sec'); }
|
||
}
|
||
function showLogin(tab) {
|
||
document.getElementById('login-modal').classList.add('show');
|
||
document.getElementById('loginError').style.display='none';
|
||
loginSwitchTab(tab || 'user');
|
||
setTimeout(()=>{ const el = document.getElementById('loginEmail'); if (el) el.focus(); }, 50);
|
||
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'); }
|
||
|
||
// Admin token (PII unmask, legacy)
|
||
function doLogin() {
|
||
const t = document.getElementById('token-input').value.trim();
|
||
state.token = t; localStorage.setItem('pgz_token', t);
|
||
closeLogin(); checkRole().then(()=>render());
|
||
}
|
||
|
||
// User login (v2 email+pwd) — primary
|
||
async function doUserLogin() {
|
||
const email = document.getElementById('loginEmail').value.trim();
|
||
const pwd = document.getElementById('loginPwd').value;
|
||
const err = document.getElementById('loginError');
|
||
err.style.display = 'none';
|
||
if (!email || !pwd) { err.textContent='Unesite email i lozinku.'; err.style.display='block'; return; }
|
||
try {
|
||
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 || 'Pogrešni podaci');
|
||
localStorage.setItem('rinet_v2_token', d.token);
|
||
localStorage.setItem('rinet_v2_user', JSON.stringify(d.user || {}));
|
||
state.v2Token = d.token; state.v2User = d.user || {};
|
||
closeLogin();
|
||
// must_change_pwd flow
|
||
if (d.user && d.user.must_change_pwd) {
|
||
document.getElementById('pwd-change-modal').classList.add('show');
|
||
return;
|
||
}
|
||
await checkRole();
|
||
render();
|
||
} catch(e) {
|
||
err.textContent = e.message || 'Greška pri prijavi';
|
||
err.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
async function doPwdChange() {
|
||
const p1 = document.getElementById('pwdNew1').value;
|
||
const p2 = document.getElementById('pwdNew2').value;
|
||
const err = document.getElementById('pwdError');
|
||
err.style.display='none';
|
||
if (p1.length < 8) { err.textContent='Lozinka mora imati barem 8 znakova.'; err.style.display='block'; return; }
|
||
if (p1 !== p2) { err.textContent='Lozinke se ne podudaraju.'; err.style.display='block'; return; }
|
||
try {
|
||
const r = await fetch('/sport/api/v2/auth/change-password', {
|
||
method:'POST', headers:{'Content-Type':'application/json','Authorization':'Bearer '+state.v2Token},
|
||
body: JSON.stringify({new_password: p1})
|
||
});
|
||
const d = await r.json();
|
||
if (!r.ok) throw new Error(d.detail || 'Greška');
|
||
document.getElementById('pwd-change-modal').classList.remove('show');
|
||
if (state.v2User) { state.v2User.must_change_pwd = false; localStorage.setItem('rinet_v2_user', JSON.stringify(state.v2User)); }
|
||
await checkRole();
|
||
render();
|
||
} catch(e) { err.textContent=e.message; err.style.display='block'; }
|
||
}
|
||
|
||
async function doLogout() {
|
||
try { await fetch('/sport/api/v2/auth/logout', {method:'POST', headers:{'Authorization':'Bearer '+(state.v2Token||'')}}); } catch(e){}
|
||
localStorage.removeItem('rinet_v2_token');
|
||
localStorage.removeItem('rinet_v2_user');
|
||
state.v2Token = ''; state.v2User = null;
|
||
document.getElementById('pwd-change-modal').classList.remove('show');
|
||
await checkRole();
|
||
render();
|
||
}
|
||
|
||
async function checkRole() {
|
||
// 1) Admin token (legacy PII unmask)
|
||
try {
|
||
const w = await api('/api/whoami');
|
||
state.isAdmin = ['super_admin','pgz_admin','pgz_user'].includes(w.user_type);
|
||
} catch(e) { state.isAdmin = false; }
|
||
|
||
// 2) v2 user session
|
||
state.v2Token = localStorage.getItem('rinet_v2_token') || '';
|
||
try { state.v2User = JSON.parse(localStorage.getItem('rinet_v2_user') || 'null'); } catch(e) { state.v2User = null; }
|
||
|
||
if (state.v2Token) {
|
||
try {
|
||
const r = await fetch('/sport/api/v2/auth/me', { headers: {'Authorization':'Bearer '+state.v2Token} });
|
||
if (r.ok) {
|
||
const me = await r.json();
|
||
state.v2User = me;
|
||
localStorage.setItem('rinet_v2_user', JSON.stringify(me));
|
||
} else if (r.status === 401) {
|
||
localStorage.removeItem('rinet_v2_token');
|
||
localStorage.removeItem('rinet_v2_user');
|
||
state.v2Token = ''; state.v2User = null;
|
||
}
|
||
} catch(e) {}
|
||
}
|
||
|
||
renderRolePill();
|
||
}
|
||
|
||
function renderRolePill() {
|
||
['role-pill', 'role-pill-mob'].forEach(id => {
|
||
const p = document.getElementById(id);
|
||
if (!p) return;
|
||
if (state.v2User && state.v2User.email) {
|
||
const u = state.v2User;
|
||
const ut = u.user_type || (u.roles && u.roles[0]) || 'user';
|
||
const name = u.ime ? (u.ime + (u.prezime ? ' ' + u.prezime[0] + '.' : '')) : (u.full_name || u.email);
|
||
p.className = 'role-pill admin';
|
||
p.innerHTML = '<span style="font-size:9px;opacity:0.7">' + ut.toUpperCase() + '</span><br><span style="font-size:10px">' + name + '</span>';
|
||
p.onclick = () => {
|
||
if (confirm('Odjaviti se?')) doLogout();
|
||
};
|
||
p.title = 'Klik za odjavu (' + u.email + ')';
|
||
} else if (state.isAdmin) {
|
||
p.className = 'role-pill admin';
|
||
p.textContent = 'ADMIN TOKEN';
|
||
p.onclick = () => { state.token = ''; localStorage.removeItem('pgz_token'); checkRole(); render(); };
|
||
} else {
|
||
p.className = 'role-pill viewer';
|
||
p.textContent = '🔐 PRIJAVA';
|
||
p.onclick = () => showLogin('user');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Global 401 handler — redirect to login
|
||
window._origFetch = window.fetch;
|
||
window.fetch = async function(...args) {
|
||
const r = await window._origFetch.apply(this, args);
|
||
if (r.status === 401) {
|
||
const url = (args[0] || '').toString();
|
||
if (url.includes('/api/v2/') && !url.includes('/auth/')) {
|
||
// soft trigger: only show login if we're not already trying to log in
|
||
const m = document.getElementById('login-modal');
|
||
if (m && !m.classList.contains('show')) {
|
||
setTimeout(() => showLogin('user'), 100);
|
||
}
|
||
}
|
||
}
|
||
return r;
|
||
};
|
||
|
||
// State extension
|
||
state.v2Token = localStorage.getItem('rinet_v2_token') || '';
|
||
try { state.v2User = JSON.parse(localStorage.getItem('rinet_v2_user') || 'null'); } catch(e) { state.v2User = null; }
|
||
|
||
// API helper extension — auto-attach v2 bearer for /api/v2/*
|
||
const _origApi = api;
|
||
window.api = async function(path, opts={}) {
|
||
const headers = { 'Content-Type':'application/json' };
|
||
if (path.startsWith('/api/v2/') && state.v2Token) {
|
||
headers['Authorization'] = 'Bearer ' + state.v2Token;
|
||
} else if (state.token) {
|
||
headers['Authorization'] = 'Bearer ' + state.token;
|
||
}
|
||
const res = await fetch(API + path, {...opts, headers:{...headers, ...(opts.headers||{})}});
|
||
if (!res.ok) {
|
||
let detail = '';
|
||
try { detail = (await res.json()).detail || ''; } catch(e) {}
|
||
throw new Error('API ' + res.status + (detail ? ': ' + detail : ''));
|
||
}
|
||
return res.json();
|
||
};
|
||
|
||
|
||
|
||
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>`; }
|
||
}
|
||
|
||
|
||
|
||
// === NAVIGACIJA: breadcrumbs, back, keyboard ===
|
||
window.navStack = window.navStack || [];
|
||
|
||
function navPush(page, params) {
|
||
// Push current state to history (max 20)
|
||
if (state.page) {
|
||
window.navStack.push({page: state.page, ...JSON.parse(JSON.stringify(state))});
|
||
if (window.navStack.length > 20) window.navStack.shift();
|
||
}
|
||
}
|
||
function navBack() {
|
||
if (window.navStack.length === 0) { goto('dashboard'); return; }
|
||
const prev = window.navStack.pop();
|
||
Object.assign(state, prev);
|
||
render();
|
||
}
|
||
// ESC = back
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape' && window.navStack.length > 0 && !document.querySelector('.modal,dialog[open]')) {
|
||
e.preventDefault();
|
||
navBack();
|
||
}
|
||
});
|
||
|
||
function breadcrumbs(items) {
|
||
// items = [{label, onclick}]
|
||
let h = '<div class="breadcrumbs" style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text2);margin-bottom:12px;flex-wrap:wrap">';
|
||
if (window.navStack.length > 0) {
|
||
h += '<button onclick="navBack()" title="Esc" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:12px">← Nazad</button>';
|
||
h += '<span style="color:var(--text3)">·</span>';
|
||
}
|
||
items.forEach((it, i) => {
|
||
if (i > 0) h += '<span style="color:var(--text3)">›</span>';
|
||
if (it.onclick) {
|
||
h += `<a onclick="${it.onclick}" style="cursor:pointer;color:${i===items.length-1?'var(--text-bright)':'var(--accent)'}">${it.label}</a>`;
|
||
} else {
|
||
h += `<span style="color:${i===items.length-1?'var(--text-bright)':'var(--text2)'}">${it.label}</span>`;
|
||
}
|
||
});
|
||
h += '</div>';
|
||
return h;
|
||
}
|
||
|
||
// Prikaži vrijednost ili "NEDOSTAJE" jasno
|
||
function val(v, label) {
|
||
if (v === null || v === undefined || v === '') {
|
||
return `<span style="color:var(--text3);font-style:italic">— ${label||'nedostaje'} —</span>`;
|
||
}
|
||
return v;
|
||
}
|
||
function valWarn(v, type) {
|
||
// type='date'/'string' - vraća badge za nedostajuće s warning
|
||
if (v === null || v === undefined || v === '') {
|
||
return `<span class="risk-medium" style="font-size:9px;padding:2px 6px;border-radius:3px">PODATAK NEDOSTAJE</span>`;
|
||
}
|
||
return v;
|
||
}
|
||
|
||
|
||
const ULOGA_BADGE = {
|
||
'igrac': '<span style="background:rgba(59,130,246,0.15);color:#60a5fa;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">⚽ IGRAČ</span>',
|
||
'trener': '<span style="background:rgba(16,185,129,0.15);color:#10b981;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">📋 TRENER</span>',
|
||
'kondicioni_trener': '<span style="background:rgba(16,185,129,0.15);color:#10b981;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">💪 KONDICIONI TRENER</span>',
|
||
'direktor': '<span style="background:rgba(245,158,11,0.15);color:#f59e0b;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">📊 DIREKTOR</span>',
|
||
'predsjednik': '<span style="background:rgba(168,85,247,0.15);color:#a855f7;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">👑 PREDSJEDNIK</span>',
|
||
'tajnik': '<span style="background:rgba(168,85,247,0.15);color:#a855f7;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">📝 TAJNIK</span>',
|
||
'fizioterapeut': '<span style="background:rgba(236,72,153,0.15);color:#ec4899;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">⚕️ FIZIO</span>',
|
||
'lijecnik': '<span style="background:rgba(236,72,153,0.15);color:#ec4899;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">⚕️ LIJEČNIK</span>',
|
||
'sudac': '<span style="background:rgba(107,114,128,0.15);color:#9ca3af;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">🦮 SUDAC</span>',
|
||
'ostalo': '<span style="background:rgba(107,114,128,0.15);color:#9ca3af;padding:3px 8px;border-radius:4px;font-size:10px;font-weight:600">▪ OSTALO</span>',
|
||
};
|
||
function ulogaBadge(u) { return ULOGA_BADGE[u] || (u ? '<span style="color:var(--text3);font-size:10px">'+u+'</span>' : ''); }
|
||
|
||
// Sport ikone (emoji) za pregled svih sportova
|
||
const SPORT_ICONS = {
|
||
'nogomet': '⚽', 'rukomet': '🤾', 'košarka': '🏀', 'kosarka': '🏀',
|
||
'vaterpolo': '🤽', 'odbojka': '🏐', 'tenis': '🎾', 'stolni tenis': '🏓',
|
||
'atletika': '🏃', 'plivanje': '🏊', 'biciklizam': '🚴', 'boks': '🥊',
|
||
'karate': '🥋', 'judo': '🥋', 'taekwondo': '🥋', 'kickboxing': '🥊',
|
||
'jedriličarstvo': '⛵', 'jedrilicarstvo': '⛵', 'skijanje': '⛷️',
|
||
'ribolov': '🎣', 'šah': '♟️', 'sah': '♟️', 'streljaštvo': '🎯',
|
||
'streličarstvo': '🏹', 'gimnastika': '🤸', 'ples': '💃', 'kuglanje': '🎳',
|
||
'pikado': '🎯', 'planinarstvo': '🏔️', 'konjički sport': '🐎',
|
||
'veslanje': '🚣', 'mačevanje': '🤺', 'hrvanje': '🤼', 'penjanje': '🧗',
|
||
'multisport': '🏆', 'rekreacija': '🎯', 'motosport': '🏎️',
|
||
'baseball': '⚾', 'softball': '⚾', 'golf': '⛳', 'hokej': '🏒',
|
||
'parasport': '♿', 'parasportski': '♿', 'paraolimpijski': '♿',
|
||
'lov': '🦌', 'kajakaštvo': '🛶', 'curling': '🥌', 'eSport': '🎮',
|
||
'borilački sport': '🥋', 'penjanje': '🧗', 'sport gluhih': '🤟',
|
||
'sport slijepih': '👁️🗨️', 'olimpijski': '🏅', 'kineziologija': '📚',
|
||
'medicina': '⚕️', 'školski sport': '🎒', 'općenito': '🏟️',
|
||
};
|
||
function sportIcon(s) { return SPORT_ICONS[(s||'').toLowerCase()] || '🏃'; }
|
||
|
||
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 , sportStats:pageSportStats, baza:pageBaza, dokumenti:pageDokumenti, kategorije:pageKategorije, funkcionari:pageFunkcionari, sportasi:pageSportasi, sportas:pageSportas, klubRoster:pageKlubRoster, sport:pageSport, audit:pageAudit };
|
||
|
||
|
||
// ===== 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() {
|
||
// GATE — moraš biti prijavljen
|
||
if (!state.v2Token || !state.v2User) {
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h"><h2>🔐 Korisnici i prava</h2><p class="muted">Za pristup ovoj stranici potrebna je prijava.</p></div>
|
||
<div class="card" style="max-width:480px;margin-top:18px;padding:24px;text-align:center">
|
||
<div style="font-size:48px;margin-bottom:8px">🔐</div>
|
||
<h3 style="margin-bottom:6px">Prijavi se za nastavak</h3>
|
||
<p class="muted" style="margin-bottom:16px">Modul "Korisnici" je dostupan samo prijavljenim administratorima.</p>
|
||
<button class="btn" onclick="showLogin('user')" style="min-width:160px">Prijavi se</button>
|
||
</div>`;
|
||
return;
|
||
}
|
||
|
||
setTopbar('Administracija', 'Korisnici i prava');
|
||
const me = state.v2User || {};
|
||
const isAdmin = ['super_admin','pgz_admin'].includes(me.user_type);
|
||
const isSuper = me.user_type === 'super_admin';
|
||
|
||
document.getElementById('content').innerHTML = `
|
||
<div class="page-h" style="margin-bottom:14px">
|
||
<h2>👥 Korisnici i prava</h2>
|
||
<p class="muted">Multi-tenant · ${isSuper?'super_admin':isAdmin?'pgz_admin':'pregled'} · klik na red → akcije</p>
|
||
</div>
|
||
|
||
${isAdmin ? `
|
||
<div class="card" style="margin-bottom:14px;padding:14px">
|
||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||
<input id="usrSearch" class="inp" placeholder="🔍 traži email / ime / prezime" style="flex:1;min-width:240px" oninput="usrSearch()">
|
||
<select id="usrFilterType" class="inp" onchange="usrSearch()">
|
||
<option value="">Svi tipovi</option>
|
||
<option value="super_admin">super_admin</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>
|
||
<button class="btn ri-btn-primary" onclick="usrShowCreate()">+ Novi korisnik</button>
|
||
<button class="btn" onclick="usrShowAudit()">📋 Audit</button>
|
||
</div>
|
||
</div>` : ''}
|
||
|
||
<div class="card" style="overflow:auto">
|
||
<div id="usrList">Učitavam...</div>
|
||
</div>
|
||
|
||
<div class="modal-bg" id="usrModal" style="display:none">
|
||
<div class="modal" id="usrModalBody" style="min-width:420px;max-width:560px"></div>
|
||
</div>
|
||
`;
|
||
|
||
await usrLoadList();
|
||
}
|
||
|
||
let _usrSearchTimer = null;
|
||
function usrSearch() {
|
||
clearTimeout(_usrSearchTimer);
|
||
_usrSearchTimer = setTimeout(usrLoadList, 300);
|
||
}
|
||
|
||
async function usrLoadList() {
|
||
const q = (document.getElementById('usrSearch')||{}).value || '';
|
||
const ut = (document.getElementById('usrFilterType')||{}).value || '';
|
||
const params = new URLSearchParams();
|
||
if (q) params.set('q', q);
|
||
if (ut) params.set('user_type', ut);
|
||
params.set('limit', '100');
|
||
|
||
try {
|
||
const d = await api('/api/v2/users/list?' + params.toString());
|
||
const me = state.v2User || {};
|
||
const isAdmin = ['super_admin','pgz_admin'].includes(me.user_type);
|
||
const isSuper = me.user_type === 'super_admin';
|
||
|
||
if (!d.results || d.results.length === 0) {
|
||
document.getElementById('usrList').innerHTML = '<div class="muted" style="padding:24px;text-align:center">Nema korisnika u tvom dosegu.</div>';
|
||
return;
|
||
}
|
||
|
||
let html = `<div class="muted" style="font-size:11px;margin-bottom:8px">${d.count} / ${d.total} korisnika</div>`;
|
||
html += '<table class="ri-tbl"><thead><tr><th>#</th><th>Email</th><th>Ime i prezime</th><th>Tip</th><th>Klub/Savez</th><th>Status</th><th>Lock</th><th>Last login</th><th style="text-align:right">Akcije</th></tr></thead><tbody>';
|
||
for (const u of d.results) {
|
||
const locked = u.locked_until && new Date(u.locked_until) > new Date();
|
||
const failBadge = u.failed_login_count > 0 ? `<span class="risk-medium">${u.failed_login_count}</span>` : '';
|
||
const lockBadge = locked ? '<span class="risk-critical">LOCKED</span>' : (u.aktivan ? '<span class="risk-low">aktivan</span>' : '<span class="risk-high">isključen</span>');
|
||
const mustCh = u.must_change_pwd ? ' <span class="risk-medium" title="Mora promijeniti lozinku">!</span>' : '';
|
||
const llogin = u.last_login ? new Date(u.last_login).toLocaleDateString('hr-HR') : '<span class="muted">nikad</span>';
|
||
const klubLabel = u.klub_id ? `klub#${u.klub_id}` : (u.savez_id ? `savez#${u.savez_id}` : '-');
|
||
let actions = '';
|
||
if (isAdmin && u.id !== me.id) {
|
||
actions = `
|
||
<button class="btn ri-btn-ghost" onclick="usrShowEdit(${u.id})" style="padding:2px 6px;font-size:10px">edit</button>
|
||
<button class="btn ri-btn-ghost" onclick="usrResetPwd(${u.id}, '${(u.email||'').replace(/'/g,'')}')" style="padding:2px 6px;font-size:10px">reset pwd</button>
|
||
<button class="btn ri-btn-ghost" onclick="usrToggle(${u.id})" style="padding:2px 6px;font-size:10px">${u.aktivan?'isključi':'aktiviraj'}</button>
|
||
${locked ? `<button class="btn ri-btn-ghost" onclick="usrUnlock(${u.id})" style="padding:2px 6px;font-size:10px">unlock</button>` : ''}
|
||
${isSuper ? `<button class="btn ri-btn-ghost" onclick="usrImpersonate(${u.id})" style="padding:2px 6px;font-size:10px;color:var(--amber)">impersonate</button>` : ''}
|
||
<button class="btn ri-btn-ghost" onclick="usrShowAudit(${u.id})" style="padding:2px 6px;font-size:10px">audit</button>`;
|
||
} else if (u.id === me.id) {
|
||
actions = `<button class="btn ri-btn-ghost" onclick="usrShowEdit(${u.id})" style="padding:2px 6px;font-size:10px">edit (ja)</button>`;
|
||
}
|
||
html += `<tr>
|
||
<td class="mono">${u.id}</td>
|
||
<td><b>${u.email}</b>${mustCh}</td>
|
||
<td>${(u.ime||'')+' '+(u.prezime||'')}</td>
|
||
<td><span class="mono" style="font-size:10px;color:var(--text3)">${u.user_type||'?'}</span></td>
|
||
<td class="mono" style="font-size:10px">${klubLabel}</td>
|
||
<td>${lockBadge} ${failBadge}</td>
|
||
<td>${locked ? `<span class="muted" title="${u.locked_until}">do ${new Date(u.locked_until).toLocaleTimeString('hr-HR',{hour:'2-digit',minute:'2-digit'})}</span>` : '-'}</td>
|
||
<td>${llogin}</td>
|
||
<td style="text-align:right;white-space:nowrap">${actions}</td>
|
||
</tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
document.getElementById('usrList').innerHTML = html;
|
||
} catch(e) {
|
||
document.getElementById('usrList').innerHTML = '<div class="banner crit">Greška: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
function usrModal(html) {
|
||
const m = document.getElementById('usrModal');
|
||
const b = document.getElementById('usrModalBody');
|
||
if (!m || !b) return;
|
||
b.innerHTML = html;
|
||
m.style.display = 'flex';
|
||
}
|
||
function usrModalClose() {
|
||
const m = document.getElementById('usrModal');
|
||
if (m) m.style.display = 'none';
|
||
}
|
||
|
||
function usrShowCreate() {
|
||
usrModal(`
|
||
<h3 style="margin-bottom:14px">+ Novi korisnik</h3>
|
||
<input id="ncEmail" class="inp" placeholder="email@pgz.hr" style="width:100%;margin-bottom:8px">
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
|
||
<input id="ncIme" class="inp" placeholder="Ime">
|
||
<input id="ncPrezime" class="inp" placeholder="Prezime">
|
||
</div>
|
||
<select id="ncType" class="inp" style="width:100%;margin-bottom:8px">
|
||
<option value="klub_user">klub_user</option>
|
||
<option value="klub_admin">klub_admin</option>
|
||
<option value="klub_clan">klub_clan</option>
|
||
<option value="savez_user">savez_user</option>
|
||
<option value="savez_admin">savez_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="pgz_admin">pgz_admin</option>
|
||
</select>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
|
||
<input id="ncKlub" class="inp" type="number" placeholder="klub_id">
|
||
<input id="ncSavez" class="inp" type="number" placeholder="savez_id">
|
||
</div>
|
||
<input id="ncTel" class="inp" placeholder="Telefon" style="width:100%;margin-bottom:8px">
|
||
<p class="muted" style="font-size:10px;margin-bottom:12px">Default lozinka: <code>PgzSport2026!</code> + must_change_pwd=true</p>
|
||
<div id="ncErr" class="banner crit" style="display:none;margin-bottom:8px"></div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn ri-btn-primary" style="flex:1" onclick="usrCreate()">Kreiraj</button>
|
||
<button class="btn" onclick="usrModalClose()">Odustani</button>
|
||
</div>
|
||
`);
|
||
}
|
||
|
||
async function usrCreate() {
|
||
const body = {
|
||
email: document.getElementById('ncEmail').value.trim(),
|
||
ime: document.getElementById('ncIme').value.trim() || null,
|
||
prezime: document.getElementById('ncPrezime').value.trim() || null,
|
||
user_type: document.getElementById('ncType').value,
|
||
klub_id: parseInt(document.getElementById('ncKlub').value) || null,
|
||
savez_id: parseInt(document.getElementById('ncSavez').value) || null,
|
||
telefon: document.getElementById('ncTel').value.trim() || null,
|
||
};
|
||
const err = document.getElementById('ncErr');
|
||
err.style.display = 'none';
|
||
if (!body.email) { err.textContent = 'Email je obavezan'; err.style.display='block'; return; }
|
||
try {
|
||
const d = await api('/api/v2/users/create', { method:'POST', body: JSON.stringify(body) });
|
||
alert('Kreiran user #' + d.id + (d.temporary_password ? '\nPrivremena lozinka: ' + d.temporary_password : ''));
|
||
usrModalClose();
|
||
await usrLoadList();
|
||
} catch(e) { err.textContent = e.message; err.style.display='block'; }
|
||
}
|
||
|
||
async function usrShowEdit(uid) {
|
||
try {
|
||
const d = await api('/api/v2/users/list?limit=200');
|
||
const u = (d.results || []).find(x => x.id === uid);
|
||
if (!u) { alert('Korisnik nije u tvom dosegu'); return; }
|
||
usrModal(`
|
||
<h3 style="margin-bottom:14px">✎ Edit #${u.id} · ${u.email}</h3>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
|
||
<input id="eIme" class="inp" placeholder="Ime" value="${u.ime||''}">
|
||
<input id="ePrezime" class="inp" placeholder="Prezime" value="${u.prezime||''}">
|
||
</div>
|
||
<select id="eType" class="inp" style="width:100%;margin-bottom:8px">
|
||
${['super_admin','pgz_admin','pgz_user','pgz_finance','pgz_zzjz','savez_admin','savez_user','klub_admin','klub_user','klub_clan'].map(t=>`<option value="${t}" ${u.user_type===t?'selected':''}>${t}</option>`).join('')}
|
||
</select>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:8px">
|
||
<input id="eKlub" class="inp" type="number" placeholder="klub_id" value="${u.klub_id||''}">
|
||
<input id="eSavez" class="inp" type="number" placeholder="savez_id" value="${u.savez_id||''}">
|
||
</div>
|
||
<input id="eTel" class="inp" placeholder="Telefon" value="${u.telefon||''}" style="width:100%;margin-bottom:8px">
|
||
<input id="eOib" class="inp" placeholder="OIB" value="${u.oib||''}" style="width:100%;margin-bottom:12px">
|
||
<div id="eErr" class="banner crit" style="display:none;margin-bottom:8px"></div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn ri-btn-primary" style="flex:1" onclick="usrEdit(${u.id})">Spremi</button>
|
||
<button class="btn" onclick="usrModalClose()">Odustani</button>
|
||
</div>
|
||
`);
|
||
} catch(e) { alert('Greška: '+e.message); }
|
||
}
|
||
|
||
async function usrEdit(uid) {
|
||
const body = {
|
||
ime: document.getElementById('eIme').value.trim() || null,
|
||
prezime: document.getElementById('ePrezime').value.trim() || null,
|
||
user_type: document.getElementById('eType').value,
|
||
klub_id: parseInt(document.getElementById('eKlub').value) || null,
|
||
savez_id: parseInt(document.getElementById('eSavez').value) || null,
|
||
telefon: document.getElementById('eTel').value.trim() || null,
|
||
oib: document.getElementById('eOib').value.trim() || null,
|
||
};
|
||
try {
|
||
await api('/api/v2/users/' + uid, { method:'PUT', body: JSON.stringify(body) });
|
||
usrModalClose();
|
||
await usrLoadList();
|
||
} catch(e) {
|
||
document.getElementById('eErr').textContent = e.message;
|
||
document.getElementById('eErr').style.display = 'block';
|
||
}
|
||
}
|
||
|
||
async function usrResetPwd(uid, email) {
|
||
if (!confirm('Resetirati lozinku korisnika ' + email + '?\nGenerirat će se nova privremena lozinka.')) return;
|
||
try {
|
||
const d = await api('/api/v2/users/' + uid + '/reset-password', { method:'POST' });
|
||
prompt('Nova privremena lozinka — kopiraj i pošalji korisniku:', d.temporary_password);
|
||
await usrLoadList();
|
||
} catch(e) { alert('Greška: ' + e.message); }
|
||
}
|
||
|
||
async function usrToggle(uid) {
|
||
try {
|
||
await api('/api/v2/users/' + uid + '/toggle-active', { method:'POST' });
|
||
await usrLoadList();
|
||
} catch(e) { alert('Greška: ' + e.message); }
|
||
}
|
||
|
||
async function usrUnlock(uid) {
|
||
try {
|
||
await api('/api/v2/users/' + uid + '/unlock', { method:'POST' });
|
||
await usrLoadList();
|
||
} catch(e) { alert('Greška: ' + e.message); }
|
||
}
|
||
|
||
async function usrImpersonate(uid) {
|
||
if (!confirm('Impersonate korisnika #' + uid + '?\nIzdaje se 2h token i ti gubiš svoju sesiju u ovom tabu.')) return;
|
||
try {
|
||
const d = await api('/api/v2/admin/impersonate', { method:'POST', body: JSON.stringify({target_user_id: uid}) });
|
||
localStorage.setItem('rinet_v2_token', d.token);
|
||
localStorage.setItem('rinet_v2_user', JSON.stringify(d.as_user));
|
||
alert('Sad si u ulozi: ' + d.as_user.email + '\nDo ' + new Date(d.expires_at).toLocaleString('hr-HR'));
|
||
await checkRole();
|
||
render();
|
||
} catch(e) { alert('Greška: ' + e.message); }
|
||
}
|
||
|
||
async function usrShowAudit(uid) {
|
||
try {
|
||
const url = uid ? '/api/v2/users/' + uid + '/audit?limit=100' : '/api/v2/admin/audit?limit=100';
|
||
const d = await api(url);
|
||
let html = '<h3 style="margin-bottom:10px">📋 Audit ' + (uid?`za #${uid}`:'(globalno)') + '</h3>';
|
||
if (!d.results.length) {
|
||
html += '<div class="muted" style="padding:14px">Nema zapisa.</div>';
|
||
} else {
|
||
html += '<table class="ri-tbl"><thead><tr><th>Datum</th><th>User</th><th>Akcija</th></tr></thead><tbody>';
|
||
for (const a of d.results) {
|
||
const dt = new Date(a.created_at).toLocaleString('hr-HR');
|
||
const who = a.email ? a.email : ('user#'+a.user_id);
|
||
html += `<tr><td class="mono" style="font-size:10px">${dt}</td><td>${who}</td><td class="mono" style="font-size:10px">${a.action||''}</td></tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
}
|
||
html += '<div style="text-align:right;margin-top:14px"><button class="btn" onclick="usrModalClose()">Zatvori</button></div>';
|
||
usrModal(html);
|
||
} catch(e) { alert('Greška: ' + e.message); }
|
||
}
|
||
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;
|
||
};
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// KATEGORIJE auto-recalculate when sport/datum changes
|
||
// ═══════════════════════════════════════════════════════
|
||
async function spRecalcCats() {
|
||
const sport = (document.getElementById('spSport')||{}).value;
|
||
const dob = (document.getElementById('spDob')||{}).value;
|
||
const preview = document.getElementById('spKatPreview');
|
||
if (!preview) return;
|
||
if (!sport || !dob) {
|
||
preview.style.display = 'none';
|
||
return;
|
||
}
|
||
preview.style.display = 'block';
|
||
preview.innerHTML = '<div class="muted">Računam kategoriju…</div>';
|
||
try {
|
||
const r = await fetch('/sport/api/v2/dobne-kategorije/auto-assign', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({datum_rodenja: dob, sport: sport})
|
||
});
|
||
const d = await r.json();
|
||
if (d.warning) {
|
||
preview.innerHTML = `<div class="muted">⚠️ ${d.warning}</div>`;
|
||
return;
|
||
}
|
||
let html = `<div style="font-size:11px;color:var(--text2);margin-bottom:6px">DOB: <b>${d.starost} godina</b> · referentna sezona ${d.referentna_godina}</div>`;
|
||
if (d.primary) {
|
||
html += `<div style="margin-bottom:6px">
|
||
<span class="muted" style="font-size:10px">Glavna kategorija:</span>
|
||
<span style="display:inline-block;padding:3px 10px;background:var(--accent);color:white;border-radius:4px;font-weight:600;margin-left:6px">
|
||
${d.primary.oznaka} · ${d.primary.naziv}
|
||
</span>
|
||
<span class="muted" style="font-size:10px;margin-left:6px">(${d.primary.organizacija})</span>
|
||
</div>`;
|
||
}
|
||
if (d.additional && d.additional.length) {
|
||
html += `<div style="margin-bottom:6px"><span class="muted" style="font-size:10px">+ Pripadne:</span> ${
|
||
d.additional.map(k => `<span style="display:inline-block;padding:2px 8px;background:var(--bg4);border:1px solid var(--border2);border-radius:4px;margin:0 4px;font-size:11px">${k.oznaka}</span>`).join('')
|
||
}</div>`;
|
||
}
|
||
if (d.promocije && d.promocije.length) {
|
||
html += `<div style="margin-top:8px;padding-top:8px;border-top:1px solid var(--border)">
|
||
<div class="muted" style="font-size:11px;margin-bottom:6px">📈 Mogućnost promocije u stariju selekciju (mladi se često priključuju glavnoj ekipi):</div>`;
|
||
d.promocije.forEach(k => {
|
||
html += `<label style="display:block;margin:3px 0;cursor:pointer;font-size:12px">
|
||
<input type="checkbox" name="spPromo" value="${k.oznaka}" style="margin-right:6px">
|
||
<b>${k.oznaka}</b> · ${k.naziv} <span class="muted" style="font-size:10px">(${k.min_godina||'?'}-${k.max_godina||'∞'} god, ${k.organizacija})</span>
|
||
</label>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
preview.innerHTML = html;
|
||
} catch(e) {
|
||
preview.innerHTML = `<div class="banner crit">Greška: ${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// KATEGORIJE PAGE — pregled i statistika po sportu
|
||
// ═══════════════════════════════════════════════════════
|
||
async function pageKategorije() {
|
||
const c = document.getElementById('content');
|
||
setTopbar('Dobne kategorije', 'Pravila iz HR sportskih saveza · auto-asssign po dobi');
|
||
c.innerHTML = '<div class="card" style="padding:20px">Učitavam…</div>';
|
||
try {
|
||
const d = await fetch('/sport/api/v2/dobne-kategorije/by-sport').then(r=>r.json());
|
||
let html = `<div class="card" style="padding:14px;margin-bottom:14px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||
<div>
|
||
<h3 style="margin:0">Dobne kategorije po sportu</h3>
|
||
<div class="muted" style="font-size:11px;margin-top:4px">${d.count} sportova s definiranim kategorijama prema pravilima HR saveza</div>
|
||
</div>
|
||
<button class="btn" onclick="recalcAllCategories()">↻ Recalc svih sportaša</button>
|
||
</div>
|
||
</div>`;
|
||
|
||
for (const sp of d.results) {
|
||
html += `<div class="card" style="margin-bottom:10px">
|
||
<div style="padding:10px 14px;background:var(--bg3);border-bottom:1px solid var(--border);display:flex;justify-content:space-between">
|
||
<h4 style="margin:0;text-transform:capitalize">${sp.sport}</h4>
|
||
<span class="muted" style="font-size:11px">${sp.broj} kategorija</span>
|
||
</div>
|
||
<table class="ri-tbl" style="margin:0">
|
||
<thead><tr><th>Oznaka</th><th>Naziv</th><th>Dob</th><th>Organizacija</th><th>Napomena</th><th>Promocija</th></tr></thead>
|
||
<tbody>`;
|
||
for (const k of sp.kategorije) {
|
||
const dobRange = `${k.min_godina ?? 0}-${k.max_godina ?? '∞'}`;
|
||
const promo = k.promocija_dozvoljena ? '✓' : '—';
|
||
html += `<tr>
|
||
<td><span style="display:inline-block;padding:2px 8px;background:var(--accent);color:white;border-radius:4px;font-weight:600;font-size:11px">${k.oznaka||'-'}</span></td>
|
||
<td><b>${k.naziv}</b></td>
|
||
<td class="mono">${dobRange}</td>
|
||
<td class="muted">${k.organizacija||'-'}</td>
|
||
<td class="muted" style="font-size:10px">${k.napomena||''}</td>
|
||
<td style="text-align:center">${promo}</td>
|
||
</tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
c.innerHTML = html;
|
||
} catch(e) { c.innerHTML = '<div class="banner crit">Greška: '+e.message+'</div>'; }
|
||
}
|
||
|
||
async function recalcAllCategories() {
|
||
if (!confirm('Pokrenuti recalc kategorija za sve sportaše? (može trajati ~20s)')) return;
|
||
try {
|
||
const r = await api('/api/v2/sportas/recalc-all-categories', { method:'POST' });
|
||
alert(`Updated: ${r.updated}, skipped: ${r.skipped_no_sport}, errors: ${r.errors}`);
|
||
} catch(e) { alert('Greška: ' + e.message); }
|
||
}
|
||
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// DOKUMENTI / PRAVILNICI / ZAKONI — RAG + AI Legal Expert
|
||
// ═══════════════════════════════════════════════════════
|
||
|
||
let _dokState = { filter_razina:'', filter_vrsta:'', filter_organizacija:'', filter_sport:'', q:'' };
|
||
|
||
async function pageDokumenti() {
|
||
const c = document.getElementById('content');
|
||
setTopbar('Pravilnici i zakoni', 'Baza znanja zakona, pravilnika, statuta i programa za sport u PGŽ · AI legal expert');
|
||
|
||
c.innerHTML = `
|
||
<div class="card" style="padding:14px;margin-bottom:14px">
|
||
<h3 style="margin:0 0 10px 0">🤖 Hybrid AI Agent <span class="muted" style="font-size:11px;font-weight:400">— SQL (operativni podaci) + RAG (zakoni i pravilnici)</span></h3>
|
||
<div style="display:flex;gap:8px;margin-bottom:10px">
|
||
<input id="dokAskQ" class="inp" style="flex:1" placeholder="Npr.: Tko je trener HNK Rijeke? · Sportski objekti u Crikvenici? · Koje obveze ima klub po Zakonu o sportu?" />
|
||
<button class="btn" onclick="dokAsk()">🤖 Pitaj</button>
|
||
</div>
|
||
<div id="dokAskResult"></div>
|
||
</div>
|
||
|
||
<div class="card" style="padding:14px;margin-bottom:14px">
|
||
<h4 style="margin:0 0 10px 0">📚 Pretraga dokumenata (RAG vector search)</h4>
|
||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr 1fr 1fr;gap:8px;margin-bottom:8px">
|
||
<input id="dokQ" class="inp" placeholder="Pretraga po sadržaju ili nazivu..." onkeydown="if(event.key==='Enter')dokSearch()" />
|
||
<select id="dokRazina" class="inp" onchange="dokLoadList()">
|
||
<option value="">— sve razine —</option>
|
||
<option>RH</option><option>EU</option><option>HOO</option>
|
||
<option>Savez</option><option>PGZ</option><option>Grad Rijeka</option>
|
||
</select>
|
||
<select id="dokVrsta" class="inp" onchange="dokLoadList()">
|
||
<option value="">— sve vrste —</option>
|
||
<option>zakon</option><option>pravilnik</option><option>pravilnik_savez</option>
|
||
<option>statut</option><option>strategija</option><option>program</option>
|
||
<option>plan</option><option>odluka</option><option>raspodjela</option>
|
||
<option>izvjestaj</option><option>natjecaj</option>
|
||
</select>
|
||
<select id="dokSport" class="inp" onchange="dokLoadList()">
|
||
<option value="">— svi sportovi —</option>
|
||
<option>nogomet</option><option>rukomet</option><option>košarka</option>
|
||
<option>odbojka</option><option>vaterpolo</option><option>plivanje</option>
|
||
<option>boćanje</option><option>tenis</option><option>stolni tenis</option>
|
||
<option>atletika</option><option>veslanje</option><option>jedriličarstvo</option>
|
||
<option>karate</option><option>judo</option><option>taekwondo</option>
|
||
<option>biciklizam</option><option>šah</option><option>lov</option>
|
||
</select>
|
||
<button class="btn" onclick="dokSearch()">🔎 Search</button>
|
||
</div>
|
||
<div id="dokSearchResult" style="margin-top:10px"></div>
|
||
</div>
|
||
|
||
<div id="dokListBox" class="card" style="padding:14px"></div>`;
|
||
|
||
dokLoadList();
|
||
}
|
||
|
||
async function dokLoadList() {
|
||
const r = document.getElementById('dokRazina').value;
|
||
const v = document.getElementById('dokVrsta').value;
|
||
const sp = document.getElementById('dokSport').value;
|
||
let url = '/sport/api/v2/dokumenti/list?limit=300';
|
||
if (r) url += '&razina=' + encodeURIComponent(r);
|
||
if (v) url += '&vrsta=' + encodeURIComponent(v);
|
||
if (sp) url += '&sport=' + encodeURIComponent(sp);
|
||
const box = document.getElementById('dokListBox');
|
||
box.innerHTML = '<div class="muted">Učitavam…</div>';
|
||
try {
|
||
const d = await fetch(url).then(x => x.json());
|
||
let html = `<div style="display:flex;justify-content:space-between;margin-bottom:10px">
|
||
<div><b>${d.count}</b> dokumenata</div>
|
||
<div class="muted" style="font-size:11px">${[r,v,sp].filter(Boolean).join(' · ') || 'svi filteri'}</div>
|
||
</div>`;
|
||
if (!d.results.length) {
|
||
html += '<div class="muted">Nema rezultata.</div>';
|
||
} else {
|
||
html += '<table class="ri-tbl" style="margin:0"><thead><tr><th>Razina</th><th>Vrsta</th><th>Naziv</th><th>Organizacija</th><th>Sport</th><th>Glasnik</th><th></th></tr></thead><tbody>';
|
||
for (const doc of d.results) {
|
||
const razinaCss = {
|
||
'RH':'background:#0066cc;color:white',
|
||
'EU':'background:#003399;color:white',
|
||
'HOO':'background:#fb923c;color:white',
|
||
'Savez':'background:#7c3aed;color:white',
|
||
'PGZ':'background:#10b981;color:white',
|
||
'Grad Rijeka':'background:#dc2626;color:white'
|
||
}[doc.razina] || 'background:var(--bg4);color:var(--text)';
|
||
html += `<tr>
|
||
<td><span style="display:inline-block;padding:2px 6px;border-radius:3px;font-size:10px;font-weight:600;${razinaCss}">${doc.razina||'-'}</span></td>
|
||
<td><span class="muted" style="font-size:10px">${doc.vrsta||'-'}</span></td>
|
||
<td><b style="cursor:pointer;color:var(--accent)" onclick="dokView(${doc.id})">${doc.naziv}</b>
|
||
<div class="muted" style="font-size:10px">${doc.kratak_opis||''}</div></td>
|
||
<td class="muted" style="font-size:11px">${doc.organizacija||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${doc.sport||'-'}</td>
|
||
<td class="muted" style="font-size:10px">${doc.sluzbeni_glasnik||'-'}</td>
|
||
<td>${doc.izvor_url ? `<a href="${doc.izvor_url}" target="_blank" class="btn" style="padding:2px 8px;font-size:10px">↗ Izvor</a>`: ''}</td>
|
||
</tr>`;
|
||
}
|
||
html += '</tbody></table>';
|
||
}
|
||
box.innerHTML = html;
|
||
} catch(e) { box.innerHTML = '<div class="banner crit">Greška: ' + e.message + '</div>'; }
|
||
}
|
||
|
||
async function dokSearch() {
|
||
const q = document.getElementById('dokQ').value.trim();
|
||
const r = document.getElementById('dokRazina').value || null;
|
||
const sp = document.getElementById('dokSport').value || null;
|
||
const box = document.getElementById('dokSearchResult');
|
||
if (!q) { box.innerHTML = ''; return; }
|
||
box.innerHTML = '<div class="muted">RAG vector search…</div>';
|
||
try {
|
||
const body = { q: q, limit: 8 };
|
||
if (r) body.razina = r;
|
||
if (sp) body.sport = sp;
|
||
const d = await fetch('/sport/api/v2/dokumenti/search', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify(body)
|
||
}).then(x => x.json());
|
||
if (!d.results || !d.results.length) {
|
||
box.innerHTML = '<div class="muted">Nema RAG rezultata.</div>'; return;
|
||
}
|
||
let html = `<div class="muted" style="font-size:11px;margin-bottom:8px">RAG vector search — ${d.count} pogodaka:</div>`;
|
||
for (const r of d.results) {
|
||
html += `<div style="padding:10px;margin-bottom:8px;border:1px solid var(--border);border-radius:6px;background:var(--bg3)">
|
||
<div style="display:flex;justify-content:space-between;margin-bottom:4px">
|
||
<b style="cursor:pointer;color:var(--accent)" onclick="dokView(${r.dokument_id})">${r.naziv}</b>
|
||
<span class="muted" style="font-size:10px">score ${r.score}</span>
|
||
</div>
|
||
<div style="font-size:11px;color:var(--text2);margin-bottom:6px">${r.razina||''} · ${r.organizacija||''} · ${r.vrsta||''} ${r.sport ? '· '+r.sport : ''}</div>
|
||
<div style="font-size:12px;color:var(--text);line-height:1.5">${r.snippet||''}</div>
|
||
${r.izvor_url ? `<a href="${r.izvor_url}" target="_blank" class="muted" style="font-size:10px;margin-top:6px;display:inline-block">↗ ${r.izvor_url}</a>` : ''}
|
||
</div>`;
|
||
}
|
||
box.innerHTML = html;
|
||
} catch(e) { box.innerHTML = '<div class="banner crit">Greška: ' + e.message + '</div>'; }
|
||
}
|
||
|
||
async function dokAsk() {
|
||
const q = document.getElementById('dokAskQ').value.trim();
|
||
const box = document.getElementById('dokAskResult');
|
||
if (!q) return;
|
||
box.innerHTML = '<div class="muted">🤖 Hybrid AI agent razmišlja… (SQL + RAG)</div>';
|
||
try {
|
||
const d = await fetch('/sport/api/v2/ai/ask', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({q: q})
|
||
}).then(x => x.json());
|
||
|
||
const modeColor = {'SQL':'#10b981','RAG':'#7c3aed','BOTH':'#f59e0b','sql_error':'#dc2626','error':'#dc2626'}[d.mode]||'var(--accent)';
|
||
let html = `<div style="padding:14px;background:var(--bg3);border:1px solid var(--border);border-radius:8px;margin-top:8px">
|
||
<div style="font-size:11px;color:var(--text2);margin-bottom:10px;display:flex;justify-content:space-between">
|
||
<span>🤖 ODGOVOR (Hybrid AI)</span>
|
||
<span><span style="display:inline-block;padding:2px 8px;border-radius:3px;background:${modeColor};color:white;font-weight:600">${d.mode||'?'}</span> ${d.sql_count!==undefined?`<span class="muted">${d.sql_count} retka</span>`:''}</span>
|
||
</div>
|
||
<div style="white-space:pre-wrap;line-height:1.6;font-size:13px;color:var(--text-bright)">${(d.answer||'').replace(/</g,'<')}</div>
|
||
${d.sql ? `<details style="margin-top:10px"><summary class="muted" style="cursor:pointer;font-size:11px">▸ generated SQL</summary><pre style="font-size:10px;background:var(--bg);padding:8px;border-radius:3px;overflow-x:auto;color:var(--text2)">${d.sql.replace(/</g,'<')}</pre></details>` : ''}`;
|
||
|
||
if (d.sources && d.sources.length) {
|
||
html += `<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
|
||
<div style="font-size:11px;color:var(--text2);margin-bottom:8px">📚 IZVORI (RAG retrieval):</div>`;
|
||
for (const src of d.sources) {
|
||
html += `<div style="margin:4px 0;font-size:11px">
|
||
<span style="display:inline-block;width:24px;color:var(--accent);font-weight:600">[${src.n}]</span>
|
||
<b style="cursor:pointer;color:var(--accent)" onclick="dokView(${src.dokument_id||''})">${src.naziv||'?'}</b>
|
||
<span class="muted">· ${src.razina||''} · ${src.organizacija||''} · score ${src.score}</span>
|
||
${src.izvor_url ? ` <a href="${src.izvor_url}" target="_blank" class="muted">↗</a>` : ''}
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
if (d.sql) {
|
||
html += `<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
|
||
<div class="muted" style="font-size:11px;margin-bottom:6px">📜 SQL (auto-generated):</div>
|
||
<pre style="background:var(--bg);padding:8px;border-radius:4px;font-size:10px;overflow-x:auto;color:var(--text2);margin:0">${d.sql}</pre>
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
box.innerHTML = html;
|
||
} catch(e) { box.innerHTML = '<div class="banner crit">Greška: ' + e.message + '</div>'; }
|
||
}
|
||
|
||
async function dokView(did) {
|
||
if (!did) return;
|
||
try {
|
||
const d = await fetch('/sport/api/v2/dokumenti/' + did).then(x => x.json());
|
||
const doc = d.dokument;
|
||
let html = `<div class="card" style="padding:18px;margin-bottom:14px">
|
||
<button class="btn" onclick="pageDokumenti()" style="margin-bottom:10px;padding:4px 10px;font-size:11px">← natrag</button>
|
||
<h2 style="margin:0 0 6px 0">${doc.naziv}</h2>
|
||
<div class="muted" style="font-size:11px;margin-bottom:14px">${doc.razina||''} · ${doc.organizacija||''} · ${doc.vrsta||''} ${doc.sport ? '· '+doc.sport : ''}</div>
|
||
${doc.kratak_opis ? `<div style="margin-bottom:14px;padding:10px;background:var(--bg3);border-radius:6px;font-size:13px">${doc.kratak_opis}</div>` : ''}
|
||
<table style="width:100%;font-size:12px">
|
||
${doc.sluzbeni_glasnik ? `<tr><td class="muted" style="width:160px">Službeni glasnik</td><td><b>${doc.sluzbeni_glasnik}</b></td></tr>` : ''}
|
||
${doc.izdano_datum ? `<tr><td class="muted">Izdano</td><td>${doc.izdano_datum}</td></tr>` : ''}
|
||
${doc.izvor_url ? `<tr><td class="muted">Izvor</td><td><a href="${doc.izvor_url}" target="_blank" class="muted">${doc.izvor_url}</a></td></tr>` : ''}
|
||
${doc.kljucne_rijeci && doc.kljucne_rijeci.length ? `<tr><td class="muted">Ključne riječi</td><td>${doc.kljucne_rijeci.map(k=>`<span style='display:inline-block;padding:2px 8px;background:var(--bg4);border-radius:3px;font-size:10px;margin:0 4px 4px 0'>${k}</span>`).join('')}</td></tr>` : ''}
|
||
</table>
|
||
</div>`;
|
||
if (doc.sadrzaj && doc.sadrzaj.length > 100) {
|
||
html += `<div class="card" style="padding:18px;margin-bottom:14px">
|
||
<h4 style="margin:0 0 10px 0">📄 Puni sadržaj</h4>
|
||
<div style="white-space:pre-wrap;font-size:12px;line-height:1.6;max-height:600px;overflow-y:auto">${doc.sadrzaj.replace(/</g,'<')}</div>
|
||
</div>`;
|
||
}
|
||
if (d.chunks && d.chunks.length) {
|
||
html += `<div class="card" style="padding:18px">
|
||
<h4 style="margin:0 0 10px 0">🧩 Chunks (${d.chunks.length}) — embedded u Qdrant</h4>`;
|
||
for (const ch of d.chunks.slice(0,8)) {
|
||
html += `<div style="margin:8px 0;padding:8px;background:var(--bg3);border-radius:4px;font-size:11px;line-height:1.5">${(ch.chunk_text||'').slice(0,400)}…</div>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
document.getElementById('content').innerHTML = html;
|
||
} catch(e) { alert('Greška: ' + e.message); }
|
||
}
|
||
|
||
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// PGŽ BAZA — agregat zaboravljenih tablica
|
||
// ═══════════════════════════════════════════════════════
|
||
let _bazaTab = 'objekti';
|
||
|
||
async function pageBaza() {
|
||
setTopbar('PGŽ baza znanja', 'Objekti · Natjecanja · Manifestacije · Najbolji · Potpore · Statistika · Vijesti');
|
||
const c = document.getElementById('content');
|
||
c.innerHTML = `
|
||
<div class="card" style="padding:0;margin-bottom:14px;overflow:hidden">
|
||
<div style="display:flex;border-bottom:1px solid var(--border);background:var(--bg3)">
|
||
<button class="bazaTab" data-t="objekti" onclick="bazaSetTab('objekti')">🏟️ Objekti</button>
|
||
<button class="bazaTab" data-t="natjecanja" onclick="bazaSetTab('natjecanja')">🏆 Natjecanja</button>
|
||
<button class="bazaTab" data-t="manifestacije" onclick="bazaSetTab('manifestacije')">🎉 Manifestacije</button>
|
||
<button class="bazaTab" data-t="najbolji" onclick="bazaSetTab('najbolji')">⭐ Najbolji</button>
|
||
<button class="bazaTab" data-t="potpore" onclick="bazaSetTab('potpore')">💰 Potpore</button>
|
||
<button class="bazaTab" data-t="statistika" onclick="bazaSetTab('statistika')">📊 Statistika</button>
|
||
<button class="bazaTab" data-t="vijesti" onclick="bazaSetTab('vijesti')">📰 Vijesti</button>
|
||
<button class="bazaTab" data-t="suci" onclick="bazaSetTab('suci')">👨⚖️ Suci</button>
|
||
<button class="bazaTab" data-t="treneri" onclick="bazaSetTab('treneri')">🎽 Treneri</button>
|
||
<button class="bazaTab" data-t="sponzori" onclick="bazaSetTab('sponzori')">🤝 Sponzori</button>
|
||
<button class="bazaTab" data-t="mediji" onclick="bazaSetTab('mediji')">📺 Mediji</button>
|
||
<button class="bazaTab" data-t="akademski" onclick="bazaSetTab('akademski')">🎓 Akademski</button>
|
||
<button class="bazaTab" data-t="hoo" onclick="bazaSetTab('hoo')">🏅 HOO kategorizirani</button>
|
||
<button class="bazaTab" data-t="stats2025" onclick="bazaSetTab('stats2025')">📊 Stats 2025</button>
|
||
</div>
|
||
<style>.bazaTab{padding:10px 16px;background:transparent;color:var(--text2);border:none;cursor:pointer;font-size:12px;border-right:1px solid var(--border)}.bazaTab.act{background:var(--bg);color:var(--accent);font-weight:600;border-bottom:2px solid var(--accent)}</style>
|
||
</div>
|
||
<div id="bazaBody"></div>`;
|
||
bazaSetTab(_bazaTab);
|
||
}
|
||
|
||
async function bazaSetTab(t) {
|
||
_bazaTab = t;
|
||
document.querySelectorAll('.bazaTab').forEach(b => b.classList.toggle('act', b.dataset.t === t));
|
||
const box = document.getElementById('bazaBody');
|
||
if (!box) return;
|
||
box.innerHTML = '<div class="card" style="padding:14px"><div class="muted">Učitavam…</div></div>';
|
||
try {
|
||
if (t === 'objekti') await bazaObjekti(box);
|
||
else if (t === 'natjecanja') await bazaNatjecanja(box);
|
||
else if (t === 'manifestacije') await bazaManifestacije(box);
|
||
else if (t === 'najbolji') await bazaNajbolji(box);
|
||
else if (t === 'potpore') await bazaPotpore(box);
|
||
else if (t === 'statistika') await bazaStatistika(box);
|
||
else if (t === 'vijesti') await bazaVijesti(box);
|
||
else if (t === 'suci') await bazaSuci(box);
|
||
else if (t === 'treneri') await bazaTreneri(box);
|
||
else if (t === 'sponzori') await bazaSponzori(box);
|
||
else if (t === 'mediji') await bazaMediji(box);
|
||
else if (t === 'akademski') await bazaAkademski(box);
|
||
else if (t === 'hoo') await bazaKategorizirani(box);
|
||
else if (t === 'stats2025') await bazaStats2025(box);
|
||
} catch(e) { box.innerHTML = '<div class="banner crit">Greška: ' + e.message + '</div>'; }
|
||
}
|
||
|
||
async function bazaObjekti(box) {
|
||
const d = await fetch('/sport/api/v2/objekti/list').then(r => r.json());
|
||
const grouped = {};
|
||
for (const o of d.results) (grouped[o.grad||'-'] = grouped[o.grad||'-']||[]).push(o);
|
||
let html = `<div class="card" style="padding:14px"><h3 style="margin:0 0 10px 0">🏟️ Sportski objekti PGŽ <span class="muted" style="font-size:11px">${d.count} objekata u ${Object.keys(grouped).length} gradova</span></h3>`;
|
||
for (const grad of Object.keys(grouped).sort()) {
|
||
html += `<div style="margin:14px 0"><h4 style="margin:0 0 6px 0;color:var(--accent)">${grad} <span class="muted" style="font-size:11px;font-weight:400">(${grouped[grad].length})</span></h4>`;
|
||
html += '<table class="ri-tbl" style="margin:0"><thead><tr><th>Naziv</th><th>Tip</th><th>Adresa</th><th>Upravitelj</th><th>Kapacitet</th><th>Sportovi</th><th>God</th><th>Web</th></tr></thead><tbody>';
|
||
for (const o of grouped[grad]) {
|
||
html += `<tr><td><b>${o.naziv}</b></td>
|
||
<td><span style="display:inline-block;padding:2px 6px;background:var(--bg4);border-radius:3px;font-size:10px">${o.tip}</span></td>
|
||
<td class="muted" style="font-size:11px">${o.adresa||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${o.upravitelj||'-'}</td>
|
||
<td class="mono">${o.kapacitet ? Number(o.kapacitet).toLocaleString() : '-'}</td>
|
||
<td class="muted" style="font-size:10px">${(o.sportovi||[]).join(', ')}</td>
|
||
<td class="mono">${o.izgradeno||'-'}</td>
|
||
<td>${o.web ? `<a href="${o.web}" target="_blank" class="muted">↗</a>` : ''}</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
html += '</div>';
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
async function bazaNatjecanja(box) {
|
||
const d = await fetch('/sport/api/v2/natjecanja/list?limit=300').then(r => r.json());
|
||
const bySport = {};
|
||
for (const n of d.results) (bySport[n.sport||'-'] = bySport[n.sport||'-']||[]).push(n);
|
||
let html = `<div class="card" style="padding:14px"><h3 style="margin:0 0 10px 0">🏆 Natjecanja <span class="muted" style="font-size:11px">${d.count} natjecanja</span></h3>`;
|
||
for (const sport of Object.keys(bySport).sort()) {
|
||
html += `<div style="margin:10px 0"><h4 style="margin:0 0 6px 0">${sport} <span class="muted" style="font-size:11px;font-weight:400">(${bySport[sport].length})</span></h4>`;
|
||
html += '<table class="ri-tbl" style="margin:0"><thead><tr><th>Naziv</th><th>Razina</th><th>Tip</th><th>Sezona</th><th>Kategorija</th><th>Početak</th><th>Status</th></tr></thead><tbody>';
|
||
for (const n of bySport[sport].slice(0, 30)) {
|
||
html += `<tr><td><b>${n.naziv}</b></td><td class="muted" style="font-size:11px">${n.razina||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${n.tip||'-'}</td>
|
||
<td class="mono" style="font-size:11px">${n.sezona||'-'}</td>
|
||
<td class="muted" style="font-size:10px">${n.kategorija||'-'}</td>
|
||
<td class="mono" style="font-size:11px">${n.datum_pocetka||'-'}</td>
|
||
<td><span style="display:inline-block;padding:2px 6px;border-radius:3px;font-size:10px;background:${n.status==='aktivno'?'#10b981':'#fb923c'};color:white">${n.status||'-'}</span></td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
html += '</div>';
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
async function bazaManifestacije(box) {
|
||
const d = await fetch('/sport/api/v2/manifestacije/list').then(r => r.json());
|
||
let html = `<div class="card" style="padding:14px"><h3 style="margin:0 0 10px 0">🎉 Manifestacije <span class="muted" style="font-size:11px">${d.count} manifestacija</span></h3>`;
|
||
html += '<table class="ri-tbl" style="margin:0"><thead><tr><th>Naziv</th><th>Mjesto</th><th>Organizator</th><th>Razina</th><th>Sudionici</th><th>Od godine</th><th>Savez</th></tr></thead><tbody>';
|
||
for (const m of d.results) {
|
||
html += `<tr><td><b>${m.naziv}</b></td>
|
||
<td>${m.mjesto||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${m.organizator||'-'}</td>
|
||
<td><span style="display:inline-block;padding:2px 6px;background:var(--bg4);border-radius:3px;font-size:10px">${m.razina||'-'}</span></td>
|
||
<td class="muted">${m.broj_ucesnika||'-'}</td>
|
||
<td class="mono">${m.godina_od||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${m.savez_naziv||'-'}</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
async function bazaNajbolji(box) {
|
||
const d = await fetch('/sport/api/v2/najbolji/list').then(r => r.json());
|
||
const byGod = {};
|
||
for (const n of d.results) (byGod[n.godina] = byGod[n.godina]||[]).push(n);
|
||
let html = `<div class="card" style="padding:14px"><h3 style="margin:0 0 10px 0">⭐ Najbolji sportaši PGŽ <span class="muted" style="font-size:11px">${d.count} priznanja kroz godine</span></h3>`;
|
||
for (const god of Object.keys(byGod).sort().reverse()) {
|
||
html += `<div style="margin:14px 0"><h4 style="margin:0 0 6px 0;color:var(--accent)">${god}</h4>`;
|
||
html += '<table class="ri-tbl" style="margin:0"><thead><tr><th>Kategorija</th><th>Ime</th><th>Klub</th><th>Sport</th></tr></thead><tbody>';
|
||
for (const n of byGod[god]) {
|
||
html += `<tr><td><b>${n.kategorija}</b></td>
|
||
<td>${n.ime_prezime||'-'}</td>
|
||
<td class="muted">${n.klub||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${n.sport||'-'}</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
}
|
||
html += '</div>';
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
async function bazaPotpore(box) {
|
||
const summary = await fetch('/sport/api/v2/potpore/by-godina').then(r => r.json());
|
||
const all = await fetch('/sport/api/v2/potpore/list').then(r => r.json());
|
||
let html = `<div class="card" style="padding:14px"><h3 style="margin:0 0 10px 0">💰 Potpore nositeljima kvalitete <span class="muted" style="font-size:11px">${all.count} isplata · ${all.total_iznos.toLocaleString()} EUR ukupno</span></h3>`;
|
||
html += '<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:14px">';
|
||
for (const s of summary.results) {
|
||
html += `<div style="padding:10px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;text-align:center">
|
||
<div class="muted" style="font-size:11px">${s.godina}</div>
|
||
<div style="font-size:18px;font-weight:700;color:var(--accent)">${Number(s.ukupno).toLocaleString()} €</div>
|
||
<div class="muted" style="font-size:10px">${s.broj} potpora</div></div>`;
|
||
}
|
||
html += '</div>';
|
||
html += '<table class="ri-tbl" style="margin:0"><thead><tr><th>Godina</th><th>Klub</th><th>Sport</th><th>Iznos</th><th>Napomena</th></tr></thead><tbody>';
|
||
for (const p of all.results) {
|
||
html += `<tr><td class="mono">${p.godina}</td>
|
||
<td><b>${p.naziv_kluba}</b></td>
|
||
<td class="muted" style="font-size:11px">${p.sport||'-'}</td>
|
||
<td class="mono" style="text-align:right;font-weight:600">${Number(p.iznos).toLocaleString()} €</td>
|
||
<td class="muted" style="font-size:10px">${p.napomena||''}</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
async function bazaStatistika(box) {
|
||
const d = await fetch('/sport/api/v2/statistika/list?godina=2024').then(r => r.json());
|
||
let html = `<div class="card" style="padding:14px"><h3 style="margin:0 0 10px 0">📊 Statistika saveza 2024 <span class="muted" style="font-size:11px">${d.count} saveza</span></h3>`;
|
||
html += '<table class="ri-tbl" style="margin:0"><thead><tr><th>Savez</th><th>Klubova članica</th><th>Kategoriziranih</th><th>Registriranih</th><th>Rekreativaca</th><th>Trenera</th><th>Reprezent.</th><th>Stipend.</th></tr></thead><tbody>';
|
||
for (const r of d.results) {
|
||
html += `<tr><td><b>${r.savez_naziv}</b></td>
|
||
<td class="mono" style="text-align:right">${r.klubova_clanica}</td>
|
||
<td class="mono" style="text-align:right">${r.kategoriziranih}</td>
|
||
<td class="mono" style="text-align:right">${r.registriranih}</td>
|
||
<td class="mono" style="text-align:right">${r.rekreativaca}</td>
|
||
<td class="mono" style="text-align:right">${r.trenera}</td>
|
||
<td class="mono" style="text-align:right">${r.reprezentativaca}</td>
|
||
<td class="mono" style="text-align:right">${r.stipendiranih}</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
async function bazaKategorizirani(box) {
|
||
const d = await fetch('/sport/api/v2/kategorizirani/list').then(r=>r.json());
|
||
const byKat = {};
|
||
for (const x of d.results) (byKat[x.hoo_kategorija]=byKat[x.hoo_kategorija]||[]).push(x);
|
||
let h = `<div class="card" style="padding:14px"><h3>🏅 HOO kategorizirani sportaši PGŽ <span class="muted" style="font-size:11px">${d.count} sportaša · izvor: Sportski godišnjak ZS PGŽ 2025</span></h3>`;
|
||
const labels = {'I':'I (vrhunski svjetski)','II':'II (međunarodni)','III':'III (državni)','IV':'IV (mladi)','V':'V (perspektivni)','VI':'VI (lokalni)'};
|
||
const colors = {'I':'#dc2626','II':'#fb923c','III':'#a855f7','IV':'#0ea5e9','V':'#10b981','VI':'#6b7280'};
|
||
for (const kat of Object.keys(byKat).sort()) {
|
||
h += `<div style="margin:14px 0">
|
||
<h4 style="margin:0 0 8px 0;color:${colors[kat]||'var(--accent)'}">${labels[kat]||kat} <span class="muted" style="font-size:11px;font-weight:400">(${byKat[kat].length})</span></h4>`;
|
||
h += '<table class="ri-tbl"><thead><tr><th>Ime</th><th>Sport</th><th>Klub</th><th>Mjesto</th><th>Vrijedi</th></tr></thead><tbody>';
|
||
for (const x of byKat[kat]) {
|
||
h += `<tr>
|
||
<td><b>${x.ime} ${x.prezime||''}</b></td>
|
||
<td><span style="display:inline-block;padding:2px 6px;background:var(--bg4);border-radius:3px;font-size:10px">${x.sport||'-'}</span></td>
|
||
<td>${x.klub_naziv||'-'}</td>
|
||
<td class="muted">${x.mjesto_rodenja||'-'}</td>
|
||
<td class="muted" style="font-size:10px">${x.hoo_kategorija_od||''} - ${x.hoo_kategorija_do||''}</td>
|
||
</tr>`;
|
||
}
|
||
h += '</tbody></table></div>';
|
||
}
|
||
h += '</div>';
|
||
box.innerHTML = h;
|
||
}
|
||
|
||
async function bazaStats2025(box) {
|
||
const d = await fetch('/sport/api/v2/statistika-2025').then(r=>r.json());
|
||
let h = `<div class="card" style="padding:14px">
|
||
<h3>📊 Sportaši PGŽ 2025 po savezu <span class="muted" style="font-size:11px">${d.izvor}</span></h3>
|
||
<div style="margin:10px 0;font-size:11px;color:var(--text2)">Ukupno: <b>${d.ukupno}</b> sportaša pregledano u sportskoj ambulanti 2025.</div>`;
|
||
h += '<table class="ri-tbl"><thead><tr><th>Savez</th><th style="text-align:right">Registriranih</th><th>Bar</th></tr></thead><tbody>';
|
||
const max = Math.max(...d.results.map(r=>r.registriranih));
|
||
for (const r of d.results) {
|
||
const w = Math.round(r.registriranih / max * 100);
|
||
h += `<tr>
|
||
<td><b>${r.savez}</b></td>
|
||
<td class="mono" style="text-align:right;font-weight:600">${r.registriranih.toLocaleString()}</td>
|
||
<td><div style="width:200px;height:14px;background:var(--bg4);border-radius:4px"><div style="width:${w}%;height:100%;background:var(--accent);border-radius:4px"></div></div></td>
|
||
</tr>`;
|
||
}
|
||
h += '</tbody></table></div>';
|
||
box.innerHTML = h;
|
||
}
|
||
|
||
async function bazaSuci(box) {
|
||
const d = await fetch('/sport/api/v2/suci/list').then(r=>r.json());
|
||
const bySport = {};
|
||
for (const x of d.results) (bySport[x.sport]=bySport[x.sport]||[]).push(x);
|
||
let h = `<div class="card" style="padding:14px"><h3>👨⚖️ Suci PGŽ <span class="muted" style="font-size:11px">${d.count} sudaca u ${Object.keys(bySport).length} sportova</span></h3>`;
|
||
for (const sp of Object.keys(bySport).sort()) {
|
||
h += `<div style="margin:10px 0"><h4 style="margin:0 0 6px 0;color:var(--accent)">${sp} <span class="muted" style="font-size:11px;font-weight:400">(${bySport[sp].length})</span></h4>`;
|
||
h += '<table class="ri-tbl"><thead><tr><th>Ime</th><th>Licenca</th><th>Razina</th><th>Org</th><th>Grad</th></tr></thead><tbody>';
|
||
for (const x of bySport[sp]) {
|
||
h += `<tr><td><b>${x.ime} ${x.prezime||''}</b></td>
|
||
<td><span style="display:inline-block;padding:2px 6px;background:var(--bg4);border-radius:3px;font-size:10px">${x.licenca||'-'}</span></td>
|
||
<td class="muted" style="font-size:11px">${x.kategorija||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${x.organizacija||'-'}</td>
|
||
<td class="muted">${x.grad||'-'}</td></tr>`;
|
||
}
|
||
h += '</tbody></table></div>';
|
||
}
|
||
h += '</div>';
|
||
box.innerHTML = h;
|
||
}
|
||
|
||
async function bazaTreneri(box) {
|
||
const d = await fetch('/sport/api/v2/treneri/list').then(r=>r.json());
|
||
let h = `<div class="card" style="padding:14px"><h3>🎽 Treneri PGŽ <span class="muted" style="font-size:11px">${d.count} trenera</span></h3>`;
|
||
h += '<table class="ri-tbl"><thead><tr><th>Sport</th><th>Ime</th><th>Klub</th><th>Pozicija</th><th>Licenca</th><th>Grad</th></tr></thead><tbody>';
|
||
for (const x of d.results) {
|
||
h += `<tr><td><span style="display:inline-block;padding:2px 6px;background:var(--bg4);border-radius:3px;font-size:10px">${x.sport}</span></td>
|
||
<td><b>${x.ime} ${x.prezime||''}</b></td>
|
||
<td>${x.klub_naziv||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${x.pozicija||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${x.licenca||'-'}</td>
|
||
<td class="muted">${x.grad||'-'}</td></tr>`;
|
||
}
|
||
h += '</tbody></table></div>';
|
||
box.innerHTML = h;
|
||
}
|
||
|
||
async function bazaSponzori(box) {
|
||
const d = await fetch('/sport/api/v2/sponzori/list').then(r=>r.json());
|
||
const byKlub = {};
|
||
for (const x of d.results) (byKlub[x.naziv_kluba]=byKlub[x.naziv_kluba]||[]).push(x);
|
||
let h = `<div class="card" style="padding:14px"><h3>🤝 Sponzori PGŽ <span class="muted" style="font-size:11px">${d.count} sponzorskih ugovora · ${Object.keys(byKlub).length} klubova</span></h3>`;
|
||
for (const klub of Object.keys(byKlub).sort()) {
|
||
h += `<div style="margin:10px 0"><h4 style="margin:0 0 6px 0;color:var(--accent)">${klub}</h4>`;
|
||
h += '<table class="ri-tbl"><thead><tr><th>Sponzor</th><th>Tip</th><th>Od</th><th>Iznos</th><th>Napomena</th></tr></thead><tbody>';
|
||
for (const x of byKlub[klub]) {
|
||
h += `<tr><td><b>${x.sponzor}</b></td>
|
||
<td><span style="display:inline-block;padding:2px 6px;background:var(--bg4);border-radius:3px;font-size:10px">${x.tip||'-'}</span></td>
|
||
<td class="mono" style="font-size:11px">${x.razdoblje_od||'-'}</td>
|
||
<td class="mono">${x.iznos_eur ? Number(x.iznos_eur).toLocaleString()+' €' : '-'}</td>
|
||
<td class="muted" style="font-size:10px">${x.napomena||''}</td></tr>`;
|
||
}
|
||
h += '</tbody></table></div>';
|
||
}
|
||
h += '</div>';
|
||
box.innerHTML = h;
|
||
}
|
||
|
||
async function bazaMediji(box) {
|
||
const d = await fetch('/sport/api/v2/mediji/list').then(r=>r.json());
|
||
const byTip = {};
|
||
for (const x of d.results) (byTip[x.tip]=byTip[x.tip]||[]).push(x);
|
||
let h = `<div class="card" style="padding:14px"><h3>📺 Sportski mediji PGŽ <span class="muted" style="font-size:11px">${d.count} medija</span></h3>`;
|
||
for (const tip of Object.keys(byTip).sort()) {
|
||
h += `<div style="margin:10px 0"><h4 style="margin:0 0 6px 0;color:var(--accent)">${tip} <span class="muted" style="font-size:11px;font-weight:400">(${byTip[tip].length})</span></h4>`;
|
||
h += '<table class="ri-tbl"><thead><tr><th>Naziv</th><th>Grad</th><th>Vlasnik</th><th>Pokrivenost</th><th>Sport</th><th>Web</th></tr></thead><tbody>';
|
||
for (const x of byTip[tip]) {
|
||
h += `<tr><td><b>${x.naziv}</b></td>
|
||
<td>${x.grad||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${x.vlasnik||'-'}</td>
|
||
<td><span style="display:inline-block;padding:2px 6px;background:var(--bg4);border-radius:3px;font-size:10px">${x.pokrivenost||'-'}</span></td>
|
||
<td class="muted" style="font-size:11px">${(x.sport_fokus||[]).join(', ')}</td>
|
||
<td>${x.web ? `<a href="${x.web}" target="_blank" class="muted">↗</a>` : ''}</td></tr>`;
|
||
}
|
||
h += '</tbody></table></div>';
|
||
}
|
||
h += '</div>';
|
||
box.innerHTML = h;
|
||
}
|
||
|
||
async function bazaAkademski(box) {
|
||
const d = await fetch('/sport/api/v2/akademski/list').then(r=>r.json());
|
||
let h = `<div class="card" style="padding:14px"><h3>🎓 Akademski sport (UNIRI) <span class="muted" style="font-size:11px">${d.count} klubova</span></h3>`;
|
||
h += '<table class="ri-tbl"><thead><tr><th>Klub</th><th>Fakultet</th><th>Sport</th><th>Razina</th><th>Članova</th><th>Web</th></tr></thead><tbody>';
|
||
for (const x of d.results) {
|
||
h += `<tr><td><b>${x.naziv}</b></td>
|
||
<td class="muted">${x.fakultet||'-'}</td>
|
||
<td>${x.sport}</td>
|
||
<td><span style="display:inline-block;padding:2px 6px;background:var(--bg4);border-radius:3px;font-size:10px">${x.razina||'-'}</span></td>
|
||
<td class="mono">${x.broj_clanova||'-'}</td>
|
||
<td>${x.web ? `<a href="${x.web}" target="_blank" class="muted">↗</a>` : ''}</td></tr>`;
|
||
}
|
||
h += '</tbody></table></div>';
|
||
box.innerHTML = h;
|
||
}
|
||
|
||
async function bazaVijesti(box) {
|
||
const d = await fetch('/sport/api/v2/vijesti/list?limit=50').then(r => r.json());
|
||
let html = `<div class="card" style="padding:14px"><h3 style="margin:0 0 10px 0">📰 Vijesti <span class="muted" style="font-size:11px">${d.count} vijesti</span></h3>`;
|
||
for (const v of d.results) {
|
||
html += `<div style="padding:10px;border-bottom:1px solid var(--border)">
|
||
<div style="display:flex;justify-content:space-between;align-items:start">
|
||
<div style="flex:1">
|
||
<b>${v.naslov||'?'}</b>
|
||
${v.kategorija ? `<span style="display:inline-block;padding:2px 6px;background:var(--bg4);border-radius:3px;font-size:10px;margin-left:8px">${v.kategorija}</span>` : ''}
|
||
${v.sazetak ? `<div class="muted" style="font-size:11px;margin-top:4px">${v.sazetak.slice(0,200)}…</div>` : ''}
|
||
</div>
|
||
<div class="muted" style="font-size:11px;margin-left:14px">${v.datum||''}</div>
|
||
</div>
|
||
${v.url ? `<a href="${v.url}" target="_blank" class="muted" style="font-size:10px">↗ ${v.url.slice(0,60)}</a>` : ''}
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
box.innerHTML = html;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// FUNKCIONARI PGŽ — IO/NO/Skupštinari saveza i klubova
|
||
// ═══════════════════════════════════════════════════════
|
||
async function pageFunkcionari() {
|
||
const c = document.getElementById('content');
|
||
setTopbar('Funkcionari PGŽ', 'Sportski rukovoditelji — saveza, klubova, skupština');
|
||
c.innerHTML = `
|
||
<div class="card" style="margin-bottom:14px;padding:14px">
|
||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||
<input id="fSearch" class="inp" placeholder="🔍 traži ime / prezime / funkcija / organizacija" style="flex:1;min-width:260px" oninput="fSearchType()">
|
||
<input id="fSportFilter" class="inp" placeholder="sport (npr. nogomet)" style="min-width:180px" oninput="fSearchType()">
|
||
<span class="muted" style="font-size:11px" id="fCount">—</span>
|
||
</div>
|
||
</div>
|
||
<div id="fGrid" class="card" style="padding:14px">Učitavam…</div>`;
|
||
await fLoad();
|
||
}
|
||
let _fT = null;
|
||
function fSearchType() { clearTimeout(_fT); _fT = setTimeout(fLoad, 300); }
|
||
async function fLoad() {
|
||
const q = (document.getElementById('fSearch')||{}).value || '';
|
||
const sp = (document.getElementById('fSportFilter')||{}).value || '';
|
||
const params = new URLSearchParams();
|
||
if (q) params.set('q', q);
|
||
if (sp) params.set('sport', sp);
|
||
params.set('limit', '300');
|
||
const grid = document.getElementById('fGrid');
|
||
try {
|
||
const d = await fetch('/sport/api/v2/osobe-funkcije/list?'+params.toString()).then(r=>r.json());
|
||
document.getElementById('fCount').textContent = `${d.count} funkcionara`;
|
||
if (!d.results || !d.results.length) {
|
||
grid.innerHTML = '<div class="muted" style="padding:40px;text-align:center">Nema rezultata.</div>';
|
||
return;
|
||
}
|
||
// Group by organizacija
|
||
const groups = {};
|
||
for (const r of d.results) {
|
||
const k = r.organizacija || r.savez_naziv || 'Ostali';
|
||
if (!groups[k]) groups[k] = [];
|
||
groups[k].push(r);
|
||
}
|
||
let html = '';
|
||
for (const [org, list] of Object.entries(groups)) {
|
||
html += `<div style="margin-bottom:18px">
|
||
<h3 style="margin-bottom:10px;color:var(--accent)">${org} <span class="muted" style="font-weight:400;font-size:11px">(${list.length})</span></h3>
|
||
<table class="ri-tbl"><thead><tr><th>#</th><th>Ime</th><th>Funkcija</th><th>Sport</th><th>Mandat</th><th>Izvor</th></tr></thead><tbody>`;
|
||
list.forEach((r,i) => {
|
||
const mandat = (r.mandate_od && r.mandate_do) ? `${r.mandate_od.slice(0,7)} → ${r.mandate_do.slice(0,7)}` : '—';
|
||
const url = r.izvor_url ? `<a href="${r.izvor_url}" target="_blank" class="muted" style="font-size:10px">${r.izvor||'link'}</a>` : (r.izvor||'—');
|
||
html += `<tr>
|
||
<td class="mono">${i+1}</td>
|
||
<td><b>${r.ime} ${r.prezime||''}</b></td>
|
||
<td>${r.funkcija||'—'}</td>
|
||
<td class="muted">${r.sport||''}</td>
|
||
<td class="mono" style="font-size:10px">${mandat}</td>
|
||
<td>${url}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
grid.innerHTML = html;
|
||
} catch(e) { grid.innerHTML = '<div class="banner crit">Greška: '+e.message+'</div>'; }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// SPORTSKI STATS — Top Scorers, Top Appearances, Klub Breakdown
|
||
// ═══════════════════════════════════════════════════════
|
||
async function pageSportStats() {
|
||
const c = document.getElementById('content');
|
||
setTopbar('Sport Stats', 'Pregled svih sportova PGŽ');
|
||
window.navStack = []; // reset breadcrumb na entry stranici
|
||
c.innerHTML = '<div class="card" style="padding:24px">Učitavam statistiku...</div>';
|
||
try {
|
||
const d = await fetch('/sport/api/v2/sport/svi/stats').then(r=>r.json());
|
||
const t = d.totals || {};
|
||
const sportovi = (d.sportovi || []).filter(s => s.sport && s.sport !== 'općenito');
|
||
|
||
let html = '';
|
||
// Top KPI summary
|
||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-bottom:14px">
|
||
${[
|
||
['Sportova', sportovi.length, '🏆'],
|
||
['Klubova', t.klubova || 0, '🏟️'],
|
||
['Sportaša', t.sportasa || 0, '🏃'],
|
||
['Saveza', t.saveza || 0, '📋'],
|
||
].map(([lbl,val,ico]) => `
|
||
<div class="ri-card" style="padding:14px;text-align:center">
|
||
<div style="font-size:22px;margin-bottom:4px">${ico}</div>
|
||
<div class="ri-kpi-value">${(val||0).toLocaleString('hr-HR')}</div>
|
||
<div class="ri-kpi-label">${lbl}</div>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
|
||
// Grid svih sportova - klikabilan
|
||
html += `<div class="card" style="margin-bottom:14px">
|
||
<h3 style="margin-bottom:12px">Sportovi PGŽ — kliknite za detalje</h3>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px">`;
|
||
sportovi.forEach(s => {
|
||
const ico = sportIcon(s.sport);
|
||
const isHns = s.sport === 'nogomet';
|
||
html += `<div class="ri-card" onclick="gotoSport('${s.sport.replace(/'/g, "'")}')"
|
||
style="cursor:pointer;padding:12px;transition:transform 0.15s,border-color 0.15s"
|
||
onmouseover="this.style.transform='translateY(-2px)';this.style.borderColor='var(--accent)'"
|
||
onmouseout="this.style.transform='';this.style.borderColor=''">
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
|
||
<div style="font-size:28px">${ico}</div>
|
||
<div style="flex:1">
|
||
<div style="font-weight:600;font-size:14px;color:var(--text-bright);text-transform:capitalize">${s.sport}</div>
|
||
${isHns ? '<div style="font-size:9px;color:var(--accent)">HNS Semafor sync</div>' : ''}
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;font-size:11px;color:var(--text2)">
|
||
<div><span class="muted">Klubova:</span> <b style="color:var(--text-bright)">${s.klubova||0}</b></div>
|
||
<div><span class="muted">Sportaša:</span> <b style="color:var(--text-bright)">${s.sportasa||0}</b></div>
|
||
<div><span class="muted">Saveza:</span> <b style="color:var(--text-bright)">${s.saveza||0}</b></div>
|
||
<div><span class="muted">Manif.:</span> <b style="color:var(--text-bright)">${s.manifestacija||0}</b></div>
|
||
${s.nagrada > 0 ? `<div style="grid-column:span 2"><span class="muted">Nagrada:</span> <b style="color:var(--amber)">${s.nagrada}</b></div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html += `</div></div>`;
|
||
|
||
c.innerHTML = html;
|
||
} catch(e) {
|
||
c.innerHTML = '<div class="banner crit">Greška: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
function gotoSport(s) { state.page='sport'; state.sport_naziv = s; render(); }
|
||
|
||
async function pageSport() {
|
||
const sport = state.sport_naziv;
|
||
const c = document.getElementById('content');
|
||
setTopbar('Sport Stats', sport);
|
||
if (!sport) { c.innerHTML='<div class="banner crit">Nema sport.</div>'; return; }
|
||
c.innerHTML = '<div class="card" style="padding:24px">Učitavam ' + sport + '...</div>';
|
||
|
||
try {
|
||
const d = await fetch('/sport/api/v2/sport/' + encodeURIComponent(sport) + '/pregled').then(r=>r.json());
|
||
const ico = sportIcon(sport);
|
||
const st = d.stats || {};
|
||
|
||
let html = '';
|
||
|
||
// Breadcrumbs
|
||
html += breadcrumbs([
|
||
{label: '🏆 Sport Stats', onclick: "goto('sportStats')"},
|
||
{label: sportIcon(sport) + ' ' + sport.charAt(0).toUpperCase() + sport.slice(1)}
|
||
]);
|
||
|
||
// Header
|
||
html += `<div class="card" style="margin-bottom:14px;padding:18px;background:linear-gradient(135deg,var(--bg2),var(--bg3))">
|
||
<div style="display:flex;align-items:center;gap:18px">
|
||
<div style="font-size:54px">${ico}</div>
|
||
<div style="flex:1">
|
||
<h1 style="margin:0;text-transform:capitalize;color:var(--text-bright);font-size:26px">${sport}</h1>
|
||
<div class="muted" style="font-size:11px;margin-top:4px">Primorsko-goranska županija · ${st.broj_gradova||0} gradova</div>
|
||
</div>
|
||
<div onclick="goto('sportStats')" style="cursor:pointer;color:var(--accent);font-size:12px">← Svi sportovi</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:0;border-top:1px solid var(--border);margin-top:14px;padding-top:14px">
|
||
${[
|
||
['Klubova', st.broj_klubova],
|
||
['Sportaša', st.broj_sportasa],
|
||
['Kategoriziranih', st.broj_kategoriziranih],
|
||
['Reprezentativaca', st.broj_reprezentativaca],
|
||
['Saveza', d.savezi.length],
|
||
['Manifestacija', d.manifestacije.length],
|
||
].map(([lbl,v]) => `<div style="text-align:center;padding:6px">
|
||
<div class="ri-kpi-value" style="font-size:20px">${(v||0).toLocaleString('hr-HR')}</div>
|
||
<div class="ri-kpi-label">${lbl}</div>
|
||
</div>`).join('')}
|
||
</div>
|
||
</div>`;
|
||
|
||
// Saveze
|
||
if (d.savezi && d.savezi.length) {
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Savezi (${d.savezi.length})</h3>`;
|
||
d.savezi.forEach(s => {
|
||
html += `<div style="padding:8px 0;border-bottom:1px solid var(--border)">
|
||
<div style="font-weight:600">${s.naziv}</div>
|
||
<div class="muted" style="font-size:11px;margin-top:2px">
|
||
${s.predsjednik ? `Predsj.: ${s.predsjednik}` : ''}
|
||
${s.tajnik ? ` · Tajnik: ${s.tajnik}` : ''}
|
||
${s.godina_osnutka ? ` · od ${s.godina_osnutka}` : ''}
|
||
${s.grad ? ` · ${s.grad}` : ''}
|
||
</div>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
|
||
// Klubovi tabela
|
||
if (d.klubovi && d.klubovi.length) {
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Klubovi (${d.klubovi.length})</h3>
|
||
<table class="ri-tbl"><thead><tr><th>Klub</th><th>Razina</th><th>Grad</th><th>Osnovan</th>
|
||
<th style="text-align:right">Članova</th><th style="text-align:right">Kateg.</th></tr></thead><tbody>`;
|
||
d.klubovi.forEach(k => {
|
||
html += `<tr style="cursor:pointer" onclick="navPush();gotoKlubRoster(${k.id})" ondblclick="navPush();gotoKlubRoster(${k.id})" title="Klik za roster · Dvoklik za otvori">
|
||
<td><b>${k.naziv}</b>${k.hns_klub_id ? ' <span style="color:var(--accent);font-size:9px" title="Sinkroniziran s HNS Semafor COMET">HNS</span>' : ''}</td>
|
||
<td>${k.razina||'-'}</td>
|
||
<td>${k.grad||'-'}</td>
|
||
<td class="mono">${k.godina_osnutka||'-'}</td>
|
||
<td style="text-align:right">${k.broj_clanova||0}</td>
|
||
<td style="text-align:right;color:var(--accent)">${k.broj_kategoriziranih||0}</td>
|
||
</tr>`;
|
||
});
|
||
html += `</tbody></table></div>`;
|
||
}
|
||
|
||
// Top sportaši (kategorizirani / reprezentativci)
|
||
if (d.top_sportasi && d.top_sportasi.length) {
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Top osobe (kategorizirani sportaši, treneri, uprava) (${d.top_sportasi.length})</h3>
|
||
<table class="ri-tbl"><thead><tr><th>Foto</th><th>Ime i prezime</th><th>Uloga</th><th>Klub</th><th>Pozicija</th><th>HOO Kateg.</th><th>Repr.</th></tr></thead><tbody>`;
|
||
d.top_sportasi.forEach(s => {
|
||
html += `<tr style="cursor:pointer" onclick="navPush();gotoSportas(${s.id})" ondblclick="navPush();gotoSportas(${s.id})" title="Klik / Dvoklik za profil sportaša">
|
||
<td>${s.slika_url ? `<img src="${s.slika_url}" style="width:32px;height:32px;border-radius:50%;object-fit:cover"/>` : '🏃'}</td>
|
||
<td><b>${s.ime||''} ${s.prezime||''}</b></td>
|
||
<td>${ulogaBadge(s.uloga||'igrac')}</td>
|
||
<td>${s.klub_naziv||'-'}</td>
|
||
<td>${s.pozicija||'-'}</td>
|
||
<td><span class="risk-low">${s.kategorija_hoo ? 'I'.repeat(s.kategorija_hoo) : '-'}</span></td>
|
||
<td>${s.reprezentativac ? '<span class="risk-low">REPR</span>' : '-'}</td>
|
||
</tr>`;
|
||
});
|
||
html += `</tbody></table></div>`;
|
||
}
|
||
|
||
// Trofeji
|
||
if (d.trofeji && d.trofeji.length) {
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Povijesni trofeji (${d.trofeji.length})</h3>
|
||
<table class="ri-tbl"><thead><tr><th>Klub</th><th>Sezona</th><th>Natjecanje</th><th>Plasman</th><th>Trofej</th></tr></thead><tbody>`;
|
||
d.trofeji.forEach(t => {
|
||
html += `<tr ${t.klub_id?'style="cursor:pointer" onclick="gotoKlubRoster('+t.klub_id+')"':''}>
|
||
<td><b>${t.klub_naziv}</b></td>
|
||
<td class="mono">${t.sezona||'-'}</td>
|
||
<td>${t.natjecanje||'-'}</td>
|
||
<td style="text-align:center"><b style="color:${t.plasiranje==1?'gold':t.plasiranje==2?'silver':t.plasiranje==3?'#cd7f32':'var(--text2)'}">${t.plasiranje||'-'}.</b></td>
|
||
<td>${t.trofej||'-'}</td>
|
||
</tr>`;
|
||
});
|
||
html += `</tbody></table></div>`;
|
||
}
|
||
|
||
// Najbolji sportaši kroz godine
|
||
if (d.najbolji && d.najbolji.length) {
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Najbolji sportaši kroz godine (${d.najbolji.length})</h3>
|
||
<table class="ri-tbl"><thead><tr><th>Godina</th><th>Kategorija</th><th>Ime i prezime</th><th>Klub</th></tr></thead><tbody>`;
|
||
d.najbolji.forEach(n => {
|
||
html += `<tr>
|
||
<td class="mono"><b>${n.godina}</b></td>
|
||
<td>${n.kategorija||'-'}</td>
|
||
<td><b>${n.ime_prezime}</b></td>
|
||
<td class="muted">${n.klub||'-'}</td>
|
||
</tr>`;
|
||
});
|
||
html += `</tbody></table></div>`;
|
||
}
|
||
|
||
// Manifestacije
|
||
if (d.manifestacije && d.manifestacije.length) {
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Manifestacije (${d.manifestacije.length})</h3>
|
||
<table class="ri-tbl"><thead><tr><th>Naziv</th><th>Mjesto</th><th>Razina</th><th>Od godine</th><th>Učesnika</th><th>Organizator</th></tr></thead><tbody>`;
|
||
d.manifestacije.forEach(m => {
|
||
html += `<tr>
|
||
<td><b>${m.naziv}</b></td>
|
||
<td>${m.mjesto||'-'}</td>
|
||
<td>${m.razina||'-'}</td>
|
||
<td class="mono">${m.godina_od||'-'}</td>
|
||
<td>${m.broj_ucesnika||'-'}</td>
|
||
<td class="muted" style="font-size:11px">${m.organizator||'-'}</td>
|
||
</tr>`;
|
||
});
|
||
html += `</tbody></table></div>`;
|
||
}
|
||
|
||
if (!d.savezi.length && !d.klubovi.length && !d.top_sportasi.length) {
|
||
html += `<div class="card" style="padding:24px;text-align:center;color:var(--text3)">Nema podataka za sport "${sport}". Možda treba popuniti podatke.</div>`;
|
||
}
|
||
|
||
c.innerHTML = html;
|
||
} catch(e) {
|
||
c.innerHTML = '<div class="banner crit">Greška: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
// (old pageSportStats removed)
|
||
|
||
async function spDoAdd() {
|
||
const promo_checks = document.querySelectorAll('input[name="spPromo"]:checked');
|
||
const promo_selected = Array.from(promo_checks).map(c => c.value);
|
||
const body = {
|
||
ime: document.getElementById('spIme').value.trim(),
|
||
prezime: document.getElementById('spPrezime').value.trim(),
|
||
klub_id: parseInt(document.getElementById('spKlub').value) || null,
|
||
datum_rodenja: document.getElementById('spDob').value || null,
|
||
mjesto_rodenja: document.getElementById('spMjesto').value.trim() || null,
|
||
broj_dresa: parseInt(document.getElementById('spDres').value) || null,
|
||
pozicija: document.getElementById('spPozicija').value || null,
|
||
dominantna_noga: document.getElementById('spNoga').value || null,
|
||
visina_cm: parseInt(document.getElementById('spVisina').value) || null,
|
||
tezina_kg: parseInt(document.getElementById('spTezina').value) || null,
|
||
slika_url: document.getElementById('spSlika').value.trim() || null,
|
||
oib: document.getElementById('spOib').value.trim() || null,
|
||
biografija: document.getElementById('spBio').value.trim() || null,
|
||
sport: (document.getElementById('spSport')||{}).value || null,
|
||
spol: (document.getElementById('spSpol')||{}).value || null,
|
||
promocija_kategorije: promo_selected,
|
||
};
|
||
const err = document.getElementById('spErr');
|
||
err.style.display = 'none';
|
||
if (!body.ime || !body.prezime || !body.klub_id) {
|
||
err.textContent = 'Ime, prezime i klub_id su obavezni';
|
||
err.style.display = 'block';
|
||
return;
|
||
}
|
||
try {
|
||
const d = await api('/api/v2/sportas/create', { method:'POST', body: JSON.stringify(body) });
|
||
alert('Kreiran sportaš #' + d.id);
|
||
usrModalClose();
|
||
if (typeof spLoad === 'function') spLoad();
|
||
if (typeof gotoSportas === 'function' && d.id) gotoSportas(d.id);
|
||
} catch(e) { err.textContent = e.message; err.style.display = 'block'; }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// SPORTAŠI GRID — pretraživa galerija svih sportaša
|
||
// ═══════════════════════════════════════════════════════
|
||
async function pageSportasi() {
|
||
const c = document.getElementById('content');
|
||
setTopbar('Sportaši', 'Pregled svih sportaša PGŽ');
|
||
c.innerHTML = `
|
||
<div class="card" style="margin-bottom:14px;padding:14px">
|
||
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||
<input id="spSearch" class="inp" placeholder="🔍 traži po imenu, prezimenu" style="flex:1;min-width:240px" oninput="spSearchType()">
|
||
<select id="spKlubFilter" class="inp" style="min-width:200px" onchange="spLoad()">
|
||
<option value="">Svi klubovi</option>
|
||
</select>
|
||
<span class="muted" style="font-size:11px" id="spCount">—</span>
|
||
<button class="btn ri-btn-primary" onclick="spAddNew()">+ Novi sportaš</button>
|
||
</div>
|
||
</div>
|
||
<div id="spGrid" class="card" style="padding:14px">Učitavam…</div>
|
||
`;
|
||
await spLoadKlubovi();
|
||
await spLoad();
|
||
}
|
||
|
||
let _spTimer = null;
|
||
function spSearchType() { clearTimeout(_spTimer); _spTimer = setTimeout(spLoad, 300); }
|
||
|
||
async function spLoadKlubovi() {
|
||
try {
|
||
const d = await fetch('/sport/api/klubovi').then(r=>r.json());
|
||
const sel = document.getElementById('spKlubFilter');
|
||
if (!sel) return;
|
||
const klubovi = d.results || d.klubovi || d || [];
|
||
klubovi.sort((a,b) => (a.naziv||'').localeCompare(b.naziv||''));
|
||
sel.innerHTML = '<option value="">Svi klubovi</option>' +
|
||
klubovi.map(k => `<option value="${k.id}">${k.naziv}</option>`).join('');
|
||
} catch(e) { console.error('klubovi load', e); }
|
||
}
|
||
|
||
async function spLoad() {
|
||
const q = (document.getElementById('spSearch')||{}).value || '';
|
||
const kid = (document.getElementById('spKlubFilter')||{}).value || '';
|
||
const params = new URLSearchParams();
|
||
if (q) params.set('q', q);
|
||
if (kid) params.set('klub_id', kid);
|
||
params.set('limit', '60');
|
||
|
||
const grid = document.getElementById('spGrid');
|
||
if (!grid) return;
|
||
|
||
try {
|
||
const d = await fetch('/sport/api/v2/sportas/search?' + params.toString()).then(r=>r.json());
|
||
document.getElementById('spCount').textContent = `${d.count} sportaša`;
|
||
if (!d.results || d.results.length === 0) {
|
||
grid.innerHTML = '<div class="muted" style="padding:40px;text-align:center">Nema rezultata. Promijeni filter ili pretragu.</div>';
|
||
return;
|
||
}
|
||
let html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px">';
|
||
for (const s of d.results) {
|
||
const dob = s.datum_rodenja ? new Date(s.datum_rodenja) : null;
|
||
const age = dob ? Math.floor((new Date() - dob) / (365.25 * 86400000)) : null;
|
||
const photo = s.slika_url || '';
|
||
html += `<div class="ri-card" onclick="gotoSportas(${s.id})" style="cursor:pointer;text-align:center;padding:12px;transition:transform 0.15s">
|
||
<div style="width:80px;height:80px;border-radius:50%;overflow:hidden;background:var(--bg4);margin:0 auto 8px;border:2px solid var(--border2)">
|
||
${photo ? `<img src="${photo}" style="width:100%;height:100%;object-fit:cover" loading="lazy"/>` : '<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:30px;color:var(--text3)">🏃</div>'}
|
||
</div>
|
||
<div style="font-size:12px;font-weight:600;color:var(--text-bright)">${s.ime||''} ${s.prezime||''}</div>
|
||
<div class="muted" style="font-size:10px;margin-top:2px">${s.klub||'—'}</div>
|
||
${age ? `<div class="muted" style="font-size:10px">${age} g.</div>` : ''}
|
||
</div>`;
|
||
}
|
||
html += '</div>';
|
||
grid.innerHTML = html;
|
||
} catch(e) {
|
||
grid.innerHTML = '<div class="banner crit">Greška: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════
|
||
// SPORTAŠ PROFIL — semafor.hns.family stil
|
||
// ═══════════════════════════════════════════════════════
|
||
function gotoSportas(id) { state.page='sportas'; state.sportas_id=id; render(); }
|
||
function gotoKlubRoster(id) { state.page='klubRoster'; state.klub_id=id; render(); }
|
||
|
||
async function pageSportas() {
|
||
const cid = state.sportas_id;
|
||
const c = document.getElementById('content');
|
||
setTopbar('Sportaši', 'Profil sportaša #' + cid);
|
||
if (!cid) { c.innerHTML='<div class="banner crit">Nema ID sportaša.</div>'; return; }
|
||
c.innerHTML = '<div class="card" style="padding:24px">Učitavam profil…</div>';
|
||
try {
|
||
const d = await api('/api/v2/clanovi/'+cid+'/full-profile');
|
||
// Map novi format → stari (bez ponavljanja koda)
|
||
if (d.sezone && !d.seasons) d.seasons = d.sezone;
|
||
if (d.karijera && !d.career) d.career = d.karijera;
|
||
if (d.utakmice && !d.matches) d.matches = d.utakmice;
|
||
const sp = d.sportas;
|
||
const klubLogo = sp.logo_url || (sp.hns_klub_id ? `https://hns.family/files/images_comet/Club/_resized/${sp.hns_klub_id}_x_100_100_wg_t.png` : '');
|
||
const photo = sp.slika_url || '';
|
||
const dob = sp.datum_rodenja ? new Date(sp.datum_rodenja) : null;
|
||
const age = dob ? Math.floor((new Date() - dob) / (365.25 * 86400000)) : null;
|
||
|
||
let html = breadcrumbs([
|
||
{label: '🏃 Sportaši', onclick: "goto('sportasi')"},
|
||
sp.klub_naziv ? {label: '🏟️ ' + sp.klub_naziv, onclick: `navPush();gotoKlubRoster(${sp.klub_id})`} : null,
|
||
{label: (sp.ime || '?') + ' ' + (sp.prezime || '?')}
|
||
].filter(Boolean));
|
||
|
||
// Source warning ako nedostaje
|
||
if (!sp.source_url || sp.source === 'manual') {
|
||
html += `<div class="banner" style="background:rgba(245,158,11,0.1);border:1px solid var(--amber);color:var(--amber);padding:8px 12px;margin-bottom:12px;border-radius:6px;font-size:12px">
|
||
⚠ Podaci o ovoj osobi su unijeti ručno i mogu biti nepotpuni. ${sp.source_url ? 'Izvor: <a href="'+sp.source_url+'" target="_blank">'+sp.source_url+'</a>' : 'Izvor nije naveden.'}
|
||
</div>`;
|
||
}
|
||
|
||
html += `
|
||
<div class="card" style="padding:0;overflow:hidden;margin-bottom:14px">
|
||
<div style="display:grid;grid-template-columns:auto 1fr;gap:18px;padding:18px;background:linear-gradient(135deg,var(--bg2),var(--bg3))">
|
||
<div style="width:120px;height:120px;border-radius:50%;overflow:hidden;border:2px solid var(--border2);background:var(--bg4)">
|
||
${photo ? `<img src="${photo}" style="width:100%;height:100%;object-fit:cover" alt="${sp.ime} ${sp.prezime}"/>` : '<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:32px">🏃</div>'}
|
||
</div>
|
||
<div>
|
||
<h1 style="font-size:24px;font-weight:600;color:var(--text-bright);margin:0">${sp.ime||''} ${sp.prezime||''}</h1>
|
||
<div style="margin-top:6px">${ulogaBadge(sp.uloga)}</div>
|
||
<div style="display:flex;gap:18px;flex-wrap:wrap;margin-top:10px;font-size:12px;color:var(--text2)">
|
||
${sp.klub_naziv ? `<div onclick="navPush();gotoKlubRoster(${sp.klub_id})" ondblclick="navPush();gotoKlubRoster(${sp.klub_id})" style="cursor:pointer;color:var(--accent)" title="Klik za klub roster">${sportIcon(sp.sport)} ${sp.klub_naziv}</div>` : ''}
|
||
${sp.sport ? `<div>📍 ${sp.sport}</div>` : ''}
|
||
${sp.razina ? `<div class="mono" style="color:var(--text3)">${sp.razina}</div>` : ''}
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:14px;margin-top:18px">
|
||
<div><div class="ri-kpi-label">Datum rođenja</div><div style="font-size:13px;color:var(--text2)">
|
||
${dob ? dob.toLocaleDateString('hr-HR') + (age?' <span class="muted">('+age+' g)</span>':'') :
|
||
(sp.godina_rodenja ? sp.godina_rodenja + ' <span class="muted">(samo godina)</span>' :
|
||
'<span style="color:var(--text3);font-style:italic">— nepoznato —</span>')}
|
||
</div></div>
|
||
<div><div class="ri-kpi-label">Mjesto rođenja</div><div style="font-size:13px;color:var(--text2)">
|
||
${sp.mjesto_rodenja || '<span style="color:var(--text3);font-style:italic">— nepoznato —</span>'}
|
||
</div></div>
|
||
${sp.pozicija ? `<div><div class="ri-kpi-label">Pozicija</div><div style="font-size:13px;color:var(--text2)">${sp.pozicija}</div></div>`:''}
|
||
${sp.broj_dresa ? `<div><div class="ri-kpi-label">Broj dresa</div><div class="mono" style="font-size:18px;color:var(--accent);font-weight:700">${sp.broj_dresa}</div></div>`:''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:0;border-top:1px solid var(--border)">
|
||
<div style="padding:12px;text-align:center;border-right:1px solid var(--border)"><div class="ri-kpi-value">${d.totals.nastupa||0}</div><div class="ri-kpi-label">Nastupi</div></div>
|
||
<div style="padding:12px;text-align:center;border-right:1px solid var(--border)"><div class="ri-kpi-value">${d.totals.pogodaka||0}</div><div class="ri-kpi-label">Pogoci</div></div>
|
||
<div style="padding:12px;text-align:center;border-right:1px solid var(--border)"><div class="ri-kpi-value" style="color:var(--amber)">${d.totals.zutih||0}</div><div class="ri-kpi-label">Žuti</div></div>
|
||
<div style="padding:12px;text-align:center"><div class="ri-kpi-value" style="color:var(--red)">${d.totals.crvenih||0}</div><div class="ri-kpi-label">Crveni</div></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
if (d.seasons && d.seasons.length) {
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Statistika po sezonama</h3><table class="ri-tbl"><thead><tr><th>Sezona</th><th>Natjecanje</th><th style="text-align:right">Nastupi</th><th style="text-align:right">Pogoci</th><th style="text-align:right">Žuti</th><th style="text-align:right">Crveni</th><th style="text-align:right">Minute</th></tr></thead><tbody>`;
|
||
d.seasons.forEach(r => {
|
||
html += `<tr><td>${r.sezona||''}</td><td>${r.natjecanje||''}</td><td style="text-align:right">${r.nastupi}</td><td style="text-align:right;color:var(--accent)">${r.pogoci}</td><td style="text-align:right">${r.zuti}</td><td style="text-align:right">${r.crveni}</td><td style="text-align:right" class="mono">${r.minute_total||'-'}</td></tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
} else {
|
||
html += `<div class="card" style="margin-bottom:14px;padding:24px;text-align:center;color:var(--text3)">Nema zabilježenih utakmica. Podaci se osvježavaju iz HNS Semafor sustava (sezonski).</div>`;
|
||
}
|
||
|
||
if (d.career && d.career.length) {
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Karijera (klubovi)</h3><table class="ri-tbl"><thead><tr><th>Klub</th><th>Od</th><th>Do</th><th style="text-align:right">Nastupa</th></tr></thead><tbody>`;
|
||
d.career.forEach(r => {
|
||
html += `<tr style="cursor:pointer" onclick="navPush();gotoKlubRoster(${r.id})" ondblclick="navPush();gotoKlubRoster(${r.id})" title="Klik za klub"><td>${r.naziv}</td><td>${r.od_dat||'-'}</td><td>${r.do_dat||'-'}</td><td style="text-align:right">${r.nastupa}</td></tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
if (d.matches && d.matches.length) {
|
||
html += `<div class="card"><h3 style="margin-bottom:10px">Posljednje utakmice (${d.matches.length})</h3><table class="ri-tbl"><thead><tr><th>Datum</th><th>Domaćin</th><th>:</th><th>Gost</th><th>Natjecanje</th><th style="text-align:right">Pogodaka</th><th>Kartoni</th><th style="text-align:right">Min</th></tr></thead><tbody>`;
|
||
d.matches.forEach(r => {
|
||
html += `<tr><td>${r.datum||'-'}</td><td>${r.klub_dom||'-'}</td><td class="mono" style="text-align:center;color:var(--text-bright)">${r.rezultat||''}</td><td>${r.klub_gost||'-'}</td><td>${r.natjecanje||''}</td><td style="text-align:right;color:var(--accent)">${r.pogodaka||0}</td><td><span class="risk-high">${r.zuti_kartoni||0}</span> <span class="risk-critical">${r.crveni_kartoni||0}</span></td><td style="text-align:right" class="mono">${r.minute||'-'}</td></tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
if (sp.source_url) {
|
||
html += `<div style="margin-top:14px;font-size:10px;color:var(--text3);text-align:right">Izvor: <a href="${sp.source_url}" target="_blank" style="color:var(--accent)">${sp.source}</a> · Sync: ${sp.source_synced_at?new Date(sp.source_synced_at).toLocaleString('hr-HR'):'-'}</div>`;
|
||
}
|
||
|
||
c.innerHTML = html;
|
||
} catch(e) { c.innerHTML = '<div class="banner crit">Greška: '+e.message+'</div>'; }
|
||
}
|
||
|
||
async function pageKlubRoster() {
|
||
const kid = state.klub_id;
|
||
const c = document.getElementById('content');
|
||
setTopbar('Klubovi', 'Roster kluba');
|
||
if (!kid) { c.innerHTML='<div class="banner crit">Nema ID kluba.</div>'; return; }
|
||
c.innerHTML = '<div class="card" style="padding:24px">Učitavam roster…</div>';
|
||
try {
|
||
const d = await api('/api/v2/klub/'+kid+'/sportasi');
|
||
const k = d.klub;
|
||
// Breadcrumb
|
||
let html = breadcrumbs([
|
||
{label: '🏟️ Klubovi', onclick: "goto('klubovi')"},
|
||
{label: k.sport ? sportIcon(k.sport) + ' ' + k.sport : '⚽', onclick: k.sport ? `goto('sport');state.sport_naziv='${k.sport}';render()` : null},
|
||
{label: k.naziv}
|
||
]);
|
||
|
||
html += `<div class="card" style="margin-bottom:14px">
|
||
<div style="display:flex;align-items:center;gap:14px">
|
||
${k.logo_url ? `<img src="${k.logo_url}" style="width:64px;height:64px;border-radius:8px"/>` : '<div style="width:64px;height:64px;border-radius:8px;background:var(--bg3);display:flex;align-items:center;justify-content:center;font-size:24px">⚽</div>'}
|
||
<div style="flex:1">
|
||
<h2 style="margin:0">${k.naziv}</h2>
|
||
<div class="muted" style="font-size:11px;margin-top:4px">${k.sport||''} · ${k.razina||''} · ${d.total} sportaša${k.hns_klub_id?' · <a href="https://semafor.hns.family/klubovi/'+k.hns_klub_id+'/'+(k.hns_slug||'')+'/" target="_blank" style="color:var(--accent)">HNS Semafor ↗</a>':''}</div>
|
||
</div>
|
||
${(state.user?.tip==='klub_admin' || state.user?.tip==='super_admin' || state.user?.tip==='pgz_admin') ? '<button class="btn-primary" onclick="addSportasPrompt('+kid+')">+ Sportaš</button>' : ''}
|
||
</div>
|
||
</div>`;
|
||
if (d.sportasi && d.sportasi.length) {
|
||
html += '<div class="card"><table class="ri-tbl"><thead><tr><th>#</th><th>Foto</th><th>Ime i prezime</th><th>Uloga</th><th>Datum rođenja</th><th>Pozicija</th><th style="text-align:right">Nast.</th><th style="text-align:right">Gol.</th><th>Izvor</th></tr></thead><tbody>';
|
||
d.sportasi.forEach(s => {
|
||
const dob = s.datum_rodenja ? new Date(s.datum_rodenja).toLocaleDateString('hr-HR') : '-';
|
||
html += `<tr style="cursor:pointer" onclick="navPush();gotoSportas(${s.id})" ondblclick="navPush();gotoSportas(${s.id})" title="Klik / Dvoklik za profil sportaša">
|
||
<td class="mono">${s.broj_dresa||'-'}</td>
|
||
<td>${s.slika_url ? `<img src="${s.slika_url}" style="width:32px;height:32px;border-radius:50%;object-fit:cover"/>` : '🏃'}</td>
|
||
<td><b>${s.ime||''} ${s.prezime||''}</b> ${s.reprezentativac?'<span class="risk-low">REPR</span>':''}</td>
|
||
<td>${ulogaBadge(s.uloga||'igrac')}</td>
|
||
<td>${dob}</td>
|
||
<td>${s.pozicija||'-'}</td>
|
||
<td style="text-align:right">${s.nastupa||0}</td>
|
||
<td style="text-align:right;color:var(--accent)">${s.pogoci||0}</td>
|
||
<td><span class="muted" style="font-size:10px">${s.source||'manual'}</span></td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
} else {
|
||
html += '<div class="card" style="padding:24px;text-align:center;color:var(--text3)">Nema upisanih sportaša. Pokreni <code>hns_semafor.py</code> scraper ili dodaj ručno.</div>';
|
||
}
|
||
c.innerHTML = html;
|
||
} catch(e) { c.innerHTML = '<div class="banner crit">Greška: '+e.message+'</div>'; }
|
||
}
|
||
|
||
|
||
async function addSportasPrompt(klub_id) {
|
||
const ime = prompt('Ime sportaša:'); if (!ime) return;
|
||
const prezime = prompt('Prezime sportaša:'); if (!prezime) return;
|
||
const broj = prompt('Broj dresa (opcionalno):') || null;
|
||
const pozicija = prompt('Pozicija (npr. Vratar / Igrač / Centarfor):') || null;
|
||
try {
|
||
const d = await api('/api/v2/sportas/create', { method:'POST', body: JSON.stringify({
|
||
klub_id, ime, prezime, broj_dresa: broj?parseInt(broj):null, pozicija
|
||
})});
|
||
alert('✓ Dodan sportaš ID '+d.id);
|
||
pageKlubRoster();
|
||
} catch(e) { alert('Greška: '+e.message); }
|
||
}
|
||
|
||
|
||
async function pageAudit() {
|
||
const c = document.getElementById('content');
|
||
setTopbar('Audit', 'Kvaliteta podataka i izvori');
|
||
c.innerHTML = '<div class="card" style="padding:24px">Učitavam audit...</div>';
|
||
try {
|
||
const d = await api('/api/v2/audit/data-quality');
|
||
let html = '';
|
||
|
||
html += breadcrumbs([{label: '🛡️ Audit kvalitete podataka'}]);
|
||
|
||
// Policy banner
|
||
html += `<div class="card" style="margin-bottom:14px;background:rgba(34,197,94,0.05);border:1px solid rgba(34,197,94,0.3)">
|
||
<h3 style="margin-bottom:8px;color:var(--green)">✓ Policy aktivna</h3>
|
||
<div style="font-size:12px;color:var(--text2)">
|
||
<div><b>Datum rođenja</b> i <b>slika</b> ne mogu biti upisani bez <code>source_url</code> — DB trigger automatski postavlja NULL ako nedostaje.</div>
|
||
<div style="margin-top:6px">Trusted sources: <code>hns_semafor</code>, <code>hbs_savez</code></div>
|
||
<div>Treba provjeru: <code>manual</code></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
// Sportaši po izvoru
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Sportaši — kvaliteta po izvoru</h3>
|
||
<table class="ri-tbl"><thead><tr>
|
||
<th>Izvor</th><th style="text-align:right">Total</th>
|
||
<th style="text-align:right">Sa source_url</th>
|
||
<th style="text-align:right">Sa datumom</th>
|
||
<th style="text-align:right">Sa godinom</th>
|
||
<th style="text-align:right">Sa mjestom</th>
|
||
<th style="text-align:right">Sa slikom</th>
|
||
<th>Trust</th>
|
||
</tr></thead><tbody>`;
|
||
d.sportasi_po_izvoru.forEach(r => {
|
||
const trusted = d.trusted_sources.includes(r.source);
|
||
const pct = r.total ? Math.round(100*r.sa_izvorom/r.total) : 0;
|
||
html += `<tr>
|
||
<td><b>${r.source||'NULL'}</b></td>
|
||
<td style="text-align:right">${r.total}</td>
|
||
<td style="text-align:right;color:${pct>90?'var(--green)':pct>50?'var(--amber)':'var(--red)'}">${r.sa_izvorom} (${pct}%)</td>
|
||
<td style="text-align:right">${r.sa_dat_rod}</td>
|
||
<td style="text-align:right">${r.sa_god_rod||0}</td>
|
||
<td style="text-align:right">${r.sa_mjesto}</td>
|
||
<td style="text-align:right">${r.sa_slikom}</td>
|
||
<td>${trusted ? '<span class="risk-low">TRUSTED</span>' : '<span class="risk-medium">VERIFY</span>'}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
|
||
// Klubovi po izvoru
|
||
html += `<div class="card" style="margin-bottom:14px"><h3 style="margin-bottom:10px">Klubovi — kvaliteta po izvoru</h3>
|
||
<table class="ri-tbl"><thead><tr>
|
||
<th>Izvor</th><th style="text-align:right">Total</th>
|
||
<th style="text-align:right">Sa scrape_url</th>
|
||
<th style="text-align:right">Sa godinom</th>
|
||
<th style="text-align:right">Sa adresom</th>
|
||
<th style="text-align:right">Sa telefonom</th>
|
||
<th style="text-align:right">Sa HNS</th>
|
||
</tr></thead><tbody>`;
|
||
d.klubovi_po_izvoru.forEach(r => {
|
||
html += `<tr>
|
||
<td><b>${r.source||'manual'}</b></td>
|
||
<td style="text-align:right">${r.total}</td>
|
||
<td style="text-align:right">${r.sa_izvorom||0}</td>
|
||
<td style="text-align:right">${r.sa_godinom||0}</td>
|
||
<td style="text-align:right">${r.sa_adresom||0}</td>
|
||
<td style="text-align:right">${r.sa_telefonom||0}</td>
|
||
<td style="text-align:right">${r.sa_hns||0}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
|
||
// Purge history
|
||
// Sumnjivi - manual bez izvora
|
||
if (d.sumnjivi_zapisi && d.sumnjivi_zapisi.length) {
|
||
html += `<div class="card" style="margin-bottom:14px">
|
||
<h3 style="margin-bottom:10px">⚠️ Sumnjivi zapisi (manual, bez izvora) — top ${d.sumnjivi_zapisi.length}</h3>
|
||
<div class="muted" style="font-size:11px;margin-bottom:8px">Klikni za otvoriti profil i validirati podatke s pravim izvorom.</div>
|
||
<table class="ri-tbl"><thead><tr><th>ID</th><th>Ime i prezime</th><th>Sport</th><th>Uloga</th><th>Klub</th><th>Quality</th></tr></thead><tbody>`;
|
||
d.sumnjivi_zapisi.forEach(s => {
|
||
const qcolor = s.quality === 0 ? 'var(--red)' : s.quality === 1 ? 'var(--amber)' : 'var(--text2)';
|
||
html += `<tr style="cursor:pointer" onclick="navPush();gotoSportas(${s.id})" ondblclick="navPush();gotoSportas(${s.id})">
|
||
<td class="mono">${s.id}</td>
|
||
<td><b>${s.ime||'?'} ${s.prezime||'?'}</b></td>
|
||
<td>${s.sport||'-'}</td>
|
||
<td>${ulogaBadge(s.uloga||'igrac')}</td>
|
||
<td class="muted">${s.klub_naziv||'-'}</td>
|
||
<td style="color:${qcolor};text-align:center"><b>${s.quality}/4</b></td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
// Trusted - sa pravim izvorom + datum
|
||
if (d.trusted_zapisi && d.trusted_zapisi.length) {
|
||
html += `<div class="card" style="margin-bottom:14px">
|
||
<h3 style="margin-bottom:10px">✅ Trusted zapisi (potvrđeni izvor + datum rođenja) — top ${d.trusted_zapisi.length}</h3>
|
||
<table class="ri-tbl"><thead><tr><th>ID</th><th>Ime i prezime</th><th>Sport</th><th>Klub</th><th>Datum rođenja</th><th>Izvor</th></tr></thead><tbody>`;
|
||
d.trusted_zapisi.forEach(s => {
|
||
const dob = s.datum_rodenja ? new Date(s.datum_rodenja).toLocaleDateString('hr-HR') : (s.godina_rodenja||'-');
|
||
html += `<tr style="cursor:pointer" onclick="navPush();gotoSportas(${s.id})" ondblclick="navPush();gotoSportas(${s.id})">
|
||
<td class="mono">${s.id}</td>
|
||
<td><b>${s.ime||'?'} ${s.prezime||'?'}</b></td>
|
||
<td>${s.sport||'-'}</td>
|
||
<td class="muted">${s.klub_naziv||'-'}</td>
|
||
<td class="mono">${dob}</td>
|
||
<td><span class="risk-low">${s.source}</span></td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
if (d.purge_history && d.purge_history.length) {
|
||
html += `<div class="card"><h3 style="margin-bottom:10px">Povijest čišćenja podataka</h3>
|
||
<table class="ri-tbl"><thead><tr><th>Datum</th><th>Akcija</th><th>Target</th><th>Detalji</th></tr></thead><tbody>`;
|
||
d.purge_history.forEach(h => {
|
||
html += `<tr>
|
||
<td class="mono">${h.created_at ? new Date(h.created_at).toLocaleString('hr-HR') : '-'}</td>
|
||
<td><code>${h.action}</code></td>
|
||
<td>${h.target_text||'-'}</td>
|
||
<td style="font-size:10px;color:var(--text2)">${JSON.stringify(h.payload||{}).substring(0,200)}</td>
|
||
</tr>`;
|
||
});
|
||
html += '</tbody></table></div>';
|
||
}
|
||
|
||
c.innerHTML = html;
|
||
} catch(e) {
|
||
c.innerHTML = '<div class="banner crit">Greška: ' + e.message + '</div>';
|
||
}
|
||
}
|
||
|
||
buildNavs();
|
||
goto('dashboard');
|
||
checkRole();
|
||
</script>
|
||
</body>
|
||
</html>
|