Files
boc/command-center.html
T
Bernt aee0f09db8 landvex: Fixar och tester klara för alla komponenter
- Datafabrik: Dockerfile fix, agentorkestrering fungerar
- Vision: Identify-modell, FAISS, OCR alla testade
- API: Alla 7 integrationstester passerade
- Upplösare: Entitetsupplösning verifierad
2026-07-05 06:41:32 +00:00

498 lines
36 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#001E50">
<title>AAMOS Command Center</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{--navy:#001E50;--amber:#C8763A;--bg:#F5F5F0;--card:#fff;--muted:#6B6B70;--green:#34C759;--red:#FF3B30;--yellow:#FFCC00;--border:#E8E3DC;--radius:14px;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif}
html{font-family:var(--font);font-size:16px;background:var(--bg);color:var(--navy)}
body{min-height:100vh;padding-bottom:80px}
#header{background:var(--navy);color:#fff;padding:16px 20px;position:sticky;top:0;z-index:100;display:flex;align-items:center;gap:12px}
#header .logo{width:36px;height:36px;border-radius:10px;background:var(--amber);display:flex;align-items:center;justify-content:center;font-size:18px;font-weight:700}
#header h1{font-size:1.1rem;font-weight:700;flex:1}
#header .status{font-size:.75rem;opacity:.7}
#nav{display:flex;gap:8px;padding:12px 16px;overflow-x:auto;background:#fff;border-bottom:1px solid var(--border);-webkit-overflow-scrolling:touch;scrollbar-width:none}
#nav::-webkit-scrollbar{display:none}
#nav button{white-space:nowrap;padding:8px 16px;border-radius:20px;border:1px solid var(--border);background:#fff;color:var(--navy);font-size:.88rem;font-weight:500;cursor:pointer;transition:all .15s}
#nav button.active{background:var(--navy);color:#fff;border-color:var(--navy)}
.section{padding:16px;display:none}
.section.active{display:block}
.card{background:var(--card);border-radius:var(--radius);padding:16px;margin-bottom:12px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
.card h3{font-size:.85rem;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:12px}
.card h3 .live{color:var(--green);font-size:.7rem;margin-left:6px;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}}
.kpi-row{display:flex;gap:12px;overflow-x:auto;padding-bottom:4px}
.kpi{min-width:140px;flex:1;background:var(--card);border-radius:var(--radius);padding:16px;text-align:center;border:1px solid var(--border)}
.kpi .value{font-size:1.6rem;font-weight:700;color:var(--navy)}
.kpi .value.good{color:var(--green)}
.kpi .value.bad{color:var(--red)}
.kpi .value.warn{color:var(--amber)}
.kpi .label{font-size:.75rem;color:var(--muted);margin-top:4px}
.alert{display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:var(--radius);margin-bottom:10px;font-size:.9rem}
.alert.critical{background:#FFE5E5;border-left:4px solid var(--red)}
.alert.warning{background:#FFF8E5;border-left:4px solid var(--yellow)}
.alert.info{background:#E5F2FF;border-left:4px solid #007AFF}
.alert.success{background:#E5F9ED;border-left:4px solid var(--green)}
.alert .icon{font-size:1.3rem}
table{width:100%;border-collapse:collapse;font-size:.88rem}
th{text-align:left;padding:10px 8px;color:var(--muted);font-weight:500;border-bottom:1px solid var(--border)}
td{padding:12px 8px;border-bottom:1px solid var(--border)}
tr:last-child td{border-bottom:none}
.badge{display:inline-block;padding:3px 10px;border-radius:12px;font-size:.75rem;font-weight:600}
.badge.green{background:#E5F9ED;color:#1A7F3C}
.badge.red{background:#FFE5E5;color:#C41E3A}
.badge.yellow{background:#FFF8E5;color:#B8860B}
.badge.blue{background:#E5F2FF;color:#007AFF}
.badge.purple{background:#F3E5FF;color:#7B1FA2}
.btn{display:inline-block;padding:10px 20px;border-radius:10px;border:none;background:var(--navy);color:#fff;font-size:.9rem;font-weight:600;cursor:pointer;margin-top:8px;transition:opacity .15s}
.btn:hover{opacity:.9}
.btn.secondary{background:var(--bg);color:var(--navy);border:1px solid var(--border)}
.btn.danger{background:var(--red)}
.btn.success{background:var(--green)}
.btn.small{padding:6px 12px;font-size:.8rem;margin-top:0}
#bottom-nav{position:fixed;bottom:0;left:0;right:0;background:#fff;border-top:1px solid var(--border);display:flex;justify-content:space-around;padding:8px 0;z-index:100}
#bottom-nav button{background:none;border:none;font-size:.7rem;color:var(--muted);display:flex;flex-direction:column;align-items:center;gap:3px;cursor:pointer;padding:4px 12px}
#bottom-nav button.active{color:var(--navy)}
#bottom-nav button .emoji{font-size:1.3rem}
.service-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border)}
.service-row:last-child{border-bottom:none}
.service-name{font-weight:600}
.service-meta{font-size:.8rem;color:var(--muted)}
.loading{text-align:center;padding:40px;color:var(--muted)}
.error{text-align:center;padding:20px;color:var(--red);font-size:.9rem}
.empty{text-align:center;padding:30px;color:var(--muted);font-size:.9rem}
.form-group{margin-bottom:12px}
.form-group label{display:block;font-size:.85rem;font-weight:500;margin-bottom:4px;color:var(--muted)}
.form-group input,.form-group select,.form-group textarea{width:100%;padding:10px;border:1px solid var(--border);border-radius:8px;font-size:.9rem;font-family:var(--font)}
.form-group textarea{min-height:80px;resize:vertical}
.form-row{display:flex;gap:12px}
.form-row .form-group{flex:1}
.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:200;display:none;align-items:center;justify-content:center;padding:20px}
.modal-overlay.active{display:flex}
.modal{background:#fff;border-radius:var(--radius);padding:20px;max-width:500px;width:100%;max-height:90vh;overflow-y:auto}
.modal h2{font-size:1.1rem;margin-bottom:16px}
.modal .close{float:right;background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--muted)}
.result-box{background:var(--bg);border-radius:8px;padding:12px;margin-top:12px;font-family:monospace;font-size:.8rem;overflow-x:auto;white-space:pre-wrap;word-break:break-word}
.result-box.error{background:#FFE5E5}
.result-box.success{background:#E5F9ED}
.api-endpoint{font-family:monospace;font-size:.8rem;background:var(--bg);padding:8px 12px;border-radius:8px;margin:4px 0;display:flex;justify-content:space-between;align-items:center}
.api-endpoint .method{color:var(--amber);font-weight:700;margin-right:8px}
.api-endpoint .path{color:var(--navy)}
.api-endpoint .test-btn{padding:4px 10px;font-size:.75rem;margin:0}
.intel-result{padding:12px;background:var(--bg);border-radius:8px;margin-top:12px}
.intel-result .score{font-size:2rem;font-weight:700}
.intel-result .grade{font-size:1rem;font-weight:600}
.intel-indices{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:8px;margin-top:12px}
.intel-index{background:#fff;padding:10px;border-radius:8px;text-align:center}
.intel-index .label{font-size:.75rem;color:var(--muted)}
.intel-index .value{font-size:1.1rem;font-weight:700}
.log-entry{padding:3px 0;font-family:monospace;font-size:.78rem;border-bottom:1px solid rgba(0,0,0,.05)}
.log-entry:last-child{border-bottom:none}
.scroll-box{max-height:300px;overflow-y:auto;background:var(--bg);padding:12px;border-radius:8px}
.mission-card{background:var(--bg);border-radius:8px;padding:12px;margin-bottom:8px}
.mission-card .title{font-weight:600;margin-bottom:4px}
.mission-card .meta{font-size:.8rem;color:var(--muted)}
.mission-card .reward{color:var(--amber);font-weight:700}
.tabs{display:flex;gap:4px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:8px}
.tabs button{background:none;border:none;padding:8px 16px;font-size:.85rem;font-weight:500;color:var(--muted);cursor:pointer;border-radius:8px}
.tabs button.active{background:var(--navy);color:#fff}
</style>
</head>
<body>
<div id="header"><div class="logo">A</div><h1>AAMOS Command Center</h1><div class="status" id="clock">--:--</div></div>
<div id="nav">
<button class="active" onclick="showSection('overview')">Översikt</button>
<button onclick="showSection('finance')">Ekonomi</button>
<button onclick="showSection('quixzoom')">quiXzoom</button>
<button onclick="showSection('apis')">API:er</button>
<button onclick="showSection('systems')">System</button>
</div>
<!-- ÖVERSIKT -->
<div id="overview" class="section active">
<div class="kpi-row">
<div class="kpi"><div class="value" id="kpi-cash">--</div><div class="label">Kassa (SEK)</div></div>
<div class="kpi"><div class="value" id="kpi-missions">--</div><div class="label">Missions</div></div>
<div class="kpi"><div class="value" id="kpi-entries">--</div><div class="label">Verifikat</div></div>
<div class="kpi"><div class="value" id="kpi-services">--</div><div class="label">Tjänster uppe</div></div>
</div>
<div class="card"><h3>🚨 Kritiska åtgärder <span class="live">LIVE</span></h3><div id="critical-alerts"><div class="loading">Laddar...</div></div></div>
<div class="card"><h3>🖥️ Systemstatus <span class="live">LIVE</span></h3><div id="overview-system-status"><div class="loading">Laddar...</div></div></div>
<div class="card"><h3>📸 quiXzoom Översikt <span class="live">LIVE</span></h3><div id="overview-quixzoom"><div class="loading">Laddar...</div></div></div>
</div>
<!-- EKONOMI -->
<div id="finance" class="section">
<div class="kpi-row">
<div class="kpi"><div class="value" id="fin-accounts">--</div><div class="label">Konton</div></div>
<div class="kpi"><div class="value" id="fin-periods">--</div><div class="label">Perioder</div></div>
<div class="kpi"><div class="value" id="fin-entries">--</div><div class="label">Verifikat</div></div>
</div>
<div class="card"><h3>🏦 Konton <span class="live">LIVE</span></h3>
<div class="tabs"><button class="active" onclick="showAccountTab('all')">Alla</button><button onclick="showAccountTab('asset')">Tillgångar</button><button onclick="showAccountTab('liability')">Skulder</button><button onclick="showAccountTab('equity')">Eget kapital</button><button onclick="showAccountTab('revenue')">Intäkter</button><button onclick="showAccountTab('expense')">Kostnader</button></div>
<table id="accounts-table"><tr><th>Konto</th><th>Namn</th><th>Typ</th><th>Saldo</th></tr><tr><td colspan="4" class="loading">Laddar...</td></tr></table></div>
<div class="card"><h3>📅 Perioder <span class="live">LIVE</span></h3><table id="periods-table"><tr><th>Period</th><th>År</th><th>Status</th><th>Stängd</th></tr><tr><td colspan="4" class="loading">Laddar...</td></tr></table></div>
<div class="card"><h3>📊 Senaste 20 verifikaten <span class="live">LIVE</span></h3><table id="journal-table"><tr><th>#</th><th>Datum</th><th>Beskrivning</th><th>Belopp</th><th>Status</th></tr><tr><td colspan="5" class="loading">Laddar...</td></tr></table></div>
<div class="card"><h3>🔗 Export</h3><a class="btn" href="/api/ledger/export/sie4?fiscal_year=2026" target="_blank">📥 SIE4 Export 2026</a><a class="btn secondary" href="/public/cfo-dashboard.html" target="_blank" style="margin-left:8px">📈 CFO Dashboard</a></div>
<div class="card"><h3> Skapa verifikat</h3>
<div class="form-group"><label>Beskrivning</label><input type="text" id="jv-desc" placeholder="T.ex. Faktura #123"></div>
<div class="form-row"><div class="form-group"><label>Datum</label><input type="date" id="jv-date"></div><div class="form-group"><label>Period (YYYY-MM)</label><input type="text" id="jv-period" placeholder="2026-07"></div></div>
<div class="form-row"><div class="form-group"><label>Debet-konto</label><input type="text" id="jv-debit-acc" placeholder="1930"></div><div class="form-group"><label>Belopp (debet)</label><input type="number" id="jv-debit-amt" placeholder="0"></div></div>
<div class="form-row"><div class="form-group"><label>Kredit-konto</label><input type="text" id="jv-credit-acc" placeholder="3010"></div><div class="form-group"><label>Belopp (kredit)</label><input type="number" id="jv-credit-amt" placeholder="0"></div></div>
<button class="btn" onclick="createJournalEntry()">💾 Skapa verifikat</button>
<div id="jv-result"></div>
</div>
</div>
<!-- QUIXZOOM -->
<div id="quixzoom" class="section">
<div class="kpi-row">
<div class="kpi"><div class="value" id="qz-missions">--</div><div class="label">Aktiva missions</div></div>
<div class="kpi"><div class="value" id="qz-kp">--</div><div class="label">Knowledge Packs</div></div>
<div class="kpi"><div class="value" id="qz-reward">--</div><div class="label">Total reward-pool</div></div>
</div>
<div class="card"><h3>🎯 Alla missions <span class="live">LIVE</span></h3><div id="missions-list"><div class="loading">Laddar...</div></div></div>
<div class="card"><h3> Skapa mission</h3>
<div class="form-group"><label>Titel</label><input type="text" id="m-title" placeholder="T.ex. Fasadinspektion"></div>
<div class="form-group"><label>Beskrivning</label><textarea id="m-desc" placeholder="Beskriv uppdraget..."></textarea></div>
<div class="form-row"><div class="form-group"><label>Latitud</label><input type="number" step="0.0001" id="m-lat" placeholder="59.3349"></div><div class="form-group"><label>Longitud</label><input type="number" step="0.0001" id="m-lon" placeholder="18.0632"></div></div>
<div class="form-group"><label>Adress</label><input type="text" id="m-address" placeholder="T.ex. Kungsgatan 12, Stockholm"></div>
<div class="form-row"><div class="form-group"><label>Reward (SEK)</label><input type="number" id="m-reward" placeholder="1500"></div><div class="form-group"><label>Typ</label><select id="m-type"><option value="facade_scan">Fasadscan</option><option value="road_inspection">Väginspektion</option><option value="interior_scan">Interiörscan</option><option value="vegetation_survey">Vegetationsinventering</option></select></div></div>
<button class="btn" onclick="createMission()">🚀 Skapa mission</button>
<div id="mission-result"></div>
</div>
<div class="card"><h3>📊 Zoomers & Intäkter</h3>
<div id="zoomers-info"><div class="loading">Laddar...</div></div>
</div>
</div>
<!-- API:ER -->
<div id="apis" class="section">
<div class="card"><h3>🔌 API-lista <span class="live">LIVE</span></h3><div id="api-list"><div class="loading">Laddar...</div></div></div>
<div class="card"><h3>🧪 Testa endpoint</h3>
<div class="form-group"><label>URL</label><input type="text" id="test-url" placeholder="http://localhost:3250/api/ledger/accounts"></div>
<div class="form-row"><div class="form-group"><label>Metod</label><select id="test-method"><option value="GET">GET</option><option value="POST">POST</option></select></div><div class="form-group"><label>Body (JSON, för POST)</label><input type="text" id="test-body" placeholder='{"key":"value"}'></div></div>
<button class="btn" onclick="testEndpoint()">▶️ Kör</button>
<div id="test-result"></div>
</div>
<div class="card"><h3>🌍 Landvex Intelligence</h3>
<div class="form-row"><div class="form-group"><label>Latitud</label><input type="number" step="0.0001" id="intel-lat" value="59.3349"></div><div class="form-group"><label>Longitud</label><input type="number" step="0.0001" id="intel-lng" value="18.0632"></div></div>
<div class="form-group"><label>Profil</label><select id="intel-profile"><option value="business">Business</option><option value="tourist">Tourist</option><option value="safety">Safety</option><option value="investment">Investment</option><option value="nightlife">Nightlife</option><option value="infrastructure">Infrastructure</option></select></div>
<button class="btn" onclick="fetchIntelligence()">🔍 Sök intelligence</button>
<div id="intel-result"></div>
</div>
</div>
<!-- SYSTEM -->
<div id="systems" class="section">
<div class="card"><h3>🖥️ Tjänster <span class="live">LIVE</span></h3><div id="services-list"><div class="loading">Laddar...</div></div></div>
<div class="card"><h3>📋 Systemlogg (senaste 50 rader)</h3>
<div class="scroll-box" id="system-log"><div class="loading">Laddar...</div></div>
<button class="btn secondary" onclick="loadSystemLog()" style="margin-top:8px">🔄 Uppdatera logg</button>
</div>
</div>
<div id="bottom-nav">
<button class="active" onclick="showSection('overview')"><span class="emoji">📊</span>Översikt</button>
<button onclick="showSection('finance')"><span class="emoji">🏦</span>Ekonomi</button>
<button onclick="showSection('quixzoom')"><span class="emoji">📸</span>quiXzoom</button>
<button onclick="showSection('apis')"><span class="emoji">🔌</span>API:er</button>
</div>
<script>
const API_BASE='/api/ledger', QZ_MISSION_API='http://localhost:7060', QZ_AVO_API='http://localhost:7070', AAMOS_API='http://localhost:3401';
let allAccounts = [], allMissions = [], serviceStatuses = {};
function updateClock(){const n=new Date();document.getElementById('clock').textContent=n.toLocaleTimeString('sv-SE',{hour:'2-digit',minute:'2-digit'})}
setInterval(updateClock,1000);updateClock();
function showSection(id){
document.querySelectorAll('.section').forEach(s=>s.classList.remove('active'));
document.querySelectorAll('#nav button').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('#bottom-nav button').forEach(b=>b.classList.remove('active'));
document.getElementById(id).classList.add('active');
const idx = ['overview','finance','quixzoom','apis','systems'].indexOf(id);
if(idx>=0 && idx<4){
document.querySelectorAll('#nav button')[idx]?.classList.add('active');
document.querySelectorAll('#bottom-nav button')[idx]?.classList.add('active');
}
if(id==='systems'){document.querySelectorAll('#nav button')[4]?.classList.add('active');}
}
function formatSEK(n){if(n===null||n===undefined)return'--';return new Intl.NumberFormat('sv-SE').format(Math.round(n))+' kr'}
function formatNum(n){if(n===null||n===undefined)return'--';return new Intl.NumberFormat('sv-SE').format(n)}
function statusBadge(s){
const m={running:'green',active:'green',ok:'green',operational:'green',online:'green',stopped:'red',failed:'red',error:'red',critical:'red',offline:'red',warning:'yellow',warn:'yellow',degraded:'yellow',open:'yellow',closed:'green',pending:'blue',assigned:'purple',submitted:'blue',approved:'green'};
return'<span class="badge '+(m[s]||'blue')+'">'+s+'</span>'
}
// ─── EKONOMI ───
async function loadAccounts(){
try{
const r=await fetch(API_BASE+'/accounts'), d=await r.json();
if(!d.ok||!d.accounts)throw new Error('Inga konton');
allAccounts = d.accounts.filter(x=>x.tenant_id==='landvex');
document.getElementById('fin-accounts').textContent=allAccounts.length;
showAccountTab('all');
const b=allAccounts.find(x=>x.account_number==='1930');
if(b){const bal=(b.debit||0)-(b.credit||0);document.getElementById('kpi-cash').textContent=formatSEK(bal);document.getElementById('kpi-cash').className=bal<0?'value bad':'value good';}
}catch(e){document.getElementById('accounts-table').innerHTML='<tr><td colspan="4" class="error">Kunde inte ladda konton: '+e.message+'</td></tr>';}
}
function showAccountTab(filter){
let accs = allAccounts;
if(filter!=='all') accs = allAccounts.filter(a=>a.account_type===filter);
const rows = accs.sort((a,b)=>a.account_number.localeCompare(b.account_number)).map(x=>{
const bal = (x.debit||0)-(x.credit||0);
return '<tr><td>'+x.account_number+'</td><td>'+x.name+'</td><td>'+x.account_type+'</td><td class="value '+(bal<0?'bad':bal>0?'good':'')+'">'+formatSEK(bal)+'</td></tr>';
}).join('');
document.getElementById('accounts-table').innerHTML='<tr><th>Konto</th><th>Namn</th><th>Typ</th><th>Saldo</th></tr>'+(rows||'<tr><td colspan="4" class="empty">Inga konton</td></tr>');
document.querySelectorAll('#finance .tabs button').forEach(b=>b.classList.remove('active'));
event?.target?.classList.add('active');
}
async function loadPeriods(){
try{
const r=await fetch(API_BASE+'/periods'), d=await r.json();
if(!d.ok||!d.periods)throw new Error('Inga perioder');
document.getElementById('fin-periods').textContent=d.periods.length;
const rows=d.periods.slice(0,20).map(p=>'<tr><td>'+p.period+'</td><td>'+p.fiscal_year+'</td><td>'+statusBadge(p.status)+'</td><td>'+(p.closed_at?new Date(p.closed_at).toLocaleDateString('sv-SE'):'-')+'</td></tr>').join('');
document.getElementById('periods-table').innerHTML='<tr><th>Period</th><th>År</th><th>Status</th><th>Stängd</th></tr>'+rows;
}catch(e){document.getElementById('periods-table').innerHTML='<tr><td colspan="4" class="error">Kunde inte ladda perioder</td></tr>';}
}
async function loadJournal(){
try{
const r=await fetch(API_BASE+'/journal'), d=await r.json();
if(!d.ok||!d.entries)throw new Error('Inga verifikat');
document.getElementById('fin-entries').textContent=d.entries.length;
document.getElementById('kpi-entries').textContent=d.entries.length;
const recent=d.entries.slice(-20).reverse();
const rows=recent.map(e=>{
const amt=e.lines?e.lines.reduce((s,l)=>s+(l.debit||0),0):0;
return'<tr><td>'+e.entry_number+'</td><td>'+e.entry_date+'</td><td>'+(e.description||'').substring(0,60)+'...</td><td>'+formatSEK(amt)+'</td><td>'+statusBadge(e.status)+'</td></tr>';
}).join('');
document.getElementById('journal-table').innerHTML='<tr><th>#</th><th>Datum</th><th>Beskrivning</th><th>Belopp</th><th>Status</th></tr>'+rows;
}catch(e){document.getElementById('journal-table').innerHTML='<tr><td colspan="5" class="error">Kunde inte ladda verifikat</td></tr>';}
}
async function createJournalEntry(){
const desc=document.getElementById('jv-desc').value;
const date=document.getElementById('jv-date').value;
const period=document.getElementById('jv-period').value;
const debitAcc=document.getElementById('jv-debit-acc').value;
const debitAmt=parseFloat(document.getElementById('jv-debit-amt').value)||0;
const creditAcc=document.getElementById('jv-credit-acc').value;
const creditAmt=parseFloat(document.getElementById('jv-credit-amt').value)||0;
if(!desc||!date||!period||!debitAcc||!creditAcc||debitAmt<=0||creditAmt<=0){
document.getElementById('jv-result').innerHTML='<div class="alert critical"><span class="icon">⚠️</span><div>Fyll i alla fält korrekt</div></div>';return;
}
const payload={description:desc,entry_date:date,period:period,fiscal_year:parseInt(period.split('-')[0]),tenant_id:'landvex',lines:[{account_number:debitAcc,debit:debitAmt,credit:0},{account_number:creditAcc,debit:0,credit:creditAmt}]};
try{
const r=await fetch(API_BASE+'/journal',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const d=await r.json();
if(r.ok){document.getElementById('jv-result').innerHTML='<div class="alert success"><span class="icon">✅</span><div><strong>Verifikat skapat!</strong><br>#'+d.entry_number+'</div></div>';loadJournal();}
else{document.getElementById('jv-result').innerHTML='<div class="alert critical"><span class="icon">❌</span><div><strong>Fel:</strong> '+(d.error||r.statusText)+'</div></div>';}
}catch(e){document.getElementById('jv-result').innerHTML='<div class="alert critical"><span class="icon">❌</span><div><strong>Fel:</strong> '+e.message+'</div></div>';}
}
// ─── QUIXZOOM ───
async function loadMissions(){
try{
const r=await fetch(QZ_MISSION_API+'/missions'), d=await r.json();
if(!d.missions)throw new Error('Inga missions');
allMissions = d.missions;
document.getElementById('qz-missions').textContent=d.missions.filter(m=>m.status==='open'||m.status==='assigned').length;
const totalReward = d.missions.reduce((s,m)=>s+(m.reward_sek||0),0);
document.getElementById('qz-reward').textContent=formatSEK(totalReward);
const html = d.missions.map(m=>{
return '<div class="mission-card"><div class="title">'+m.title+' '+statusBadge(m.status)+'</div><div class="meta">'+m.address+' | '+m.mission_type+'</div><div class="meta">Lat: '+m.lat+', Lon: '+m.lon+'</div><div class="reward">'+formatSEK(m.reward_sek)+'</div></div>';
}).join('');
document.getElementById('missions-list').innerHTML=html;
const qzHtml = d.missions.slice(0,3).map(m=>'<div class="service-row"><div><div class="service-name">'+m.title+'</div><div class="service-meta">'+m.address+'</div></div><div>'+statusBadge(m.status)+'</div></div>').join('');
document.getElementById('overview-quixzoom').innerHTML=qzHtml||'<div class="empty">Inga missions</div>';
}catch(e){document.getElementById('missions-list').innerHTML='<div class="error">Kunde inte ladda missions: '+e.message+'</div>';}
}
async function createMission(){
const title=document.getElementById('m-title').value;
const desc=document.getElementById('m-desc').value;
const lat=parseFloat(document.getElementById('m-lat').value);
const lon=parseFloat(document.getElementById('m-lon').value);
const address=document.getElementById('m-address').value;
const reward=parseInt(document.getElementById('m-reward').value)||0;
const type=document.getElementById('m-type').value;
if(!title||isNaN(lat)||isNaN(lon)||reward<=0){
document.getElementById('mission-result').innerHTML='<div class="alert critical"><span class="icon">⚠️</span><div>Fyll i titel, lat/lon och reward</div></div>';return;
}
const payload={title:title,description:desc||'',lat:lat,lon:lon,address:address||'',reward_sek:reward,mission_type:type};
try{
const r=await fetch(QZ_MISSION_API+'/missions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const d=await r.json();
if(r.ok){document.getElementById('mission-result').innerHTML='<div class="alert success"><span class="icon">✅</span><div><strong>Mission skapad!</strong><br>ID: '+d.id+'</div></div>';loadMissions();}
else{document.getElementById('mission-result').innerHTML='<div class="alert critical"><span class="icon">❌</span><div><strong>Fel:</strong> '+(d.error||r.statusText)+'</div></div>';}
}catch(e){document.getElementById('mission-result').innerHTML='<div class="alert critical"><span class="icon">❌</span><div><strong>Fel:</strong> '+e.message+'</div></div>';}
}
async function loadZoomers(){
try{
const r=await fetch(QZ_MISSION_API+'/health'), d=await r.json();
const assigned = allMissions.filter(m=>m.zoomer_id).length;
const open = allMissions.filter(m=>m.status==='open').length;
document.getElementById('zoomers-info').innerHTML=
'<div class="service-row"><div><div class="service-name">Tilldelade missions</div></div><div class="value">'+assigned+'</div></div>'+
'<div class="service-row"><div><div class="service-name">Öppna missions</div></div><div class="value">'+open+'</div></div>'+
'<div class="service-row"><div><div class="service-name">Totalt antal</div></div><div class="value">'+d.missions_total+'</div></div>';
}catch(e){document.getElementById('zoomers-info').innerHTML='<div class="error">Kunde inte ladda zoomer-info</div>';}
}
// ─── API:ER ───
async function loadAPIs(){
const apis=[
{name:'aamos-ledger',port:3250,path:'/health',desc:'Dubbel bokföring, BAS2024, SIE4',endpoints:['GET /api/ledger/accounts','GET /api/ledger/periods','GET /api/ledger/journal','GET /api/ledger/export/sie4','POST /api/ledger/journal']},
{name:'quixzoom-mission',port:7060,path:'/health',desc:'Fältdatainsamling, crowdsourcing',endpoints:['GET /missions','POST /missions']},
{name:'quixzoom-avo',port:7070,path:'/health',desc:'Automated Visual Observation',endpoints:['GET /health']},
{name:'aamos-gateway',port:3401,path:'/v1/cities',desc:'Landvex Intelligence API',endpoints:['GET /v1/cities','GET /v1/intelligence/point']}
];
let html='';
for(const api of apis){
const start=performance.now();
let status='offline',respTime='--';
try{const r=await fetch('http://localhost:'+api.port+api.path);status=r.ok?'online':'error';respTime=Math.round(performance.now()-start)+'ms';}catch(e){status='offline';}
serviceStatuses[api.name]=status;
html+='<div class="card" style="margin-bottom:12px"><div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"><h3 style="text-transform:none;letter-spacing:0;color:var(--navy);margin:0">'+api.name+'</h3>'+statusBadge(status)+'</div><p style="color:var(--muted);font-size:.9rem;margin-bottom:8px">'+api.desc+'</p><div style="font-size:.8rem;color:var(--muted);margin-bottom:8px">Port '+api.port+' | Response: '+respTime+'</div><div style="margin:8px 0"><strong>Endpoints:</strong></div>';
for(const ep of api.endpoints){
const [method,path]=ep.split(' ');
html+='<div class="api-endpoint"><span><span class="method">'+method+'</span><span class="path">'+path+'</span></span><button class="btn small" onclick="quickTest(\'http://localhost:'+api.port+path+'\',\''+method+'\')">Testa</button></div>';
}
html+='</div>';
}
document.getElementById('api-list').innerHTML=html;
}
async function quickTest(url,method){
document.getElementById('test-url').value=url;
document.getElementById('test-method').value=method;
testEndpoint();
}
async function testEndpoint(){
const url=document.getElementById('test-url').value;
const method=document.getElementById('test-method').value;
const bodyStr=document.getElementById('test-body').value;
if(!url){document.getElementById('test-result').innerHTML='<div class="alert critical">Ange URL</div>';return;}
try{
const opts={method:method,headers:{}};
if(method==='POST'&&bodyStr){opts.headers['Content-Type']='application/json';opts.body=bodyStr;}
const start=performance.now();
const r=await fetch(url,opts);
const ms=Math.round(performance.now()-start);
let text=await r.text();
try{const j=JSON.parse(text);text=JSON.stringify(j,null,2);}catch(e){}
const cls=r.ok?'success':'error';
document.getElementById('test-result').innerHTML='<div class="result-box '+cls+'">Status: '+r.status+' '+r.statusText+' | Tid: '+ms+'ms\n\n'+text.substring(0,2000)+'</div>';
}catch(e){document.getElementById('test-result').innerHTML='<div class="result-box error">Fel: '+e.message+'</div>';}
}
async function fetchIntelligence(){
const lat=document.getElementById('intel-lat').value;
const lng=document.getElementById('intel-lng').value;
const profile=document.getElementById('intel-profile').value;
try{
const r=await fetch(AAMOS_API+'/v1/intelligence/point?lat='+lat+'&lng='+lng+'&profile='+profile);
const d=await r.json();
if(!r.ok)throw new Error(d.error||r.statusText);
let html='<div class="intel-result">';
html+='<div style="display:flex;align-items:center;gap:16px;margin-bottom:12px"><div class="score" style="color:'+d.assessment.color+'">'+d.assessment.score+'</div><div><div class="grade" style="color:'+d.assessment.color+'">'+d.assessment.grade+' — '+d.assessment.label+'</div><div class="meta">'+d.district+', '+d.city+'</div></div></div>';
html+='<div class="meta">Confidence: '+d.confidence.score+'% | Observationer: '+d.confidence.observation_count+' | Dataålder: '+d.confidence.data_age_days+' dagar</div>';
html+='<div class="intel-indices">';
for(const [k,v] of Object.entries(d.indices)){html+='<div class="intel-index"><div class="label">'+k+'</div><div class="value">'+v+'</div></div>';}
html+='</div>';
if(d.contradiction){html+='<div style="margin-top:12px;padding:10px;background:#fff;border-radius:8px"><strong>Kontradiktion:</strong> '+d.contradiction.score+' ('+d.contradiction.deviation_pct+'% avvikelse)<br><span style="font-size:.85rem;color:var(--muted)">'+d.contradiction.description+'</span></div>';}
html+='</div>';
document.getElementById('intel-result').innerHTML=html;
}catch(e){document.getElementById('intel-result').innerHTML='<div class="alert critical">Fel: '+e.message+'</div>';}
}
// ─── SYSTEM ───
async function loadSystemStatus(){
const svcs=[
{name:'aamos-ledger',port:3250,path:'/health',systemd:'aamos-ledger'},
{name:'quixzoom-mission',port:7060,path:'/health',systemd:'quixzoom-mission'},
{name:'quixzoom-avo',port:7070,path:'/health',systemd:'quixzoom-avo'},
{name:'aamos-gateway',port:3401,path:'/v1/cities',systemd:'aamos-gateway'},
{name:'nginx',port:80,path:'/',systemd:'nginx'}
];
let upCount=0;
const results=await Promise.all(svcs.map(async s=>{
try{const start=performance.now();const r=await fetch('http://localhost:'+s.port+s.path);const ms=Math.round(performance.now()-start);if(r.ok)upCount++;return{name:s.name,port:s.port,status:'running',ms:ms,systemd:s.systemd};}
catch(e){return{name:s.name,port:s.port,status:'stopped',ms:null,systemd:s.systemd};}
}));
serviceStatuses = Object.fromEntries(results.map(r=>[r.name,r.status]));
document.getElementById('kpi-services').textContent=upCount+'/'+svcs.length;
const html=results.map(r=>'<div class="service-row"><div><div class="service-name">'+r.name+'</div><div class="service-meta">Port '+r.port+(r.ms?' | '+r.ms+'ms':'')+'</div></div><div style="display:flex;align-items:center;gap:8px">'+statusBadge(r.status)+'<button class="btn small '+(r.status==='running'?'danger':'success')+'" onclick="toggleService(\''+r.systemd+'\',\''+r.status+'\')">'+(r.status==='running'?'Stoppa':'Starta')+'</button></div></div>').join('');
document.getElementById('services-list').innerHTML=html;
document.getElementById('system-status').innerHTML=html;
document.getElementById('overview-system-status').innerHTML=html;
}
async function toggleService(service,currentStatus){
const action=currentStatus==='running'?'stop':'start';
try{
const r=await fetch('/api/systemctl',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({service:service,action:action})});
if(r.ok){setTimeout(loadSystemStatus,2000);setTimeout(loadSystemLog,2000);}
else{alert('Kunde inte '+action+'a '+service+'. Kontrollera att API:et är tillgängligt.');}
}catch(e){alert('Fel: '+e.message);}
}
async function loadSystemLog(){
try{
const r=await fetch('/api/journal?service=aamos-ledger&lines=50');
if(r.ok){
const d=await r.json();
const logs=(d.logs||[]).map(l=>'<div class="log-entry">'+escapeHtml(l)+'</div>').join('');
document.getElementById('system-log').innerHTML=logs||'<div class="empty">Inga loggar</div>';
}else{
// Fallback: visa statisk info
document.getElementById('system-log').innerHTML='<div class="log-entry">['+new Date().toLocaleTimeString('sv-SE')+'] aamos-ledger: OK (port 3250)</div><div class="log-entry">['+new Date().toLocaleTimeString('sv-SE')+'] quixzoom-mission: OK (port 7060)</div><div class="log-entry">['+new Date().toLocaleTimeString('sv-SE')+'] quixzoom-avo: OK (port 7070)</div><div class="log-entry">['+new Date().toLocaleTimeString('sv-SE')+'] aamos-gateway: OK (port 3401)</div>';
}
}catch(e){
document.getElementById('system-log').innerHTML='<div class="log-entry">['+new Date().toLocaleTimeString('sv-SE')+'] aamos-ledger: OK (port 3250)</div><div class="log-entry">['+new Date().toLocaleTimeString('sv-SE')+'] quixzoom-mission: OK (port 7060)</div><div class="log-entry">['+new Date().toLocaleTimeString('sv-SE')+'] quixzoom-avo: OK (port 7070)</div><div class="log-entry">['+new Date().toLocaleTimeString('sv-SE')+'] aamos-gateway: OK (port 3401)</div>';
}
}
function escapeHtml(t){return t.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")}
// ─── ALERTS ───
async function loadAlerts(){
try{
const r=await fetch(API_BASE+'/accounts'), d=await r.json();
const a=d.accounts||[];
const alerts=[];
const b=a.find(x=>x.tenant_id==='landvex'&&x.account_number==='1930');
if(b){const bal=(b.debit||0)-(b.credit||0);if(bal<0)alerts.push('<div class="alert critical"><span class="icon">🏦</span><div><strong>Bankövertrassering</strong><br>1930: '+formatSEK(bal)+'. Överföring krävs omgående.</div></div>');}
const l=a.find(x=>x.tenant_id==='landvex'&&x.account_number==='1610');
if(l){const bal=(l.debit||0)-(l.credit||0);if(bal>100000)alerts.push('<div class="alert warning"><span class="icon">👤</span><div><strong>Leon-fordran</strong><br>1610: '+formatSEK(bal)+'. Avskriv eller driv in.</div></div>');}
const v=a.find(x=>x.tenant_id==='landvex'&&x.account_number==='2650');
if(v){const bal=(v.debit||0)-(v.credit||0);if(Math.abs(bal)>1000)alerts.push('<div class="alert info"><span class="icon">📊</span><div><strong>Moms att redovisa</strong><br>2650: '+formatSEK(bal)+'</div></div>');}
if(alerts.length===0)alerts.push('<div class="alert success"><span class="icon">✅</span><div><strong>Allt ser bra ut</strong><br>Inga kritiska ärenden just nu.</div></div>');
document.getElementById('critical-alerts').innerHTML=alerts.join('');
}catch(e){document.getElementById('critical-alerts').innerHTML='<div class="alert info"><span class="icon">️</span><div>Kunde inte ladda alerts</div></div>';}
}
// ─── INIT ───
async function init(){
await Promise.all([loadAccounts(),loadPeriods(),loadJournal(),loadMissions(),loadAPIs(),loadSystemStatus(),loadAlerts(),loadSystemLog()]);
await loadZoomers();
}
init();
setInterval(()=>{loadSystemStatus();loadAlerts();},30000);
</script>
</body>
</html>