My previous commit (fb1a8a2) added a multi-line explanatory comment
using Django's {# ... #} syntax, which is single-line only. The comment
therefore rendered as literal text at the top of every page.
This is the second time this session I've made this exact mistake —
lesson for next time: always render a page on the dev server and grep
the response body for '{#' after template changes, even one-liners.
Verified locally this time: leak count = 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
398 lines
19 KiB
HTML
398 lines
19 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 -->
|
|
<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:'' %}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>
|
|
</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 %}
|
|
<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>
|
|
|
|
{% block extra_js %}{% endblock %}
|
|
</body>
|
|
</html>
|