Shared work log payroll modal + safe DOM builder in base.html
Modal shell + JS click handler live in base.html so any page opts in by adding data-log-id to a row. JS uses createElement + textContent (matches worker_lookup_ajax pattern) to build the modal body from JSON — no innerHTML. Supervisors never receive the markup. Footer 'Open full page' links to /history/<id>/. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9ae75b45ad
commit
2e60124b9f
@ -392,6 +392,260 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# === WORK LOG PAYROLL MODAL — click handler + safe DOM builder === #}
|
||||
{# Builds the modal body from JSON via createElement + textContent. #}
|
||||
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
|
||||
<script>
|
||||
(function() {
|
||||
var modalEl = document.getElementById('workLogPayrollModal');
|
||||
if (!modalEl) return;
|
||||
var bodyEl = document.getElementById('workLogPayrollBody');
|
||||
var fullLinkEl = document.getElementById('workLogPayrollFullLink');
|
||||
var bsModal = new bootstrap.Modal(modalEl);
|
||||
|
||||
// --- Safe element creator (copied from the worker lookup pattern) ---
|
||||
function el(tag, className, text) {
|
||||
var node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined && text !== null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function link(href, text, className) {
|
||||
var a = document.createElement('a');
|
||||
a.setAttribute('href', href);
|
||||
a.className = className || 'text-decoration-none';
|
||||
a.textContent = text;
|
||||
return a;
|
||||
}
|
||||
|
||||
function formatRand(amount) {
|
||||
return 'R ' + Number(amount).toLocaleString('en-ZA', {
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
var span = document.createElement('span');
|
||||
if (status === 'Paid') {
|
||||
span.className = 'badge bg-success';
|
||||
span.textContent = 'Paid';
|
||||
} else if (status === 'Priced, not paid') {
|
||||
span.className = 'badge bg-info text-dark';
|
||||
span.textContent = 'Priced, not paid';
|
||||
} else {
|
||||
span.className = 'badge bg-warning text-dark';
|
||||
span.textContent = 'Unpaid';
|
||||
}
|
||||
return span;
|
||||
}
|
||||
|
||||
// --- Reset body to a spinner ---
|
||||
function showSpinner() {
|
||||
while (bodyEl.firstChild) bodyEl.removeChild(bodyEl.firstChild);
|
||||
var wrap = el('div', 'text-center py-4 text-muted');
|
||||
var spin = el('div', 'spinner-border');
|
||||
spin.setAttribute('role', 'status');
|
||||
wrap.appendChild(spin);
|
||||
wrap.appendChild(el('p', 'mt-2 small', 'Loading…'));
|
||||
bodyEl.appendChild(wrap);
|
||||
}
|
||||
|
||||
// --- Replace body content with a built DOM fragment ---
|
||||
function render(data) {
|
||||
while (bodyEl.firstChild) bodyEl.removeChild(bodyEl.firstChild);
|
||||
|
||||
// Header strip (date + project/team/supervisor)
|
||||
var header = el('div', 'mb-3');
|
||||
var dateLine = el('div', 'fs-5 fw-semibold', data.date || '');
|
||||
header.appendChild(dateLine);
|
||||
var subLine = el('div', 'text-muted small');
|
||||
if (data.project) subLine.appendChild(link('/projects/' + data.project.id + '/', data.project.name));
|
||||
else subLine.appendChild(document.createTextNode('—'));
|
||||
subLine.appendChild(document.createTextNode(' · '));
|
||||
if (data.team) subLine.appendChild(link('/teams/' + data.team.id + '/', data.team.name));
|
||||
else subLine.appendChild(document.createTextNode('—'));
|
||||
subLine.appendChild(document.createTextNode(' · ' + data.worker_rows.length + ' worker' +
|
||||
(data.worker_rows.length === 1 ? '' : 's')));
|
||||
if (data.supervisor) subLine.appendChild(document.createTextNode(' · Logged by ' + data.supervisor));
|
||||
header.appendChild(subLine);
|
||||
bodyEl.appendChild(header);
|
||||
|
||||
// Unpriced OT banner (if needed)
|
||||
if (data.overtime_needs_pricing) {
|
||||
var banner = el('div', 'alert alert-warning py-2 px-3 mb-3 small');
|
||||
banner.appendChild(document.createTextNode('Overtime on this log hasn\u2019t been priced yet. '));
|
||||
banner.appendChild(link('/payroll/', 'Price now', 'alert-link'));
|
||||
banner.appendChild(document.createTextNode('.'));
|
||||
bodyEl.appendChild(banner);
|
||||
}
|
||||
|
||||
// Workers table
|
||||
var wrap = el('div', 'table-responsive mb-3');
|
||||
var table = el('table', 'table table-sm align-middle mb-0');
|
||||
var thead = document.createElement('thead');
|
||||
var headRow = document.createElement('tr');
|
||||
['Worker', 'Status', 'Earned', 'Payslip', 'Paid on'].forEach(function(h, i) {
|
||||
var th = el('th', i === 2 ? 'text-end' : null, h);
|
||||
headRow.appendChild(th);
|
||||
});
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
var tbody = document.createElement('tbody');
|
||||
data.worker_rows.forEach(function(row) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
// Worker cell with link + optional Inactive badge
|
||||
var tdW = document.createElement('td');
|
||||
var wLink = link('/workers/' + row.worker_id + '/', row.worker_name);
|
||||
if (!row.worker_active) wLink.className += ' text-decoration-line-through';
|
||||
tdW.appendChild(wLink);
|
||||
if (!row.worker_active) {
|
||||
var badge = el('span', 'badge bg-secondary ms-1', 'Inactive');
|
||||
tdW.appendChild(badge);
|
||||
}
|
||||
tr.appendChild(tdW);
|
||||
|
||||
// Status
|
||||
var tdS = document.createElement('td');
|
||||
tdS.appendChild(statusBadge(row.status));
|
||||
tr.appendChild(tdS);
|
||||
|
||||
// Earned
|
||||
tr.appendChild(el('td', 'text-end', formatRand(row.earned)));
|
||||
|
||||
// Payslip link or em-dash
|
||||
var tdP = document.createElement('td');
|
||||
if (row.payroll_record_id) {
|
||||
tdP.appendChild(link('/payroll/payslip/' + row.payroll_record_id + '/', '#' + row.payroll_record_id));
|
||||
} else {
|
||||
tdP.textContent = '\u2014';
|
||||
}
|
||||
tr.appendChild(tdP);
|
||||
|
||||
// Paid on
|
||||
tr.appendChild(el('td', null, row.paid_date || '\u2014'));
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
table.appendChild(tbody);
|
||||
wrap.appendChild(table);
|
||||
bodyEl.appendChild(wrap);
|
||||
|
||||
// Adjustments (optional)
|
||||
if (data.adjustments && data.adjustments.length) {
|
||||
var adjWrap = el('div', 'mb-3');
|
||||
adjWrap.appendChild(el('h6', 'fw-semibold small text-uppercase text-muted mb-2', 'Adjustments on this log'));
|
||||
var adjTable = el('table', 'table table-sm align-middle mb-0');
|
||||
var adjHead = document.createElement('thead');
|
||||
var adjHeadRow = document.createElement('tr');
|
||||
['Type', 'Worker', 'Amount', 'Payslip'].forEach(function(h, i) {
|
||||
adjHeadRow.appendChild(el('th', i === 2 ? 'text-end' : null, h));
|
||||
});
|
||||
adjHead.appendChild(adjHeadRow);
|
||||
adjTable.appendChild(adjHead);
|
||||
|
||||
var adjBody = document.createElement('tbody');
|
||||
data.adjustments.forEach(function(adj) {
|
||||
var tr = document.createElement('tr');
|
||||
tr.appendChild(el('td', null, adj.type));
|
||||
var wTd = document.createElement('td');
|
||||
wTd.appendChild(link('/workers/' + adj.worker_id + '/', adj.worker_name));
|
||||
tr.appendChild(wTd);
|
||||
tr.appendChild(el('td', 'text-end', formatRand(adj.amount)));
|
||||
var pTd = document.createElement('td');
|
||||
if (adj.payroll_record_id) {
|
||||
pTd.appendChild(link('/payroll/payslip/' + adj.payroll_record_id + '/', '#' + adj.payroll_record_id));
|
||||
} else {
|
||||
pTd.appendChild(el('span', 'text-muted', 'unpaid'));
|
||||
}
|
||||
tr.appendChild(pTd);
|
||||
adjBody.appendChild(tr);
|
||||
});
|
||||
adjTable.appendChild(adjBody);
|
||||
adjWrap.appendChild(adjTable);
|
||||
bodyEl.appendChild(adjWrap);
|
||||
}
|
||||
|
||||
// Totals footer
|
||||
var totals = el('div', 'd-flex gap-4 pt-2 border-top small');
|
||||
function totalPair(label, value) {
|
||||
var wrap = document.createElement('div');
|
||||
wrap.appendChild(el('span', 'text-muted', label + ' '));
|
||||
wrap.appendChild(el('strong', null, formatRand(value)));
|
||||
return wrap;
|
||||
}
|
||||
totals.appendChild(totalPair('Total earned:', data.total_earned));
|
||||
totals.appendChild(totalPair('Paid:', data.total_paid));
|
||||
totals.appendChild(totalPair('Outstanding:', data.total_outstanding));
|
||||
bodyEl.appendChild(totals);
|
||||
|
||||
// Footer "Open full page" link target
|
||||
fullLinkEl.setAttribute('href', data.full_page_url);
|
||||
}
|
||||
|
||||
function renderError() {
|
||||
while (bodyEl.firstChild) bodyEl.removeChild(bodyEl.firstChild);
|
||||
bodyEl.appendChild(el('div', 'alert alert-danger', 'Failed to load payroll info for this log.'));
|
||||
}
|
||||
|
||||
// --- Open the modal and fetch data ---
|
||||
function openForLog(logId) {
|
||||
showSpinner();
|
||||
fullLinkEl.setAttribute('href', '/history/' + logId + '/');
|
||||
bsModal.show();
|
||||
|
||||
fetch('/history/' + logId + '/payroll/ajax/')
|
||||
.then(function(resp) {
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
return resp.json();
|
||||
})
|
||||
.then(render)
|
||||
.catch(renderError);
|
||||
}
|
||||
|
||||
// --- Delegated click listener: any [data-log-id] triggers the modal ---
|
||||
document.addEventListener('click', function(ev) {
|
||||
var target = ev.target.closest('[data-log-id]');
|
||||
if (!target) return;
|
||||
// Let real links/buttons inside the row do their own thing.
|
||||
if (ev.target.closest('a, button')) return;
|
||||
ev.preventDefault();
|
||||
openForLog(target.getAttribute('data-log-id'));
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% block extra_js %}{% endblock %}
|
||||
|
||||
{# === WORK LOG PAYROLL MODAL (admin-only) === #}
|
||||
{# Hidden by default. Any element with data-log-id anywhere in the app #}
|
||||
{# triggers this modal. Fetches JSON and builds the DOM safely. #}
|
||||
{% if user.is_authenticated and user.is_staff or user.is_superuser %}
|
||||
<div class="modal fade" id="workLogPayrollModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="fas fa-calendar-day me-2"></i>Work Log Payroll</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="workLogPayrollBody">
|
||||
<div class="text-center py-4 text-muted">
|
||||
<div class="spinner-border" role="status"></div>
|
||||
<p class="mt-2 small">Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<a href="#" id="workLogPayrollFullLink" class="btn btn-sm btn-accent">
|
||||
<i class="fas fa-external-link-alt me-1"></i>Open full page
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user