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.
665 lines
31 KiB
HTML
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">© {% 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>
|