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:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Jetour BI Dashboard
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
474
static/index.html
Normal file
474
static/index.html
Normal 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 & 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 & 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 & 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 N−1 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>
|
||||||
124
static/settings.html
Normal file
124
static/settings.html
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Jetour BI — Settings</title>
|
||||||
|
<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}
|
||||||
|
.banner{background:linear-gradient(135deg,#1a1a2e,#16213e,#0f3460);padding:16px 32px;border-bottom:1px solid #1e293b;display:flex;align-items:center;gap:16px}
|
||||||
|
.banner h1{font-size:18px;font-weight:700}
|
||||||
|
.banner a{color:#60a5fa;text-decoration:none;font-size:13px}
|
||||||
|
main{max-width:900px;margin:0 auto;padding:24px}
|
||||||
|
.tabs{display:flex;gap:4px;margin-bottom:20px;flex-wrap:wrap}
|
||||||
|
.tab{padding:10px 20px;border:1px solid #334155;border-radius:8px 8px 0 0;background:#1e293b;color:#94a3b8;cursor:pointer;font-size:13px;border-bottom:none}
|
||||||
|
.tab.active{background:#1e293b;color:#f1f5f9;border-color:#334155;font-weight:600}
|
||||||
|
.tab-content{display:none;background:#1e293b;border:1px solid #334155;border-radius:0 8px 8px 8px;padding:20px}
|
||||||
|
.tab-content.active{display:block}
|
||||||
|
.add-row{display:flex;gap:8px;margin-bottom:16px}
|
||||||
|
.add-row input{padding:10px 14px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:13px;flex:1}
|
||||||
|
.add-row button,.form-btn{padding:10px 18px;border:none;border-radius:6px;background:#2563eb;color:#fff;cursor:pointer;font-size:13px;font-weight:600;white-space:nowrap}
|
||||||
|
.rec-list{list-style:none}
|
||||||
|
.rec-item{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;border-bottom:1px solid #1e293b;gap:12px}
|
||||||
|
.rec-item:last-child{border-bottom:none}
|
||||||
|
.rec-info{flex:1;min-width:0}
|
||||||
|
.rec-email{font-size:14px;color:#e2e8f0;font-weight:500}
|
||||||
|
.rec-name{font-size:11px;color:#64748b;margin-top:2px}
|
||||||
|
.rec-actions{display:flex;gap:8px;align-items:center}
|
||||||
|
.btn-sm{padding:5px 12px;border:1px solid #334155;border-radius:5px;background:transparent;color:#94a3b8;cursor:pointer;font-size:11px}
|
||||||
|
.btn-sm.del{color:#f87171;border-color:#7f1d1d}
|
||||||
|
.form-row{display:flex;gap:12px;margin-bottom:12px}
|
||||||
|
.form-row input,.form-row select{padding:10px 14px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:13px;flex:1}
|
||||||
|
.section-title{font-size:11px;text-transform:uppercase;color:#64748b;letter-spacing:1px;margin:16px 0 8px}
|
||||||
|
.status{position:fixed;bottom:20px;right:20px;padding:12px 20px;border-radius:8px;font-size:13px;z-index:100}
|
||||||
|
.status.ok{background:#065f46;color:#34d399}
|
||||||
|
.status.err{background:#7f1d1d;color:#fca5a5}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="banner"><h1>Settings</h1><a href="/">Back to Dashboard</a></div>
|
||||||
|
<main>
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tab active" data-tab="daily">Daily Report</div>
|
||||||
|
<div class="tab" data-tab="weekly">Weekly Report</div>
|
||||||
|
<div class="tab" data-tab="monthly">Monthly Report</div>
|
||||||
|
<div class="tab" data-tab="smtp">SMTP Config</div>
|
||||||
|
<div class="tab" data-tab="users">Users</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content active" id="tab-report">
|
||||||
|
<div class="add-row">
|
||||||
|
<input type="email" id="new-email" placeholder="email@example.com">
|
||||||
|
<input type="text" id="new-name" placeholder="Name (optional)">
|
||||||
|
<button onclick="addRecipient()">+ Add</button>
|
||||||
|
</div>
|
||||||
|
<ul class="rec-list" id="rec-list"></ul>
|
||||||
|
<div style="color:#64748b;padding:8px" id="rec-empty">No recipients yet.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-smtp">
|
||||||
|
<form onsubmit="saveSmtp(event)">
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="text" id="smtp-host" placeholder="SMTP Host" required>
|
||||||
|
<input type="number" id="smtp-port" placeholder="Port" value="587" style="width:90px;flex:none">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="text" id="smtp-user" placeholder="Username / Email" required>
|
||||||
|
<input type="password" id="smtp-pass" placeholder="Password">
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="email" id="smtp-from" placeholder="From address" required>
|
||||||
|
<label style="font-size:13px;color:#94a3b8"><input type="checkbox" id="smtp-tls" checked> TLS</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="form-btn">Save SMTP</button>
|
||||||
|
</form>
|
||||||
|
<div class="form-row" style="margin-top:16px">
|
||||||
|
<input type="email" id="test-email" placeholder="test@example.com">
|
||||||
|
<button onclick="testSmtp()" style="padding:10px 18px;border:1px solid #334155;border-radius:6px;background:#1e293b;color:#94a3b8;cursor:pointer;font-size:13px">Test</button>
|
||||||
|
</div>
|
||||||
|
<div id="smtp-status" style="margin-top:12px;font-size:12px;color:#34d399"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content" id="tab-users">
|
||||||
|
<div class="section-title">Change Password</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="password" id="settingsCurrPass" placeholder="Current password">
|
||||||
|
<input type="password" id="settingsNewPass" placeholder="New password">
|
||||||
|
</div>
|
||||||
|
<button class="form-btn" onclick="changePassword()" style="margin-bottom:16px">Update Password</button>
|
||||||
|
<div class="section-title">Manage Users</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="text" id="newUser" placeholder="Username">
|
||||||
|
<input type="email" id="newUserEmail" placeholder="Email (optional)"><input type="password" id="newPass" placeholder="Password">
|
||||||
|
<select id="newRole" style="flex:none;width:120px">
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="form-btn" onclick="addUser()" style="margin-bottom:12px">+ Add User</button>
|
||||||
|
<div id="userList"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="editUserModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10001;align-items:center;justify-content:center">
|
||||||
|
<div style="background:#1e293b;border-radius:12px;padding:24px;width:440px;border:1px solid #334155">
|
||||||
|
<h3 style="margin-bottom:16px">Edit User: <span id="editUserName"></span></h3>
|
||||||
|
<label style="font-size:11px;color:#94a3b8">Email</label>
|
||||||
|
<input type="email" id="editUserEmail" placeholder="Email" style="width:100%;padding:10px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:13px;margin-bottom:10px">
|
||||||
|
<label style="font-size:11px;color:#94a3b8">Role</label>
|
||||||
|
<select id="editUserRole" style="width:100%;padding:10px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:13px;margin-bottom:10px">
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
<label style="font-size:11px;color:#94a3b8">New Password (leave blank to keep)</label>
|
||||||
|
<input type="password" id="editUserPass" placeholder="New password" style="width:100%;padding:10px;border:1px solid #334155;border-radius:6px;background:#0f172a;color:#e2e8f0;font-size:13px;margin-bottom:14px">
|
||||||
|
<button onclick="saveUserEdit()" style="width:100%;padding:10px;background:#2563eb;border:none;border-radius:6px;color:#fff;cursor:pointer;font-size:13px;margin-bottom:8px">Save Changes</button>
|
||||||
|
<button onclick="document.getElementById('editUserModal').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>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
<div class="status" id="status" style="display:none"></div>
|
||||||
|
<script src="/settings.js?v=3"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user