CLAUDE.md gotcha #5: multi-line {# ... #} blocks render as literal text in Django templates. Converted to {% comment %} blocks in edit.html and list.html (also scanned log.html / log_confirm.html for safety). Adds an 'Absences' entry to the Resources dropdown in base.html so the feature is discoverable from the topbar.
659 lines
30 KiB
HTML
659 lines
30 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 %}
|
|
<!-- Resources dropdown: Workers, Teams, Projects, Absences -->
|
|
<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">
|
|
<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>
|
|
<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>
|
|
<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>
|