Fix invisible error messages + UX improvements + calendar multi-select

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 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-22 23:00:04 +02:00
parent 94c061fc19
commit f9423c0b3e
4 changed files with 279 additions and 122 deletions

View File

@ -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'
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',
}

View File

@ -172,7 +172,7 @@
{# --- Submit Button --- #}
<div class="d-grid mt-5">
<button type="submit" class="btn btn-lg btn-accent shadow-sm" style="border-radius: 8px;">
<i class="fas fa-save me-2"></i>Save Attendance Log
<i class="fas fa-save me-2"></i>Log Work
</button>
</div>
</form>

View File

@ -179,20 +179,48 @@
</div>
{# === 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. #}
<div class="card shadow-sm border-0 d-none" id="dayDetailPanel">
<div class="card-header py-2 bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-bold" id="dayDetailTitle">
<i class="fas fa-calendar-day me-2"></i>Details
</h6>
<button type="button" class="btn btn-sm btn-outline-secondary" id="closeDayDetail">
<i class="fas fa-times"></i>
</button>
<div class="card-header py-2 bg-white">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-bold" id="dayDetailTitle">
<i class="fas fa-calendar-day me-2"></i>Details
</h6>
<div class="d-flex gap-2 align-items-center">
<span class="badge bg-primary rounded-pill d-none" id="daySelectionCount"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="clearDaySelection"
title="Clear selection">
<i class="fas fa-times-circle me-1"></i> Clear
</button>
</div>
</div>
{# Hint text for multi-select #}
<small class="text-muted d-block mt-1" id="multiSelectHint">
<i class="fas fa-info-circle me-1"></i>Click more days to add them to the selection
</small>
</div>
<div class="card-body p-0" id="dayDetailBody">
{# Content built by JavaScript #}
</div>
{# === Totals Footer (admin only, shown when days are selected) === #}
{% if is_admin %}
<div class="card-footer bg-white border-top d-none" id="dayDetailFooter">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>Total:</strong>
<span class="text-muted ms-2" id="totalDays">0 days</span>
<span class="text-muted mx-1">·</span>
<span class="text-muted" id="totalLogs">0 logs</span>
<span class="text-muted mx-1">·</span>
<span class="text-muted" id="totalWorkers">0 unique workers</span>
</div>
<div>
<strong class="fs-5" style="color: var(--accent-color, #10b981);" id="totalAmount">R 0.00</strong>
</div>
</div>
</div>
{% endif %}
</div>
{# 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');
});
})();

View File

@ -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)