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>
|
</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 %}
|
{% 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user