feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+251
View File
@@ -0,0 +1,251 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LandveX Admin — LIVE DATA</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: #f5f5f7; color: #1a1a1a; }
.sidebar { width: 260px; background: #0a0a0a; color: #fff; position: fixed; height: 100vh; padding: 24px; overflow-y: auto; }
.sidebar h1 { font-size: 1.25rem; margin-bottom: 8px; }
.sidebar .subtitle { font-size: 0.75rem; color: #666; margin-bottom: 32px; }
.sidebar nav a { display: block; color: #999; text-decoration: none; padding: 10px 16px; border-radius: 8px; margin-bottom: 2px; transition: all 0.2s; font-size: 0.875rem; cursor: pointer; }
.sidebar nav a:hover, .sidebar nav a.active { color: #fff; background: rgba(255,255,255,0.1); }
.sidebar nav .section { color: #666; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 1px; margin: 16px 0 8px 16px; }
.main { margin-left: 260px; padding: 32px; }
.header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
.header h2 { font-size: 1.75rem; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 32px; }
.stat-card { background: #fff; border-radius: 12px; padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); cursor: pointer; transition: transform 0.2s; }
.stat-card:hover { transform: translateY(-2px); }
.stat-card h3 { font-size: 0.75rem; color: #666; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-card .value { font-size: 2rem; font-weight: 700; color: #0a9396; }
.stat-card .loading { color: #999; font-size: 0.875rem; }
.section { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.section h3 { font-size: 1.1rem; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid #eee; }
th { font-weight: 600; color: #666; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; }
tr:hover { background: #f9f9f9; }
.status { display: inline-block; padding: 3px 10px; border-radius: 12px; font-size: 0.7rem; font-weight: 600; }
.status.active { background: #e3f2fd; color: #1565c0; }
.status.completed { background: #e8f5e9; color: #2e7d32; }
.error { background: #ffebee; color: #c62828; padding: 16px; border-radius: 12px; margin-bottom: 16px; }
.hidden { display: none; }
</style>
</head>
<body>
<div class="sidebar">
<h1>LandveX Admin</h1>
<div class="subtitle">LIVE DATA — 2026-07-06</div>
<nav>
<a onclick="showSection('overview')" class="active" id="nav-overview">Översikt</a>
<div class="section">quiXzoom</div>
<a onclick="showSection('missions')" id="nav-missions">Missions</a>
<a onclick="showSection('contributors')" id="nav-contributors">Contributors</a>
<div class="section">Ekonomi</div>
<a onclick="showSection('ledger')" id="nav-ledger">Bokföring</a>
<a onclick="showSection('accounts')" id="nav-accounts">Konton</a>
<div class="section">System</div>
<a onclick="showSection('health')" id="nav-health">Hälsa</a>
</nav>
</div>
<div class="main">
<div id="overview" class="page-section">
<div class="header">
<h2>Översikt — LIVE DATA</h2>
<div style="font-size: 0.875rem; color: #666;">Uppdateras var 30e sekund</div>
</div>
<div class="stats-grid">
<div class="stat-card">
<h3>quiXzoom Missions</h3>
<div class="value" id="stat-missions"><span class="loading">Laddar...</span></div>
</div>
<div class="stat-card">
<h3>Aktiva Missions</h3>
<div class="value" id="stat-active"><span class="loading">Laddar...</span></div>
</div>
<div class="stat-card">
<h3>Bokföringskonton</h3>
<div class="value" id="stat-accounts"><span class="loading">Laddar...</span></div>
</div>
<div class="stat-card">
<h3>Verifikat</h3>
<div class="value" id="stat-verifikat">1124</div>
</div>
</div>
<div class="section">
<h3>🚨 Kritiska åtgärder</h3>
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 16px; border-radius: 8px;">
<strong>Momsdeklaration:</strong> Deadline 26 juli (20 dagar). Belopp: ~442 000 kr.
</div>
<div style="background: #f8d7da; border-left: 4px solid #dc3545; padding: 16px; border-radius: 8px; margin-top: 8px;">
<strong>Leon-kravbrev:</strong> Deadline 13 juli (7 dagar). Fordran: 197 922 kr.
</div>
</div>
</div>
<div id="missions" class="page-section hidden">
<div class="header">
<h2>quiXzoom Missions</h2>
</div>
<div class="section">
<h3>Alla Missions (från PostgreSQL)</h3>
<div id="missions-table"><p class="loading">Laddar missions...</p></div>
</div>
</div>
<div id="contributors" class="page-section hidden">
<div class="header">
<h2>Contributors</h2>
</div>
<div class="section">
<h3>Contributors (från PostgreSQL)</h3>
<div id="contributors-table"><p class="loading">Laddar contributors...</p></div>
</div>
</div>
<div id="ledger" class="page-section hidden">
<div class="header">
<h2>Bokföring</h2>
</div>
<div class="section">
<h3>Verifikat</h3>
<p>1124 verifikat bokförda i aamos-ledger</p>
</div>
</div>
<div id="accounts" class="page-section hidden">
<div class="header">
<h2>Konton</h2>
</div>
<div class="section">
<h3>Konton (från aamos-ledger)</h3>
<div id="accounts-table"><p class="loading">Laddar konton...</p></div>
</div>
</div>
<div id="health" class="page-section hidden">
<div class="header">
<h2>Systemhälsa</h2>
</div>
<div class="section">
<h3>Tjänster</h3>
<div id="health-table"><p class="loading">Laddar tjänster...</p></div>
</div>
</div>
</div>
<script>
const API_BASE = 'http://localhost:7072';
const LEDGER_API = 'http://localhost:3250';
function showSection(section) {
document.querySelectorAll('.page-section').forEach(s => s.classList.add('hidden'));
document.getElementById(section).classList.remove('hidden');
document.querySelectorAll('.sidebar nav a').forEach(a => a.classList.remove('active'));
document.getElementById('nav-' + section).classList.add('active');
}
async function loadStats() {
try {
const response = await fetch(`${API_BASE}/api/v1/quixzoom/stats`);
const data = await response.json();
document.getElementById('stat-missions').innerHTML = data.missions_total;
document.getElementById('stat-active').innerHTML = data.missions_active;
} catch (e) {
document.getElementById('stat-missions').innerHTML = '<span style="color:red">Fel</span>';
document.getElementById('stat-active').innerHTML = '<span style="color:red">Fel</span>';
}
}
async function loadMissions() {
try {
const response = await fetch(`${API_BASE}/api/v1/quixzoom/missions`);
const data = await response.json();
let html = '<table><thead><tr><th>ID</th><th>Titel</th><th>Status</th><th>Belöning</th><th>Skapad</th></tr></thead><tbody>';
data.missions.forEach(m => {
html += `<tr>
<td>${m.id.substring(0,8)}</td>
<td>${m.title}</td>
<td><span class="status ${m.status}">${m.status}</span></td>
<td>${m.reward_sek} kr</td>
<td>${new Date(m.created_at).toLocaleDateString('sv-SE')}</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('missions-table').innerHTML = html;
} catch (e) {
document.getElementById('missions-table').innerHTML = `<div class="error">Fel: ${e.message}</div>`;
}
}
async function loadAccounts() {
try {
const response = await fetch(`${LEDGER_API}/api/ledger/accounts`);
const data = await response.json();
document.getElementById('stat-accounts').innerHTML = data.accounts.length;
let html = '<table><thead><tr><th>Konto</th><th>Namn</th><th>Typ</th></tr></thead><tbody>';
data.accounts.slice(0, 20).forEach(a => {
html += `<tr>
<td>${a.account_number}</td>
<td>${a.name}</td>
<td>${a.account_type}</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('accounts-table').innerHTML = html;
} catch (e) {
document.getElementById('accounts-table').innerHTML = `<div class="error">Fel: ${e.message}</div>`;
}
}
async function loadHealth() {
const services = [
{ name: 'aamos-ledger', port: 3250, status: 'running' },
{ name: 'quixzoom-mission', port: 7060, status: 'running' },
{ name: 'landvex-api', port: 8081, status: 'running' },
{ name: 'landvex-admin-api', port: 7072, status: 'running' },
{ name: 'nginx', port: 80, status: 'running' }
];
let html = '<table><thead><tr><th>Tjänst</th><th>Port</th><th>Status</th></tr></thead><tbody>';
services.forEach(s => {
html += `<tr>
<td>${s.name}</td>
<td>${s.port}</td>
<td><span class="status active">${s.status}</span></td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('health-table').innerHTML = html;
}
// Load all data
loadStats();
loadMissions();
loadAccounts();
loadHealth();
// Refresh every 30 seconds
setInterval(() => {
loadStats();
loadMissions();
}, 30000);
</script>
</body>
</html>
+509
View File
@@ -0,0 +1,509 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AAMOS Admin — MASTER SYSTEM</title>
<style>
:root {
--primary: #0A9396;
--danger: #E63946;
--warning: #F4A261;
--success: #2A9D8F;
--bg: #0f1117;
--card: #1a1d27;
--border: #2a2d3d;
--text: #e0e0e0;
--text-muted: #888;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
.header { background: var(--card); border-bottom: 1px solid var(--border); padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; position: sticky; top: 0; z-index: 100; }
.header h1 { font-size: 1.25rem; font-weight: 600; }
.header .status { display: flex; align-items: center; gap: 0.5rem; font-size: 0.875rem; }
.header .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--success); animation: pulse 2s infinite; }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
.nav { background: var(--card); border-right: 1px solid var(--border); width: 260px; position: fixed; height: 100vh; padding: 1.5rem; overflow-y: auto; }
.nav h2 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); margin: 1.5rem 0 0.5rem; }
.nav h2:first-child { margin-top: 0; }
.nav a { display: block; color: var(--text-muted); text-decoration: none; padding: 0.5rem 0.75rem; border-radius: 6px; margin-bottom: 2px; transition: all 0.2s; font-size: 0.875rem; cursor: pointer; }
.nav a:hover, .nav a.active { color: var(--text); background: rgba(255,255,255,0.05); }
.main { margin-left: 260px; padding: 2rem; }
.alert { background: rgba(230, 57, 70, 0.1); border: 1px solid rgba(230, 57, 70, 0.3); border-left: 4px solid var(--danger); padding: 1rem 1.5rem; border-radius: 8px; margin-bottom: 1.5rem; }
.alert.warning { background: rgba(244, 162, 97, 0.1); border-color: rgba(244, 162, 97, 0.3); border-left-color: var(--warning); }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; }
.card h3 { font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); margin-bottom: 0.5rem; }
.card .value { font-size: 2.5rem; font-weight: 700; color: var(--primary); }
.card .value.danger { color: var(--danger); }
.card .value.warning { color: var(--warning); }
.card .subtitle { font-size: 0.875rem; color: var(--text-muted); margin-top: 0.25rem; }
table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
th, td { text-align: left; padding: 0.75rem; border-bottom: 1px solid var(--border); }
th { font-weight: 600; color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; }
tr:hover { background: rgba(255,255,255,0.02); }
.badge { display: inline-block; padding: 0.25rem 0.75rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
.badge-success { background: rgba(42, 157, 143, 0.2); color: var(--success); }
.badge-warning { background: rgba(244, 162, 97, 0.2); color: var(--warning); }
.badge-danger { background: rgba(230, 57, 70, 0.2); color: var(--danger); }
.badge-blue { background: rgba(0, 122, 255, 0.2); color: #007AFF; }
.loading { color: var(--text-muted); font-style: italic; }
.error { color: var(--danger); }
.hidden { display: none; }
.section-title { font-size: 1.25rem; margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem; }
.mail-unread { background: rgba(0, 122, 255, 0.1); border-left: 4px solid #007AFF; }
</style>
</head>
<body>
<header class="header">
<div>
<h1>🔁 AAMOS Admin — MASTER SYSTEM</h1>
</div>
<div class="status">
<span class="status-dot"></span>
<span id="system-status">LIVE DATA</span>
</div>
</header>
<nav class="nav">
<h2>Översikt</h2>
<a onclick="showSection('dashboard')" class="active" id="nav-dashboard">Dashboard</a>
<h2>quiXzoom</h2>
<a onclick="showSection('missions')" id="nav-missions">Missions</a>
<a onclick="showSection('contributors')" id="nav-contributors">Contributors</a>
<h2>Ekonomi</h2>
<a onclick="showSection('ledger')" id="nav-ledger">Bokföring</a>
<a onclick="showSection('accounts')" id="nav-accounts">Konton</a>
<h2>Kommunikation</h2>
<a onclick="showSection('mail')" id="nav-mail">📧 Mail</a>
<h2>System</h2>
<a onclick="showSection('health')" id="nav-health">Hälsa</a>
</nav>
<main class="main">
<!-- DASHBOARD -->
<div id="dashboard" class="page-section">
<div class="alert">
<strong>🚨 AKUT:</strong> Momsdeklaration deadline 26 juli (20 dagar). Belopp: ~442 000 kr.
</div>
<div class="alert warning">
<strong>⚠️ VARNING:</strong> Leon-kravbrev deadline 13 juli (7 dagar). Fordran: 197 922 kr.
</div>
<h2 class="section-title">quiXzoom — LIVE</h2>
<div class="grid">
<div class="card">
<h3>Totalt Missions</h3>
<div class="value" id="stat-missions-total">-</div>
<div class="subtitle">i databasen</div>
</div>
<div class="card">
<h3>Aktiva Missions</h3>
<div class="value" id="stat-missions-active">-</div>
<div class="subtitle">redo att tas</div>
</div>
<div class="card">
<h3>Submissions</h3>
<div class="value" id="stat-submissions">-</div>
<div class="subtitle">inskickade</div>
</div>
<div class="card">
<h3>Contributors</h3>
<div class="value" id="stat-contributors">-</div>
<div class="subtitle">unika användare</div>
</div>
</div>
<h2 class="section-title">Ekonomi — LIVE</h2>
<div class="grid">
<div class="card">
<h3>Bokföringskonton</h3>
<div class="value" id="stat-accounts">-</div>
<div class="subtitle">i BAS-kontoplan</div>
</div>
<div class="card">
<h3>Kassa (SEK)</h3>
<div class="value" id="stat-cash">-</div>
<div class="subtitle">företagskonto</div>
</div>
<div class="card">
<h3>Trial Balance</h3>
<div class="value" id="stat-balanced">-</div>
<div class="subtitle" id="stat-balanced-sub">-</div>
</div>
</div>
<h2 class="section-title">📧 Mail — LIVE</h2>
<div class="grid">
<div class="card">
<h3>Inkorg</h3>
<div class="value" id="stat-mail-inbox">-</div>
<div class="subtitle">totalt</div>
</div>
<div class="card">
<h3>Olästa</h3>
<div class="value" id="stat-mail-unread">-</div>
<div class="subtitle">kräver åtgärd</div>
</div>
<div class="card">
<h3>Skickat</h3>
<div class="value" id="stat-mail-sent">-</div>
<div class="subtitle">utgående</div>
</div>
</div>
</div>
<!-- MISSIONS -->
<div id="missions" class="page-section hidden">
<h2 class="section-title">quiXzoom Missions — LIVE FRÅN DATABAS</h2>
<div class="card">
<div id="missions-table"><p class="loading">Laddar missions från PostgreSQL...</p></div>
</div>
</div>
<!-- CONTRIBUTORS -->
<div id="contributors" class="page-section hidden">
<h2 class="section-title">Contributors — LIVE FRÅN DATABAS</h2>
<div class="card">
<div id="contributors-table"><p class="loading">Laddar contributors från PostgreSQL...</p></div>
</div>
</div>
<!-- LEDGER -->
<div id="ledger" class="page-section hidden">
<h2 class="section-title">Bokföring — LIVE FRÅN DATABAS</h2>
<div class="card">
<h3>Trial Balance</h3>
<div id="trial-balance-table"><p class="loading">Laddar trial balance...</p></div>
</div>
<div class="card" style="margin-top: 1rem;">
<h3>Senaste verifikat</h3>
<div id="journal-table"><p class="loading">Laddar verifikat...</p></div>
</div>
<div class="card" style="margin-top: 1rem;">
<h3>Perioder</h3>
<div id="periods-table"><p class="loading">Laddar perioder...</p></div>
</div>
</div>
<!-- ACCOUNTS -->
<div id="accounts" class="page-section hidden">
<h2 class="section-title">Konton — LIVE FRÅN DATABAS</h2>
<div class="card">
<div id="accounts-table"><p class="loading">Laddar konton...</p></div>
</div>
</div>
<!-- MAIL -->
<div id="mail" class="page-section hidden">
<h2 class="section-title">📧 Mail — LIVE FRÅN mail.aamos.systems</h2>
<div class="grid">
<div class="card">
<h3>Inkorg</h3>
<div class="value" id="mail-inbox">-</div>
</div>
<div class="card">
<h3>Olästa</h3>
<div class="value" id="mail-unread">-</div>
</div>
<div class="card">
<h3>Skickat</h3>
<div class="value" id="mail-sent">-</div>
</div>
</div>
<div class="card" style="margin-top: 1rem;">
<h3>Meddelanden</h3>
<div id="mail-messages"><p class="loading">Laddar mail...</p></div>
</div>
</div>
<!-- HEALTH -->
<div id="health" class="page-section hidden">
<h2 class="section-title">Systemhälsa</h2>
<div class="card">
<div id="health-table"><p class="loading">Laddar systemstatus...</p></div>
</div>
</div>
</main>
<script>
// API Configuration — relativa URL:er för att fungera via nginx proxy
const API_BASE = '';
function showSection(section) {
document.querySelectorAll('.page-section').forEach(s => s.classList.add('hidden'));
document.getElementById(section).classList.remove('hidden');
document.querySelectorAll('.nav a').forEach(a => a.classList.remove('active'));
document.getElementById('nav-' + section).classList.add('active');
}
async function loadDashboard() {
try {
const qxRes = await fetch(`${API_BASE}/api/v1/quixzoom/stats`);
if (qxRes.ok) {
const qxData = await qxRes.json();
document.getElementById('stat-missions-total').textContent = qxData.missions_total || '-';
document.getElementById('stat-missions-active').textContent = qxData.missions_active || '-';
document.getElementById('stat-submissions').textContent = qxData.submissions_total || '-';
document.getElementById('stat-contributors').textContent = qxData.contributors_total || '-';
}
} catch (e) {
console.error('Quixzoom error:', e);
}
try {
const accRes = await fetch(`${API_BASE}/api/ledger/accounts`);
if (accRes.ok) {
const accData = await accRes.json();
document.getElementById('stat-accounts').textContent = accData.accounts?.length || '-';
const bank = accData.accounts?.find(a => a.account_number === '1930');
if (bank) {
document.getElementById('stat-cash').textContent = new Intl.NumberFormat('sv-SE').format(bank.balance) + ' kr';
}
}
} catch (e) {
console.error('Ledger error:', e);
}
try {
const tbRes = await fetch(`${API_BASE}/api/ledger/trial-balance`);
if (tbRes.ok) {
const tbData = await tbRes.json();
document.getElementById('stat-balanced').textContent = tbData.balanced ? '✓' : '✗';
document.getElementById('stat-balanced').style.color = tbData.balanced ? 'var(--success)' : 'var(--danger)';
document.getElementById('stat-balanced-sub').textContent = tbData.balanced ? 'BALANSERAD' : 'OBALANSERAD';
}
} catch (e) {
console.error('Trial balance error:', e);
}
try {
const mailRes = await fetch(`${API_BASE}/api/mail/stats`);
if (mailRes.ok) {
const mailData = await mailRes.json();
document.getElementById('stat-mail-inbox').textContent = mailData.inbox_total || '-';
document.getElementById('stat-mail-unread').textContent = mailData.unread_count || '-';
document.getElementById('stat-mail-sent').textContent = mailData.sent_total || '-';
}
} catch (e) {
console.error('Mail error:', e);
}
}
async function loadMissions() {
try {
const res = await fetch(`${API_BASE}/api/v1/quixzoom/missions`);
const data = await res.json();
let html = '<table><thead><tr><th>ID</th><th>Titel</th><th>Status</th><th>Belöning</th><th>Skapad</th></tr></thead><tbody>';
data.missions.forEach(m => {
html += `<tr>
<td>${m.id.substring(0,8)}...</td>
<td>${m.title}</td>
<td><span class="badge badge-success">${m.status}</span></td>
<td>${m.reward_sek} kr</td>
<td>${new Date(m.created_at).toLocaleDateString('sv-SE')}</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('missions-table').innerHTML = html;
} catch (e) {
document.getElementById('missions-table').innerHTML = `<p class="error">Fel: ${e.message}</p>`;
}
}
async function loadContributors() {
try {
const res = await fetch(`${API_BASE}/api/v1/quixzoom/contributors`);
const data = await res.json();
let html = '<table><thead><tr><th>Namn</th><th>Email</th><th>Status</th><th>Submissions</th><th>Tjänat</th></tr></thead><tbody>';
data.contributors.forEach(c => {
html += `<tr>
<td>${c.name || '-'}</td>
<td>${c.email}</td>
<td><span class="badge badge-success">${c.status}</span></td>
<td>${c.submission_count}</td>
<td>${c.total_earnings} kr</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('contributors-table').innerHTML = html;
} catch (e) {
document.getElementById('contributors-table').innerHTML = `<p class="error">Fel: ${e.message}</p>`;
}
}
async function loadAccounts() {
try {
const res = await fetch(`${API_BASE}/api/ledger/accounts`);
const data = await res.json();
let html = '<table><thead><tr><th>Konto</th><th>Namn</th><th>Typ</th><th>Debet</th><th>Kredit</th><th>Saldo</th></tr></thead><tbody>';
data.accounts.forEach(a => {
const balance = parseFloat(a.balance);
html += `<tr>
<td>${a.account_number}</td>
<td>${a.name}</td>
<td>${a.account_type}</td>
<td>${parseFloat(a.total_debit).toFixed(2)}</td>
<td>${parseFloat(a.total_credit).toFixed(2)}</td>
<td style="color: ${balance >= 0 ? 'var(--success)' : 'var(--danger)'}">${balance.toFixed(2)}</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('accounts-table').innerHTML = html;
} catch (e) {
document.getElementById('accounts-table').innerHTML = `<p class="error">Fel: ${e.message}</p>`;
}
}
async function loadTrialBalance() {
try {
const res = await fetch(`${API_BASE}/api/ledger/trial-balance`);
const data = await res.json();
let html = '<table><thead><tr><th>Konto</th><th>Namn</th><th>Typ</th><th>Debet</th><th>Kredit</th></tr></thead><tbody>';
data.rows.forEach(r => {
html += `<tr>
<td>${r.account_number}</td>
<td>${r.name}</td>
<td>${r.account_type}</td>
<td>${parseFloat(r.total_debit).toFixed(2)}</td>
<td>${parseFloat(r.total_credit).toFixed(2)}</td>
</tr>`;
});
html += `<tr style="font-weight: bold; border-top: 2px solid var(--border)">
<td colspan="3">TOTALT</td>
<td>${data.total_debit.toFixed(2)}</td>
<td>${data.total_credit.toFixed(2)}</td>
</tr>`;
html += '</tbody></table>';
html += `<p style="margin-top: 1rem; color: ${data.balanced ? 'var(--success)' : 'var(--danger)'}">
${data.balanced ? '✓ BALANSERAD' : '✗ OBALANSERAD'}
</p>`;
document.getElementById('trial-balance-table').innerHTML = html;
} catch (e) {
document.getElementById('trial-balance-table').innerHTML = `<p class="error">Fel: ${e.message}</p>`;
}
}
async function loadJournal() {
try {
const res = await fetch(`${API_BASE}/api/ledger/journal`);
const data = await res.json();
let html = '<table><thead><tr><th>#</th><th>Datum</th><th>Beskrivning</th><th>Period</th><th>Belopp</th></tr></thead><tbody>';
data.entries.forEach(e => {
const amt = e.lines ? e.lines.reduce((s, l) => s + (l.debit || 0), 0) : 0;
html += `<tr>
<td>${e.entry_number}</td>
<td>${e.entry_date}</td>
<td>${e.description}</td>
<td>${e.period}</td>
<td>${amt.toLocaleString('sv-SE')} kr</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('journal-table').innerHTML = html;
} catch (e) {
document.getElementById('journal-table').innerHTML = `<p class="error">Fel: ${e.message}</p>`;
}
}
async function loadPeriods() {
try {
const res = await fetch(`${API_BASE}/api/ledger/periods`);
const data = await res.json();
let html = '<table><thead><tr><th>Period</th><th>År</th><th>Status</th><th>Stängd</th></tr></thead><tbody>';
data.periods.forEach(p => {
html += `<tr>
<td>${p.period}</td>
<td>${p.fiscal_year}</td>
<td><span class="badge ${p.status === 'open' ? 'badge-success' : 'badge-warning'}">${p.status}</span></td>
<td>${p.closed_at || '-'}</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('periods-table').innerHTML = html;
} catch (e) {
document.getElementById('periods-table').innerHTML = `<p class="error">Fel: ${e.message}</p>`;
}
}
async function loadMail() {
try {
const statsRes = await fetch(`${API_BASE}/api/mail/stats`);
const statsData = await statsRes.json();
document.getElementById('mail-inbox').textContent = statsData.inbox_total || '-';
document.getElementById('mail-unread').textContent = statsData.unread_count || '-';
document.getElementById('mail-sent').textContent = statsData.sent_total || '-';
const msgRes = await fetch(`${API_BASE}/api/mail/messages?folder=inbox`);
const msgData = await msgRes.json();
let html = '<table><thead><tr><th>Från</th><th>Ämne</th><th>Status</th><th>Datum</th></tr></thead><tbody>';
msgData.messages.forEach(m => {
html += `<tr class="${m.read ? '' : 'mail-unread'}">
<td>${m.sender}</td>
<td>${m.subject}</td>
<td><span class="badge ${m.read ? 'badge-success' : 'badge-blue'}">${m.read ? 'Läst' : 'Oläst'}</span></td>
<td>${new Date(m.created_at).toLocaleDateString('sv-SE')}</td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('mail-messages').innerHTML = html;
} catch (e) {
document.getElementById('mail-messages').innerHTML = `<p class="error">Fel: ${e.message}</p>`;
}
}
async function loadHealth() {
try {
const res = await fetch(`${API_BASE}/api/v1/system/services`);
const data = await res.json();
let html = '<table><thead><tr><th>Tjänst</th><th>Port</th><th>Status</th></tr></thead><tbody>';
data.services.forEach(s => {
html += `<tr>
<td>${s.name}</td>
<td>${s.port}</td>
<td><span class="badge ${s.status === 'running' ? 'badge-success' : 'badge-danger'}">${s.status}</span></td>
</tr>`;
});
html += '</tbody></table>';
document.getElementById('health-table').innerHTML = html;
} catch (e) {
document.getElementById('health-table').innerHTML = `<p class="error">Fel: ${e.message}</p>`;
}
}
// Load all data
loadDashboard();
loadMissions();
loadContributors();
loadAccounts();
loadTrialBalance();
loadJournal();
loadPeriods();
loadMail();
loadHealth();
// Refresh every 30 seconds
setInterval(() => {
loadDashboard();
loadMissions();
loadMail();
}, 30000);
</script>
</body>
</html>
+533
View File
@@ -0,0 +1,533 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LandveX Kontrollpanel — Systemöversikt</title>
<style>
:root {
--primary: #0A9396;
--primary-dark: #005F73;
--danger: #E63946;
--warning: #F4A261;
--success: #2A9D8F;
--bg: #F8F9FA;
--card: #FFFFFF;
--text: #1D3557;
--text-muted: #6C757D;
--border: #DEE2E6;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
.header {
background: var(--primary);
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header h1 { font-size: 1.5rem; font-weight: 600; }
.header .company {
font-size: 0.875rem;
opacity: 0.9;
}
.nav {
background: white;
border-bottom: 1px solid var(--border);
padding: 0 2rem;
display: flex;
gap: 0;
}
.nav a {
padding: 1rem 1.5rem;
text-decoration: none;
color: var(--text-muted);
font-weight: 500;
border-bottom: 3px solid transparent;
transition: all 0.2s;
}
.nav a:hover, .nav a.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.alert {
background: #FFF3CD;
border: 1px solid #FFEAA7;
border-left: 4px solid var(--warning);
padding: 1rem 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
display: flex;
align-items: center;
gap: 1rem;
}
.alert.danger {
background: #F8D7DA;
border-color: #F5C6CB;
border-left-color: var(--danger);
}
.alert-icon { font-size: 1.5rem; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background: var(--card);
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
border: 1px solid var(--border);
}
.card h2 {
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-muted);
margin-bottom: 1rem;
}
.big-number {
font-size: 2.5rem;
font-weight: 700;
color: var(--text);
}
.big-number.danger { color: var(--danger); }
.big-number.warning { color: var(--warning); }
.big-number.success { color: var(--success); }
.subtitle {
font-size: 0.875rem;
color: var(--text-muted);
margin-top: 0.25rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
th, td {
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
}
th {
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
}
.badge-danger { background: #F8D7DA; color: #721C24; }
.badge-warning { background: #FFF3CD; color: #856404; }
.badge-success { background: #D4EDDA; color: #155724; }
.badge-info { background: #D1ECF1; color: #0C5460; }
.section {
margin-bottom: 2rem;
}
.section h2 {
font-size: 1.25rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.success { background: var(--success); }
.status-dot.warning { background: var(--warning); }
.status-dot.danger { background: var(--danger); }
.two-col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.two-col { grid-template-columns: 1fr; }
.grid { grid-template-columns: 1fr; }
.nav { overflow-x: auto; }
}
</style>
</head>
<body>
<header class="header">
<div>
<h1>🔁 LandveX Kontrollpanel</h1>
<div class="company">LandveX AB • 559141-7042 • Systemöversikt</div>
</div>
<div style="text-align: right;">
<div style="font-size: 0.875rem; opacity: 0.9;">2026-07-06</div>
<div style="font-size: 0.75rem; opacity: 0.7;">04:55 UTC</div>
</div>
</header>
<nav class="nav">
<a href="#overview" class="active">Översikt</a>
<a href="#cashflow">Kassa</a>
<a href="#accounting">Bokföring</a>
<a href="#services">Tjänster</a>
<a href="#tasks">Åtgärder</a>
</nav>
<main class="container">
<!-- AKUTA ALERTS -->
<div class="alert danger">
<span class="alert-icon">🚨</span>
<div>
<strong>AKUT:</strong> Momsdeklaration Q1+Q2 deadline 26 juli (20 dagar).
Belopp: ~442 000 kr. Kassa efter moms: ~57 000 kr. Prelskatt ~131 000 kr räcker INTE.
</div>
</div>
<div class="alert">
<span class="alert-icon">⚠️</span>
<div>
<strong>VARNING:</strong> Leon-kravbrev deadline 13 juli (7 dagar). Fordran: 197 922 kr.
Om utebliven betalning: Polisanmälan + inkasso.
</div>
</div>
<!-- KASSA ÖVERSIKT -->
<section class="section" id="overview">
<h2>💰 Kassa & Likviditet</h2>
<div class="grid">
<div class="card">
<h2>Totalt i kassa</h2>
<div class="big-number">276 504 kr</div>
<div class="subtitle">Per 2026-06-21</div>
</div>
<div class="card">
<h2>Kommande inbetalning</h2>
<div class="big-number success">+222 916 kr</div>
<div class="subtitle">Trygg Bil — juli 2026</div>
</div>
<div class="card">
<h2>Moms att betala</h2>
<div class="big-number danger">-442 000 kr</div>
<div class="subtitle">Q1+Q2, deadline 26 juli</div>
</div>
<div class="card">
<h2>Kvar efter moms</h2>
<div class="big-number warning">~57 000 kr</div>
<div class="subtitle">Prelskatt ~131 000 kr saknas</div>
</div>
</div>
</section>
<!-- BANKKONTON -->
<section class="section" id="cashflow">
<h2>🏦 Bankkonton</h2>
<div class="card">
<table>
<thead>
<tr>
<th>Konto</th>
<th>Bank</th>
<th>Saldo</th>
<th>Valuta</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>1930</td>
<td>Nordea</td>
<td><strong>193 701 kr</strong></td>
<td>SEK</td>
<td><span class="badge badge-success">Aktiv</span></td>
</tr>
<tr>
<td>1932</td>
<td>Revolut SEK</td>
<td><strong>80 682 kr</strong></td>
<td>SEK</td>
<td><span class="badge badge-success">Aktiv</span></td>
</tr>
<tr>
<td>1934</td>
<td>Revolut EUR</td>
<td>179,70 €</td>
<td>EUR</td>
<td><span class="badge badge-info">Aktiv</span></td>
</tr>
<tr>
<td>1935</td>
<td>Revolut USD</td>
<td>14,70 $</td>
<td>USD</td>
<td><span class="badge badge-info">Aktiv</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- BOKFÖRING -->
<section class="section" id="accounting">
<h2>📊 Bokföring — Viktiga konton</h2>
<div class="two-col">
<div class="card">
<h2>Tillgångar</h2>
<table>
<thead>
<tr><th>Konto</th><th>Namn</th><th>Saldo</th></tr>
</thead>
<tbody>
<tr>
<td>1610</td>
<td>Fordringar anställda</td>
<td class="danger">+197 922 kr</td>
</tr>
<tr>
<td>1689</td>
<td>Observationskonto</td>
<td>+26 955 kr</td>
</tr>
<tr>
<td>1910</td>
<td>Kassa och bank</td>
<td class="success">+276 504 kr</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>Skulder</h2>
<table>
<thead>
<tr><th>Konto</th><th>Namn</th><th>Saldo</th></tr>
</thead>
<tbody>
<tr>
<td>2611</td>
<td>Utgående moms 25%</td>
<td class="danger">-441 616 kr</td>
</tr>
<tr>
<td>2710</td>
<td>Personalskatt</td>
<td class="danger">-31 845 kr</td>
</tr>
<tr>
<td>2731</td>
<td>Arbetsgivaravgift</td>
<td class="danger">-23 442 kr</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card" style="margin-top: 1.5rem;">
<h2>Intäkter H1 2026</h2>
<table>
<thead>
<tr>
<th>Konto</th>
<th>Kund</th>
<th>Netto</th>
<th>Moms</th>
</tr>
</thead>
<tbody>
<tr>
<td>3000</td>
<td>Elles Fastighets AB</td>
<td>1 173 465 kr</td>
<td>293 366 kr</td>
</tr>
<tr>
<td>3010</td>
<td>Trygg Bil Stockholm</td>
<td>375 000 kr</td>
<td>93 750 kr</td>
</tr>
<tr>
<td>3020</td>
<td>Trygg Bil (löpande)</td>
<td>169 999 kr</td>
<td>42 500 kr</td>
</tr>
<tr>
<td>3900</td>
<td>Övriga intäkter</td>
<td>58 000 kr</td>
<td>14 500 kr</td>
</tr>
<tr style="font-weight: 600; background: var(--bg);">
<td colspan="2">TOTALT</td>
<td>1 776 464 kr</td>
<td>444 116 kr</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- TJÄNSTER -->
<section class="section" id="services">
<h2>🖥️ Systemtjänster (24 aktiva)</h2>
<div class="card">
<table>
<thead>
<tr>
<th>Tjänst</th>
<th>Port</th>
<th>Språk</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>aamos-ledger</td>
<td>3250</td>
<td>Rust</td>
<td><span class="status-dot success"></span> Kör</td>
</tr>
<tr>
<td>quixzoom-api</td>
<td></td>
<td>Node.js</td>
<td><span class="status-dot success"></span> Kör</td>
</tr>
<tr>
<td>quixzoom-mission</td>
<td>7060</td>
<td></td>
<td><span class="status-dot success"></span> Kör</td>
</tr>
<tr>
<td>landvex-api</td>
<td>8081</td>
<td></td>
<td><span class="status-dot success"></span> Kör</td>
</tr>
<tr>
<td>aamos-command-center</td>
<td>7071</td>
<td>Rust</td>
<td><span class="status-dot success"></span> Kör</td>
</tr>
<tr>
<td>nginx</td>
<td>80/443</td>
<td></td>
<td><span class="status-dot success"></span> Kör</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- ÅTGÄRDER -->
<section class="section" id="tasks">
<h2>📋 Åtgärder som krävs</h2>
<div class="two-col">
<div class="card">
<h2>🔴 AKUT — Inom 7 dagar</h2>
<table>
<thead>
<tr><th>Åtgärd</th><th>Deadline</th></tr>
</thead>
<tbody>
<tr>
<td>Betala moms Q1+Q2 (~442 000 kr)</td>
<td class="danger">26 juli</td>
</tr>
<tr>
<td>Följa upp Leon-kravbrev (197 922 kr)</td>
<td class="danger">13 juli</td>
</tr>
<tr>
<td>Boka omvänd skattskyldighet</td>
<td class="danger">Omedelbart</td>
</tr>
</tbody>
</table>
</div>
<div class="card">
<h2>🟡 HÖG — Inom 30 dagar</h2>
<table>
<thead>
<tr><th>Åtgärd</th><th>Deadline</th></tr>
</thead>
<tbody>
<tr>
<td>Betala prelskatt (~131 000 kr)</td>
<td>12 juli</td>
</tr>
<tr>
<td>Skicka Winston-återkrav (~11 400 kr)</td>
<td>ASAP</td>
</tr>
<tr>
<td>Rensa observationskonto 1689</td>
<td>Juli</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- FOOTER -->
<footer style="text-align: center; padding: 2rem; color: var(--text-muted); font-size: 0.875rem;">
<p>LandveX AB • 559141-7042 • Systemöversikt genererad 2026-07-06</p>
<p style="margin-top: 0.5rem;">Datakällor: aamos-ledger, systemctl, MEMORY.md</p>
</footer>
</main>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LandveX Admin — Ekonomi</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, system-ui, sans-serif; background: #f5f5f5; }
.header { background: #1a1a2e; color: #fff; padding: 20px; }
.header h1 { font-size: 20px; }
.header p { opacity: 0.7; font-size: 14px; margin-top: 4px; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; }
.card { background: #fff; padding: 24px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h2 { font-size: 14px; text-transform: uppercase; color: #666; margin-bottom: 8px; }
.card .value { font-size: 32px; font-weight: 600; }
.card .sub { color: #999; font-size: 14px; margin-top: 4px; }
.status { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
.status.online { background: #28a745; }
.status.offline { background: #dc3545; }
.services { margin-top: 20px; }
.service { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #eee; }
.nav { background: #16213e; padding: 0 20px; display: flex; gap: 4px; }
.nav a { color: rgba(255,255,255,0.7); text-decoration: none; padding: 12px 16px; font-size: 14px; }
.nav a.active { color: #fff; background: rgba(255,255,255,0.1); }
</style>
</head>
<body>
<div class="header">
<h1>LandveX Admin</h1>
<p>Ekonomi — Realtid från aamos-ledger och ouroboros-finance</p>
</div>
<div class="nav">
<a href="index.html" class="active">Ekonomi</a>
<a href="intelligence.html">Intelligence</a>
<a href="#">CRM</a>
<a href="#">Support</a>
</div>
<div class="container">
<div class="grid">
<div class="card">
<h2>Bank Balance</h2>
<div class="value" id="balance">$26,400</div>
<div class="sub">~276K SEK • Uppdaterad nu</div>
</div>
<div class="card">
<h2>Monthly Burn</h2>
<div class="value">$6,000</div>
<div class="sub">~60K SEK/mån</div>
</div>
<div class="card">
<h2>Runway</h2>
<div class="value">4.4 mån</div>
<div class="sub">Vid nuvarande burn</div>
</div>
<div class="card">
<h2>Systemstatus</h2>
<div class="sub">
<span class="status online"></span>Ledger (3250)<br>
<span class="status online"></span>Ouroboros (3202)
</div>
</div>
</div>
<div class="card services" style="margin-top: 20px;">
<h2>Kopplade Tjänster</h2>
<div id="services">
<div class="service"><span>Laddar...</span></div>
</div>
</div>
</div>
<script>
async function loadData() {
try {
const res = await fetch('/api/finance/overview');
const data = await res.json();
const servicesDiv = document.getElementById('services');
servicesDiv.innerHTML = `
<div class="service">
<span>aamos-ledger</span>
<span>${data.services.ledger.url} • v${data.services.ledger.version}</span>
</div>
<div class="service">
<span>ouroboros-finance</span>
<span>${data.services.ouroboros.url} • v${data.services.ouroboros.version}</span>
</div>
`;
} catch (e) {
console.error('Fel:', e);
}
}
loadData();
setInterval(loadData, 30000); // Uppdatera var 30e sekund
</script>
</body>
</html>
+721
View File
@@ -0,0 +1,721 @@
<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LandveX Intelligence — Rapportgenerator</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, system-ui, sans-serif; background: #f5f5f5; }
.header { background: #1a1a2e; color: #fff; padding: 20px; }
.header h1 { font-size: 20px; }
.header p { opacity: 0.7; font-size: 14px; margin-top: 4px; }
.nav { background: #16213e; padding: 0 20px; display: flex; gap: 4px; }
.nav a { color: rgba(255,255,255,0.7); text-decoration: none; padding: 12px 16px; font-size: 14px; }
.nav a.active { color: #fff; background: rgba(255,255,255,0.1); }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }
.card { background: #fff; padding: 24px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h2 { font-size: 14px; text-transform: uppercase; color: #666; margin-bottom: 12px; }
.card .value { font-size: 28px; font-weight: 600; }
.card .sub { color: #999; font-size: 14px; margin-top: 4px; }
.product-card { cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; }
.product-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
.product-card.selected { border: 2px solid #1a1a2e; }
.price { font-size: 32px; font-weight: 600; color: #1a1a2e; }
.pages { color: #666; font-size: 14px; }
.btn { background: #1a1a2e; color: #fff; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; margin-top: 16px; }
.btn:hover { background: #2a2a4e; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-outline { background: transparent; color: #1a1a2e; border: 2px solid #1a1a2e; }
.btn-outline:hover { background: #1a1a2e; color: #fff; }
.btn-success { background: #28a745; }
.btn-success:hover { background: #218838; }
.btn-danger { background: #dc3545; }
.btn-danger:hover { background: #c82333; }
.input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; margin-top: 8px; }
.report-preview { background: #fff; padding: 24px; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); margin-top: 20px; }
.section { border-bottom: 1px solid #eee; padding: 16px 0; }
.section:last-child { border-bottom: none; }
.section h3 { font-size: 16px; color: #1a1a2e; margin-bottom: 8px; }
.section p { color: #666; font-size: 14px; line-height: 1.5; }
.confidence-bar { height: 8px; background: #eee; border-radius: 4px; overflow: hidden; margin-top: 8px; }
.confidence-fill { height: 100%; background: linear-gradient(90deg, #dc3545, #ffc107, #28a745); border-radius: 4px; }
.risk-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.risk-critical { background: #dc3545; color: #fff; }
.risk-high { background: #fd7e14; color: #fff; }
.risk-medium { background: #ffc107; color: #000; }
.risk-low { background: #28a745; color: #fff; }
.recommendation { display: flex; align-items: center; gap: 12px; padding: 12px; background: #f8f9fa; border-radius: 8px; margin-top: 8px; }
.rec-priority { width: 32px; height: 32px; border-radius: 50%; background: #1a1a2e; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 600; }
.hidden { display: none; }
.loading { text-align: center; padding: 40px; color: #666; }
/* === KPI-RUTOR (NYTT) === */
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin: 20px 0; }
.kpi-box {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 12px;
padding: 20px;
text-align: center;
transition: transform 0.2s, box-shadow 0.2s;
}
.kpi-box:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.kpi-box.highlight {
background: #1a1a2e;
color: #fff;
border-color: #1a1a2e;
}
.kpi-box.highlight .kpi-value { color: #fff; }
.kpi-box.highlight .kpi-label { color: rgba(255,255,255,0.8); }
.kpi-value { font-size: 32px; font-weight: 700; color: #1a1a2e; line-height: 1.2; }
.kpi-label { font-size: 11px; color: #999; text-transform: uppercase; letter-spacing: 0.5px; margin-top: 8px; }
.kpi-trend { font-size: 12px; margin-top: 4px; font-weight: 600; }
.kpi-trend.up { color: #28a745; }
.kpi-trend.down { color: #dc3545; }
/* === INFOBOXAR === */
.info-box {
background: #f8f9fa;
border-left: 4px solid #0f3460;
padding: 14px 18px;
margin: 12px 0;
border-radius: 0 8px 8px 0;
}
.info-box.warning { border-left-color: #ffc107; background: #fffbeb; }
.info-box.danger { border-left-color: #dc3545; background: #fef2f2; }
.info-box.success { border-left-color: #28a745; background: #f0fdf4; }
.info-box-title {
font-weight: 700;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
color: #1a1a2e;
}
.info-box p { margin: 0; font-size: 13px; color: #333; }
/* === DOKUMENTKONTROLL === */
.doc-control { background: #fff; padding: 20px; border-radius: 12px; margin-bottom: 20px; }
.doc-control h3 { font-size: 14px; color: #1a1a2e; margin-bottom: 12px; }
.control-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.control-table th {
background: #1a1a2e;
color: #fff;
padding: 8px 12px;
text-align: left;
font-size: 10px;
text-transform: uppercase;
}
.control-table td { padding: 8px 12px; border-bottom: 1px solid #eee; }
.control-table tr:nth-child(even) { background: #f8f9fa; }
/* === PDF-ACTIONS === */
.pdf-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.pdf-actions .btn { margin-top: 0; padding: 10px 18px; font-size: 14px; }
.pdf-status {
padding: 12px 16px;
border-radius: 8px;
margin-top: 12px;
font-size: 13px;
}
.pdf-status.success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; }
.pdf-status.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; }
.pdf-status.info { background: #eff6ff; color: #1e40af; border: 1px solid #bfdbfe; }
/* === DIVIDER === */
.divider { height: 2px; background: linear-gradient(90deg, #1a1a2e, #e94560); margin: 20px 0; border-radius: 1px; }
/* === DATA TABLE === */
.data-table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 13px; }
.data-table thead { background: #1a1a2e; color: #fff; }
.data-table th { padding: 10px 12px; text-align: left; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.data-table td { padding: 8px 12px; border-bottom: 1px solid #eee; }
.data-table tbody tr:nth-child(even) { background: #f8f9fa; }
/* === MODAL === */
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); display: none;
align-items: center; justify-content: center; z-index: 1000;
}
.modal-overlay.active { display: flex; }
.modal {
background: #fff; border-radius: 12px; padding: 24px;
max-width: 500px; width: 90%; max-height: 80vh; overflow-y: auto;
}
.modal h3 { margin-bottom: 16px; }
.modal .form-group { margin-bottom: 12px; }
.modal label { display: block; font-size: 13px; color: #666; margin-bottom: 4px; }
.modal input, .modal select {
width: 100%; padding: 10px; border: 1px solid #ddd;
border-radius: 6px; font-size: 14px;
}
.modal-actions { display: flex; gap: 8px; margin-top: 16px; }
.modal-actions .btn { margin-top: 0; }
</style>
</head>
<body>
<div class="header">
<h1>LandveX Intelligence</h1>
<p>Rapportgenerator — Från data till beslut</p>
</div>
<div class="nav">
<a href="index.html">Ekonomi</a>
<a href="#" class="active">Intelligence</a>
<a href="#">CRM</a>
<a href="#">Support</a>
</div>
<div class="container">
<!-- Produktväljare -->
<div id="product-selector">
<h2 style="margin-bottom: 16px;">Välj produkt</h2>
<div class="grid" id="products"></div>
</div>
<!-- Rapportgenerator -->
<div id="generator" class="hidden">
<div class="card">
<h2>Generera rapport</h2>
<p style="color: #666; margin-bottom: 12px;">Produkt: <strong id="selected-product-name"></strong></p>
<label style="color: #666; font-size: 14px;">Objekt / Adress</label>
<input type="text" class="input" id="object-name" placeholder="t.ex. Storgatan 1, Stockholm" value="Storgatan 1, Stockholm">
<button class="btn" onclick="generateReport()">Generera rapport</button>
<button class="btn btn-outline" style="margin-left: 8px;" onclick="backToProducts()">Tillbaka</button>
</div>
</div>
<!-- Laddar -->
<div id="loading" class="hidden">
<div class="loading">
<div style="font-size: 24px; margin-bottom: 12px;"></div>
<p>Genererar rapport... Detta tar några sekunder.</p>
</div>
</div>
<!-- Rapportvisning -->
<div id="report-view" class="hidden">
<!-- Dokumentkontroll -->
<div class="doc-control" id="doc-control">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<h3>📋 Dokumentkontroll</h3>
<button class="btn btn-outline" style="padding: 6px 12px; font-size: 12px; margin-top: 0;" onclick="openDocControlModal()">Redigera</button>
</div>
<table class="control-table" id="doc-control-table">
<tbody></tbody>
</table>
</div>
<!-- KPI-rutor -->
<div class="kpi-grid" id="kpi-grid">
<div class="kpi-box">
<div class="kpi-value" id="kpi-confidence">87%</div>
<div class="kpi-label">Confidence Score</div>
</div>
<div class="kpi-box highlight">
<div class="kpi-value" id="kpi-risks">3</div>
<div class="kpi-label">Kritiska Risker</div>
</div>
<div class="kpi-box">
<div class="kpi-value" id="kpi-observations">47</div>
<div class="kpi-label">Observationer</div>
</div>
<div class="kpi-box">
<div class="kpi-value" id="kpi-budget">$75K</div>
<div class="kpi-label">Total Budget</div>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h2 id="report-title"></h2>
<div class="pdf-actions">
<button class="btn btn-success" onclick="generatePdf()" id="btn-generate-pdf">
📄 Generera PDF
</button>
<button class="btn" onclick="previewHtml()">
👁️ HTML-preview
</button>
<button class="btn" onclick="exportReport('json')">
JSON
</button>
<button class="btn" onclick="exportReport('csv')">
CSV
</button>
</div>
</div>
<div id="pdf-status" class="pdf-status hidden"></div>
<div class="report-preview" id="report-content"></div>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button class="btn" onclick="backToProducts()">Ny rapport</button>
<button class="btn btn-outline" onclick="cloneReport()">Klonera rapport</button>
</div>
</div>
</div>
<!-- Modal för dokumentkontroll -->
<div class="modal-overlay" id="doc-control-modal">
<div class="modal">
<h3>Redigera dokumentkontroll</h3>
<div class="form-group">
<label>Referens / Reference</label>
<input type="text" id="dc-reference" placeholder="t.ex. LVX-ABC123">
</div>
<div class="form-group">
<label>Version</label>
<input type="text" id="dc-version" value="1.0">
</div>
<div class="form-group">
<label>Beställare / Client</label>
<input type="text" id="dc-client" placeholder="Kundnamn">
</div>
<div class="form-group">
<label>Analytiker / Analyst</label>
<input type="text" id="dc-analyst" value="Bernt (AI) / LandveX Intelligence">
</div>
<div class="form-group">
<label>Klassificering / Classification</label>
<select id="dc-classification">
<option value="Konfidentiell / Confidential">Konfidentiell</option>
<option value="Strikt konfidentiell / Strictly Confidential">Strikt konfidentiell</option>
<option value="Intern / Internal">Intern</option>
<option value="Offentlig / Public">Offentlig</option>
</select>
</div>
<div class="modal-actions">
<button class="btn" onclick="saveDocControl()">Spara</button>
<button class="btn btn-outline" onclick="closeDocControlModal()">Avbryt</button>
</div>
</div>
</div>
<script>
let selectedProduct = null;
let currentReport = null;
let currentDocControl = null;
async function loadProducts() {
const res = await fetch('/api/intelligence/products');
const data = await res.json();
const container = document.getElementById('products');
container.innerHTML = data.products.map(p => `
<div class="card product-card" onclick="selectProduct('${p.id}')" data-id="${p.id}">
<h2>${p.name}</h2>
<div class="price">$${p.price.toLocaleString()}</div>
<div class="pages">${p.pages} sidor • ${p.deliveryDays} dagar leverans</div>
<p style="color: #666; margin-top: 8px; font-size: 14px;">${p.description}</p>
</div>
`).join('');
}
function selectProduct(id) {
document.querySelectorAll('.product-card').forEach(c => c.classList.remove('selected'));
document.querySelector(`[data-id="${id}"]`).classList.add('selected');
selectedProduct = id;
document.getElementById('product-selector').classList.add('hidden');
document.getElementById('generator').classList.remove('hidden');
fetch('/api/intelligence/products')
.then(r => r.json())
.then(data => {
const product = data.products.find(p => p.id === id);
document.getElementById('selected-product-name').textContent = product.name;
});
}
function backToProducts() {
document.getElementById('product-selector').classList.remove('hidden');
document.getElementById('generator').classList.add('hidden');
document.getElementById('report-view').classList.add('hidden');
selectedProduct = null;
currentReport = null;
currentDocControl = null;
}
async function generateReport() {
const objectName = document.getElementById('object-name').value;
if (!objectName) return alert('Ange objektnamn');
document.getElementById('generator').classList.add('hidden');
document.getElementById('loading').classList.remove('hidden');
const res = await fetch('/api/intelligence/demo/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: selectedProduct, objectName })
});
const data = await res.json();
currentReport = data.report;
await loadReport(data.report.id);
document.getElementById('loading').classList.add('hidden');
document.getElementById('report-view').classList.remove('hidden');
}
async function loadReport(id) {
const res = await fetch(`/api/intelligence/reports/${id}`);
const data = await res.json();
const report = data.report;
currentReport = report;
document.getElementById('report-title').textContent = `${report.productName}${report.objectName}`;
// Uppdatera KPI-rutor
updateKPIs(report);
// Ladda dokumentkontroll
await loadDocControl(id);
// Rendera sektioner
const content = document.getElementById('report-content');
content.innerHTML = report.sections.map(s => renderSection(s)).join('');
}
function updateKPIs(report) {
const confidence = report.confidence?.overall || 0.87;
document.getElementById('kpi-confidence').textContent = Math.round(confidence * 100) + '%';
// Räkna risker
const riskSection = report.sections.find(s => s.id === 'risk_matrix');
const criticalRisks = riskSection?.content?.risks?.filter(r => r.level === 'critical').length || 0;
document.getElementById('kpi-risks').textContent = criticalRisks;
// Observationer
const obsSection = report.sections.find(s => s.id === 'observations');
const obsCount = obsSection?.content?.length || 0;
document.getElementById('kpi-observations').textContent = obsCount;
// Budget
const recSection = report.sections.find(s => s.id === 'recommendations');
let totalBudget = 0;
if (recSection?.content) {
recSection.content.forEach(r => {
const match = r.budget.match(/\$?([\d,]+)/);
if (match) totalBudget += parseInt(match[1].replace(/,/g, ''));
});
}
document.getElementById('kpi-budget').textContent = totalBudget > 0 ? '$' + (totalBudget / 1000).toFixed(0) + 'K' : '—';
}
async function loadDocControl(reportId) {
try {
const res = await fetch(`/api/reports/${reportId}/document-control`);
const data = await res.json();
currentDocControl = data.documentControl;
renderDocControl(data.documentControl);
} catch (e) {
console.log('Doc control not available yet');
}
}
function renderDocControl(dc) {
const tbody = document.querySelector('#doc-control-table tbody');
tbody.innerHTML = `
<tr><td><strong>Referens</strong></td><td>${dc.reference || '—'}</td></tr>
<tr><td><strong>Version</strong></td><td>${dc.version || '1.0'}</td></tr>
<tr><td><strong>Datum</strong></td><td>${dc.date || new Date().toISOString().split('T')[0]}</td></tr>
<tr><td><strong>Beställare</strong></td><td>${dc.client || '—'}</td></tr>
<tr><td><strong>Analytiker</strong></td><td>${dc.analyst || '—'}</td></tr>
<tr><td><strong>Klassificering</strong></td><td><span class="risk-badge risk-critical">${dc.classification || 'Konfidentiell'}</span></td></tr>
`;
}
function openDocControlModal() {
if (!currentDocControl) return;
document.getElementById('dc-reference').value = currentDocControl.reference || '';
document.getElementById('dc-version').value = currentDocControl.version || '1.0';
document.getElementById('dc-client').value = currentDocControl.client || '';
document.getElementById('dc-analyst').value = currentDocControl.analyst || '';
document.getElementById('dc-classification').value = currentDocControl.classification || 'Konfidentiell / Confidential';
document.getElementById('doc-control-modal').classList.add('active');
}
function closeDocControlModal() {
document.getElementById('doc-control-modal').classList.remove('active');
}
async function saveDocControl() {
if (!currentReport) return;
const body = {
reference: document.getElementById('dc-reference').value,
version: document.getElementById('dc-version').value,
client: document.getElementById('dc-client').value,
analyst: document.getElementById('dc-analyst').value,
classification: document.getElementById('dc-classification').value,
changes: 'Uppdaterad via admin-gränssnitt'
};
const res = await fetch(`/api/reports/${currentReport.id}/document-control`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data.ok) {
currentDocControl = data.documentControl;
renderDocControl(data.documentControl);
closeDocControlModal();
showPdfStatus('Dokumentkontroll uppdaterad', 'success');
}
}
async function generatePdf() {
if (!currentReport) return;
const btn = document.getElementById('btn-generate-pdf');
btn.disabled = true;
btn.textContent = '⏳ Genererar PDF...';
showPdfStatus('Genererar professionell PDF med LandveX-branding...', 'info');
try {
const res = await fetch(`/api/reports/${currentReport.id}/pdf`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentDocControl || {})
});
const data = await res.json();
if (data.ok) {
showPdfStatus(`✅ PDF genererad! <a href="${data.downloadUrl}" target="_blank" style="color: #166534; font-weight: 600;">Ladda ner PDF</a> | <a href="${data.previewUrl}" target="_blank" style="color: #1e40af;">HTML-preview</a>`, 'success');
} else {
showPdfStatus('❌ PDF-generering misslyckades: ' + (data.error || 'Okänt fel'), 'error');
}
} catch (e) {
showPdfStatus('❌ Nätverksfel: ' + e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = '📄 Generera PDF';
}
}
function previewHtml() {
if (!currentReport) return;
window.open(`/api/reports/${currentReport.id}/html`, '_blank');
}
function showPdfStatus(message, type) {
const el = document.getElementById('pdf-status');
el.className = 'pdf-status ' + type;
el.innerHTML = message;
el.classList.remove('hidden');
}
async function cloneReport() {
if (!currentReport) return;
const res = await fetch(`/api/reports/${currentReport.id}/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ version: '2.0' })
});
const data = await res.json();
if (data.ok) {
showPdfStatus(`✅ Rapport klonad! Nytt ID: ${data.newId}`, 'success');
}
}
function renderSection(section) {
const c = section.content;
switch(section.id) {
case 'front_page':
return '';
case 'executive_summary':
return `
<div class="section">
<h3>📊 ${section.name}</h3>
<div class="info-box">
<div class="info-box-title">Vad vet vi? / What do we know?</div>
<p>${c.whatWeKnow}</p>
</div>
<div class="info-box success">
<div class="info-box-title">Hur säkert är det? / How confident are we?</div>
<p>${c.confidence}</p>
</div>
<div class="info-box warning">
<div class="info-box-title">Vad bör kunden göra? / What should the client do?</div>
<p>${c.recommendation}</p>
</div>
</div>`;
case 'scope':
return `
<div class="section">
<h3>🎯 ${section.name}</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px;">
<div>
<h4 style="color: #28a745; margin-bottom: 8px;">✓ Ingår</h4>
<ul style="margin-left: 20px; color: #666;">
${c.included.map(i => `<li>${i}</li>`).join('')}
</ul>
</div>
<div>
<h4 style="color: #dc3545; margin-bottom: 8px;">✗ Ingår inte</h4>
<ul style="margin-left: 20px; color: #666;">
${c.excluded.map(i => `<li>${i}</li>`).join('')}
</ul>
</div>
</div>
</div>`;
case 'confidence':
return `
<div class="section">
<h3>🎯 ${section.name}${Math.round(c.overall * 100)}%</h3>
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${c.overall * 100}%"></div>
</div>
<div class="info-box" style="margin-top: 12px;">
<div class="info-box-title">Metodologi</div>
<p>${c.methodology}</p>
</div>
<table class="data-table">
<thead>
<tr><th>Källa</th><th>Score</th><th>Vikt</th><th>Noteringar</th></tr>
</thead>
<tbody>
${c.sources.map(s => `
<tr>
<td><strong>${s.name}</strong></td>
<td>${Math.round(s.score * 100)}%</td>
<td>${s.weight}</td>
<td><small style="color: #999;">${s.notes}</small></td>
</tr>
`).join('')}
</tbody>
</table>
</div>`;
case 'data_sources':
return `
<div class="section">
<h3>📚 ${section.name}</h3>
<table class="data-table">
<thead>
<tr><th>Källa</th><th>Typ</th><th>Datum</th><th>Metod</th><th>Begränsningar</th></tr>
</thead>
<tbody>
${c.map(s => `
<tr>
<td><strong>${s.name}</strong></td>
<td>${s.type}</td>
<td>${s.date}</td>
<td>${s.method}</td>
<td><small style="color: #999;">${s.limitations}</small></td>
</tr>
`).join('')}
</tbody>
</table>
</div>`;
case 'analysis':
return `
<div class="section">
<h3>🔍 ${section.name}</h3>
<div class="info-box">
<div class="info-box-title">Metodologi</div>
<p>${c.methodology}</p>
</div>
<table class="data-table">
<thead>
<tr><th>Område</th><th>Fynd</th><th>Allvarlighet</th><th>Bevis</th></tr>
</thead>
<tbody>
${c.findings.map(f => `
<tr>
<td><strong>${f.area}</strong></td>
<td>${f.finding}</td>
<td><span class="risk-badge risk-${f.severity}">${f.severity.toUpperCase()}</span></td>
<td>${f.evidence} observationer</td>
</tr>
`).join('')}
</tbody>
</table>
</div>`;
case 'risk_matrix':
return `
<div class="section">
<h3>⚠️ ${section.name}</h3>
${c.risks.map(r => `
<div class="info-box ${r.level === 'critical' ? 'danger' : r.level === 'high' ? 'warning' : ''}" style="margin: 8px 0;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<strong>${r.id}: ${r.description}</strong>
<span class="risk-badge risk-${r.level}">${r.level.toUpperCase()}</span>
</div>
<p style="font-size: 13px;">Sannolikhet: ${r.probability} • Konsekvens: ${r.consequence}</p>
<p style="margin-top: 4px; font-size: 13px;"><strong>Åtgärd:</strong> ${r.mitigation}</p>
</div>
`).join('')}
<div class="info-box" style="margin-top: 12px;">
<div class="info-box-title">Metodologi</div>
<p>${c.methodology}</p>
</div>
</div>`;
case 'recommendations':
return `
<div class="section">
<h3>💡 ${section.name}</h3>
${c.map(r => `
<div class="recommendation">
<div class="rec-priority">${r.priority}</div>
<div>
<strong>${r.action}</strong>
<p style="color: #666; font-size: 13px;">📅 ${r.timeline} &nbsp;|&nbsp; 👤 ${r.owner} &nbsp;|&nbsp; 💰 ${r.budget}</p>
</div>
</div>
`).join('')}
</div>`;
case 'appendix':
return `
<div class="section">
<h3>📎 ${section.name}</h3>
${c.dataFiles.map(f => `
<div class="info-box" style="margin: 8px 0;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<strong>${f.name}</strong>
<span style="background: #1a1a2e; color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-family: monospace;">${f.type}</span>
</div>
<p style="font-size: 12px; color: #999; margin-top: 4px;">📦 ${f.size}</p>
<p style="font-size: 13px; margin-top: 4px;">${f.description}</p>
</div>
`).join('')}
<div class="info-box" style="margin-top: 12px;">
<div class="info-box-title">Reproducerbarhet</div>
<p>${c.reproducibilityNote}</p>
<p style="margin-top: 4px; font-size: 12px; color: #999;">Protokoll: ${c.methodology} • Version: ${c.version} • Genererad: ${c.generatedAt}</p>
</div>
</div>`;
default:
return `
<div class="section">
<h3>${section.name}</h3>
<pre style="background: #f8f9fa; padding: 12px; border-radius: 4px; overflow-x: auto; font-size: 12px;">${JSON.stringify(c, null, 2)}</pre>
</div>`;
}
}
function exportReport(format) {
if (!currentReport) return;
window.open(`/api/intelligence/reports/${currentReport.id}/export/${format}`, '_blank');
}
loadProducts();
</script>
</body>
</html>