From f9423c0b3ed35819a94d422afbd066bb008f9e55 Mon Sep 17 00:00:00 2001 From: Konrad du Plessis Date: Sun, 22 Feb 2026 23:00:04 +0200 Subject: [PATCH] Fix invisible error messages + UX improvements + calendar multi-select MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Add MESSAGE_TAGS to settings.py — Django's messages.error() uses tag "error" but Bootstrap needs "danger". Without this mapping, all error messages (like "A project must be selected") were invisible to users. 2. Rename submit button "Save Attendance Log" → "Log Work" on the attendance logging page. 3. Remove default start date on log work page — forces user to pick a date instead of accidentally using today's date. 4. Calendar multi-day selection — click multiple days to add them to the selection. Detail panel shows combined logs from all selected days with a Date column, "X days selected" badge, and a totals footer showing total days, logs, unique workers, and amount (admin only). Click a selected day again to deselect it. Clear button resets all. Co-Authored-By: Claude Opus 4.6 --- config/settings.py | 11 +- core/templates/core/attendance_log.html | 2 +- core/templates/core/work_history.html | 381 ++++++++++++++++-------- core/views.py | 7 +- 4 files changed, 279 insertions(+), 122 deletions(-) diff --git a/config/settings.py b/config/settings.py index 9d8bcde..d906642 100644 --- a/config/settings.py +++ b/config/settings.py @@ -190,4 +190,13 @@ if EMAIL_USE_SSL: DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' LOGIN_URL = 'login' LOGIN_REDIRECT_URL = 'home' -LOGOUT_REDIRECT_URL = 'login' \ No newline at end of file +LOGOUT_REDIRECT_URL = 'login' + +# === MESSAGE TAGS === +# Django's messages.error() tags messages as "error", but Bootstrap uses "danger" +# for red alerts. Without this mapping, error messages would render as "alert-error" +# which doesn't exist in Bootstrap — making them invisible to the user! +from django.contrib.messages import constants as message_constants +MESSAGE_TAGS = { + message_constants.ERROR: 'danger', +} \ No newline at end of file diff --git a/core/templates/core/attendance_log.html b/core/templates/core/attendance_log.html index 6903ade..10223c0 100644 --- a/core/templates/core/attendance_log.html +++ b/core/templates/core/attendance_log.html @@ -172,7 +172,7 @@ {# --- Submit Button --- #}
diff --git a/core/templates/core/work_history.html b/core/templates/core/work_history.html index 8e4b194..765b19c 100644 --- a/core/templates/core/work_history.html +++ b/core/templates/core/work_history.html @@ -179,20 +179,48 @@ {# === Day Detail Panel === #} - {# Hidden by default. When you click a day cell with logs, this panel - appears showing full details for all entries on that day. #} + {# Hidden by default. Click day cells to select them (multi-select supported). + The panel shows combined details for all selected days with totals. #}
-
-
- Details -
- +
+
+
+ Details +
+
+ + +
+
+ {# Hint text for multi-select #} + + Click more days to add them to the selection +
{# Content built by JavaScript #}
+ {# === Totals Footer (admin only, shown when days are selected) === #} + {% if is_admin %} + + {% endif %}
{# Pass calendar detail data to JavaScript safely using json_script #} @@ -202,133 +230,254 @@ (function() { 'use strict'; - // Parse calendar detail data (keyed by date string) + // === CALENDAR MULTI-DAY SELECTION === + // Click a day to add it to the selection. Click again to deselect. + // The detail panel shows combined data from ALL selected days. + // Admin users see a total amount across all selected days. + + // Parse calendar detail data (keyed by date string, e.g. "2026-02-22") var calDetail = JSON.parse(document.getElementById('calDetailJson').textContent); var detailPanel = document.getElementById('dayDetailPanel'); var detailTitle = document.getElementById('dayDetailTitle'); var detailBody = document.getElementById('dayDetailBody'); - var closeBtn = document.getElementById('closeDayDetail'); + var clearBtn = document.getElementById('clearDaySelection'); + var selCountBadge = document.getElementById('daySelectionCount'); + var multiSelectHint = document.getElementById('multiSelectHint'); var isAdmin = {{ is_admin|yesno:"true,false" }}; + var detailFooter = document.getElementById('dayDetailFooter'); + + // Track which dates are currently selected (array of date strings) + var selectedDates = []; + + // Short month names for formatting dates + var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + + // === Format a date string (YYYY-MM-DD) for display (e.g. "22 Feb") === + function formatDateShort(dateStr) { + var parts = dateStr.split('-'); + var day = parseInt(parts[2], 10); + var monthIdx = parseInt(parts[1], 10) - 1; + return day + ' ' + months[monthIdx]; + } + + // === Format a date string for longer display (e.g. "22 Feb 2026") === + function formatDateLong(dateStr) { + var parts = dateStr.split('-'); + var day = parseInt(parts[2], 10); + var monthIdx = parseInt(parts[1], 10) - 1; + return day + ' ' + months[monthIdx] + ' ' + parts[0]; + } + + // === Update the detail panel with data from all selected dates === + function updateDetailPanel() { + if (selectedDates.length === 0) { + // Nothing selected — hide the panel + detailPanel.classList.add('d-none'); + return; + } + + // Sort selected dates chronologically + selectedDates.sort(); + + // Collect all entries from all selected dates + var allEntries = []; + var totalAmount = 0; + var uniqueWorkers = {}; + + selectedDates.forEach(function(dateStr) { + var entries = calDetail[dateStr] || []; + entries.forEach(function(entry) { + // Tag each entry with its date for display + allEntries.push({ date: dateStr, entry: entry }); + // Track unique workers + entry.workers.forEach(function(w) { + uniqueWorkers[w] = true; + }); + // Sum amounts (admin only) + if (isAdmin && entry.amount !== undefined) { + totalAmount += entry.amount; + } + }); + }); + + // === Update panel title === + detailTitle.textContent = ''; + var icon = document.createElement('i'); + icon.className = 'fas fa-calendar-day me-2'; + detailTitle.appendChild(icon); + + if (selectedDates.length === 1) { + // Single day: show full date + detailTitle.appendChild(document.createTextNode( + formatDateLong(selectedDates[0]) + ' — ' + allEntries.length + ' log(s)' + )); + } else { + // Multiple days: show date range or count + detailTitle.appendChild(document.createTextNode( + selectedDates.length + ' days selected — ' + allEntries.length + ' log(s)' + )); + } + + // Update selection count badge + if (selectedDates.length > 1) { + selCountBadge.textContent = selectedDates.length + ' days'; + selCountBadge.classList.remove('d-none'); + multiSelectHint.classList.add('d-none'); + } else { + selCountBadge.classList.add('d-none'); + multiSelectHint.classList.remove('d-none'); + } + + // === Clear previous content === + while (detailBody.firstChild) detailBody.removeChild(detailBody.firstChild); + + // === Build detail table === + var table = document.createElement('table'); + table.className = 'table table-sm table-hover mb-0'; + + var thead = document.createElement('thead'); + thead.className = 'table-light'; + var headRow = document.createElement('tr'); + // Show Date column when multiple days are selected + var headers = selectedDates.length > 1 + ? ['Date', 'Project', 'Workers', 'Supervisor', 'OT', 'Status'] + : ['Project', 'Workers', 'Supervisor', 'OT', 'Status']; + if (isAdmin) headers.push('Amount'); + headers.forEach(function(h) { + var th = document.createElement('th'); + th.className = (h === 'Project' || h === 'Date') ? 'ps-3' : ''; + th.textContent = h; + headRow.appendChild(th); + }); + thead.appendChild(headRow); + table.appendChild(thead); + + var tbody = document.createElement('tbody'); + allEntries.forEach(function(item) { + var entry = item.entry; + var tr = document.createElement('tr'); + + // Date column (only for multi-day selection) + if (selectedDates.length > 1) { + var tdDate = document.createElement('td'); + tdDate.className = 'ps-3'; + tdDate.textContent = formatDateShort(item.date); + tr.appendChild(tdDate); + } + + // Project + var tdProj = document.createElement('td'); + tdProj.className = selectedDates.length === 1 ? 'ps-3' : ''; + var strong = document.createElement('strong'); + strong.textContent = entry.project; + tdProj.appendChild(strong); + tr.appendChild(tdProj); + + // Workers + var tdWork = document.createElement('td'); + tdWork.textContent = entry.workers.join(', '); + tr.appendChild(tdWork); + + // Supervisor + var tdSup = document.createElement('td'); + tdSup.textContent = entry.supervisor; + tr.appendChild(tdSup); + + // Overtime + var tdOt = document.createElement('td'); + if (entry.overtime) { + var otBadge = document.createElement('span'); + otBadge.className = 'badge bg-warning text-dark'; + otBadge.textContent = entry.overtime; + tdOt.appendChild(otBadge); + } else { + tdOt.textContent = '-'; + tdOt.className = 'text-muted'; + } + tr.appendChild(tdOt); + + // Status + var tdStatus = document.createElement('td'); + var statusBadge = document.createElement('span'); + if (entry.is_paid) { + statusBadge.className = 'badge bg-success'; + statusBadge.textContent = 'Paid'; + } else { + statusBadge.className = 'badge bg-danger bg-opacity-75'; + statusBadge.textContent = 'Unpaid'; + } + tdStatus.appendChild(statusBadge); + tr.appendChild(tdStatus); + + // Amount (admin only) + if (isAdmin) { + var tdAmt = document.createElement('td'); + tdAmt.textContent = entry.amount !== undefined + ? 'R ' + entry.amount.toFixed(2) + : '-'; + tr.appendChild(tdAmt); + } + + tbody.appendChild(tr); + }); + table.appendChild(tbody); + detailBody.appendChild(table); + + // === Update totals footer (admin only) === + if (isAdmin && detailFooter) { + var totalDaysEl = document.getElementById('totalDays'); + var totalLogsEl = document.getElementById('totalLogs'); + var totalWorkersEl = document.getElementById('totalWorkers'); + var totalAmountEl = document.getElementById('totalAmount'); + + var uniqueCount = Object.keys(uniqueWorkers).length; + + totalDaysEl.textContent = selectedDates.length + ' day' + (selectedDates.length !== 1 ? 's' : ''); + totalLogsEl.textContent = allEntries.length + ' log' + (allEntries.length !== 1 ? 's' : ''); + totalWorkersEl.textContent = uniqueCount + ' unique worker' + (uniqueCount !== 1 ? 's' : ''); + totalAmountEl.textContent = 'R ' + totalAmount.toFixed(2); + + detailFooter.classList.remove('d-none'); + } + + // Show the panel and scroll to it + detailPanel.classList.remove('d-none'); + detailPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } // === Click handler for day cells with logs === + // Toggle selection: click to add, click again to remove document.querySelectorAll('.cal-day--has-logs').forEach(function(cell) { cell.addEventListener('click', function() { var dateStr = this.dataset.date; var entries = calDetail[dateStr] || []; if (entries.length === 0) return; - // Remove "selected" class from all cells, add to clicked one - document.querySelectorAll('.cal-day--selected').forEach(function(c) { - c.classList.remove('cal-day--selected'); - }); - this.classList.add('cal-day--selected'); + // Toggle this date in the selection + var idx = selectedDates.indexOf(dateStr); + if (idx !== -1) { + // Already selected — remove it + selectedDates.splice(idx, 1); + this.classList.remove('cal-day--selected'); + } else { + // Not selected — add it + selectedDates.push(dateStr); + this.classList.add('cal-day--selected'); + } - // Format date for display (e.g. "22 Feb 2026") - var parts = dateStr.split('-'); - var dateObj = new Date(parts[0], parts[1] - 1, parts[2]); - var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; - var displayDate = dateObj.getDate() + ' ' + months[dateObj.getMonth()] + ' ' + dateObj.getFullYear(); - - // Update panel title - detailTitle.textContent = ''; - var icon = document.createElement('i'); - icon.className = 'fas fa-calendar-day me-2'; - detailTitle.appendChild(icon); - detailTitle.appendChild(document.createTextNode(displayDate + ' — ' + entries.length + ' log(s)')); - - // Clear previous content - while (detailBody.firstChild) detailBody.removeChild(detailBody.firstChild); - - // Build detail table - var table = document.createElement('table'); - table.className = 'table table-sm table-hover mb-0'; - - var thead = document.createElement('thead'); - thead.className = 'table-light'; - var headRow = document.createElement('tr'); - var headers = ['Project', 'Workers', 'Supervisor', 'OT', 'Status']; - if (isAdmin) headers.push('Amount'); - headers.forEach(function(h) { - var th = document.createElement('th'); - th.className = h === 'Project' ? 'ps-3' : ''; - th.textContent = h; - headRow.appendChild(th); - }); - thead.appendChild(headRow); - table.appendChild(thead); - - var tbody = document.createElement('tbody'); - entries.forEach(function(entry) { - var tr = document.createElement('tr'); - - // Project - var tdProj = document.createElement('td'); - tdProj.className = 'ps-3'; - var strong = document.createElement('strong'); - strong.textContent = entry.project; - tdProj.appendChild(strong); - tr.appendChild(tdProj); - - // Workers - var tdWork = document.createElement('td'); - tdWork.textContent = entry.workers.join(', '); - tr.appendChild(tdWork); - - // Supervisor - var tdSup = document.createElement('td'); - tdSup.textContent = entry.supervisor; - tr.appendChild(tdSup); - - // Overtime - var tdOt = document.createElement('td'); - if (entry.overtime) { - var otBadge = document.createElement('span'); - otBadge.className = 'badge bg-warning text-dark'; - otBadge.textContent = entry.overtime; - tdOt.appendChild(otBadge); - } else { - tdOt.textContent = '-'; - tdOt.className = 'text-muted'; - } - tr.appendChild(tdOt); - - // Status - var tdStatus = document.createElement('td'); - var statusBadge = document.createElement('span'); - if (entry.is_paid) { - statusBadge.className = 'badge bg-success'; - statusBadge.textContent = 'Paid'; - } else { - statusBadge.className = 'badge bg-danger bg-opacity-75'; - statusBadge.textContent = 'Unpaid'; - } - tdStatus.appendChild(statusBadge); - tr.appendChild(tdStatus); - - // Amount (admin only) - if (isAdmin) { - var tdAmt = document.createElement('td'); - tdAmt.textContent = entry.amount !== undefined ? 'R ' + entry.amount.toFixed(2) : '-'; - tr.appendChild(tdAmt); - } - - tbody.appendChild(tr); - }); - table.appendChild(tbody); - detailBody.appendChild(table); - - // Show the panel and scroll to it - detailPanel.classList.remove('d-none'); - detailPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + // Refresh the detail panel with the updated selection + updateDetailPanel(); }); }); - // Close detail panel - closeBtn.addEventListener('click', function() { - detailPanel.classList.add('d-none'); + // === Clear all selections === + clearBtn.addEventListener('click', function() { + selectedDates = []; document.querySelectorAll('.cal-day--selected').forEach(function(c) { c.classList.remove('cal-day--selected'); }); + detailPanel.classList.add('d-none'); + if (detailFooter) detailFooter.classList.add('d-none'); }); })(); diff --git a/core/views.py b/core/views.py index a6aa595..f96a455 100644 --- a/core/views.py +++ b/core/views.py @@ -324,10 +324,9 @@ def attendance_log(request): return redirect('home') else: - form = AttendanceLogForm( - user=user, - initial={'date': timezone.now().date()} - ) + # Don't pre-fill the start date — force the user to pick one + # so they don't accidentally log work on the wrong day + form = AttendanceLogForm(user=user) # Build a list of worker data for the estimated cost JavaScript # (admins only — supervisors don't see the cost card)