Files
boc/landvex-admin-v2/ui/admin-master.html
T
Bernt 6989a98d75 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
2026-07-07 07:11:50 +00:00

510 lines
19 KiB
HTML

<!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>