🎉 v1.0.0 — Vela Platform launch

Self-hosted P&L tracking app with component-level pricing.
Offers: Atlas/Atlas+/Rif/Rif+ with granular cost breakdown.
API + MCP + multi-user auth.
This commit is contained in:
2026-06-15 23:05:59 +01:00
commit d1160673a7
14 changed files with 2387 additions and 0 deletions

View File

@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Vela Platform{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
body { padding-top: 70px; }
.navbar-brand { font-weight: 700; letter-spacing: -0.5px; }
.card-stat { border-left: 4px solid var(--bs-primary); }
.card-stat.green { border-left-color: var(--bs-success); }
.card-stat.red { border-left-color: var(--bs-danger); }
.card-stat.yellow { border-left-color: var(--bs-warning); }
.component-row td { vertical-align: middle; }
.component-row input { min-width: 60px; }
.total-label { font-weight: 700; font-size: 1.05rem; }
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body-tertiary fixed-top border-bottom">
<div class="container">
<a class="navbar-brand" href="/">
🏔️ Vela Platform
</a>
{% if current_user %}
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/"><i class="bi bi-speedometer2"></i> Dashboard</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/transactions"><i class="bi bi-list-ul"></i> Transactions</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/services"><i class="bi bi-gear"></i> Offers</a>
</li>
</ul>
<span class="navbar-text me-3">
<i class="bi bi-person-circle"></i> {{ current_user.display_name }}
<span class="badge bg-secondary ms-1">{{ current_user.role }}</span>
</span>
<a href="/logout" class="btn btn-outline-secondary btn-sm">Logout</a>
</div>
{% endif %}
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
const TOKEN_KEY = 'vela_token';
function setToken(token) { localStorage.setItem(TOKEN_KEY, token); }
function getToken() { return localStorage.getItem(TOKEN_KEY); }
function clearToken() { localStorage.removeItem(TOKEN_KEY); }
async function apiFetch(url, options = {}) {
const token = getToken();
const headers = { ...options.headers };
if (token && !(options.body instanceof FormData)) {
headers['Authorization'] = `Bearer ${token}`;
headers['Content-Type'] = 'application/json';
} else if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const resp = await fetch(url, { ...options, headers });
if (resp.status === 401) {
clearToken();
window.location.href = '/login';
throw new Error('Unauthorized');
}
return resp;
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,159 @@
{% extends "base.html" %}
{% block title %}Dashboard — Vela Platform{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-speedometer2"></i> Dashboard</h2>
<span class="badge bg-primary fs-6">{{ pnl.month }}</span>
</div>
<!-- P&L Summary Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3 col-6">
<div class="card card-stat h-100">
<div class="card-body">
<small class="text-muted">Revenue</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.total_revenue) }} MAD</h4>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card card-stat h-100">
<div class="card-body">
<small class="text-muted">Cost</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.total_cost) }} MAD</h4>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card card-stat green h-100">
<div class="card-body">
<small class="text-muted">Gross Profit</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.gross_profit) }} MAD</h4>
</div>
</div>
</div>
<div class="col-md-3 col-6">
<div class="card card-stat {{ 'green' if pnl.net_profit >= 0 else 'red' }} h-100">
<div class="card-body">
<small class="text-muted">Net Profit</small>
<h4 class="mt-1">{{ "%.0f"|format(pnl.net_profit) }} MAD</h4>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">Margin Breakdown</div>
<div class="card-body">
<table class="table table-sm mb-0">
<tr><td>Gross Margin</td><td class="text-end">{{ "%.1f"|format(pnl.gross_margin_pct) }}%</td></tr>
<tr><td>OpEx (fixed)</td><td class="text-end">{{ "%.0f"|format(pnl.opex) }} MAD</td></tr>
<tr class="fw-bold"><td>Net Margin</td><td class="text-end">{{ "%.1f"|format(pnl.net_margin_pct) }}%</td></tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">Active Offers — Cost Breakdown</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Offer</th>
<th class="text-end">Sell</th>
<th class="text-end">Cost</th>
<th class="text-end">Margin</th>
<th class="text-end">Components</th>
</tr>
</thead>
<tbody>
{% for s in services %}
<tr>
<td><strong>{{ s.name }}</strong></td>
<td class="text-end">{{ "%.0f"|format(s.sell_price) }} MAD</td>
<td class="text-end text-muted">{{ "%.0f"|format(s.cost_price) }} MAD</td>
<td class="text-end {{ 'text-success' if (s.sell_price - s.cost_price) > 0 else 'text-danger' }}">
{{ "%.0f"|format(s.sell_price - s.cost_price) }} MAD
</td>
<td class="text-end text-muted">
<small>{{ s.components|length }} items</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 12-Month Trend Chart -->
<div class="card mb-4">
<div class="card-header">12-Month Trend</div>
<div class="card-body">
<canvas id="trendChart" height="80"></canvas>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const trendData = {{ trend | tojson }};
const labels = trendData.map(d => d.month);
const revenue = trendData.map(d => d.total_revenue);
const cost = trendData.map(d => d.total_cost);
const netProfit = trendData.map(d => d.net_profit);
new Chart(document.getElementById('trendChart'), {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Revenue',
data: revenue,
borderColor: '#0d6efd',
backgroundColor: 'rgba(13,110,253,0.1)',
fill: true,
tension: 0.3,
},
{
label: 'Cost',
data: cost,
borderColor: '#dc3545',
backgroundColor: 'rgba(220,53,69,0.1)',
fill: true,
tension: 0.3,
},
{
label: 'Net Profit',
data: netProfit,
borderColor: '#198754',
backgroundColor: 'rgba(25,135,84,0.1)',
fill: true,
tension: 0.3,
borderWidth: 2,
},
],
},
options: {
responsive: true,
plugins: {
legend: { position: 'bottom' },
},
scales: {
y: {
ticks: { callback: v => v + ' MAD' },
},
},
},
});
</script>
{% endblock %}

View File

@@ -0,0 +1,60 @@
{% extends "base.html" %}
{% block title %}Login — Vela Platform{% endblock %}
{% block content %}
<div class="row justify-content-center mt-5">
<div class="col-md-5 col-lg-4">
<div class="card shadow">
<div class="card-body p-4">
<h3 class="card-title text-center mb-4">🔐 Sign In</h3>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username"
placeholder="Enter username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="Enter password" required>
</div>
<div id="loginError" class="alert alert-danger d-none py-2"></div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
<p class="text-muted text-center mt-3 mb-0" style="font-size: 0.85rem;">
Demo: admin / admin123 &nbsp;|&nbsp; viewer / viewer123
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const errorEl = document.getElementById('loginError');
errorEl.classList.add('d-none');
const formData = new FormData(form);
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
body: formData,
});
if (!resp.ok) {
const data = await resp.json();
errorEl.textContent = data.detail || 'Login failed';
errorEl.classList.remove('d-none');
return;
}
const data = await resp.json();
setToken(data.token);
window.location.href = '/';
} catch (err) {
errorEl.textContent = 'Network error. Please try again.';
errorEl.classList.remove('d-none');
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,345 @@
{% extends "base.html" %}
{% block title %}Offers — Vela Platform{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-gear"></i> Offers &amp; Pricing</h2>
{% if current_user.role == 'admin' %}
<button class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#serviceModal"
onclick="resetServiceForm()">
<i class="bi bi-plus-lg"></i> Add Offer
</button>
{% endif %}
</div>
{% for s in services %}
<div class="card mb-4" id="svc-card-{{ s.id }}">
<!-- Service Header -->
<div class="card-header d-flex justify-content-between align-items-center"
style="cursor: pointer;" onclick="toggleService({{ s.id }})">
<div>
<strong class="fs-5">{{ s.name }}</strong>
<small class="text-muted ms-2">{{ s.description }}</small>
</div>
<div class="d-flex align-items-center gap-3">
<span class="badge bg-success fs-6">Sell: {{ "%.0f"|format(s.sell_price) }} MAD</span>
<span class="badge bg-danger fs-6">Cost: {{ "%.0f"|format(s.cost_price) }} MAD</span>
<span class="badge {{ 'bg-success' if (s.sell_price - s.cost_price) > 0 else 'bg-warning' }} fs-6">
Margin: {{ "%.0f"|format(s.sell_price - s.cost_price) }} MAD
</span>
<span class="badge {{ 'bg-success' if s.active else 'bg-secondary' }}">
{{ 'Active' if s.active else 'Inactive' }}
</span>
<i class="bi bi-chevron-down" id="chevron-{{ s.id }}"></i>
</div>
</div>
<!-- Service Body (collapse) -->
<div id="svc-body-{{ s.id }}">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width:22%">Component</th>
<th style="width:14%">Unit Cost</th>
<th style="width:14%">Unit Sell</th>
<th style="width:8%">Qty</th>
<th style="width:14%">Row Cost</th>
<th style="width:14%">Row Sell</th>
<th style="width:8%">Margin</th>
{% if current_user.role == 'admin' %}
<th style="width:6%"></th>
{% endif %}
</tr>
</thead>
<tbody id="comp-tbody-{{ s.id }}">
{% for c in s.components %}
<tr class="component-row" id="comp-row-{{ c.id }}">
<td><strong>{{ c.name }}</strong>
{% if c.notes %}<br><small class="text-muted">{{ c.notes }}</small>{% endif %}
</td>
<td>{{ "%.0f"|format(c.unit_cost) }}</td>
<td>{{ "%.0f"|format(c.unit_sell) }}</td>
<td>{{ c.quantity }}</td>
<td>{{ "%.0f"|format(c.unit_cost * c.quantity) }}</td>
<td>{{ "%.0f"|format(c.unit_sell * c.quantity) }}</td>
<td class="{{ 'text-success' if (c.unit_sell - c.unit_cost) > 0 else 'text-danger' }}">
{{ "%.0f"|format(c.unit_sell - c.unit_cost) }}
</td>
{% if current_user.role == 'admin' %}
<td>
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-secondary"
onclick="editComponent({{ c.id }}, '{{ c.name|e }}', {{ c.unit_cost }}, {{ c.unit_sell }}, {{ c.quantity }}, '{{ c.notes|e }}')">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-danger"
onclick="deleteComponent({{ c.id }})">
<i class="bi bi-trash"></i>
</button>
</div>
</td>
{% endif %}
</tr>
{% else %}
<tr class="text-muted"><td colspan="8" class="text-center py-3">No components yet.</td></tr>
{% endfor %}
</tbody>
<tfoot class="table-light fw-bold">
<tr>
<td>TOTAL</td>
<td></td>
<td></td>
<td></td>
<td id="tot-cost-{{ s.id }}">{{ "%.0f"|format(s.cost_price) }}</td>
<td id="tot-sell-{{ s.id }}">{{ "%.0f"|format(s.sell_price) }}</td>
<td id="tot-margin-{{ s.id }}"
class="{{ 'text-success' if (s.sell_price - s.cost_price) > 0 else 'text-danger' }}">
{{ "%.0f"|format(s.sell_price - s.cost_price) }}
</td>
{% if current_user.role == 'admin' %}
<td>
<button class="btn btn-sm btn-outline-primary"
onclick="addComponent({{ s.id }})">
<i class="bi bi-plus-lg"></i>
</button>
</td>
{% endif %}
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-5">
<i class="bi bi-box" style="font-size: 3rem;"></i>
<p class="mt-3">No offers yet. Create your first one!</p>
</div>
</div>
{% endfor %}
{% if current_user.role == 'admin' %}
<!-- Add/Edit Service Modal -->
<div class="modal fade" id="serviceModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="serviceForm">
<div class="modal-header">
<h5 class="modal-title" id="serviceModalTitle">Add Offer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="svcId" name="id" value="">
<div class="mb-3">
<label for="svcName" class="form-label">Offer Name</label>
<input type="text" class="form-control" id="svcName" name="name"
placeholder="e.g., Atlas" required>
</div>
<div class="mb-3">
<label for="svcDesc" class="form-label">Description</label>
<input type="text" class="form-control" id="svcDesc" name="description"
placeholder="Short description">
</div>
<div id="svcError" class="alert alert-danger d-none py-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add/Edit Component Modal -->
<div class="modal fade" id="compModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<form id="compForm">
<div class="modal-header">
<h5 class="modal-title" id="compModalTitle">Add Component</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="compId" name="comp_id" value="">
<input type="hidden" id="compServiceId" name="service_id" value="">
<div class="mb-3">
<label for="compName" class="form-label">Component Name</label>
<input type="text" class="form-control" id="compName" name="name"
placeholder="e.g., RAM, HDD, Transport" required>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<label for="compUnitCost" class="form-label">Unit Cost (MAD)</label>
<input type="number" class="form-control" id="compUnitCost" name="unit_cost"
step="0.01" min="0" value="0" required>
</div>
<div class="col-6">
<label for="compUnitSell" class="form-label">Unit Sell (MAD)</label>
<input type="number" class="form-control" id="compUnitSell" name="unit_sell"
step="0.01" min="0" value="0" required>
</div>
</div>
<div class="row g-2 mb-3">
<div class="col-4">
<label for="compQty" class="form-label">Quantity</label>
<input type="number" class="form-control" id="compQty" name="quantity"
min="1" value="1" required>
</div>
<div class="col-8">
<label for="compNotes" class="form-label">Notes</label>
<input type="text" class="form-control" id="compNotes" name="notes"
placeholder="Optional notes">
</div>
</div>
<div id="compError" class="alert alert-danger d-none py-2"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Component</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
{% if current_user.role == 'admin' %}
const compModal = new bootstrap.Modal(document.getElementById('compModal'));
const svcModal = new bootstrap.Modal(document.getElementById('serviceModal'));
// Toggle service body
function toggleService(id) {
const body = document.getElementById(`svc-body-${id}`);
const chevron = document.getElementById(`chevron-${id}`);
if (body.style.display === 'none') {
body.style.display = '';
chevron.className = 'bi bi-chevron-down';
} else {
body.style.display = 'none';
chevron.className = 'bi bi-chevron-up';
}
}
// Service CRUD
function resetServiceForm() {
document.getElementById('svcId').value = '';
document.getElementById('svcName').value = '';
document.getElementById('svcDesc').value = '';
document.getElementById('serviceModalTitle').textContent = 'Add Offer';
document.getElementById('svcError').classList.add('d-none');
}
document.getElementById('serviceForm').addEventListener('submit', async (e) => {
e.preventDefault();
const svcError = document.getElementById('svcError');
svcError.classList.add('d-none');
const formData = new FormData(e.target);
const id = formData.get('id');
const isEdit = !!id;
try {
const url = isEdit ? `/api/services/${id}` : '/api/services';
const method = isEdit ? 'PUT' : 'POST';
const resp = await apiFetch(url, { method, body: formData });
if (!resp.ok) {
const data = await resp.json();
svcError.textContent = data.detail || 'Failed to save offer';
svcError.classList.remove('d-none');
return;
}
svcModal.hide();
window.location.reload();
} catch (err) {
svcError.textContent = 'Error saving offer';
svcError.classList.remove('d-none');
}
});
// Component CRUD
function addComponent(serviceId) {
document.getElementById('compId').value = '';
document.getElementById('compServiceId').value = serviceId;
document.getElementById('compName').value = '';
document.getElementById('compUnitCost').value = '0';
document.getElementById('compUnitSell').value = '0';
document.getElementById('compQty').value = '1';
document.getElementById('compNotes').value = '';
document.getElementById('compModalTitle').textContent = 'Add Component';
document.getElementById('compError').classList.add('d-none');
compModal.show();
}
function editComponent(id, name, unitCost, unitSell, qty, notes) {
// Find the service ID from the card
const row = document.getElementById(`comp-row-${id}`);
const card = row.closest('.card');
const cardId = card.id; // svc-card-N
const svcId = parseInt(cardId.split('-')[2]);
document.getElementById('compId').value = id;
document.getElementById('compServiceId').value = svcId;
document.getElementById('compName').value = name;
document.getElementById('compUnitCost').value = unitCost;
document.getElementById('compUnitSell').value = unitSell;
document.getElementById('compQty').value = qty;
document.getElementById('compNotes').value = notes;
document.getElementById('compModalTitle').textContent = 'Edit Component';
document.getElementById('compError').classList.add('d-none');
compModal.show();
}
document.getElementById('compForm').addEventListener('submit', async (e) => {
e.preventDefault();
const compError = document.getElementById('compError');
compError.classList.add('d-none');
const compId = document.getElementById('compId').value;
const serviceId = document.getElementById('compServiceId').value;
const name = document.getElementById('compName').value;
const unitCost = parseFloat(document.getElementById('compUnitCost').value) || 0;
const unitSell = parseFloat(document.getElementById('compUnitSell').value) || 0;
const quantity = parseInt(document.getElementById('compQty').value) || 1;
const notes = document.getElementById('compNotes').value;
const body = JSON.stringify({ name, unit_cost: unitCost, unit_sell: unitSell, quantity, notes });
const isEdit = !!compId;
try {
const url = isEdit ? `/api/components/${compId}` : `/api/services/${serviceId}/components`;
const method = isEdit ? 'PUT' : 'POST';
const resp = await apiFetch(url, { method, body });
if (!resp.ok) {
const data = await resp.json();
compError.textContent = data.detail || 'Failed to save component';
compError.classList.remove('d-none');
return;
}
compModal.hide();
window.location.reload();
} catch (err) {
compError.textContent = 'Error saving component';
compError.classList.remove('d-none');
}
});
async function deleteComponent(id) {
if (!confirm('Delete this component?')) return;
try {
const resp = await apiFetch(`/api/components/${id}`, { method: 'DELETE' });
if (resp.ok) window.location.reload();
} catch (err) {
alert('Error deleting component');
}
}
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}Transactions — Vela Platform{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-list-ul"></i> Transactions</h2>
</div>
<div class="row g-3">
<!-- Add Transaction Form -->
<div class="col-lg-4">
<div class="card">
<div class="card-header">Add Transaction</div>
<div class="card-body">
<form id="txnForm">
<div class="mb-3">
<label for="serviceId" class="form-label">Service</label>
<select class="form-select" id="serviceId" name="service_id" required>
<option value="">Select service...</option>
{% for s in services %}
<option value="{{ s.id }}">{{ s.name }} ({{ "%.0f"|format(s.sell_price) }} MAD)</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="quantity" class="form-label">Quantity</label>
<input type="number" class="form-control" id="quantity" name="quantity"
value="1" min="1" required>
</div>
<div class="mb-3">
<label for="month" class="form-label">Month</label>
<input type="month" class="form-control" id="month" name="month"
value="{{ current_month }}" required>
</div>
<div class="mb-3">
<label for="notes" class="form-label">Notes</label>
<input type="text" class="form-control" id="notes" name="notes"
placeholder="Optional notes">
</div>
<div id="txnError" class="alert alert-danger d-none py-2"></div>
<div id="txnSuccess" class="alert alert-success d-none py-2"></div>
<button type="submit" class="btn btn-primary w-100">Add Transaction</button>
</form>
</div>
</div>
</div>
<!-- Transactions List -->
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span>Transaction History</span>
<div class="d-flex gap-2">
<input type="month" id="filterMonth" class="form-control form-control-sm"
style="width: auto;" value="{{ current_month }}">
<button class="btn btn-sm btn-outline-secondary" onclick="loadTransactions()">
<i class="bi bi-funnel"></i> Filter
</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Service</th>
<th>Qty</th>
<th>Revenue</th>
<th>Cost</th>
<th>Month</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody id="txnTableBody">
<tr><td colspan="7" class="text-center text-muted py-3">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Monthly Summary -->
<div class="card-footer" id="monthlySummary" style="display:none;">
<div class="d-flex gap-4">
<span><strong>Revenue:</strong> <span id="sumRevenue">0</span> MAD</span>
<span><strong>Cost:</strong> <span id="sumCost">0</span> MAD</span>
<span><strong>Profit:</strong> <span id="sumProfit">0</span> MAD</span>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const txnForm = document.getElementById('txnForm');
const txnError = document.getElementById('txnError');
const txnSuccess = document.getElementById('txnSuccess');
txnForm.addEventListener('submit', async (e) => {
e.preventDefault();
txnError.classList.add('d-none');
txnSuccess.classList.add('d-none');
const formData = new FormData(txnForm);
try {
const resp = await apiFetch('/api/transactions', {
method: 'POST',
body: formData,
});
if (!resp.ok) {
const data = await resp.json();
txnError.textContent = data.detail || 'Failed to add transaction';
txnError.classList.remove('d-none');
return;
}
txnSuccess.textContent = 'Transaction added!';
txnSuccess.classList.remove('d-none');
txnForm.reset();
document.getElementById('month').value = '{{ current_month }}';
document.getElementById('quantity').value = '1';
loadTransactions();
setTimeout(() => txnSuccess.classList.add('d-none'), 3000);
} catch (err) {
txnError.textContent = 'Error adding transaction';
txnError.classList.remove('d-none');
}
});
async function loadTransactions() {
const month = document.getElementById('filterMonth').value;
const tbody = document.getElementById('txnTableBody');
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">Loading...</td></tr>';
try {
const url = month ? `/api/transactions?month=${month}` : '/api/transactions';
const resp = await apiFetch(url);
if (!resp.ok) throw new Error('Failed');
const data = await resp.json();
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">No transactions found</td></tr>';
document.getElementById('monthlySummary').style.display = 'none';
return;
}
let sumRevenue = 0, sumCost = 0;
tbody.innerHTML = data.map(t => {
sumRevenue += t.revenue;
sumCost += t.cost;
return `
<tr>
<td>${escapeHtml(t.service_name)}</td>
<td>${t.quantity}</td>
<td>${t.revenue.toFixed(0)} MAD</td>
<td>${t.cost.toFixed(0)} MAD</td>
<td>${t.month}</td>
<td><small>${escapeHtml(t.notes || '')}</small></td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTxn(${t.id})"
${getToken() ? '' : 'disabled'}>
<i class="bi bi-trash"></i>
</button>
</td>
</tr>`;
}).join('');
document.getElementById('sumRevenue').textContent = sumRevenue.toFixed(0);
document.getElementById('sumCost').textContent = sumCost.toFixed(0);
document.getElementById('sumProfit').textContent = (sumRevenue - sumCost).toFixed(0);
document.getElementById('monthlySummary').style.display = '';
} catch (err) {
tbody.innerHTML = '<tr><td colspan="7" class="text-center text-danger py-3">Error loading transactions</td></tr>';
}
}
async function deleteTxn(id) {
if (!confirm('Delete this transaction?')) return;
try {
const resp = await apiFetch(`/api/transactions/${id}`, { method: 'DELETE' });
if (resp.ok) loadTransactions();
} catch (err) {
alert('Error deleting transaction');
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Initial load
loadTransactions();
</script>
{% endblock %}