Fix Add Adjustment form silently failing — add validation + required fields

Root cause: V5 was missing required attributes that V2 had on the Add
Adjustment form. When a user submitted without selecting a project (for
types that require one), the server rejected it with messages.error()
but the error was invisible before the MESSAGE_TAGS fix. Combined with
no client-side validation for workers, the form would silently create
0 adjustments or redirect with no visible feedback.

Fixes:
- Add required attribute to Project select (toggles off for Loan types)
- Add client-side validation: blocks submit if no workers selected
- Add backend validation: returns error if no workers in POST data
- Add "Select All" / "Clear" links for worker checkboxes (matches V2)
- Add "X worker(s) selected" counter for visual feedback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Konrad du Plessis 2026-02-22 23:22:20 +02:00
parent 0fa25e1538
commit b837932bb4
2 changed files with 99 additions and 10 deletions

View File

@ -382,10 +382,10 @@
</select>
</div>
{# Project #}
{# Project — required for most types, hidden for Loan types #}
<div class="col-md-6" id="addAdjProjectGroup">
<label class="form-label">Project</label>
<select name="project" class="form-select" id="addAdjProject">
<select name="project" class="form-select" id="addAdjProject" required>
<option value="">-- Select Project --</option>
{% for p in active_projects %}
<option value="{{ p.id }}">{{ p.name }}</option>
@ -408,13 +408,15 @@
{# Workers - multi-select with team helper #}
<div class="col-12">
<label class="form-label">Workers</label>
<div class="mb-2">
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
<select id="addAdjTeamSelect" class="form-select form-select-sm" style="max-width: 250px;">
<option value="">Quick select by team...</option>
{% for team in all_teams %}
<option value="{{ team.id }}">{{ team.name }}</option>
{% endfor %}
</select>
<a href="#" id="adjSelectAll" class="small text-primary text-decoration-none text-nowrap">Select All</a>
<a href="#" id="adjDeselectAll" class="small text-muted text-decoration-none text-nowrap">Clear</a>
</div>
<div style="max-height: 200px; overflow-y: auto; border: 1px solid #dee2e6; border-radius: 4px; padding: 8px;">
{% for w in all_workers %}
@ -425,6 +427,13 @@
</div>
{% endfor %}
</div>
{# Counter + validation message for workers #}
<div class="form-text mt-1">
<span id="adjSelectedCount">0</span> worker(s) selected
</div>
<div class="invalid-feedback" id="addAdjWorkerError" style="display: none;">
Please select at least one worker.
</div>
</div>
{# Description #}
@ -906,35 +915,108 @@ document.addEventListener('DOMContentLoaded', function() {
}
// =================================================================
// ADD ADJUSTMENT — Team quick-select + project visibility
// ADD ADJUSTMENT — Team quick-select, project visibility,
// worker count display, and form validation
// =================================================================
const addAdjType = document.getElementById('addAdjType');
const addAdjProjectGroup = document.getElementById('addAdjProjectGroup');
const addAdjProject = document.getElementById('addAdjProject');
const addAdjTeamSelect = document.getElementById('addAdjTeamSelect');
const addAdjWorkerCheckboxes = document.querySelectorAll('.add-adj-worker');
const adjSelectedCount = document.getElementById('adjSelectedCount');
const addAdjWorkerError = document.getElementById('addAdjWorkerError');
// Show/hide project field based on adjustment type
// Show/hide project field based on adjustment type.
// Also toggles the HTML "required" attribute so browser validation
// only enforces the project when the type actually needs one.
function toggleProjectField() {
if (!addAdjType || !addAdjProjectGroup) return;
// Loan and Loan Repayment don't need a project
const noProjectTypes = ['New Loan', 'Loan Repayment'];
addAdjProjectGroup.style.display = noProjectTypes.indexOf(addAdjType.value) !== -1 ? 'none' : '';
var noProjectTypes = ['New Loan', 'Loan Repayment'];
var needsProject = noProjectTypes.indexOf(addAdjType.value) === -1;
addAdjProjectGroup.style.display = needsProject ? '' : 'none';
if (addAdjProject) {
if (needsProject) {
addAdjProject.setAttribute('required', '');
} else {
addAdjProject.removeAttribute('required');
addAdjProject.value = ''; // Clear selection when hidden
}
}
}
if (addAdjType) {
addAdjType.addEventListener('change', toggleProjectField);
toggleProjectField();
}
// Update the "X worker(s) selected" counter
function updateWorkerCount() {
var count = 0;
addAdjWorkerCheckboxes.forEach(function(cb) {
if (cb.checked) count++;
});
if (adjSelectedCount) adjSelectedCount.textContent = count;
// Hide the error message when workers are selected
if (addAdjWorkerError && count > 0) {
addAdjWorkerError.style.display = 'none';
}
}
addAdjWorkerCheckboxes.forEach(function(cb) {
cb.addEventListener('change', updateWorkerCount);
});
// Select All / Deselect All links
var adjSelectAllLink = document.getElementById('adjSelectAll');
var adjDeselectAllLink = document.getElementById('adjDeselectAll');
if (adjSelectAllLink) {
adjSelectAllLink.addEventListener('click', function(e) {
e.preventDefault();
addAdjWorkerCheckboxes.forEach(function(cb) { cb.checked = true; });
updateWorkerCount();
});
}
if (adjDeselectAllLink) {
adjDeselectAllLink.addEventListener('click', function(e) {
e.preventDefault();
addAdjWorkerCheckboxes.forEach(function(cb) { cb.checked = false; });
updateWorkerCount();
});
}
// Team quick-select: check workers in that team
if (addAdjTeamSelect) {
addAdjTeamSelect.addEventListener('change', function() {
const teamId = this.value;
var teamId = this.value;
if (!teamId) return;
const workerIds = teamWorkersMap[teamId] || [];
document.querySelectorAll('.add-adj-worker').forEach(function(cb) {
var workerIds = teamWorkersMap[teamId] || [];
addAdjWorkerCheckboxes.forEach(function(cb) {
if (workerIds.indexOf(parseInt(cb.value)) !== -1) {
cb.checked = true;
}
});
updateWorkerCount();
});
}
// Form validation: ensure at least one worker is selected before submit.
// Without this, the form would submit and silently create 0 adjustments.
var addAdjForm = document.querySelector('#addAdjustmentModal form');
if (addAdjForm) {
addAdjForm.addEventListener('submit', function(e) {
var anyChecked = false;
addAdjWorkerCheckboxes.forEach(function(cb) {
if (cb.checked) anyChecked = true;
});
if (!anyChecked) {
e.preventDefault();
if (addAdjWorkerError) {
addAdjWorkerError.style.display = 'block';
}
// Scroll to the workers section so the error is visible
var workersLabel = addAdjForm.querySelector('.col-12');
if (workersLabel) workersLabel.scrollIntoView({behavior: 'smooth', block: 'center'});
return false;
}
});
}

View File

@ -1100,6 +1100,13 @@ def add_adjustment(request):
date_str = request.POST.get('date', '')
project_id = request.POST.get('project', '')
# Validate workers — at least one must be selected.
# The frontend also checks this, but this is a safety net in case
# the user has JavaScript disabled or submits via other means.
if not worker_ids:
messages.error(request, 'Please select at least one worker.')
return redirect('payroll_dashboard')
# Validate amount
try:
amount = Decimal(amount_str)