38686-vm/core/templates/base.html
Konrad du Plessis a6cf766394 fix(absences): pre-push polish — admin sync + bulk-delete cascade + supervisor menu
Three small fixes from the final review:
- AbsenceAdmin.save_model() now runs _sync_absence_payroll_adjustment
  so toggling is_paid via /admin/ updates the linked Bonus consistently
  with the friendly UI.
- _delete_adjustment_with_cascade clears absence.is_paid when deleting
  a Bonus linked to an Absence — closes the state-drift window after
  bulk-delete from /payroll/?status=adjustments.
- base.html — Resources dropdown 'Absences' entry now shows for
  supervisors as well as staff (was staff-only). View-layer permission
  helpers (_absence_user_queryset, _user_can_log_absences) already
  enforce the real access boundary; this just makes the menu honest.
2 regression tests.
2026-05-14 23:04:12 +02:00

665 lines
31 KiB
HTML

{% load static %}
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}FoxFitt{% endblock %}</title>
<!-- === THEME: Apply saved preference BEFORE first paint (prevents flash) === -->
<script>
(function() {
var saved = localStorage.getItem('foxfitt-theme');
if (saved === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
<!-- Bootstrap 5.3 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<!-- Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Poppins:wght@500;600;700&display=swap" rel="stylesheet">
<!-- Font Awesome 6 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- Custom CSS (cache-busted with deployment_timestamp from context processor) -->
{# deployment_timestamp comes from core.context_processors.project_context #}
<link rel="stylesheet" href="{% static 'css/custom.css' %}?v={{ deployment_timestamp|default:'1' }}">
{% block extra_css %}{% endblock %}
</head>
<body>
{% if user.is_authenticated %}
<!-- ===================================================================
APP LAYOUT — top bar (desktop + mobile) + bottom tab bar (mobile)
=================================================================== -->
<div class="app-layout">
<!-- === TOP BAR (always visible — nav links on desktop, brand-only on mobile) === -->
<header class="app-topbar d-print-none">
<div class="topbar-inner">
<!-- Brand / Logo -->
<a href="{% url 'home' %}" class="topbar-brand">
<div class="sidebar-brand__icon">
<i class="fas fa-bolt"></i>
</div>
<span class="topbar-brand__text">
<span>Fox</span>Fitt
</span>
</a>
<!-- Desktop Navigation Links (hidden on mobile — bottom tab bar handles it) -->
<!-- Order: Dashboard · Log Work · Payroll · History · Workers · Receipts · Admin -->
<nav class="topbar-nav">
<a href="{% url 'home' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
<i class="fas fa-th-large"></i><span>Dashboard</span>
</a>
<a href="{% url 'attendance_log' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}">
<i class="fas fa-clipboard-list"></i><span>Log Work</span>
</a>
{% if user.is_staff %}
<a href="{% url 'payroll_dashboard' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}">
<i class="fas fa-wallet"></i><span>Payroll</span>
</a>
{% endif %}
<a href="{% url 'work_history' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
<i class="fas fa-clock"></i><span>History</span>
</a>
{% if user.is_staff or user.supervised_teams.exists %}
{# Resources dropdown — Workers/Teams/Projects are staff-only; Absences also shows for supervisors. #}
<div class="dropdown">
<a href="#" class="topbar-nav__link dropdown-toggle {% if 'worker' in request.resolver_match.url_name|default:'' or 'team' in request.resolver_match.url_name|default:'' or 'project' in request.resolver_match.url_name|default:'' or 'absence' in request.resolver_match.url_name|default:'' %}active{% endif %}"
data-bs-toggle="dropdown" aria-expanded="false" role="button">
<i class="fas fa-hard-hat"></i><span>Resources</span>
</a>
<ul class="dropdown-menu">
{% if user.is_staff %}
<li><a class="dropdown-item" href="{% url 'worker_list' %}"><i class="fas fa-hard-hat me-2" style="color: var(--accent);"></i>Workers</a></li>
<li><a class="dropdown-item" href="{% url 'team_list' %}"><i class="fas fa-users me-2" style="color: var(--accent);"></i>Teams</a></li>
<li><a class="dropdown-item" href="{% url 'project_list' %}"><i class="fas fa-project-diagram me-2" style="color: var(--accent);"></i>Projects</a></li>
{% endif %}
{# Absences is broader — supervisors see it too (view-layer enforces real access). #}
<li><a class="dropdown-item" href="{% url 'absence_list' %}"><i class="fas fa-user-clock me-2" style="color: var(--accent);"></i>Absences</a></li>
</ul>
</div>
{% endif %}
<a href="{% url 'create_receipt' %}" class="topbar-nav__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
<i class="fas fa-receipt"></i><span>Receipts</span>
</a>
{% if user.is_staff %}
<a href="{% url 'admin:index' %}" class="topbar-nav__link">
<i class="fas fa-cog"></i><span>Admin</span>
</a>
{% endif %}
</nav>
<!-- Right side: theme toggle + user + logout + hamburger (mobile) -->
<div class="topbar-actions">
<button type="button" class="theme-toggle" id="themeToggle" title="Toggle dark/light mode">
<i class="fas fa-moon" id="themeIcon"></i>
</button>
<div class="topbar-user d-none d-md-flex">
<div class="topbar-user__avatar">
{{ user.username|make_list|first|upper }}
</div>
<span class="topbar-user__name">{{ user.first_name|default:user.username }}</span>
</div>
<form method="post" action="{% url 'logout' %}" class="d-none d-lg-block">
{% csrf_token %}
<button type="submit" class="theme-toggle" title="Sign out" style="color: var(--color-danger);">
<i class="fas fa-sign-out-alt"></i>
</button>
</form>
<!-- Hamburger button (mobile only) -->
<button type="button" class="hamburger-btn d-lg-none" id="hamburgerBtn" aria-label="Open menu">
<i class="fas fa-bars" id="hamburgerIcon"></i>
</button>
</div>
</div>
</header>
<!-- === MOBILE MENU (slides down from topbar when hamburger is tapped) === -->
<div class="mobile-menu d-lg-none d-print-none" id="mobileMenu">
<!-- Mobile nav — same order as desktop:
Dashboard · Log Work · Payroll · History · Workers · Receipts · Admin -->
<nav class="mobile-menu__nav">
<a href="{% url 'home' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
<i class="fas fa-th-large"></i><span>Dashboard</span>
</a>
<a href="{% url 'attendance_log' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}">
<i class="fas fa-clipboard-list"></i><span>Log Work</span>
</a>
{% if user.is_staff %}
<a href="{% url 'payroll_dashboard' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}">
<i class="fas fa-wallet"></i><span>Payroll</span>
</a>
{% endif %}
<a href="{% url 'work_history' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
<i class="fas fa-clock"></i><span>History</span>
</a>
{% if user.is_staff %}
<!-- Resources: flat list on mobile (dropdowns are clumsy in slide-down drawers) -->
<a href="{% url 'worker_list' %}" class="mobile-menu__link">
<i class="fas fa-hard-hat"></i><span>Workers</span>
</a>
<a href="{% url 'team_list' %}" class="mobile-menu__link">
<i class="fas fa-users"></i><span>Teams</span>
</a>
<a href="{% url 'project_list' %}" class="mobile-menu__link">
<i class="fas fa-project-diagram"></i><span>Projects</span>
</a>
{% endif %}
{% if user.is_staff or user.supervised_teams.exists %}
{# Absences shows for supervisors too — Workers/Teams/Projects stay staff-only. #}
<a href="{% url 'absence_list' %}" class="mobile-menu__link">
<i class="fas fa-user-clock"></i><span>Absences</span>
</a>
{% endif %}
<a href="{% url 'create_receipt' %}" class="mobile-menu__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
<i class="fas fa-receipt"></i><span>Receipts</span>
</a>
{% if user.is_staff %}
<a href="{% url 'admin:index' %}" class="mobile-menu__link">
<i class="fas fa-cog"></i><span>Admin</span>
</a>
{% endif %}
</nav>
<!-- User info + logout at bottom of mobile menu -->
<div class="mobile-menu__footer">
<div class="d-flex align-items-center gap-2">
<div class="topbar-user__avatar">
{{ user.username|make_list|first|upper }}
</div>
<div>
<div style="color: var(--text-on-nav); font-size: 0.85rem; font-weight: 500;">{{ user.first_name|default:user.username }}</div>
<div style="color: var(--text-on-nav-muted); font-size: 0.7rem;">{% if user.is_staff %}Administrator{% else %}Supervisor{% endif %}</div>
</div>
</div>
<form method="post" action="{% url 'logout' %}">
{% csrf_token %}
<button type="submit" class="theme-toggle" title="Sign out" style="color: var(--color-danger);">
<i class="fas fa-sign-out-alt"></i>
</button>
</form>
</div>
</div>
<!-- === MAIN CONTENT AREA === -->
<div class="app-main">
<!-- Decorative gradient glows (separate from app-main to avoid stacking context trapping modals) -->
<div class="app-glow d-print-none"></div>
<!-- === Flash messages (Django messages framework) === -->
{% if messages %}
<div class="container-fluid px-3 px-lg-4 mt-3">
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{% if message.tags == 'success' %}<i class="fas fa-check-circle me-2"></i>
{% elif message.tags == 'error' or message.tags == 'danger' %}<i class="fas fa-exclamation-circle me-2"></i>
{% elif message.tags == 'warning' %}<i class="fas fa-exclamation-triangle me-2"></i>
{% elif message.tags == 'info' %}<i class="fas fa-info-circle me-2"></i>
{% endif %}
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
</div>
{% endif %}
<!-- === Page Content === -->
<div class="app-content">
{% endif %}
{% block content %}
{% endblock %}
{% if user.is_authenticated %}
</div>
<!-- === Footer (inside main area) === -->
<footer class="app-footer d-print-none">
<div class="container">
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-center">
<p class="mb-1 mb-sm-0">
<span style="color: var(--accent); font-weight: 600;">Fox</span><span style="font-weight: 600;">Fitt</span>
<span class="ms-1">Construction</span>
</p>
<p class="mb-0">&copy; {% now "Y" %} All rights reserved.</p>
</div>
</div>
</footer>
<!-- === BOTTOM TAB BAR (mobile only, hidden on desktop via CSS) === -->
<nav class="app-bottom-nav d-print-none">
<div class="bottom-nav-inner">
<a href="{% url 'home' %}" class="bottom-nav__link {% if request.resolver_match.url_name == 'home' %}active{% endif %}">
<i class="fas fa-th-large"></i>
<span>Dashboard</span>
</a>
<a href="{% url 'attendance_log' %}" class="bottom-nav__link {% if request.resolver_match.url_name == 'attendance_log' %}active{% endif %}">
<i class="fas fa-clipboard-list"></i>
<span>Log Work</span>
</a>
{% if user.is_staff %}
<a href="{% url 'payroll_dashboard' %}" class="bottom-nav__link {% if request.resolver_match.url_name == 'payroll_dashboard' %}active{% endif %}">
<i class="fas fa-wallet"></i>
<span>Payroll</span>
</a>
{% endif %}
<a href="{% url 'work_history' %}" class="bottom-nav__link {% if request.resolver_match.url_name == 'work_history' %}active{% endif %}">
<i class="fas fa-clock"></i>
<span>History</span>
</a>
<a href="{% url 'create_receipt' %}" class="bottom-nav__link {% if request.resolver_match.url_name == 'create_receipt' %}active{% endif %}">
<i class="fas fa-receipt"></i>
<span>Receipts</span>
</a>
</div>
</nav>
</div>
</div>
{% endif %}
<!-- Bootstrap 5.3 JS Bundle (includes Popper) -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<!-- === GLOBAL TOOLTIP INIT ===
Any element on any page with `data-bs-toggle="tooltip"` and a `title`
attribute will automatically become a Bootstrap tooltip. Expose
`window.initTooltipsIn(element)` so dynamic content (e.g. newly-added
formset rows) can re-init without a full page reload.
Cost: one querySelectorAll on page load + ~1KB state per tooltip.
Negligible for this app's scale. -->
<script>
(function() {
function initTooltipsIn(root) {
root = root || document;
var triggers = root.querySelectorAll('[data-bs-toggle="tooltip"]');
triggers.forEach(function(el) {
// Avoid double-init if called on the same element twice
if (!bootstrap.Tooltip.getInstance(el)) {
new bootstrap.Tooltip(el, { container: 'body' });
}
});
}
window.initTooltipsIn = initTooltipsIn;
document.addEventListener('DOMContentLoaded', function() { initTooltipsIn(document); });
})();
</script>
<!-- === THEME TOGGLE — switches dark/light and persists to localStorage === -->
<script>
(function() {
var btn = document.getElementById('themeToggle');
var icon = document.getElementById('themeIcon');
function updateIcon() {
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
if (icon) icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
if (btn) btn.title = isDark ? 'Switch to light mode' : 'Switch to dark mode';
}
updateIcon();
if (btn) {
btn.addEventListener('click', function() {
var current = document.documentElement.getAttribute('data-theme');
var next = (current === 'light') ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('foxfitt-theme', next);
updateIcon();
});
}
})();
</script>
<!-- === HAMBURGER MENU — toggles mobile navigation panel open/closed === -->
<!-- Menu is position: fixed below the topbar so it stays visible when scrolled down -->
<script>
(function() {
var hamburger = document.getElementById('hamburgerBtn');
var menu = document.getElementById('mobileMenu');
var icon = document.getElementById('hamburgerIcon');
// Create a backdrop overlay — closes the menu when tapping outside it
var backdrop = document.createElement('div');
backdrop.className = 'mobile-menu-backdrop';
document.body.appendChild(backdrop);
function closeMenu() {
menu.classList.remove('open');
backdrop.classList.remove('open');
if (icon) icon.className = 'fas fa-bars';
}
function openMenu() {
menu.classList.add('open');
backdrop.classList.add('open');
if (icon) icon.className = 'fas fa-times';
}
if (hamburger && menu) {
hamburger.addEventListener('click', function() {
if (menu.classList.contains('open')) {
closeMenu();
} else {
openMenu();
}
});
// Close menu when backdrop is tapped
backdrop.addEventListener('click', closeMenu);
// Close menu when a nav link is tapped (instant navigation feel)
var links = menu.querySelectorAll('.mobile-menu__link');
for (var i = 0; i < links.length; i++) {
links[i].addEventListener('click', closeMenu);
}
}
})();
</script>
<!-- === NUMBER FORMATTING — adds space thousands separators to R amounts === -->
<!-- Finds text like "R 8000.00" and reformats to "R 8 000.00" (SA convention) -->
<!-- Uses non-breaking spaces (\u00A0) so "R 6 666.00" never wraps mid-number -->
<script>
(function() {
var NBSP = '\u00A0'; // non-breaking space — prevents line break inside number
function formatMoney(text) {
// Match "R" optionally followed by space, then a number (with optional decimals and minus)
return text.replace(/R\s*(-?\d[\d]*(?:\.\d+)?)/g, function(match, num) {
var parts = num.split('.');
var whole = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, NBSP);
return 'R' + NBSP + whole + (parts[1] ? '.' + parts[1] : '');
});
}
// Walk all text nodes inside the main content area and format monetary values
function formatAllMoney() {
var root = document.querySelector('.app-content') || document.body;
var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
var node;
while (node = walker.nextNode()) {
// Only process nodes that have "R" followed by 4+ digit numbers (worth formatting)
if (/R\s*-?\d{4,}/.test(node.nodeValue)) {
node.nodeValue = formatMoney(node.nodeValue);
}
}
}
// Run after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', formatAllMoney);
} else {
formatAllMoney();
}
})();
</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>
document.addEventListener('DOMContentLoaded', 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');
// Prefer the short display label from the server; fall back to the
// raw DB value if a stale JSON response doesn't include type_label
// (graceful degradation — never render a blank cell).
tr.appendChild(el('td', null, adj.type_label || 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-labelledby="workLogPayrollModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="workLogPayrollModalLabel"><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>