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