🎉 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:
196
backend/templates/transactions.html
Normal file
196
backend/templates/transactions.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user