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
+51 -51
View File
@@ -19,12 +19,12 @@ body{font-family:-apple-system,'SF Pro Text',system-ui,sans-serif;background:#F2
.nav-inner{height:44px;display:flex;align-items:center;padding:0 16px;gap:12px}
.nav-title{flex:1;text-align:center;font-size:17px;font-weight:600;letter-spacing:-.4px}
.nav-logo{font-size:20px;font-weight:900;background:linear-gradient(135deg,#FF6B35,#F7C948);-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-.5px}
.nav-btn-icon{width:32px;height:32px;border-radius:16px;background:#007AFF;color:#fff;border:none;font-size:14px;display:flex;align-items:center;justify-content:center;cursor:pointer}
.nav-btn-icon{width:32px;height:32px;border-radius: var(--radius-lg);background:#007AFF;color:#fff;border:none;font-size:14px;display:flex;align-items:center;justify-content:center;cursor:pointer}
/* Customer switcher */
.customer-switcher{background:#fff;border-bottom:.33px solid rgba(60,60,67,.12);padding:10px 16px;display:flex;gap:8px;overflow-x:auto;scrollbar-width:none}
.customer-switcher::-webkit-scrollbar{display:none}
.cust-chip{padding:7px 14px;border-radius:20px;font-size:13px;font-weight:600;cursor:pointer;border:none;background:#F2F2F7;color:#000;white-space:nowrap;transition:all .15s;-webkit-tap-highlight-color:transparent}
.cust-chip{padding:7px 14px;border-radius: var(--radius-xl);font-size:13px;font-weight:600;cursor:pointer;border:none;background:#F2F2F7;color:#000;white-space:nowrap;transition:all .15s;-webkit-tap-highlight-color:transparent}
.cust-chip.active{background:#007AFF;color:#fff}
/* Large title */
@@ -46,7 +46,7 @@ body{font-family:-apple-system,'SF Pro Text',system-ui,sans-serif;background:#F2
/* KPI strip */
.kpi-strip{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;padding:12px 16px 8px}
.kpi-box{background:#fff;border-radius:12px;padding:14px 12px;text-align:center;box-shadow:0 1px 0 rgba(0,0,0,.08)}
.kpi-box{background:#fff;border-radius: var(--radius-md);padding:14px 12px;text-align:center;box-shadow:0 1px 0 rgba(0,0,0,.08)}
.kpi-val{font-size:20px;font-weight:700;letter-spacing:-.5px}
.kpi-val-accent{color:#FF6B35}
.kpi-lbl{font-size:10px;color:#666666;font-weight:500;margin-top:2px}
@@ -56,11 +56,11 @@ body{font-family:-apple-system,'SF Pro Text',system-ui,sans-serif;background:#F2
.section-header{font-size:13px;font-weight:600;color:#555555;text-transform:uppercase;padding:0 32px 6px}
/* Card list */
.card-list{background:#fff;border-radius:12px;margin:0 16px;overflow:hidden;box-shadow:0 1px 0 rgba(0,0,0,.08)}
.card-list{background:#fff;border-radius: var(--radius-md);margin:0 16px;overflow:hidden;box-shadow:0 1px 0 rgba(0,0,0,.08)}
.card-item{display:flex;align-items:center;gap:12px;padding:13px 16px;border-bottom:.33px solid rgba(60,60,67,.12);-webkit-tap-highlight-color:transparent;transition:background .1s}
.card-item:last-child{border-bottom:none}
.card-item:active{background:#F2F2F7}
.card-icon{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.card-icon{width:40px;height:40px;border-radius: var(--radius-md);display:flex;align-items:center;justify-content:center;font-size:20px;flex-shrink:0}
.ci-blue{background:#EBF5FF}.ci-green{background:#E8F8EE}.ci-orange{background:#FFF3E0}.ci-red{background:#FFE8E6}.ci-purple{background:#F4EEFF}.ci-gray{background:#F2F2F7}
.card-body{flex:1;min-width:0}
.card-title{font-size:15px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@@ -69,7 +69,7 @@ body{font-family:-apple-system,'SF Pro Text',system-ui,sans-serif;background:#F2
.chevron{color:#C7C7CC;font-size:16px}
/* Badges */
.badge{display:inline-flex;align-items:center;padding:3px 8px;border-radius:6px;font-size:12px;font-weight:600;white-space:nowrap}
.badge{display:inline-flex;align-items:center;padding:3px 8px;border-radius: var(--radius-sm);font-size:12px;font-weight:600;white-space:nowrap}
.bg-green{background:#E8F8EE;color:#1D7A3F}
.bg-orange{background:#FFF3E0;color:#C07000}
.bg-blue{background:#EBF5FF;color:#0051A8}
@@ -81,8 +81,8 @@ body{font-family:-apple-system,'SF Pro Text',system-ui,sans-serif;background:#F2
.stars{color:#FFD60A;font-size:13px}
/* Favorite zoomer card */
.fav-card{background:#fff;border-radius:14px;margin:0 16px;padding:16px;display:flex;align-items:center;gap:14px;box-shadow:0 1px 0 rgba(0,0,0,.08);margin-bottom:8px}
.fav-avatar{width:52px;height:52px;border-radius:26px;background:linear-gradient(135deg,#FF6B35,#F7C948);display:flex;align-items:center;justify-content:center;font-size:24px;flex-shrink:0}
.fav-card{background:#fff;border-radius: var(--radius-lg);margin:0 16px;padding:16px;display:flex;align-items:center;gap:14px;box-shadow:0 1px 0 rgba(0,0,0,.08);margin-bottom:8px}
.fav-avatar{width:52px;height:52px;border-radius: 26px;background:linear-gradient(135deg,#FF6B35,#F7C948);display:flex;align-items:center;justify-content:center;font-size:24px;flex-shrink:0}
.fav-name{font-size:16px;font-weight:700}
.fav-spec{font-size:13px;color:#666666}
.fav-meta{font-size:12px;color:#666666;margin-top:4px}
@@ -93,22 +93,22 @@ body{font-family:-apple-system,'SF Pro Text',system-ui,sans-serif;background:#F2
.bar-chart-title{font-size:15px;font-weight:600;margin-bottom:14px}
.bar-row{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.bar-label{font-size:12px;color:#555555;width:80px;flex-shrink:0}
.bar-track{flex:1;height:8px;background:#E9E9EB;border-radius:4px;overflow:hidden}
.bar-fill{height:8px;border-radius:4px;background:linear-gradient(90deg,#FF6B35,#F7C948);transition:width .7s ease}
.bar-track{flex:1;height:8px;background:#E9E9EB;border-radius: var(--radius-sm);overflow:hidden}
.bar-fill{height:8px;border-radius: var(--radius-sm);background:linear-gradient(90deg,#FF6B35,#F7C948);transition:width .7s ease}
.bar-val{font-size:12px;color:#555555;width:80px;text-align:right;flex-shrink:0}
/* Book modal */
.modal-overlay{position:fixed;inset:0;background: rgba(242,242,247,.4);z-index:300;display:none;align-items:flex-end}
.modal-overlay.open{display:flex}
.modal{background:#fff;border-radius:20px 20px 0 0;padding:20px 20px calc(20px + env(safe-area-inset-bottom,0px));width:100%;max-height:90dvh;overflow-y:auto}
.modal-handle{width:36px;height:4px;border-radius:2px;background:#C7C7CC;margin:0 auto 16px}
.modal{background:#fff;border-radius: var(--radius-xl) 20px 0 0;padding:20px 20px calc(20px + env(safe-area-inset-bottom,0px));width:100%;max-height:90dvh;overflow-y:auto}
.modal-handle{width:36px;height:4px;border-radius: var(--radius-sm);background:#C7C7CC;margin:0 auto 16px}
.modal-title{font-size:20px;font-weight:700;margin-bottom:4px}
.modal-sub{font-size:14px;color:#666666;margin-bottom:20px}
.form-group{margin-bottom:14px}
.form-label{font-size:13px;font-weight:600;color:#555555;margin-bottom:6px;display:block}
.form-input,.form-select{width:100%;background:#F2F2F7;border:none;border-radius:10px;padding:12px 14px;color:#000;font-size:15px;font-family:inherit;outline:none}
.form-input,.form-select{width:100%;background:#F2F2F7;border:none;border-radius: var(--radius-md);padding:12px 14px;color:#000;font-size:15px;font-family:inherit;outline:none}
.btn-row{display:flex;flex-direction:column;gap:10px;margin-top:20px}
.btn{display:flex;align-items:center;justify-content:center;gap:8px;padding:13px;border-radius:12px;font-size:17px;font-weight:600;cursor:pointer;border:none;-webkit-tap-highlight-color:transparent;transition:opacity .15s}
.btn{display:flex;align-items:center;justify-content:center;gap:8px;padding:13px;border-radius: var(--radius-md);font-size:17px;font-weight:600;cursor:pointer;border:none;-webkit-tap-highlight-color:transparent;transition:opacity .15s}
.btn:active{opacity:.7}
.btn-orange{background:linear-gradient(135deg,#FF6B35,#FF9500);color:#fff}
.btn-gray{background:#F2F2F7;color:#000}
@@ -118,7 +118,7 @@ body{font-family:-apple-system,'SF Pro Text',system-ui,sans-serif;background:#F2
.empty-icon{font-size:36px;margin-bottom:8px}
/* Toast */
.toast{position:fixed;bottom:calc(83px + 12px + env(safe-area-inset-bottom,0px));left:50%;transform:translateX(-50%) translateY(60px);background:#323232;color:#fff;padding:10px 18px;border-radius:10px;font-size:14px;font-weight:600;z-index:999;transition:transform .3s;white-space:nowrap}
.toast{position:fixed;bottom:calc(83px + 12px + env(safe-area-inset-bottom,0px));left:50%;transform:translateX(-50%) translateY(60px);background:#323232;color:#fff;padding:10px 18px;border-radius: var(--radius-md);font-size:14px;font-weight:600;z-index:999;transition:transform .3s;white-space:nowrap}
.toast.show{transform:translateX(-50%) translateY(0)}
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
@@ -156,7 +156,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="nav-inner">
<div class="nav-logo">quiX</div>
<div class="nav-title" id="nav-title">Kunddashboard</div>
<button class="btn btn-orange" style="padding:7px 14px;font-size:14px;font-weight:700;border-radius:10px;border:none;color:#fff;background:linear-gradient(135deg,#FF6B35,#FF9500);cursor:pointer" onclick="openBookModal()">+ Boka</button>
<button class="btn btn-orange" style="padding:7px 14px;font-size:14px;font-weight:700;border-radius: var(--radius-md);border:none;color:#fff;background:linear-gradient(135deg,#FF6B35,#FF9500);cursor:pointer" onclick="openBookModal()">+ Boka</button>
</div>
</div>
@@ -187,7 +187,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<!-- HISTORY -->
<div class="content" id="tab-history">
<div style="height:12px"></div>
<div style="background:#fff;border-radius:12px;margin:0 16px" id="spending-chart"></div>
<div style="background:#fff;border-radius: var(--radius-md);margin:0 16px" id="spending-chart"></div>
<div style="height:12px"></div>
<div class="section">
<div class="section-header">Alla uppdrag</div>
@@ -208,17 +208,17 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="content" id="tab-favorites">
<div style="height:12px"></div>
<div style="padding:0 16px 12px;display:flex;justify-content:flex-end">
<button class="btn btn-orange" style="font-size:14px;padding:8px 16px;border-radius:10px;border:none;color:#fff;background:linear-gradient(135deg,#FF6B35,#FF9500);cursor:pointer" onclick="openAddFavModal()">+ Favorit</button>
<button class="btn btn-orange" style="font-size:14px;padding:8px 16px;border-radius: var(--radius-md);border:none;color:#fff;background:linear-gradient(135deg,#FF6B35,#FF9500);cursor:pointer" onclick="openAddFavModal()">+ Favorit</button>
</div>
<div id="favs-list"></div>
</div>
<!-- Tab bar -->
<nav class="tab-bar">
<div class="tab-item active" onclick="showTab('dashboard',this,'Hem')"><div class="tab-icon">🏠</div><div class="tab-label">Hem</div></div>
<div class="tab-item" onclick="showTab('history',this,'Historik')"><div class="tab-icon">📋</div><div class="tab-label">Historik</div></div>
<div class="tab-item" onclick="showTab('billing',this,'Fakturering')"><div class="tab-icon">💳</div><div class="tab-label">Fakturering</div></div>
<div class="tab-item" onclick="showTab('favorites',this,'Favoriter')"><div class="tab-icon"></div><div class="tab-label">Favoriter</div></div>
<div class="tab-item active" onclick="showTab('dashboard',this,'Hem')"><div class="tab-icon"></div><div class="tab-label">Hem</div></div>
<div class="tab-item" onclick="showTab('history',this,'Historik')"><div class="tab-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></div><div class="tab-label">Historik</div></div>
<div class="tab-item" onclick="showTab('billing',this,'Fakturering')"><div class="tab-icon"></div><div class="tab-label">Fakturering</div></div>
<div class="tab-item" onclick="showTab('favorites',this,'Favoriter')"><div class="tab-icon"></div><div class="tab-label">Favoriter</div></div>
</nav>
<!-- Book modal -->
@@ -228,13 +228,13 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="modal-title">Boka uppdrag</div>
<div class="modal-sub" id="book-sub">Beskriv ditt uppdrag</div>
<div class="form-group"><label class="form-label">Titel</label><input class="form-input" id="bk-title" placeholder="Vad behöver du hjälp med?"></div>
<div class="form-group"><label class="form-label">Typ</label><select class="form-select" id="bk-type"><option value="photography">Fotografi 📸</option><option value="video">Video 🎬</option><option value="drone">Drönare 🚁</option></select></div>
<div class="form-group"><label class="form-label">Typ</label><select class="form-select" id="bk-type"><option value="photography">Fotografi </option><option value="video">Video </option><option value="drone">Drönare <svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2V6M4 4H12" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M2 8H6L8 6L10 8H14" stroke="#666" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 8V12H10V8" stroke="#666" stroke-width="1.5"/></svg></option></select></div>
<div class="form-group"><label class="form-label">Startdatum & tid</label><input class="form-input" type="datetime-local" id="bk-start"></div>
<div class="form-group"><label class="form-label">Duration (timmar)</label><input class="form-input" type="number" id="bk-duration" value="2" min="1"></div>
<div class="form-group"><label class="form-label">Plats</label><input class="form-input" id="bk-location" placeholder="Var ska uppdraget utföras?"></div>
<div class="form-group"><label class="form-label">Budget (SEK)</label><input class="form-input" type="number" id="bk-budget" placeholder="2500"></div>
<div class="btn-row">
<button class="btn btn-orange" onclick="submitBook()">🚀 Boka nu</button>
<button class="btn btn-orange" onclick="submitBook()"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2L10 8L14 9L8 14L6 8L2 7L8 2Z" fill="#666"/></svg> Boka nu</button>
<button class="btn btn-gray" onclick="closeModal('book-modal')">Avbryt</button>
</div>
</div>
@@ -250,7 +250,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="form-group"><label class="form-label">Namn</label><input class="form-input" id="fav-name" placeholder="Förnamn E."></div>
<div class="form-group"><label class="form-label">Specialitet</label><select class="form-select" id="fav-spec"><option>Photography</option><option>Video</option><option>Drone</option><option>General</option></select></div>
<div class="btn-row">
<button class="btn btn-orange" onclick="submitFav()"> Lägg till</button>
<button class="btn btn-orange" onclick="submitFav()"> Lägg till</button>
<button class="btn btn-gray" onclick="closeModal('fav-modal')">Avbryt</button>
</div>
</div>
@@ -259,7 +259,7 @@ body { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;
<div class="toast" id="toast"></div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
// State
const CUSTOMERS = ['cust-101','cust-102','cust-103'];
let activeCustomer = 'cust-101';
let customerCache = {};
@@ -268,20 +268,20 @@ let spendingCache = {};
let invoiceCache = {};
let favCache = {};
// ── Utils ──────────────────────────────────────────────────────────────────
// Utils
function fmtMoney(n){return new Intl.NumberFormat('sv-SE').format(n)+' kr'}
function fmtDate(s){if(!s)return'—';return new Date(s).toLocaleDateString('sv-SE',{day:'2-digit',month:'short'})}
function fmtDateLong(s){if(!s)return'—';return new Date(s).toLocaleDateString('sv-SE',{day:'2-digit',month:'long',year:'numeric'})}
function toast(msg){const el=document.getElementById('toast');el.textContent=msg;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),2500)}
function statusBadge(s){return{completed:'<span class="badge bg-green">✓ Klar</span>',active:'<span class="badge bg-blue">🔄 Aktiv</span>',open:'<span class="badge bg-orange">🔍 Öppen</span>',cancelled:'<span class="badge bg-red">✗ Avbruten</span>'}[s]||`<span class="badge bg-gray">${s}</span>`}
function missionIcon(t){return{photography:'📸',video:'🎬',drone:'🚁'}[t]||'📷'}
function statusBadge(s){return{completed:'<span class="badge bg-green"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> Klar</span>',active:'<span class="badge bg-blue"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 8C2 11.3 4.7 14 8 14C10.5 14 12.7 12.5 13.7 10.3" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M14 8C14 4.7 11.3 2 8 2C5.5 2 3.3 3.5 2.3 5.7" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M13.5 7.5L14 10L11.5 9.5" stroke="#666" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M2.5 8.5L2 6L4.5 6.5" stroke="#666" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg> Aktiv</span>',open:'<span class="badge bg-orange"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="5" stroke="#666" stroke-width="2"/><path d="M11 11L14 14" stroke="#666" stroke-width="2" stroke-linecap="round"/></svg> Öppen</span>',cancelled:'<span class="badge bg-red"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 4L12 12M12 4L4 12" stroke="#ef4444" stroke-width="2" stroke-linecap="round"/></svg> Avbruten</span>'}[s]||`<span class="badge bg-gray">${s}</span>`}
function missionIcon(t){return{photography:'',video:'',drone:'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2V6M4 4H12" stroke="#666" stroke-width="1.5" stroke-linecap="round"/><path d="M2 8H6L8 6L10 8H14" stroke="#666" stroke-width="1.5" stroke-linejoin="round"/><path d="M6 8V12H10V8" stroke="#666" stroke-width="1.5"/></svg>'}[t]||'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg>'}
function missionIconClass(s){return{completed:'ci-green',active:'ci-blue',open:'ci-orange',cancelled:'ci-red'}[s]||'ci-gray'}
function stars(r){if(!r)return'<span style="color:#666666;font-size:12px">Väntar</span>';return'<span class="stars">'+(''.repeat(Math.round(r))+''.repeat(5-Math.round(r)))+'</span><span style="font-size:12px;color:#666666;margin-left:4px">'+r+'</span>'}
function stars(r){if(!r)return'<span style="color:#666666;font-size:12px">Väntar</span>';return'<span class="stars">'+(''.repeat(Math.round(r))+''.repeat(5-Math.round(r)))+'</span><span style="font-size:12px;color:#666666;margin-left:4px">'+r+'</span>'}
function invStatusBadge(s){return s==='paid'?'<span class="badge bg-green">Betald</span>':'<span class="badge bg-orange">Obetald</span>'}
function closeModal(id){document.getElementById(id).classList.remove('open')}
document.querySelectorAll('.modal-overlay').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('open')}));
// ── Tab ────────────────────────────────────────────────────────────────────
// Tab
function showTab(id,el,title){
document.querySelectorAll('.content').forEach(c=>c.classList.remove('active'));
document.querySelectorAll('.tab-item').forEach(t=>t.classList.remove('active'));
@@ -293,7 +293,7 @@ function showTab(id,el,title){
if(id==='favorites')renderFavorites();
}
// ── Customer switcher ──────────────────────────────────────────────────────
// Customer switcher
function renderSwitcher(){
const c = Object.values(customerCache);
document.getElementById('customer-switcher').innerHTML = c.map(cust=>`
@@ -308,7 +308,7 @@ async function switchCustomer(id){
renderDashboard();
}
// ── Data loading ───────────────────────────────────────────────────────────
// Data loading
async function loadAll(){
// Load all customers
const custR=await fetch('https://api.quixzoom.com/api/qz/customers').then(r=>r.json()).catch(()=>({ok:false}));
@@ -333,7 +333,7 @@ async function loadCustomerData(id){
if(fR.ok)favCache[id]=fR.items||[];
}
// ── Dashboard ──────────────────────────────────────────────────────────────
// Dashboard
function renderDashboard(){
const cust=customerCache[activeCustomer];
const missions=missionCache[activeCustomer]||[];
@@ -355,12 +355,12 @@ function renderDashboard(){
const activeMissions=missions.filter(m=>['active','open'].includes(m.status));
document.getElementById('active-missions').innerHTML=activeMissions.length
? activeMissions.map(renderMissionCard).join('')
: '<div class="empty"><div class="empty-icon">🎯</div><div style="font-weight:600">Inga aktiva uppdrag</div><div style="font-size:13px;margin-top:4px">Boka ett nytt uppdrag!</div></div>';
: '<div class="empty"><div class="empty-icon"></div><div style="font-weight:600">Inga aktiva uppdrag</div><div style="font-size:13px;margin-top:4px">Boka ett nytt uppdrag!</div></div>';
// Recent (completed, max 3)
const recent=missions.filter(m=>m.status==='completed').slice(0,3);
document.getElementById('recent-missions').innerHTML=recent.length
? recent.map(renderMissionCard).join('')
: '<div class="empty"><div class="empty-icon">📋</div><div style="font-weight:600">Inga avslutade uppdrag</div></div>';
: '<div class="empty"><div class="empty-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></div><div style="font-weight:600">Inga avslutade uppdrag</div></div>';
}
function renderMissionCard(m){
@@ -378,7 +378,7 @@ function renderMissionCard(m){
</div>`;
}
// ── History tab ────────────────────────────────────────────────────────────
// History tab
function renderHistory(){
const missions=missionCache[activeCustomer]||[];
const spending=spendingCache[activeCustomer]||{};
@@ -400,39 +400,39 @@ function renderHistory(){
// All missions
document.getElementById('all-missions').innerHTML=missions.length
? missions.map(renderMissionCard).join('')
: '<div class="empty"><div class="empty-icon">📋</div>Inga uppdrag</div>';
: '<div class="empty"><div class="empty-icon"><svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="3" y="2" width="10" height="12" rx="1" stroke="#666" stroke-width="1.5"/><path d="M6 5H10M6 8H10M6 11H8" stroke="#666" stroke-width="1.5" stroke-linecap="round"/></svg></div>Inga uppdrag</div>';
}
// ── Billing tab ────────────────────────────────────────────────────────────
// Billing tab
function renderBilling(){
const invs=invoiceCache[activeCustomer]||[];
document.getElementById('invoices-list').innerHTML=invs.length
? invs.map(inv=>`
<div class="card-item">
<div class="card-icon ${inv.status==='paid'?'ci-green':'ci-orange'}">${inv.status==='paid'?'':'🧾'}</div>
<div class="card-icon ${inv.status==='paid'?'ci-green':'ci-orange'}">${inv.status==='paid'?'<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="#22c55e" stroke-width="2"/><path d="M6 10L9 13L14 7" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>':''}</div>
<div class="card-body"><div class="card-title">${inv.number}</div><div class="card-sub">Förfaller ${fmtDate(inv.due_date)}</div></div>
<div class="card-right">${invStatusBadge(inv.status)}<div style="font-size:14px;font-weight:700;margin-left:8px">${fmtMoney(inv.amount_sek)}</div><span class="chevron"></span></div>
</div>`).join('')
: '<div class="empty"><div class="empty-icon">💳</div>Inga fakturor</div>';
: '<div class="empty"><div class="empty-icon"></div>Inga fakturor</div>';
}
// ── Favorites tab ──────────────────────────────────────────────────────────
// Favorites tab
function renderFavorites(){
const favs=favCache[activeCustomer]||[];
document.getElementById('favs-list').innerHTML=favs.length
? favs.map(f=>`
<div class="fav-card">
<div class="fav-avatar">${f.avatar||'📷'}</div>
<div class="fav-avatar">${f.avatar||'<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="2" y="4" width="12" height="9" rx="2" stroke="#666" stroke-width="1.5"/><circle cx="8" cy="8.5" r="2.5" stroke="#666" stroke-width="1.5"/><path d="M5 4L6 2H10L11 4" stroke="#666" stroke-width="1.5"/></svg>'}</div>
<div>
<div class="fav-name">${f.name}</div>
<div class="fav-spec">${f.specialty}</div>
<div class="fav-meta">${f.missions_completed} uppdrag · ${f.rating?' '+f.rating:'Ny'}</div>
<div class="fav-meta">${f.missions_completed} uppdrag · ${f.rating?' '+f.rating:'Ny'}</div>
</div>
<div class="fav-actions">
<button class="btn btn-gray" style="font-size:13px;padding:7px 12px;border-radius:9px;border:none;cursor:pointer" onclick="removeFav('${f.zoomer_id}')"></button>
<button class="btn btn-gray" style="font-size:13px;padding:7px 12px;border-radius: var(--radius-md);border:none;cursor:pointer" onclick="removeFav('${f.zoomer_id}')"></button>
</div>
</div>`).join('')
: '<div class="empty"><div class="empty-icon"></div><div style="font-weight:600">Inga favoriter</div><div style="font-size:13px;margin-top:4px">Lägg till zoomers du gillar!</div></div>';
: '<div class="empty"><div class="empty-icon"></div><div style="font-weight:600">Inga favoriter</div><div style="font-size:13px;margin-top:4px">Lägg till zoomers du gillar!</div></div>';
}
async function removeFav(zid){
@@ -445,7 +445,7 @@ async function removeFav(zid){
} else toast('Fel: '+r.error);
}
// ── Book modal ─────────────────────────────────────────────────────────────
// Book modal
function openBookModal(){document.getElementById('book-modal').classList.add('open')}
async function submitBook(){
const title=document.getElementById('bk-title').value;
@@ -460,28 +460,28 @@ async function submitBook(){
};
const r=await fetch(`/api/quixzoom/customers/${activeCustomer}/book`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(r=>r.json());
if(r.ok){
toast('🚀 Uppdrag bokat!');closeModal('book-modal');
toast('<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 2L10 8L14 9L8 14L6 8L2 7L8 2Z" fill="#666"/></svg> Uppdrag bokat!');closeModal('book-modal');
const mR=await fetch(`/api/quixzoom/customers/${activeCustomer}/missions`).then(r=>r.json());
missionCache[activeCustomer]=mR.items||[];renderDashboard();
document.getElementById('bk-title').value='';document.getElementById('bk-location').value='';document.getElementById('bk-budget').value='';
} else toast('Fel: '+r.error);
}
// ── Add favorite modal ─────────────────────────────────────────────────────
// Add favorite modal
function openAddFavModal(){document.getElementById('fav-modal').classList.add('open')}
async function submitFav(){
const body={zoomer_id:document.getElementById('fav-id').value,name:document.getElementById('fav-name').value,specialty:document.getElementById('fav-spec').value};
if(!body.zoomer_id||!body.name){toast('Fyll i ID och namn');return}
const r=await fetch(`/api/quixzoom/customers/${activeCustomer}/favorites`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}).then(r=>r.json());
if(r.ok){
toast(' Favorit tillagd!');closeModal('fav-modal');
toast(' Favorit tillagd!');closeModal('fav-modal');
const fR=await fetch(`/api/quixzoom/customers/${activeCustomer}/favorites`).then(r=>r.json());
favCache[activeCustomer]=fR.items||[];renderFavorites();
document.getElementById('fav-id').value='';document.getElementById('fav-name').value='';
} else toast('Fel: '+r.error);
}
// ── Init ───────────────────────────────────────────────────────────────────
// Init
loadAll();
</script>
</body>