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:
parent
94c061fc19
commit
f9423c0b3e
@ -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',
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user