feat(dashboard): receipt-style order detail with inline transport and discount rows
Replace totals bar + VAT subtotals table with transport/discount as table rows (with CODMAT from settings, proper VAT rate) and a single Total footer. Right-align qty/price/TVA columns, thousands separator (ro-RO), discount shown as qty=-1 price=positive. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -416,6 +416,12 @@ async def order_detail(order_number: str):
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Add settings for receipt display
|
||||||
|
app_settings = await sqlite_service.get_app_settings()
|
||||||
|
order["transport_vat"] = app_settings.get("transport_vat") or "21"
|
||||||
|
order["transport_codmat"] = app_settings.get("transport_codmat") or ""
|
||||||
|
order["discount_codmat"] = app_settings.get("discount_codmat") or ""
|
||||||
|
|
||||||
return detail
|
return detail
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -522,14 +522,12 @@ async function openDashOrderDetail(orderNumber) {
|
|||||||
document.getElementById('detailIdPartener').textContent = '-';
|
document.getElementById('detailIdPartener').textContent = '-';
|
||||||
document.getElementById('detailIdAdresaFact').textContent = '-';
|
document.getElementById('detailIdAdresaFact').textContent = '-';
|
||||||
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
document.getElementById('detailIdAdresaLivr').textContent = '-';
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center">Se incarca...</td></tr>';
|
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center">Se incarca...</td></tr>';
|
||||||
document.getElementById('detailError').style.display = 'none';
|
document.getElementById('detailError').style.display = 'none';
|
||||||
|
document.getElementById('detailReceipt').innerHTML = '';
|
||||||
|
document.getElementById('detailReceiptMobile').innerHTML = '';
|
||||||
const invInfo = document.getElementById('detailInvoiceInfo');
|
const invInfo = document.getElementById('detailInvoiceInfo');
|
||||||
if (invInfo) invInfo.style.display = 'none';
|
if (invInfo) invInfo.style.display = 'none';
|
||||||
const detailItemsTotal = document.getElementById('detailItemsTotal');
|
|
||||||
if (detailItemsTotal) detailItemsTotal.textContent = '-';
|
|
||||||
const detailOrderTotal = document.getElementById('detailOrderTotal');
|
|
||||||
if (detailOrderTotal) detailOrderTotal.textContent = '-';
|
|
||||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||||
if (mobileContainer) mobileContainer.innerHTML = '';
|
if (mobileContainer) mobileContainer.innerHTML = '';
|
||||||
|
|
||||||
@@ -574,46 +572,23 @@ async function openDashOrderDetail(orderNumber) {
|
|||||||
document.getElementById('detailError').style.display = '';
|
document.getElementById('detailError').style.display = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const dlvEl = document.getElementById('detailDeliveryCost');
|
|
||||||
if (dlvEl) dlvEl.textContent = order.delivery_cost > 0 ? Number(order.delivery_cost).toFixed(2) + ' lei' : '–';
|
|
||||||
|
|
||||||
const dscEl = document.getElementById('detailDiscount');
|
|
||||||
if (dscEl) {
|
|
||||||
if (order.discount_total > 0 && order.discount_split && typeof order.discount_split === 'object') {
|
|
||||||
const entries = Object.entries(order.discount_split);
|
|
||||||
if (entries.length > 1) {
|
|
||||||
const parts = entries.map(([vat, amt]) => `–${Number(amt).toFixed(2)} (TVA ${vat}%)`);
|
|
||||||
dscEl.innerHTML = parts.join('<br>');
|
|
||||||
} else {
|
|
||||||
dscEl.textContent = '–' + Number(order.discount_total).toFixed(2) + ' lei';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dscEl.textContent = order.discount_total > 0 ? '–' + Number(order.discount_total).toFixed(2) + ' lei' : '–';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = data.items || [];
|
const items = data.items || [];
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="6" class="text-center text-muted">Niciun articol</td></tr>';
|
document.getElementById('detailItemsBody').innerHTML = '<tr><td colspan="7" class="text-center text-muted">Niciun articol</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update totals row
|
|
||||||
const itemsTotal = items.reduce((sum, item) => sum + (Number(item.price || 0) * Number(item.quantity || 0)), 0);
|
|
||||||
document.getElementById('detailItemsTotal').textContent = itemsTotal.toFixed(2) + ' lei';
|
|
||||||
document.getElementById('detailOrderTotal').textContent = order.order_total != null ? Number(order.order_total).toFixed(2) + ' lei' : '-';
|
|
||||||
|
|
||||||
// Store items for quick map pre-population
|
// Store items for quick map pre-population
|
||||||
window._detailItems = items;
|
window._detailItems = items;
|
||||||
|
|
||||||
// Mobile article flat list
|
// Mobile article flat list
|
||||||
const mobileContainer = document.getElementById('detailItemsMobile');
|
const mobileContainer = document.getElementById('detailItemsMobile');
|
||||||
if (mobileContainer) {
|
if (mobileContainer) {
|
||||||
mobileContainer.innerHTML = '<div class="detail-item-flat">' + items.map((item, idx) => {
|
let mobileHtml = items.map((item, idx) => {
|
||||||
const codmatText = item.codmat_details?.length
|
const codmatText = item.codmat_details?.length
|
||||||
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
|
? item.codmat_details.map(d => `<code>${esc(d.codmat)}</code>${d.direct ? ' <span class="badge bg-secondary" style="font-size:0.55rem">direct</span>' : ''}`).join(' ')
|
||||||
: `<code>${esc(item.codmat || '–')}</code>`;
|
: `<code>${esc(item.codmat || '–')}</code>`;
|
||||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
const valoare = (Number(item.price || 0) * Number(item.quantity || 0));
|
||||||
return `<div class="dif-item">
|
return `<div class="dif-item">
|
||||||
<div class="dif-row">
|
<div class="dif-row">
|
||||||
<span class="dif-sku dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
|
<span class="dif-sku dif-codmat-link" onclick="openQuickMap('${esc(item.sku)}','${esc(item.product_name||'')}','${esc(orderNumber)}', ${idx})">${esc(item.sku)}</span>
|
||||||
@@ -622,29 +597,166 @@ async function openDashOrderDetail(orderNumber) {
|
|||||||
<div class="dif-row">
|
<div class="dif-row">
|
||||||
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
<span class="dif-name">${esc(item.product_name || '–')}</span>
|
||||||
<span class="dif-qty">x${item.quantity || 0}</span>
|
<span class="dif-qty">x${item.quantity || 0}</span>
|
||||||
<span class="dif-val">${valoare} lei</span>
|
<span class="dif-val">${fmtNum(valoare)} lei</span>
|
||||||
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${item.vat != null ? Number(item.vat) : '?'}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Transport row (mobile)
|
||||||
|
if (order.delivery_cost > 0) {
|
||||||
|
const tVat = order.transport_vat || '21';
|
||||||
|
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name text-muted">Transport</span>
|
||||||
|
<span class="dif-qty">x1</span>
|
||||||
|
<span class="dif-val">${fmtNum(order.delivery_cost)} lei</span>
|
||||||
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${tVat}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('') + '</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('detailItemsBody').innerHTML = items.map((item, idx) => {
|
// Discount rows (mobile)
|
||||||
const valoare = (Number(item.price || 0) * Number(item.quantity || 0)).toFixed(2);
|
if (order.discount_total > 0) {
|
||||||
|
const discSplit = computeDiscountSplit(items, order);
|
||||||
|
if (discSplit) {
|
||||||
|
Object.entries(discSplit)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.forEach(([rate, amt]) => {
|
||||||
|
if (amt > 0) mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name text-muted">Discount</span>
|
||||||
|
<span class="dif-qty">x\u20131</span>
|
||||||
|
<span class="dif-val">${fmtNum(amt)} lei</span>
|
||||||
|
<span class="dif-vat text-muted" style="font-size:0.75rem">TVA ${Number(rate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mobileHtml += `<div class="dif-item" style="opacity:0.7">
|
||||||
|
<div class="dif-row">
|
||||||
|
<span class="dif-name text-muted">Discount</span>
|
||||||
|
<span class="dif-qty">x\u20131</span>
|
||||||
|
<span class="dif-val">${fmtNum(order.discount_total)} lei</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mobileContainer.innerHTML = '<div class="detail-item-flat">' + mobileHtml + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
let tableHtml = items.map((item, idx) => {
|
||||||
|
const valoare = Number(item.price || 0) * Number(item.quantity || 0);
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td><code class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
|
<td><code class="codmat-link" onclick="openQuickMap('${esc(item.sku)}', '${esc(item.product_name || '')}', '${esc(orderNumber)}', ${idx})" title="Click pentru mapare">${esc(item.sku)}</code></td>
|
||||||
<td>${esc(item.product_name || '-')}</td>
|
<td>${esc(item.product_name || '-')}</td>
|
||||||
<td>${renderCodmatCell(item)}</td>
|
<td>${renderCodmatCell(item)}</td>
|
||||||
<td>${item.quantity || 0}</td>
|
<td class="text-end">${item.quantity || 0}</td>
|
||||||
<td>${item.price != null ? Number(item.price).toFixed(2) : '-'}</td>
|
<td class="text-end">${item.price != null ? fmtNum(item.price) : '-'}</td>
|
||||||
<td class="text-end">${valoare}</td>
|
<td class="text-end">${item.vat != null ? Number(item.vat) : '-'}</td>
|
||||||
|
<td class="text-end">${fmtNum(valoare)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Transport row
|
||||||
|
if (order.delivery_cost > 0) {
|
||||||
|
const tVat = order.transport_vat || '21';
|
||||||
|
const tCodmat = order.transport_codmat || '';
|
||||||
|
tableHtml += `<tr class="table-light">
|
||||||
|
<td></td><td class="text-muted">Transport</td>
|
||||||
|
<td>${tCodmat ? '<code>' + esc(tCodmat) + '</code>' : ''}</td>
|
||||||
|
<td class="text-end">1</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
|
||||||
|
<td class="text-end">${tVat}</td><td class="text-end">${fmtNum(order.delivery_cost)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discount rows (split by VAT rate)
|
||||||
|
if (order.discount_total > 0) {
|
||||||
|
const dCodmat = order.discount_codmat || '';
|
||||||
|
const discSplit = computeDiscountSplit(items, order);
|
||||||
|
if (discSplit) {
|
||||||
|
Object.entries(discSplit)
|
||||||
|
.sort(([a], [b]) => Number(a) - Number(b))
|
||||||
|
.forEach(([rate, amt]) => {
|
||||||
|
if (amt > 0) tableHtml += `<tr class="table-light">
|
||||||
|
<td></td><td class="text-muted">Discount</td>
|
||||||
|
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||||
|
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(amt)}</td>
|
||||||
|
<td class="text-end">${Number(rate)}</td><td class="text-end">\u2013${fmtNum(amt)}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
tableHtml += `<tr class="table-light">
|
||||||
|
<td></td><td class="text-muted">Discount</td>
|
||||||
|
<td>${dCodmat ? '<code>' + esc(dCodmat) + '</code>' : ''}</td>
|
||||||
|
<td class="text-end">\u20131</td><td class="text-end">${fmtNum(order.discount_total)}</td>
|
||||||
|
<td class="text-end">-</td><td class="text-end">\u2013${fmtNum(order.discount_total)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('detailItemsBody').innerHTML = tableHtml;
|
||||||
|
|
||||||
|
// Receipt footer (just total)
|
||||||
|
renderReceipt(items, order);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('detailError').textContent = err.message;
|
document.getElementById('detailError').textContent = err.message;
|
||||||
document.getElementById('detailError').style.display = '';
|
document.getElementById('detailError').style.display = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fmtNum(v) {
|
||||||
|
return Number(v).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeDiscountSplit(items, order) {
|
||||||
|
if (order.discount_split && typeof order.discount_split === 'object')
|
||||||
|
return order.discount_split;
|
||||||
|
|
||||||
|
// Compute proportionally from items by VAT rate
|
||||||
|
const byRate = {};
|
||||||
|
items.forEach(item => {
|
||||||
|
const rate = item.vat != null ? Number(item.vat) : null;
|
||||||
|
if (rate === null) return;
|
||||||
|
if (!byRate[rate]) byRate[rate] = 0;
|
||||||
|
byRate[rate] += Number(item.price || 0) * Number(item.quantity || 0);
|
||||||
|
});
|
||||||
|
const rates = Object.keys(byRate).sort((a, b) => Number(a) - Number(b));
|
||||||
|
if (rates.length === 0) return null;
|
||||||
|
|
||||||
|
const grandTotal = rates.reduce((s, r) => s + byRate[r], 0);
|
||||||
|
if (grandTotal <= 0) return null;
|
||||||
|
|
||||||
|
const split = {};
|
||||||
|
let remaining = order.discount_total;
|
||||||
|
rates.forEach((rate, i) => {
|
||||||
|
if (i === rates.length - 1) {
|
||||||
|
split[rate] = Math.round(remaining * 100) / 100;
|
||||||
|
} else {
|
||||||
|
const amt = Math.round(order.discount_total * byRate[rate] / grandTotal * 100) / 100;
|
||||||
|
split[rate] = amt;
|
||||||
|
remaining -= amt;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReceipt(items, order) {
|
||||||
|
const desktop = document.getElementById('detailReceipt');
|
||||||
|
const mobile = document.getElementById('detailReceiptMobile');
|
||||||
|
if (!items.length) {
|
||||||
|
desktop.innerHTML = '';
|
||||||
|
mobile.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = order.order_total != null ? fmtNum(order.order_total) : '-';
|
||||||
|
const html = `<span><strong>Total: ${total} lei</strong></span>`;
|
||||||
|
desktop.innerHTML = html;
|
||||||
|
mobile.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Quick Map Modal ───────────────────────────────
|
// ── Quick Map Modal ───────────────────────────────
|
||||||
|
|
||||||
function openQuickMap(sku, productName, orderNumber, itemIdx) {
|
function openQuickMap(sku, productName, orderNumber, itemIdx) {
|
||||||
|
|||||||
@@ -135,12 +135,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="detailTotals" class="d-flex gap-3 mb-2 flex-wrap" style="font-size:0.875rem">
|
|
||||||
<span><small class="text-muted">Valoare:</small> <strong id="detailItemsTotal">-</strong></span>
|
|
||||||
<span id="detailDiscountWrap"><small class="text-muted">Discount:</small> <strong id="detailDiscount">-</strong></span>
|
|
||||||
<span id="detailDeliveryWrap"><small class="text-muted">Transport:</small> <strong id="detailDeliveryCost">-</strong></span>
|
|
||||||
<span><small class="text-muted">Total:</small> <strong id="detailOrderTotal">-</strong></span>
|
|
||||||
</div>
|
|
||||||
<div class="table-responsive d-none d-md-block">
|
<div class="table-responsive d-none d-md-block">
|
||||||
<table class="table table-sm table-bordered mb-0">
|
<table class="table table-sm table-bordered mb-0">
|
||||||
<thead class="table-light">
|
<thead class="table-light">
|
||||||
@@ -148,16 +142,19 @@
|
|||||||
<th>SKU</th>
|
<th>SKU</th>
|
||||||
<th>Produs</th>
|
<th>Produs</th>
|
||||||
<th>CODMAT</th>
|
<th>CODMAT</th>
|
||||||
<th>Cant.</th>
|
<th class="text-end">Cant.</th>
|
||||||
<th>Pret</th>
|
<th class="text-end">Pret</th>
|
||||||
|
<th class="text-end">TVA%</th>
|
||||||
<th class="text-end">Valoare</th>
|
<th class="text-end">Valoare</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="detailItemsBody">
|
<tbody id="detailItemsBody">
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div id="detailReceipt" class="d-flex flex-wrap gap-2 mt-1 justify-content-end"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-md-none" id="detailItemsMobile"></div>
|
<div class="d-md-none" id="detailItemsMobile"></div>
|
||||||
|
<div id="detailReceiptMobile" class="d-flex flex-wrap gap-2 mt-1 d-md-none justify-content-end"></div>
|
||||||
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
<div id="detailError" class="alert alert-danger mt-3" style="display:none;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@@ -204,5 +201,5 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=17"></script>
|
<script src="{{ request.scope.get('root_path', '') }}/static/js/dashboard.js?v=21"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user