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
@ -191,3 +191,12 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
|||||||
LOGIN_URL = 'login'
|
LOGIN_URL = 'login'
|
||||||
LOGIN_REDIRECT_URL = 'home'
|
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 --- #}
|
{# --- Submit Button --- #}
|
||||||
<div class="d-grid mt-5">
|
<div class="d-grid mt-5">
|
||||||
<button type="submit" class="btn btn-lg btn-accent shadow-sm" style="border-radius: 8px;">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -179,20 +179,48 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# === Day Detail Panel === #}
|
{# === Day Detail Panel === #}
|
||||||
{# Hidden by default. When you click a day cell with logs, this panel
|
{# Hidden by default. Click day cells to select them (multi-select supported).
|
||||||
appears showing full details for all entries on that day. #}
|
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 shadow-sm border-0 d-none" id="dayDetailPanel">
|
||||||
<div class="card-header py-2 bg-white d-flex justify-content-between align-items-center">
|
<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">
|
<h6 class="mb-0 fw-bold" id="dayDetailTitle">
|
||||||
<i class="fas fa-calendar-day me-2"></i>Details
|
<i class="fas fa-calendar-day me-2"></i>Details
|
||||||
</h6>
|
</h6>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="closeDayDetail">
|
<div class="d-flex gap-2 align-items-center">
|
||||||
<i class="fas fa-times"></i>
|
<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>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="card-body p-0" id="dayDetailBody">
|
||||||
{# Content built by JavaScript #}
|
{# Content built by JavaScript #}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{# Pass calendar detail data to JavaScript safely using json_script #}
|
{# Pass calendar detail data to JavaScript safely using json_script #}
|
||||||
@ -202,55 +230,122 @@
|
|||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'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 calDetail = JSON.parse(document.getElementById('calDetailJson').textContent);
|
||||||
var detailPanel = document.getElementById('dayDetailPanel');
|
var detailPanel = document.getElementById('dayDetailPanel');
|
||||||
var detailTitle = document.getElementById('dayDetailTitle');
|
var detailTitle = document.getElementById('dayDetailTitle');
|
||||||
var detailBody = document.getElementById('dayDetailBody');
|
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 isAdmin = {{ is_admin|yesno:"true,false" }};
|
||||||
|
var detailFooter = document.getElementById('dayDetailFooter');
|
||||||
|
|
||||||
// === Click handler for day cells with logs ===
|
// Track which dates are currently selected (array of date strings)
|
||||||
document.querySelectorAll('.cal-day--has-logs').forEach(function(cell) {
|
var selectedDates = [];
|
||||||
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
|
// Short month names for formatting dates
|
||||||
document.querySelectorAll('.cal-day--selected').forEach(function(c) {
|
|
||||||
c.classList.remove('cal-day--selected');
|
|
||||||
});
|
|
||||||
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 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
|
// === 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 = '';
|
detailTitle.textContent = '';
|
||||||
var icon = document.createElement('i');
|
var icon = document.createElement('i');
|
||||||
icon.className = 'fas fa-calendar-day me-2';
|
icon.className = 'fas fa-calendar-day me-2';
|
||||||
detailTitle.appendChild(icon);
|
detailTitle.appendChild(icon);
|
||||||
detailTitle.appendChild(document.createTextNode(displayDate + ' — ' + entries.length + ' log(s)'));
|
|
||||||
|
|
||||||
// Clear previous content
|
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);
|
while (detailBody.firstChild) detailBody.removeChild(detailBody.firstChild);
|
||||||
|
|
||||||
// Build detail table
|
// === Build detail table ===
|
||||||
var table = document.createElement('table');
|
var table = document.createElement('table');
|
||||||
table.className = 'table table-sm table-hover mb-0';
|
table.className = 'table table-sm table-hover mb-0';
|
||||||
|
|
||||||
var thead = document.createElement('thead');
|
var thead = document.createElement('thead');
|
||||||
thead.className = 'table-light';
|
thead.className = 'table-light';
|
||||||
var headRow = document.createElement('tr');
|
var headRow = document.createElement('tr');
|
||||||
var headers = ['Project', 'Workers', 'Supervisor', 'OT', 'Status'];
|
// 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');
|
if (isAdmin) headers.push('Amount');
|
||||||
headers.forEach(function(h) {
|
headers.forEach(function(h) {
|
||||||
var th = document.createElement('th');
|
var th = document.createElement('th');
|
||||||
th.className = h === 'Project' ? 'ps-3' : '';
|
th.className = (h === 'Project' || h === 'Date') ? 'ps-3' : '';
|
||||||
th.textContent = h;
|
th.textContent = h;
|
||||||
headRow.appendChild(th);
|
headRow.appendChild(th);
|
||||||
});
|
});
|
||||||
@ -258,12 +353,21 @@
|
|||||||
table.appendChild(thead);
|
table.appendChild(thead);
|
||||||
|
|
||||||
var tbody = document.createElement('tbody');
|
var tbody = document.createElement('tbody');
|
||||||
entries.forEach(function(entry) {
|
allEntries.forEach(function(item) {
|
||||||
|
var entry = item.entry;
|
||||||
var tr = document.createElement('tr');
|
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
|
// Project
|
||||||
var tdProj = document.createElement('td');
|
var tdProj = document.createElement('td');
|
||||||
tdProj.className = 'ps-3';
|
tdProj.className = selectedDates.length === 1 ? 'ps-3' : '';
|
||||||
var strong = document.createElement('strong');
|
var strong = document.createElement('strong');
|
||||||
strong.textContent = entry.project;
|
strong.textContent = entry.project;
|
||||||
tdProj.appendChild(strong);
|
tdProj.appendChild(strong);
|
||||||
@ -308,7 +412,9 @@
|
|||||||
// Amount (admin only)
|
// Amount (admin only)
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
var tdAmt = document.createElement('td');
|
var tdAmt = document.createElement('td');
|
||||||
tdAmt.textContent = entry.amount !== undefined ? 'R ' + entry.amount.toFixed(2) : '-';
|
tdAmt.textContent = entry.amount !== undefined
|
||||||
|
? 'R ' + entry.amount.toFixed(2)
|
||||||
|
: '-';
|
||||||
tr.appendChild(tdAmt);
|
tr.appendChild(tdAmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -317,18 +423,61 @@
|
|||||||
table.appendChild(tbody);
|
table.appendChild(tbody);
|
||||||
detailBody.appendChild(table);
|
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
|
// Show the panel and scroll to it
|
||||||
detailPanel.classList.remove('d-none');
|
detailPanel.classList.remove('d-none');
|
||||||
detailPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
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;
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the detail panel with the updated selection
|
||||||
|
updateDetailPanel();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close detail panel
|
// === Clear all selections ===
|
||||||
closeBtn.addEventListener('click', function() {
|
clearBtn.addEventListener('click', function() {
|
||||||
detailPanel.classList.add('d-none');
|
selectedDates = [];
|
||||||
document.querySelectorAll('.cal-day--selected').forEach(function(c) {
|
document.querySelectorAll('.cal-day--selected').forEach(function(c) {
|
||||||
c.classList.remove('cal-day--selected');
|
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')
|
return redirect('home')
|
||||||
else:
|
else:
|
||||||
form = AttendanceLogForm(
|
# Don't pre-fill the start date — force the user to pick one
|
||||||
user=user,
|
# so they don't accidentally log work on the wrong day
|
||||||
initial={'date': timezone.now().date()}
|
form = AttendanceLogForm(user=user)
|
||||||
)
|
|
||||||
|
|
||||||
# Build a list of worker data for the estimated cost JavaScript
|
# Build a list of worker data for the estimated cost JavaScript
|
||||||
# (admins only — supervisors don't see the cost card)
|
# (admins only — supervisors don't see the cost card)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user