Files
jetour-bi/static/index.html
root 8f206aa4f1 Initial commit: Jetour BI Dashboard
FastAPI backend with PostgreSQL queries for sales/CRM pipeline.
Chart.js dark-themed dashboard with:
- Pipeline funnel, velocity, conversion rates
- Monthly trend, lead sources, model distribution
- Vehicle order overview with per-color breakdown + subtotals
- Salesperson leaderboard, new vs used, lost reason analysis
- Email report settings (recipients + SMTP config)
- n8n integration for daily/weekly/monthly reports
2026-06-18 20:41:05 +00:00

474 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Jetour BI — Sales &amp; CRM Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:#0b1120;color:#e2e8f0;min-height:100vh}
/* Login */
.login-overlay{position:fixed;inset:0;background:#0b1120;display:flex;align-items:center;justify-content:center;z-index:9999}
.login-box{background:#1e293b;border-radius:12px;padding:32px;width:360px;border:1px solid #334155}
.login-box h2{font-size:20px;margin-bottom:4px}
.login-box .sub{font-size:12px;color:#64748b;margin-bottom:20px}
.login-box label{display:block;font-size:12px;color:#94a3b8;margin-bottom:6px}
.login-box input{width:100%;padding:10px 12px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:14px;margin-bottom:14px}
.login-box button{width:100%;padding:10px;background:#2563eb;border:none;border-radius:6px;color:#fff;font-size:14px;cursor:pointer;font-weight:600}
.login-box button:hover{background:#1d4ed8}
.login-box .err{color:#f87171;font-size:12px;margin-top:8px;display:none}
/* Banner */
.banner{background:linear-gradient(135deg,#1a1a2e,#16213e,#0f3460);padding:20px 32px;border-bottom:1px solid #1e293b;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:16px}
.banner h1{font-size:22px;font-weight:700;color:#f1f5f9}
.banner .sub{font-size:12px;color:#64748b;text-transform:uppercase;letter-spacing:2px}
.banner .controls{display:flex;gap:8px;align-items:center}
.banner button,.banner select,.banner a.btn{padding:8px 16px;border:1px solid #334155;border-radius:6px;background:#1e293b;color:#e2e8f0;cursor:pointer;font-size:13px;text-decoration:none}
.banner button:hover,.banner a.btn:hover{background:#334155}
.banner .btn-settings{font-size:18px;padding:8px 10px;line-height:1}
.banner .btn-logout{color:#f87171;border-color:#7f1d1d;margin-left:4px}
.banner .btn-logout:hover{background:#7f1d1d}
.sel-group{display:inline-flex;align-items:center;gap:4px}
.sel-label{font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:.5px}
.sel-group select{padding:8px 12px;border:1px solid #334155;border-radius:6px;background:#1e293b;color:#e2e8f0;cursor:pointer;font-size:13px}
#userLabel{font-size:11px;color:#64748b;padding:4px 8px}
/* Dashboard */
main{max-width:1400px;margin:0 auto;padding:24px}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:24px}
.kpi{background:#1e293b;border-radius:10px;padding:16px;border:1px solid #334155}
.kpi .label{font-size:10px;text-transform:uppercase;letter-spacing:1.5px;color:#64748b;margin-bottom:4px}
.kpi .value{font-size:24px;font-weight:700;color:#f1f5f9}
.kpi .subval{font-size:11px;color:#94a3b8;margin-top:2px}
.kpi.accent .value{color:#60a5fa}
.kpi.green .value{color:#34d399}
.kpi.amber .value{color:#fbbf24}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(420px,1fr));gap:20px;margin-bottom:24px}
.card{background:#1e293b;border-radius:10px;padding:20px;border:1px solid #334155}
.card h3{font-size:13px;color:#94a3b8;text-transform:uppercase;letter-spacing:1px;margin-bottom:16px}
.card canvas{max-height:280px}
.card .caption{font-size:10px;color:#475569;margin-top:10px;line-height:1.5;border-top:1px solid #1e293b;padding-top:8px}
.card .caption code{background:#0f172a;color:#60a5fa;padding:1px 5px;border-radius:3px;font-size:9px}
.full{grid-column:1/-1}
.vo-group{background:#1e293b;border:1px solid #334155;border-radius:10px;margin-bottom:12px;overflow:hidden}
.vo-gh{display:flex;align-items:center;padding:14px 20px;cursor:pointer;gap:14px}
.vo-gh:hover{background:#263348}
.vo-gh .chevron{font-size:12px;color:#64748b;transition:transform .2s;width:14px;flex-shrink:0}
.vo-gh.open .chevron{transform:rotate(90deg)}
.vo-gname{font-weight:700;font-size:15px;color:#f1f5f9;min-width:100px}
.vo-bar-wrap{flex:1;height:20px;background:#0f172a;border-radius:10px;overflow:hidden}
.vo-bar{height:100%;border-radius:10px;transition:width .4s}
.vo-stats{display:flex;gap:20px;font-size:12px;color:#94a3b8;white-space:nowrap}
.vo-stats strong{color:#e2e8f0;font-size:14px}
.vo-detail{display:none;border-top:1px solid #334155;padding:8px 0}
.vo-gh.open+.vo-detail{display:block}
.vo-model-header.open+.vo-detail{display:block}
.vo-model-block{padding:2px 0}
.vo-model-header{padding:8px 24px;font-weight:600;font-size:13px;color:#cbd5e1;border-bottom:1px solid #1a2235;background:#1a2335;cursor:pointer}
.vo-model-header:hover{background:#1e2a40}
.vo-sub{width:100%;border-collapse:collapse;font-size:12px}
.vo-sub th{padding:6px 20px;color:#64748b;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid #1e293b;text-align:left}
.vo-sub td{padding:5px 20px;border-bottom:1px solid #0f172a;color:#cbd5e1}
.vo-subtotal{color:#60a5fa!important;font-weight:600;border-top:1px solid #1e3a5f!important}
.vo-subtotal td{padding:6px 20px;background:#0f1a2e;font-size:11px}
.color-swatch{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px;vertical-align:middle}
.type-tag{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600}
.type-new{background:#1e3a5f;color:#60a5fa}
.type-used{background:#78350f;color:#fbbf24}
.sold-dot{display:inline-block;width:7px;height:7px;background:#34d399;border-radius:50%;margin-right:3px}
.conv-item{margin-bottom:16px}
.conv-item .conv-label{display:flex;justify-content:space-between;font-size:12px;color:#94a3b8;margin-bottom:4px}
.conv-item .conv-bar-wrap{height:28px;background:#0f172a;border-radius:6px;overflow:hidden}
.conv-item .conv-bar{height:100%;border-radius:6px;transition:width .6s;display:flex;align-items:center;padding-left:10px;font-size:12px;font-weight:600}
.conv-bar.p1{background:linear-gradient(90deg,#1e3a5f,#60a5fa)}
.conv-bar.p2{background:linear-gradient(90deg,#5b21b6,#a78bfa)}
.conv-bar.p3{background:linear-gradient(90deg,#065f46,#34d399)}
.perf-table{width:100%;border-collapse:collapse;font-size:12px}
.perf-table th{padding:8px 14px;color:#64748b;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid #1e293b;text-align:left}
.perf-table td{padding:7px 14px;border-bottom:1px solid #0f172a;color:#cbd5e1}
.perf-table tr:hover td{background:#1e3a5f}
.section-title{font-size:14px;color:#94a3b8;text-transform:uppercase;letter-spacing:1px;margin:24px 0 12px}
.caption{font-size:10px;color:#475569;line-height:1.5;margin-top:8px}
.caption code{background:#0f172a;color:#60a5fa;padding:1px 5px;border-radius:3px;font-size:9px}
footer{text-align:center;padding:24px;color:#475569;font-size:12px}
.num{font-variant-numeric:tabular-nums}
@media(max-width:768px){.vo-stats{flex-wrap:wrap;gap:8px}.vo-gh{flex-wrap:wrap}}
</style>
</head>
<body>
<!-- Login Overlay -->
<div id="loginOverlay" class="login-overlay">
<div class="login-box">
<h2>Jetour BI</h2>
<div class="sub">Sales &amp; CRM Dashboard</div>
<label>Username</label>
<input type="text" id="loginUser" placeholder="Enter username" autocomplete="username">
<label>Password</label>
<input type="password" id="loginPass" placeholder="Enter password" autocomplete="current-password">
<button onclick="doLogin()">Sign In</button>
<div class="err" id="loginErr"></div>
<a href="#" onclick="showForgotPassword()" style="display:block;margin-top:12px;font-size:12px;color:#64748b;text-align:center;text-decoration:none">Forgot password?</a>
</div>
</div>
<!-- App (hidden until login) -->
<div id="app" style="display:none">
<div class="banner">
<div>
<div class="sub">Sales &amp; CRM Dashboard</div>
<h1>Jetour Mauritius — BI Pipeline</h1>
</div>
<div class="controls">
<div class="sel-group">
<span class="sel-label">Year</span>
<select id="sel-year" onchange="onYearChange()"></select>
</div>
<div class="sel-group">
<span class="sel-label">Month</span>
<select id="sel-month" onchange="onMonthChange()"></select>
</div>
<button onclick="loadAll()">↻ Refresh</button>
<a href="/settings.html" class="btn" id="btnSettings" style="display:none;padding:8px 10px;font-size:18px;line-height:1;text-decoration:none;border:1px solid #334155;border-radius:6px;background:#1e293b;color:#e2e8f0" title="Settings">⚙️</a>
<div class="user-dropdown" style="position:relative">
<span id="userLabel" style="cursor:pointer" onclick="toggleUserMenu()">👤</span>
<div id="userMenu" style="display:none;position:absolute;top:100%;right:0;background:#1e293b;border:1px solid #334155;border-radius:8px;padding:4px;min-width:160px;z-index:100">
<div onclick="showChangePassword()" style="padding:8px 12px;cursor:pointer;font-size:12px;color:#e2e8f0;border-radius:4px" onmouseover="this.style.background='#334155'" onmouseout="this.style.background=''">🔑 Change Password</div>
<a href="/settings.html" id="userMenuSettings" style="display:none;padding:8px 12px;font-size:12px;color:#e2e8f0;border-radius:4px;text-decoration:none" onmouseover="this.style.background='#334155'" onmouseout="this.style.background=''">⚙️ Settings</a>
<div onclick="logout()" style="padding:8px 12px;cursor:pointer;font-size:12px;color:#f87171;border-radius:4px;margin-top:2px;border-top:1px solid #334155" onmouseover="this.style.background='#334155'" onmouseout="this.style.background=''">Logout</div>
</div>
</div>
</div>
</div>
<main>
<div class="kpis" id="kpis">
<div class="kpi"><div class="label">Total Prospects</div><div class="value num" id="kp-prospects"></div></div>
<div class="kpi accent"><div class="label">Total OTPs</div><div class="value num" id="kp-otps"></div><div class="subval" id="kp-otptype"></div></div>
<div class="kpi green"><div class="label">Sold</div><div class="value num" id="kp-sold"></div></div>
<div class="kpi amber"><div class="label">Pipeline Value</div><div class="value" id="kp-amount"></div></div>
<div class="kpi"><div class="label">Pending</div><div class="value num" id="kp-pending"></div></div>
<div class="kpi"><div class="label">Finance Applied</div><div class="value num" id="kp-finance"></div></div>
</div>
<div class="grid">
<div class="card"><h3>Pipeline Funnel</h3><canvas id="chartPipeline"></canvas><div class="caption" id="capPipeline"></div></div>
<div class="card"><h3>Pipeline Velocity (Days)</h3><canvas id="chartVelocity"></canvas><div class="caption" id="capVelocity"></div></div>
<div class="card"><h3>Conversion Rates</h3><div id="convFunnel"></div><div class="caption" id="capConv"></div></div>
<div class="card"><h3>Monthly Trend</h3><canvas id="chartTrend"></canvas><div class="caption" id="capTrend"></div></div>
<div class="card"><h3>Lead Sources</h3><canvas id="chartSources"></canvas><div class="caption" id="capSources"></div></div>
<div class="card"><h3>Model Distribution</h3><canvas id="chartModels"></canvas><div class="caption" id="capModels"></div></div>
<div class="card full"><h3>Model Performance</h3><div id="modelPerf"></div><div class="caption" id="capPerf"></div></div>
</div>
<div class="section-title">Vehicle Order Overview</div>
<div id="vehicleOrders" class="full" style="margin-bottom:24px">Loading…</div>
<div class="caption" id="capVO" style="max-width:1400px;margin:0 auto 24px;padding:0 24px"></div>
</main>
<footer>Jetour BI Dashboard · Live CMS BI data · Updated daily via n8n</footer>
</div>
<!-- Change Password Modal -->
<div id="pwModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;align-items:center;justify-content:center">
<div style="background:#1e293b;border-radius:12px;padding:24px;width:380px;border:1px solid #334155">
<h3 style="margin-bottom:12px">Change Password</h3>
<input type="password" id="pwCurr" placeholder="Current password" style="width:100%;padding:10px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:13px;margin-bottom:10px">
<input type="password" id="pwNew" placeholder="New password" style="width:100%;padding:10px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:13px;margin-bottom:10px">
<input type="password" id="pwNew2" placeholder="Confirm new password" style="width:100%;padding:10px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:13px;margin-bottom:10px">
<button onclick="doChangePassword()" style="width:100%;padding:10px;background:#2563eb;border:none;border-radius:6px;color:#fff;cursor:pointer;font-size:13px;margin-bottom:8px">Update Password</button>
<div id="pwMsg" style="font-size:12px;text-align:center;margin-bottom:8px"></div>
<button onclick="document.getElementById('pwModal').style.display='none'" style="width:100%;padding:8px;border:1px solid #334155;border-radius:6px;background:transparent;color:#94a3b8;cursor:pointer;font-size:12px">Cancel</button>
</div>
</div>
<!-- Forgot Password Modal -->
<div id="forgotModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;align-items:center;justify-content:center">
<div style="background:#1e293b;border-radius:12px;padding:24px;width:380px;border:1px solid #334155">
<h3 style="margin-bottom:8px">Forgot Password</h3>
<p style="font-size:12px;color:#94a3b8;margin-bottom:14px">Enter your email and we'll send you a reset link.</p>
<input type="email" id="forgotEmail" placeholder="your@email.com" style="width:100%;padding:10px 12px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:14px;margin-bottom:12px">
<button onclick="doForgotPassword()" style="width:100%;padding:10px;background:#2563eb;border:none;border-radius:6px;color:#fff;cursor:pointer;font-size:14px">Send Reset Link</button>
<div id="forgotMsg" style="font-size:12px;margin-top:8px;text-align:center"></div>
<button onclick="document.getElementById('forgotModal').style.display='none'" style="margin-top:12px;width:100%;padding:8px;border:1px solid #334155;border-radius:6px;background:transparent;color:#94a3b8;cursor:pointer;font-size:12px">Cancel</button>
</div>
</div>
<script>
// ═══ Auth ═══════════════════════════════════════════════════════════
let authToken = localStorage.getItem('jbi_token');
let currentUser = null;
try { currentUser = JSON.parse(localStorage.getItem('jbi_user')); } catch(e){}
const API = '/api';
async function fa(p, opts = {}) {
const headers = { 'Content-Type': 'application/json', ...opts.headers };
if (authToken) headers['Authorization'] = 'Bearer ' + authToken;
const r = await fetch(`${API}${p}`, { ...opts, headers });
if (r.status === 401) { logout(); throw new Error('Session expired'); }
if (!r.ok) throw new Error(`${r.status}`);
return r.json();
}
async function doLogin() {
const u = document.getElementById('loginUser').value.trim();
const p = document.getElementById('loginPass').value;
const err = document.getElementById('loginErr');
if (!u || !p) { err.textContent = 'Enter username and password'; err.style.display='block'; return; }
try {
const r = await fetch(`${API}/auth/login`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({username:u, password:p})
});
if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Invalid credentials'); }
const d = await r.json();
authToken = d.token; currentUser = d.user;
localStorage.setItem('jbi_token', authToken);
localStorage.setItem('jbi_user', JSON.stringify(currentUser));
showApp();
} catch(e) { err.textContent = e.message; err.style.display='block'; }
}
function showApp() {
document.getElementById('loginOverlay').style.display = 'none';
document.getElementById('app').style.display = 'block';
if (currentUser.role === 'admin') {
document.getElementById('userMenuSettings').style.display = 'block';
document.getElementById('btnSettings').style.display = '';
}
document.getElementById('userLabel').textContent = `👤 ${currentUser.username} (${currentUser.role})`;
initPeriodSelector();
}
function toggleUserMenu() {
var m = document.getElementById('userMenu');
m.style.display = m.style.display === 'block' ? 'none' : 'block';
}
function logout() {
localStorage.removeItem('jbi_token'); localStorage.removeItem('jbi_user');
authToken = null; currentUser = null;
document.getElementById('app').style.display = 'none';
document.getElementById('loginOverlay').style.display = 'flex';
document.getElementById('loginUser').value = ''; document.getElementById('loginPass').value = '';
}
function showChangePassword() {
document.getElementById('userMenu').style.display = 'none';
document.getElementById('pwModal').style.display = 'flex';
document.getElementById('pwCurr').value = '';
document.getElementById('pwNew').value = '';
document.getElementById('pwNew2').value = '';
document.getElementById('pwMsg').textContent = '';
}
async function doChangePassword() {
var cp = document.getElementById('pwCurr').value;
var np = document.getElementById('pwNew').value;
var np2 = document.getElementById('pwNew2').value;
var msg = document.getElementById('pwMsg');
if (!cp || !np || !np2) { msg.textContent = 'Fill in all fields'; return; }
if (np !== np2) { msg.textContent = 'New passwords do not match'; return; }
msg.textContent = 'Updating...'; msg.style.color = '#94a3b8';
try {
var r = await fetch('/api/auth/change-password', {
method: 'POST', headers: {'Content-Type':'application/json','Authorization':'Bearer '+authToken},
body: JSON.stringify({current: cp, password: np})
});
if (r.ok) {
msg.textContent = 'Password updated!';
msg.style.color = '#34d399';
setTimeout(function(){ document.getElementById('pwModal').style.display = 'none'; }, 1500);
} else {
var d = await r.json();
msg.textContent = d.detail || 'Error';
msg.style.color = '#f87171';
}
} catch(e) { msg.textContent = 'Connection error'; msg.style.color = '#f87171'; }
}
function showForgotPassword() {
document.getElementById('forgotModal').style.display = 'flex';
document.getElementById('forgotMsg').textContent = '';
document.getElementById('forgotEmail').value = '';
}
async function doForgotPassword() {
var email = document.getElementById('forgotEmail').value.trim();
var msg = document.getElementById('forgotMsg');
if (!email || email.indexOf('@') < 0) { msg.textContent = 'Enter a valid email'; msg.style.color = '#f87171'; return; }
msg.textContent = 'Sending...'; msg.style.color = '#94a3b8';
try {
var r = await fetch('/api/auth/forgot-password', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({email: email})
});
var d = await r.json();
msg.textContent = d.message || 'Check your email for the reset link.';
msg.style.color = '#34d399';
} catch(e) {
msg.textContent = 'Error. Please try again.';
msg.style.color = '#f87171';
}
}
// ═══ Dashboard ═════════════════════════════════════════════════════
let charts = {}, currentPeriod = 'all', availableMonths = [];
const COLORS = ['#60a5fa','#34d399','#fbbf24','#f87171','#a78bfa','#fb923c','#2dd4bf','#e879f9','#94a3b8','#f472b6'];
const COLOR_MAP = {
'lunar silver':'#94a3b8','aqua green':'#2dd4bf','onyx black':'#1e293b','glacier white':'#f8fafc',
'green':'#34d399','black':'#1e293b','silver snow':'#cbd5e1','gray':'#6b7280','white':'#f8fafc',
'blue':'#3b82f6','khaki white':'#d4c5a9','red':'#ef4444','highway gray':'#6b7280','highway grey':'#6b7280',
'ocean blue':'#2563eb','space gray':'#4b5563','crystal white':'#f1f5f9','arctic blue':'#93c5fd',
'grey':'#6b7280','sunset orange':'#f97316','desert sand':'#d4a574','midnight black':'#0f172a'
};
function fm(v) { return 'MUR ' + (v/1e6).toFixed(1) + 'M'; }
function fk(v) { return 'MUR ' + (v/1000).toFixed(0) + 'K'; }
function dc(id) { if (charts[id]) { charts[id].destroy(); delete charts[id]; } }
function mc(id, type, data, opts = {}) {
dc(id);
const ctx = document.getElementById(id).getContext('2d');
charts[id] = new Chart(ctx, { type, data, options: { responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#94a3b8', font: { size: 11 } } } },
scales: opts.scales || {}, ...opts } });
}
// ═══ Period Selector ═══════════════════════════════════════════════
async function initPeriodSelector() {
try {
const data = await fa('/months');
availableMonths = data.months;
const selYear = document.getElementById('sel-year');
data.years.forEach(y => { const o = document.createElement('option'); o.value = y; o.textContent = y; selYear.appendChild(o); });
onYearChange();
} catch(e) { console.error(e); }
}
function onYearChange() {
const year = document.getElementById('sel-year').value;
const selMonth = document.getElementById('sel-month');
selMonth.innerHTML = '<option value="all">All Time</option>';
availableMonths.filter(m => m.year === year).forEach(m => {
const o = document.createElement('option');
o.value = m.month;
o.textContent = new Date(m.month + '-01').toLocaleString('en', { month: 'short', year: 'numeric' });
selMonth.appendChild(o);
});
onMonthChange();
}
function onMonthChange() {
currentPeriod = document.getElementById('sel-month').value;
loadAll();
}
// ═══ Data Loading ══════════════════════════════════════════════════
async function loadAll() {
try {
const period = (currentPeriod === 'all' || !currentPeriod) ? 'all' : currentPeriod;
const [sum, pipeline, velocity, trend, sources, models, vo, report, perf] = await Promise.all([
fa(`/summary?period=${period}`), fa(`/pipeline?period=${period}`), fa(`/pipeline-velocity?period=${period}`),
fa(`/trend?period=${period}`), fa(`/sources?period=${period}`), fa(`/models?period=${period}`), fa(`/vehicle-orders?period=${period}`),
fa(`/reports/kpis?period=${period}`), fa(`/reports/performance?period=${period}`)
]);
// KPIs
document.getElementById('kp-prospects').textContent = sum.total_prospects?.toLocaleString() || '—';
document.getElementById('kp-otps').textContent = sum.total_otps?.toLocaleString() || '—';
document.getElementById('kp-otptype').textContent = Object.entries(sum.otp_by_type || {}).map(([k,v]) => `${k}:${v}`).join(' / ');
document.getElementById('kp-sold').textContent = sum.sold?.toLocaleString() || '—';
document.getElementById('kp-amount').textContent = fm(sum.total_amount || 0);
document.getElementById('kp-pending').textContent = sum.pending?.toLocaleString() || '—';
document.getElementById('kp-finance').textContent = sum.finance?.toLocaleString() || '—';
// Pipeline funnel
const order = ['Pending', 'OTP', 'FinanceApplied', 'Sold'];
mc('chartPipeline', 'bar', {
labels: order,
datasets: [{ data: order.map(s => (pipeline.find(p => p.status === s) || {}).count || 0),
backgroundColor: ['#fbbf24','#60a5fa','#a78bfa','#34d399'], borderRadius: 6 }]
}, { indexAxis: 'y', scales: { x: { grid: { color: '#1e293b' }, ticks: { color: '#64748b' } },
y: { grid: { display: false }, ticks: { color: '#e2e8f0' } } }, plugins: { legend: { display: false } } });
document.getElementById('capPipeline').innerHTML = `Count of prospects grouped by their current <code>prospect_status</code> field. Shows the full pipeline from Pending → OTP → Finance Applied → Sold.`;
// Pipeline Velocity
if (velocity?.stages && velocity.stages.length) {
const vLabels = velocity.stages.map(v => v.label);
const vAvg = velocity.stages.map(v => v.avg);
const vMed = velocity.stages.map(v => v.median);
mc('chartVelocity', 'bar', {
labels: vLabels,
datasets: [
{ label: 'Avg Days', data: vAvg, backgroundColor: '#60a5fa', borderRadius: 4 },
{ label: 'Median Days', data: vMed, backgroundColor: '#a78bfa', borderRadius: 4 }
]
}, { scales: { x: { grid: { color: '#1e293b' }, ticks: { color: '#64748b' } },
y: { grid: { color: '#1e293b' }, ticks: { color: '#64748b' }, title: { display: true, text: 'Days', color: '#64748b' } } } });
}
document.getElementById('capVelocity').innerHTML = `Average and median calendar days between pipeline stages. OTP→Invoice uses <code>dt_otp_created</code> vs <code>dt_invoiced</code>. OTP→Delivery uses the earliest Delivery appointment <code>dt_created</code> from the appointments table. Invoice→Delivery is the gap between invoicing and delivery appointment.`;
// Conversion rates
const c = report?.conversion || {}, t = report?.totals || {};
document.getElementById('convFunnel').innerHTML = `
<div class="conv-item"><div class="conv-label"><span>Lead → OTP</span><span>${c.lead_to_otp||0}%</span></div><div class="conv-bar-wrap"><div class="conv-bar p1" style="width:${Math.min(c.lead_to_otp||0,100)}%">${t.otp_status||0} OTPs</div></div></div>
<div class="conv-item"><div class="conv-label"><span>OTP → Finance</span><span>${c.otp_to_finance||0}%</span></div><div class="conv-bar-wrap"><div class="conv-bar p2" style="width:${Math.min(c.otp_to_finance||0,100)}%">${t.finance||0} finance</div></div></div>
<div class="conv-item"><div class="conv-label"><span>→ Sold</span><span>${c.finance_to_sold||0}%</span></div><div class="conv-bar-wrap"><div class="conv-bar p3" style="width:${Math.min(c.finance_to_sold||0,100)}%">${t.sold||0} sold</div></div></div>
<div style="margin-top:12px;font-size:11px;color:#64748b">OTPs: ${t.total_otps||0} · Pipeline: ${fm(t.pipeline_value||0)} · Avg: ${fk(t.avg_otp||0)}</div>`;
document.getElementById('capConv').innerHTML = `Percentage of prospects that move from one stage to the next in the selected period. Calculated as: stage N count ÷ stage N1 count × 100. Counts only prospects created within the selected time window.`;
// Trend
mc('chartTrend', 'line', {
labels: trend.map(t => t.month),
datasets: [
{ label: 'Prospects', data: trend.map(t => t.prospects), borderColor: '#60a5fa', backgroundColor: 'transparent', tension: .3 },
{ label: 'Sold', data: trend.map(t => t.sold), borderColor: '#34d399', backgroundColor: 'transparent', tension: .3 }
]
}, { scales: { x: { grid: { color: '#1e293b' }, ticks: { color: '#64748b', maxRotation: 45 } },
y: { grid: { color: '#1e293b' }, ticks: { color: '#64748b' } } } });
document.getElementById('capTrend').innerHTML = `Month-by-month count of prospects created vs those that reached Sold status. Based on <code>dt_prospect_created</code> for creation date and <code>prospect_status = 'Sold'</code> for sales. Each month's Sold count reflects total sales of prospects created that month, regardless of when the sale occurred.`;
// Sources pie
mc('chartSources', 'pie', {
labels: sources.map(s => s.source),
datasets: [{ data: sources.map(s => s.count), backgroundColor: COLORS, borderColor: '#1e293b', borderWidth: 1 }]
});
document.getElementById('capSources').innerHTML = `Breakdown of all prospects by their <code>source_of_initial_contact</code> field (Internet, Walk-In, Phone-In, etc.). Shows where your leads originate.`;
// Models doughnut
mc('chartModels', 'doughnut', {
labels: models.map(m => m.model),
datasets: [{ data: models.map(m => m.count), backgroundColor: COLORS, borderColor: '#1e293b', borderWidth: 2 }]
});
document.getElementById('capModels').innerHTML = `Top 10 vehicle models by OTP count across all time. Based on the <code>model</code> field in the prospect_otps table. Shows which models generate the most orders.`;
// Model performance table
document.getElementById('modelPerf').innerHTML = `<table class="perf-table"><thead><tr><th>Group</th><th>OTPs</th><th>Value</th><th>Avg</th><th>Sold</th><th>Conv%</th></tr></thead><tbody>${
(perf||[]).slice(0,7).map(g => `<tr><td>${g.group}</td><td class="num">${g.total_otps}</td><td class="num">${fm(g.total_value)}</td><td class="num" style="color:#64748b">${fk(g.avg_value)}</td><td class="num">${g.sold ? '<span class="sold-dot"></span>' + g.sold : '—'}</td><td class="num">${g.conversion}%</td></tr>`).join('')
}</tbody></table>`;
document.getElementById('capPerf').innerHTML = `Aggregated by model group (T2, T1, Dashing, X70, etc.). OTP count, total pipeline value, average OTP value, sold count, and conversion rate (sold ÷ OTPs × 100). Model groups are derived from <code>model_range</code> or parsed from the model name.`;
// Vehicle orders
const maxOtp = Math.max(...vo.map(g => g.total_otps), 1);
document.getElementById('vehicleOrders').innerHTML = vo.map((g, i) => {
const pct = (g.total_otps / maxOtp * 100).toFixed(0), bc = COLORS[i % COLORS.length];
let mh = '';
for (const m of g.models) {
let cr = '';
for (const c of m.colors) {
const sw = COLOR_MAP[(c.color || '').toLowerCase()] || '#334155';
cr += `<tr><td style="padding-left:28px"><span class="color-swatch" style="background:${sw}"></span>${c.color}</td><td><span class="type-tag ${c.type==='New'?'type-new':'type-used'}">${c.type}</span></td><td class="num">${c.count}</td><td class="num">${fm(c.amount)}</td><td class="num" style="color:#64748b">${fk(c.avg_amount)}</td><td class="num">${c.sold?'<span class="sold-dot"></span>'+c.sold:'—'}</td><td class="num">${c.invoiced||'—'}</td><td class="num">${c.in_stock||'—'}</td></tr>`;
}
mh += `<div class="vo-model-block"><div class="vo-model-header" onclick="this.classList.toggle('open')">${m.model}</div><div class="vo-detail"><table class="vo-sub"><thead><tr><th style="width:24%">Color</th><th style="width:6%">Type</th><th style="width:6%">Count</th><th style="width:15%">Amount</th><th style="width:12%">Avg</th><th style="width:8%">Sold</th><th style="width:8%">Invoiced</th><th style="width:8%">Stock</th></tr></thead><tbody>${cr}<tr class="vo-subtotal"><td>Subtotal</td><td></td><td class="num"><strong>${m.total_otps}</strong></td><td class="num"><strong>${fm(m.total_amount)}</strong></td><td></td><td class="num"><strong>${m.sold}</strong></td><td class="num"><strong>${m.invoiced||0}</strong></td><td class="num"><strong>${m.in_stock||0}</strong></td></tr></tbody></table></div></div>`;
}
return `<div class="vo-group"><div class="vo-gh" onclick="this.classList.toggle('open')"><span class="chevron">▶</span><span class="vo-gname">${g.group}</span><div class="vo-bar-wrap"><div class="vo-bar" style="width:${pct}%;background:${bc}"></div></div><div class="vo-stats"><span><strong>${g.total_otps}</strong> OTPs</span><span><strong>${fm(g.total_amount)}</strong></span><span><span class="sold-dot"></span><strong>${g.sold}</strong> sold</span><span>📦 <strong>${g.invoiced||0}</strong> invoiced</span><span>🏪 <strong>${g.in_stock||0}</strong> stock</span></div></div><div class="vo-detail">${mh}</div></div>`;
}).join('');
document.getElementById('capVO').innerHTML = `Expandable table grouped by model family (derived from <code>model_range</code> or model name). Colour-level breakdown with individual OTP count, total amount (MUR), average price, sold count (prospect status = 'Sold'), invoiced count (<code>dt_invoiced IS NOT NULL</code>), and vehicles in stock (<code>stock_no IS NOT NULL</code>). Subtotal rows aggregate per model.`;
} catch(e) { console.error('Dashboard load failed:', e); }
}
// ═══ Init ═══════════════════════════════════════════════════════════
if (authToken && currentUser) showApp();
document.getElementById('loginPass').addEventListener('keydown', e => { if (e.key === 'Enter') doLogin(); });
</script>
</body>
</html>