Files

498 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vela Platform</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
/* ── Theme variables ── */
:root {
--bg: #0f0f0f;
--bg-surface: #1a1a1a;
--border: #2a2a2a;
--text: #e0e0e0;
--text-dim: #888;
--text-bright: #fff;
--input-bg: #0f0f0f;
--hover-row: #1a1a1a;
--accent: #3b82f6;
--accent-hover: #2563eb;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--card-value-default: #e0e0e0;
}
[data-theme="light"] {
--bg: #f5f5f5;
--bg-surface: #ffffff;
--border: #e0e0e0;
--text: #333333;
--text-dim: #777;
--text-bright: #111;
--input-bg: #ffffff;
--hover-row: #f0f0f0;
--accent: #3b82f6;
--accent-hover: #2563eb;
--green: #16a34a;
--red: #dc2626;
--yellow: #d97706;
--card-value-default: #333;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
transition: background 0.2s, color 0.2s;
}
/* ── Login ── */
#loginScreen { display:none; align-items:center; justify-content:center; min-height:100vh; }
.login-box { background:var(--bg-surface); border:1px solid var(--border); border-radius:12px; padding:40px; width:360px; }
.login-box h1 { font-size:24px; margin-bottom:8px; color:var(--text-bright); }
.login-box .sub { color:var(--text-dim); font-size:14px; margin-bottom:24px; }
.login-box label { display:block; font-size:13px; color:var(--text-dim); margin-bottom:4px; }
.login-box input { width:100%; padding:10px 12px; background:var(--input-bg); border:1px solid var(--border); border-radius:6px; color:var(--text-bright); font-size:15px; margin-bottom:16px; }
.login-box input:focus { outline:none; border-color:var(--accent); }
.login-box button { width:100%; padding:12px; background:var(--accent); color:#fff; border:none; border-radius:6px; font-size:15px; font-weight:600; cursor:pointer; }
.login-box button:hover { background:var(--accent-hover); }
.login-box .error { color:var(--red); font-size:13px; margin-top:8px; display:none; }
/* ── App ── */
#appScreen { display:none; }
.topbar { background:var(--bg-surface); border-bottom:1px solid var(--border); padding:0 24px; display:flex; align-items:center; justify-content:space-between; height:56px; }
.topbar h1 { font-size:18px; color:var(--text-bright); }
.topbar .userinfo { color:var(--text-dim); font-size:13px; margin-right:16px; }
.topbar .logout { color:var(--text-dim); cursor:pointer; font-size:13px; border:none; background:none; }
.topbar .logout:hover { color:var(--red); }
.tabs { display:flex; gap:0; border-bottom:1px solid var(--border); background:var(--bg-surface); padding:0 24px; }
.tab { padding:12px 24px; cursor:pointer; color:var(--text-dim); font-size:14px; border-bottom:2px solid transparent; transition:all .15s; }
.tab:hover { color:var(--text); }
.tab.active { color:var(--accent); border-bottom-color:var(--accent); }
.content { padding:24px; max-width:1100px; margin:0 auto; }
.panel { display:none; }
.panel.active { display:block; }
/* ── Theme toggle ── */
.theme-btn { background:none; border:1px solid var(--border); color:var(--text-dim); padding:4px 10px; border-radius:6px; cursor:pointer; font-size:14px; margin-right:12px; transition:all .15s; }
.theme-btn:hover { color:var(--text-bright); border-color:var(--accent); }
/* ── Cards ── */
.card-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:16px; margin-bottom:24px; }
.card { background:var(--bg-surface); border:1px solid var(--border); border-radius:8px; padding:16px; }
.card .label { font-size:12px; color:var(--text-dim); text-transform:uppercase; letter-spacing:.5px; }
.card .value { font-size:28px; font-weight:700; margin-top:4px; color:var(--card-value-default); }
.card .value.green { color:var(--green); }
.card .value.red { color:var(--red); }
.card .value.blue { color:var(--accent); }
/* ── Tables ── */
table { width:100%; border-collapse:collapse; font-size:13px; }
th { text-align:left; padding:10px 12px; color:var(--text-dim); font-weight:500; border-bottom:1px solid var(--border); }
td { padding:10px 12px; border-bottom:1px solid var(--border); }
tr:hover td { background:var(--hover-row); }
/* ── Buttons ── */
.btn { padding:8px 16px; border-radius:6px; border:none; font-size:13px; cursor:pointer; font-weight:500; }
.btn-blue { background:var(--accent); color:#fff; } .btn-blue:hover { background:var(--accent-hover); }
.btn-red { background:var(--red); color:#fff; } .btn-red:hover { filter:brightness(.9); }
.btn-ghost { background:transparent; color:var(--text-dim); border:1px solid var(--border); } .btn-ghost:hover { background:var(--hover-row); color:var(--text); }
.btn-sm { padding:4px 10px; font-size:12px; }
/* ── Forms ── */
.form-row { display:flex; gap:12px; margin-bottom:12px; align-items:end; flex-wrap:wrap; }
.form-row input, .form-row select { padding:8px 12px; background:var(--input-bg); border:1px solid var(--border); border-radius:6px; color:var(--text-bright); font-size:13px; }
.form-row input:focus, .form-row select:focus { outline:none; border-color:var(--accent); }
.form-row label { font-size:12px; color:var(--text-dim); display:block; margin-bottom:4px; }
/* ── Badges ── */
.badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.badge-paid { background:#22c55e22; color:var(--green); }
.badge-unpaid { background:#f59e0b22; color:var(--yellow); }
.badge-active { background:#22c55e22; color:var(--green); }
.badge-inactive { background:#ef444422; color:var(--red); }
.section-title { font-size:16px; font-weight:600; margin-bottom:16px; color:var(--text-bright); }
.empty { color:var(--text-dim); font-style:italic; padding:24px; text-align:center; }
.spinner { text-align:center; padding:60px; color:var(--text-dim); }
/* ── Tier cards ── */
.tier-card { background:var(--bg-surface); border:1px solid var(--border); border-radius:8px; padding:16px; margin-bottom:16px; }
.tier-name { font-size:16px; font-weight:600; color:var(--text-bright); }
.tier-detail { color:var(--text-dim); font-size:12px; margin-left:8px; }
.tier-cost { color:var(--text-dim); font-size:12px; margin-left:4px; opacity:0.7; }
.tier-margin { color:var(--green); font-size:12px; margin-left:4px; }
.tier-notes { color:var(--text-dim); font-size:11px; opacity:0.6; }
.profit-green { color:var(--green); }
.profit-red { color:var(--red); }
.notes-dim { color:var(--text-dim); }
</style>
</head>
<body>
<!-- LOGIN SCREEN -->
<div id="loginScreen">
<div class="login-box">
<h1>Vela Platform</h1>
<p class="sub">P&L Manager — Sign in</p>
<form id="loginForm">
<label>Username</label>
<input type="text" id="username" autocomplete="username" required autofocus>
<label>Password</label>
<input type="password" id="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
<div class="error" id="loginError"></div>
</form>
</div>
</div>
<!-- LOADING -->
<div id="loadingScreen" style="display:flex; align-items:center; justify-content:center; min-height:100vh;">
<div class="spinner">Loading...</div>
</div>
<!-- APP SCREEN -->
<div id="appScreen">
<div class="topbar">
<div style="display:flex;align-items:center;gap:16px;">
<h1>Vela Platform</h1>
<span class="userinfo" id="userDisplay"></span>
</div>
<div style="display:flex;align-items:center;">
<button class="theme-btn" onclick="toggleTheme()" id="themeBtn">☀️</button>
<button class="logout" onclick="logout()">Sign out</button>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="tiers">Tiers</div>
<div class="tab" data-tab="transactions">Transactions</div>
<div class="tab" data-tab="expenses">Expenses</div>
</div>
<div class="content">
<!-- DASHBOARD -->
<div class="panel active" id="panel-dashboard">
<div class="card-grid" id="pnlCards"></div>
<div style="margin-bottom:16px;display:flex;gap:12px;align-items:center;">
<span style="color:#888;font-size:13px;">Month:</span>
<input type="month" id="pnlMonthFilter" onchange="loadDashboard()">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('pnlMonthFilter').value='';loadDashboard();">All</button>
</div>
<div id="pnlTable"></div>
</div>
<!-- TIERS -->
<div class="panel" id="panel-tiers"><div id="tiersList"></div></div>
<!-- TRANSACTIONS -->
<div class="panel" id="panel-transactions">
<div class="section-title">New Sale</div>
<div class="form-row">
<div><label>Service</label><select id="txnService"></select></div>
<div><label>Qty</label><input type="number" id="txnQty" value="1" min="1" style="width:60px;"></div>
<div><label>Month</label><input type="month" id="txnMonth"></div>
<div><label>Payment</label><select id="txnPayment"><option value="unpaid">Unpaid</option><option value="paid">Paid</option><option value="partial">Partial</option></select></div>
<div><label>Notes</label><input type="text" id="txnNotes" placeholder="optional"></div>
<div style="align-self:end;"><button class="btn btn-blue" onclick="addTransaction()">Add</button></div>
</div>
<div style="margin:16px 0;display:flex;gap:12px;align-items:center;">
<span style="color:#888;font-size:13px;">Filter:</span>
<input type="month" id="txnMonthFilter" onchange="loadTransactions()">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('txnMonthFilter').value='';loadTransactions();">Clear</button>
</div>
<div id="txnTable"></div>
</div>
<!-- EXPENSES -->
<div class="panel" id="panel-expenses">
<div class="section-title">New Expense</div>
<div class="form-row">
<div><label>Name</label><input type="text" id="expName" placeholder="e.g. Internet bill"></div>
<div><label>Amount (MAD)</label><input type="number" id="expAmount" step="0.01" min="0"></div>
<div><label>Category</label><input type="text" id="expCategory" placeholder="utilities"></div>
<div><label>Month</label><input type="month" id="expMonth"></div>
<div style="align-self:end;"><button class="btn btn-blue" onclick="addExpense()">Add</button></div>
</div>
<div style="margin:16px 0;display:flex;gap:12px;align-items:center;">
<span style="color:#888;font-size:13px;">Filter:</span>
<input type="month" id="expMonthFilter" onchange="loadExpenses()">
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('expMonthFilter').value='';loadExpenses();">Clear</button>
<button class="btn btn-ghost btn-sm" style="margin-left:auto;" onclick="generateMonth()">Generate from templates</button>
</div>
<div id="expTable"></div>
</div>
</div>
</div>
<script>
// ── Theme ──
function getTheme() { return localStorage.getItem('vela_theme') || 'dark'; }
function setTheme(t) {
document.documentElement.setAttribute('data-theme', t);
document.getElementById('themeBtn').textContent = t === 'light' ? '🌙' : '☀️';
localStorage.setItem('vela_theme', t);
}
function toggleTheme() { setTheme(getTheme() === 'dark' ? 'light' : 'dark'); }
setTheme(getTheme()); // apply on load
// ── State ──
let token = localStorage.getItem('vela_token') || '';
let currentUser = '';
// ── Auth flow ──
async function checkAuth() {
if (!token) { showLogin(); return; }
try {
const r = await fetch('/api/verify', { headers: { 'Authorization': 'Bearer ' + token } });
if (!r.ok) { token = ''; localStorage.removeItem('vela_token'); showLogin(); return; }
const d = await r.json();
currentUser = d.email;
showApp();
} catch(e) { showLogin(); }
}
function showLogin() {
document.getElementById('loadingScreen').style.display = 'none';
document.getElementById('loginScreen').style.display = 'flex';
document.getElementById('appScreen').style.display = 'none';
document.getElementById('loginError').style.display = 'none';
document.getElementById('password').value = '';
}
function showApp() {
document.getElementById('loadingScreen').style.display = 'none';
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('appScreen').style.display = 'block';
document.getElementById('userDisplay').textContent = currentUser;
loadDashboard();
}
// ── Login ──
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('loginError');
err.style.display = 'none';
const form = new FormData();
form.append('username', document.getElementById('username').value);
form.append('password', document.getElementById('password').value);
try {
const r = await fetch('/api/auth/login', { method: 'POST', body: form });
if (!r.ok) { const d = await r.json(); err.textContent = d.detail || 'Wrong credentials'; err.style.display = 'block'; return; }
const d = await r.json();
token = d.token;
currentUser = d.email;
localStorage.setItem('vela_token', token);
showApp();
} catch(ex) { err.textContent = 'Network error'; err.style.display = 'block'; }
});
function logout() {
fetch('/api/auth/logout', { method: 'POST' });
token = '';
currentUser = '';
localStorage.removeItem('vela_token');
showLogin();
document.getElementById('password').value = '';
}
// ── API helper ──
async function api(url, opts = {}) {
opts.headers = opts.headers || {};
opts.headers['Authorization'] = 'Bearer ' + token;
const r = await fetch(url, opts);
if (r.status === 401) { logout(); throw new Error('Session expired'); }
return r;
}
// ── Tabs ──
document.querySelectorAll('.tab').forEach(t => {
t.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
document.querySelectorAll('.panel').forEach(x => x.classList.remove('active'));
t.classList.add('active');
document.getElementById('panel-' + t.dataset.tab).classList.add('active');
const fn = 'load' + t.dataset.tab.charAt(0).toUpperCase() + t.dataset.tab.slice(1);
if (typeof window[fn] === 'function') window[fn]();
});
});
// ── Dashboard ──
async function loadDashboard() {
let month = document.getElementById('pnlMonthFilter')?.value || '';
let url = '/api/pnl' + (month ? '?month=' + month : '');
const r = await api(url);
const d = await r.json();
const s = d.summary;
document.getElementById('pnlCards').innerHTML = `
<div class="card"><div class="label">Revenue</div><div class="value blue">${s.revenue.toLocaleString()}</div></div>
<div class="card"><div class="label">Parts cost</div><div class="value">${s.cogs.toLocaleString()}</div></div>
<div class="card"><div class="label">Gross profit</div><div class="value green">${s.gross_profit.toLocaleString()}</div></div>
<div class="card"><div class="label">Margin</div><div class="value">${s.margin_pct}%</div></div>
<div class="card"><div class="label">Expenses</div><div class="value red">${s.expenses.toLocaleString()}</div></div>
<div class="card"><div class="label">Net profit</div><div class="value ${s.net_profit >= 0 ? 'green' : 'red'}">${s.net_profit.toLocaleString()}</div></div>
`;
const months = d.months;
let html = '<table><thead><tr><th>Month</th><th>Revenue</th><th>Parts</th><th>Gross</th><th>Expenses</th><th>Net</th><th>Sales</th></tr></thead><tbody>';
for (const [m, v] of Object.entries(months)) {
html += `<tr><td>${m}</td><td>${v.revenue.toLocaleString()}</td><td>${v.cogs.toLocaleString()}</td><td class="${v.gross_profit>=0?'profit-green':'profit-red'}">${v.gross_profit.toLocaleString()}</td><td>${v.expenses.toLocaleString()}</td><td class="${v.net_profit>=0?'profit-green':'profit-red'}">${v.net_profit.toLocaleString()}</td><td>${v.txn_count}</td></tr>`;
}
html += '</tbody></table>';
if (!Object.keys(months).length) html = '<div class="empty">No data yet</div>';
document.getElementById('pnlTable').innerHTML = html;
}
// ── Tiers ──
async function loadTiers() {
const r = await api('/api/services');
const services = await r.json();
let html = '';
for (const s of services) {
const cost = s.components.reduce((sum, c) => sum + c.unit_cost * c.quantity, 0);
const margin = s.sell_price > 0 ? ((s.sell_price - cost) / s.sell_price * 100).toFixed(0) : 0;
html += `<div class="tier-card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
<div>
<span class="tier-name">${s.name}</span>
<span class="badge ${s.active ? 'badge-active' : 'badge-inactive'}" style="margin-left:8px;">${s.active ? 'Active' : 'Off'}</span>
<span class="tier-detail">Sell: ${s.sell_price.toLocaleString()} MAD</span>
<span class="tier-cost">| Cost: ${cost.toLocaleString()} MAD</span>
<span class="tier-margin">| ~${margin}%</span>
</div>
<div>
<button class="btn btn-ghost btn-sm" onclick="toggleService(${s.id})">${s.active ? 'Disable' : 'Enable'}</button>
<button class="btn btn-blue btn-sm" style="margin-left:4px;" onclick="editService(${s.id},'${s.name.replace(/'/g,"\\'")}',${s.sell_price})">Edit</button>
</div>
</div>
<table><thead><tr><th>Component</th><th>Unit Cost</th><th>Qty</th><th>Total</th><th style="width:50px;"></th></tr></thead><tbody>`;
for (const c of s.components) {
html += `<tr><td>${c.name} ${c.notes ? '<span class="tier-notes">(' + c.notes + ')</span>' : ''}</td><td>${c.unit_cost}</td><td>${c.quantity}</td><td>${(c.unit_cost * c.quantity).toLocaleString()}</td><td><button class="btn btn-ghost btn-sm" onclick="editComponent(${s.id},${c.id},'${c.name.replace(/'/g,"\\'")}',${c.unit_cost},${c.quantity})">✎</button></td></tr>`;
}
html += '</tbody></table></div>';
}
document.getElementById('tiersList').innerHTML = html;
}
async function toggleService(id) { await api('/api/services/' + id + '/toggle', { method: 'PUT' }); loadTiers(); }
function editService(id, name, price) {
const n = prompt('Name:', name); if (!n) return;
const p = prompt('Sell price (MAD):', price); if (p === null) return;
api('/api/services/' + id, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: n, sell_price: parseFloat(p) }) }).then(loadTiers);
}
function editComponent(sid, cid, name, cost, qty) {
const n = prompt('Name:', name); if (!n) return;
const c = prompt('Unit cost:', cost); if (c === null) return;
const q = prompt('Qty:', qty); if (q === null) return;
api('/api/services/' + sid + '/components/' + cid, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: n, unit_cost: parseFloat(c), quantity: parseInt(q) }) }).then(loadTiers);
}
// ── Transactions ──
async function loadTransactions() {
const month = document.getElementById('txnMonthFilter')?.value || '';
const url = '/api/transactions' + (month ? '?month=' + month : '');
const r = await api(url);
const txns = await r.json();
let html = '<table><thead><tr><th>Date</th><th>Service</th><th>Qty</th><th>Revenue</th><th>Parts</th><th>Profit</th><th>Month</th><th>Paid?</th><th>Notes</th><th style="width:60px;"></th></tr></thead><tbody>';
for (const t of txns) {
html += `<tr id="txn-${t.id}"><td>${t.created_at ? t.created_at.slice(0, 10) : ''}</td><td>${t.service_name}</td><td>${t.quantity}</td><td>${t.revenue.toLocaleString()}</td><td>${t.cogs.toLocaleString()}</td><td class="${t.profit >= 0 ? 'profit-green' : 'profit-red'}">${t.profit.toLocaleString()}</td><td>${t.month}</td><td><span class="badge ${t.payment_status === 'paid' ? 'badge-paid' : 'badge-unpaid'}">${t.payment_status}</span></td><td class="notes-dim">${t.notes || ''}</td><td><button class="btn btn-red btn-sm" onclick="deleteTxn(${t.id})">✕</button></td></tr>`;
}
html += '</tbody></table>';
if (!txns.length) html = '<div class="empty">No sales yet</div>';
document.getElementById('txnTable').innerHTML = html;
// Populate dropdowns
const sr = await api('/api/services');
const svcs = await sr.json();
document.getElementById('txnService').innerHTML = '<option value="">Manual</option>' + svcs.filter(s => s.active).map(s => `<option value="${s.id}">${s.name} (${s.sell_price} MAD)</option>`).join('');
document.getElementById('txnMonth').value = new Date().toISOString().slice(0, 7);
}
async function addTransaction() {
const sid = document.getElementById('txnService').value || null;
const qty = parseInt(document.getElementById('txnQty').value) || 1;
const month = document.getElementById('txnMonth').value;
await api('/api/transactions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ service_id: sid ? parseInt(sid) : null, quantity: qty, month, payment_status: document.getElementById('txnPayment').value, notes: document.getElementById('txnNotes').value }) });
document.getElementById('txnNotes').value = '';
loadTransactions(); loadDashboard();
}
let deleteTimers = {};
async function deleteTxn(id) {
const row = document.getElementById('txn-'+id);
if (!row) return;
const btn = row.querySelector('button');
const original = btn.textContent;
const originalClass = btn.className;
// If already confirming, do the delete
if (btn.textContent === 'Sure?') {
clearTimeout(deleteTimers[id]);
await api('/api/transactions/'+id, {method:'DELETE'});
row.style.opacity = '0.3';
row.style.textDecoration = 'line-through';
btn.textContent = '↩';
btn.className = 'btn btn-blue btn-sm';
btn.onclick = () => undoDeleteTxn(id);
// Auto-remove after 8s
deleteTimers[id] = setTimeout(() => { row.remove(); }, 8000);
loadDashboard();
return;
}
// First click: ask confirm
btn.textContent = 'Sure?';
btn.className = 'btn btn-red btn-sm';
// Reset after 4s if not confirmed
deleteTimers[id] = setTimeout(() => {
btn.textContent = original;
btn.className = originalClass;
delete deleteTimers[id];
}, 4000);
}
async function undoDeleteTxn(id) {
// Undo isn't possible at API level — just reload
loadTransactions();
loadDashboard();
}
// ── Expenses ──
async function loadExpenses() {
const month = document.getElementById('expMonthFilter')?.value || '';
const url = '/api/expenses' + (month ? '?month=' + month : '');
const r = await api(url);
const expenses = await r.json();
let html = '<table><thead><tr><th>Name</th><th>Amount</th><th>Category</th><th>Month</th><th style="width:50px;"></th></tr></thead><tbody>';
for (const e of expenses) {
html += `<tr><td>${e.name}</td><td>${e.amount.toLocaleString()} MAD</td><td>${e.category}</td><td>${e.month}</td><td><button class="btn btn-red btn-sm" onclick="deleteExpense(${e.id})">✕</button></td></tr>`;
}
html += '</tbody></table>';
if (!expenses.length) html = '<div class="empty">No expenses yet</div>';
document.getElementById('expTable').innerHTML = html;
document.getElementById('expMonth').value = new Date().toISOString().slice(0, 7);
}
async function addExpense() {
const name = document.getElementById('expName').value;
const amount = parseFloat(document.getElementById('expAmount').value) || 0;
if (!name || !amount) return;
await api('/api/expenses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, amount, category: document.getElementById('expCategory').value || 'general', month: document.getElementById('expMonth').value }) });
document.getElementById('expName').value = '';
document.getElementById('expAmount').value = '';
loadExpenses(); loadDashboard();
}
async function deleteExpense(id) { await api('/api/expenses/' + id, { method: 'DELETE' }); loadExpenses(); loadDashboard(); }
async function generateMonth() {
const month = document.getElementById('expMonthFilter').value || new Date().toISOString().slice(0, 7);
await api('/api/expenses/generate-month?month=' + month, { method: 'POST' });
loadExpenses(); loadDashboard();
}
// ── Init ──
checkAuth();
</script>
</body>
</html>