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
This commit is contained in:
root
2026-06-18 20:41:05 +00:00
commit 8f206aa4f1
4 changed files with 1716 additions and 0 deletions

474
static/index.html Normal file
View File

@@ -0,0 +1,474 @@
<!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>