Self-hosted P&L tracking app with component-level pricing. Offers: Atlas/Atlas+/Rif/Rif+ with granular cost breakdown. API + MCP + multi-user auth.
197 lines
8.0 KiB
HTML
197 lines
8.0 KiB
HTML
{% 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 %}
|