feat(adjustments): replace Choices.js chip-multiselect with popover-checkbox filters

Checkpoint-1 feedback from Konrad: the Choices.js chip pattern for
Type / Workers / Teams was visually intrusive once multiple options
were picked — the filter bar dominated the viewport.

Replacement: each filter is now a compact pill (like Feature 1's
inline-filter pills on the report page) that opens a popover with a
scrollable checkbox list, live-search, and Select All / Invert /
Clear action buttons. OK commits the pending state into hidden form
inputs; Cancel / Esc / click-outside revert. The existing Apply button
still submits the form normally.

Reuses Feature 1's .filter-pill / .filter-popover CSS vocabulary —
only new CSS is a scrollable checkbox-list rule and a pill-count
badge style. No new modals. Choices.js CDN stays loaded (other
tabs still use it).
This commit is contained in:
Konrad du Plessis 2026-04-23 17:07:50 +02:00
parent b59eb313c0
commit 4f15e4bd5f
2 changed files with 331 additions and 40 deletions

View File

@ -556,47 +556,151 @@
{# Row actions reuse the existing Edit / Delete / Preview modals so no new JS is needed. #}
{% if active_tab == 'adjustments' %}
{# --- Sticky filter bar (Choices.js enhances the multi-selects below) --- #}
{# --- Sticky filter bar (pill-popover checkbox filters for Type/Workers/Teams) --- #}
<div class="adjustments-filter-bar" id="adjustmentsFilters">
<form method="get" action="{% url 'payroll_dashboard' %}"
class="d-flex flex-wrap gap-3 align-items-end w-100" id="adjFilterForm">
<input type="hidden" name="status" value="adjustments">
{# --- Type multi-select (Bonus / Overtime / etc.) --- #}
{# Each label/input pair below has matching for=/id= so screen #}
{# readers announce the field name when focus moves into the #}
{# select. The .adj-multi class still drives Choices.js init — #}
{# adding id= is purely additive. #}
<div class="flex-grow-1" style="min-width: 180px;">
<label for="adjTypeSelect" class="form-label small mb-1">Type</label>
<select id="adjTypeSelect" name="type" class="form-select form-select-sm adj-multi" multiple
data-placeholder="All types">
{% for t in adj_type_choices %}
<option value="{{ t }}" {% if t in adj_filter_values.type %}selected{% endif %}>{{ t }}</option>
{# === Type filter: pill-button opens popover with checkbox list === #}
{# Replaced the Choices.js chip multi-select (Apr 2026): the chip #}
{# pattern dominated the filter bar when multiple options were #}
{# picked. Now: a compact pill shows "Type" (or "Type (N)") and #}
{# clicking it opens a popover with search + checkbox list + OK. #}
{# OK commits to hidden inputs below; the Apply button submits. #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjTypePill" data-adj-filter="type"
aria-expanded="false" aria-controls="adjTypePopover">
<i class="fas fa-tag me-1"></i>
<span class="filter-pill__label" data-pill-label>Type</span>
<span class="filter-pill__count ms-1" data-pill-count hidden></span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjTypePopover" role="dialog"
aria-label="Filter by type" hidden>
<div class="filter-popover__body">
<input type="search" class="form-control form-control-sm mb-2"
placeholder="Search types..." data-popover-search>
<div class="adj-checkbox-list" data-popover-list>
{% for t in adj_type_choices %}
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="checkbox" class="form-check-input adj-filter-cb"
data-adj-filter="type" value="{{ t }}"
{% if t in adj_filter_values.type %}checked{% endif %}>
<span class="adj-cb-label">{{ t }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="filter-popover__footer d-flex flex-wrap gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary me-auto"
data-popover-action="select-all">All</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="invert">Invert</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="clear">Clear</button>
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
{# Hidden inputs — the actual form-submit state. Rewritten on OK. #}
<div class="adj-hidden-inputs" data-adj-filter="type">
{% for v in adj_filter_values.type %}
<input type="hidden" name="type" value="{{ v }}">
{% endfor %}
</select>
</div>
</div>
{# --- Workers multi-select (cross-filtered by Teams in Task 7) --- #}
<div class="flex-grow-1" style="min-width: 180px;">
<label for="adjWorkerSelect" class="form-label small mb-1">Workers</label>
<select name="worker" id="adjWorkerSelect" class="form-select form-select-sm adj-multi" multiple
data-placeholder="All workers">
{% for w in all_workers_for_filter %}
<option value="{{ w.id }}" {% if w.id in adj_filter_values.worker %}selected{% endif %}>{{ w.name }}</option>
{# === Workers filter: same pill + popover pattern as Type === #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjWorkerPill" data-adj-filter="worker"
aria-expanded="false" aria-controls="adjWorkerPopover">
<i class="fas fa-user me-1"></i>
<span class="filter-pill__label" data-pill-label>Workers</span>
<span class="filter-pill__count ms-1" data-pill-count hidden></span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjWorkerPopover" role="dialog"
aria-label="Filter by workers" hidden>
<div class="filter-popover__body">
<input type="search" class="form-control form-control-sm mb-2"
placeholder="Search workers..." data-popover-search>
<div class="adj-checkbox-list" data-popover-list>
{% for w in all_workers_for_filter %}
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="checkbox" class="form-check-input adj-filter-cb"
data-adj-filter="worker" value="{{ w.id }}"
{% if w.id in adj_filter_values.worker %}checked{% endif %}>
<span class="adj-cb-label">{{ w.name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="filter-popover__footer d-flex flex-wrap gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary me-auto"
data-popover-action="select-all">All</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="invert">Invert</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="clear">Clear</button>
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
<div class="adj-hidden-inputs" data-adj-filter="worker">
{% for v in adj_filter_values.worker %}
<input type="hidden" name="worker" value="{{ v }}">
{% endfor %}
</select>
</div>
</div>
{# --- Teams multi-select --- #}
<div class="flex-grow-1" style="min-width: 160px;">
<label for="adjTeamSelect" class="form-label small mb-1">Teams</label>
<select name="team" id="adjTeamSelect" class="form-select form-select-sm adj-multi" multiple
data-placeholder="All teams">
{% for t in all_teams_for_filter %}
<option value="{{ t.id }}" {% if t.id in adj_filter_values.team %}selected{% endif %}>{{ t.name }}</option>
{# === Teams filter: same pill + popover pattern as Type === #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjTeamPill" data-adj-filter="team"
aria-expanded="false" aria-controls="adjTeamPopover">
<i class="fas fa-users me-1"></i>
<span class="filter-pill__label" data-pill-label>Teams</span>
<span class="filter-pill__count ms-1" data-pill-count hidden></span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjTeamPopover" role="dialog"
aria-label="Filter by teams" hidden>
<div class="filter-popover__body">
<input type="search" class="form-control form-control-sm mb-2"
placeholder="Search teams..." data-popover-search>
<div class="adj-checkbox-list" data-popover-list>
{% for t in all_teams_for_filter %}
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="checkbox" class="form-check-input adj-filter-cb"
data-adj-filter="team" value="{{ t.id }}"
{% if t.id in adj_filter_values.team %}checked{% endif %}>
<span class="adj-cb-label">{{ t.name }}</span>
</label>
{% endfor %}
</div>
</div>
<div class="filter-popover__footer d-flex flex-wrap gap-1">
<button type="button" class="btn btn-sm btn-outline-secondary me-auto"
data-popover-action="select-all">All</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="invert">Invert</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-popover-action="clear">Clear</button>
<button type="button" class="btn btn-sm btn-outline-secondary popover-cancel">Cancel</button>
<button type="button" class="btn btn-sm btn-accent popover-ok">OK</button>
</div>
</div>
<div class="adj-hidden-inputs" data-adj-filter="team">
{% for v in adj_filter_values.team %}
<input type="hidden" name="team" value="{{ v }}">
{% endfor %}
</select>
</div>
</div>
{# --- Status single-select (Unpaid / Paid / All) --- #}
@ -3244,24 +3348,175 @@ document.addEventListener('DOMContentLoaded', function() {
}
// =================================================================
// ADJUSTMENTS TAB — Choices.js multi-selects + direct delete button
// ADJUSTMENTS TAB — pill-popover filter module + direct delete button
// Both blocks are no-ops on other tabs (the filter bar element only
// exists when the Adjustments tab is active).
// =================================================================
if (document.getElementById('adjustmentsFilters')) {
// --- Lazy-init Choices.js on the three multi-selects ---
// Choices.js is heavy, so we only enhance selects on this tab.
if (typeof Choices !== 'undefined') {
document.querySelectorAll('#adjFilterForm .adj-multi').forEach(function(sel) {
new Choices(sel, {
removeItemButton: true,
shouldSort: false,
placeholder: true,
placeholderValue: sel.getAttribute('data-placeholder') || '',
});
// === Pill-popover filter module ===
// Replaces the prior Choices.js chip-multiselect for Type / Workers / Teams.
// Each pill opens a popover with a checkbox list + search + action buttons.
// OK commits the pending checkbox state into the hidden <input> block that
// the form submits; Cancel / Esc / click-outside revert to the URL state.
// --- Display labels shown on each pill button ---
var filterLabels = { type: 'Type', worker: 'Workers', team: 'Teams' };
// --- Close every adj popover except (optionally) one we want to keep open ---
function closeAllAdjPopovers(except) {
document.querySelectorAll('.adj-filter-pill').forEach(function(pill) {
var id = pill.getAttribute('aria-controls');
if (id !== except) {
var pop = document.getElementById(id);
if (pop) pop.hidden = true;
pill.setAttribute('aria-expanded', 'false');
}
});
}
// --- Rewrite the pill's label based on how many hidden inputs exist ---
// 0 selected: "Type"
// 1 selected: "Type: Bonus" (looks up checkbox label text)
// 2+ selected: "Type (2)" with a count badge
function renderPillLabel(filterName) {
var pill = document.querySelector('.adj-filter-pill[data-adj-filter="' + filterName + '"]');
var labelEl = pill.querySelector('[data-pill-label]');
var countEl = pill.querySelector('[data-pill-count]');
var hiddenInputs = document.querySelectorAll(
'.adj-hidden-inputs[data-adj-filter="' + filterName + '"] input[type=hidden]'
);
var n = hiddenInputs.length;
if (n === 0) {
labelEl.textContent = filterLabels[filterName];
countEl.hidden = true;
} else if (n === 1) {
var v = hiddenInputs[0].value;
// Prefer the checkbox's visible label text over the raw value (IDs read poorly)
var cb = document.querySelector(
'.adj-filter-cb[data-adj-filter="' + filterName + '"][value="' + v + '"]'
);
var text = cb ? cb.closest('label').querySelector('.adj-cb-label').textContent : v;
labelEl.textContent = filterLabels[filterName] + ': ' + text;
countEl.hidden = true;
} else {
labelEl.textContent = filterLabels[filterName];
countEl.textContent = '(' + n + ')';
countEl.hidden = false;
}
}
// --- Reset checkboxes to match the current hidden-input set (Cancel / initial load) ---
function revertCheckboxes(filterName) {
var values = Array.from(document.querySelectorAll(
'.adj-hidden-inputs[data-adj-filter="' + filterName + '"] input[type=hidden]'
)).map(function(i) { return i.value; });
document.querySelectorAll(
'.adj-filter-cb[data-adj-filter="' + filterName + '"]'
).forEach(function(cb) {
cb.checked = values.indexOf(cb.value) !== -1;
});
}
// --- Commit checkbox state into the hidden-input block (OK) ---
// Clears then rebuilds the hidden inputs so the form submits exactly
// the set of values currently ticked. Uses replaceChildren() to clear
// (DOM-safe — no innerHTML) then createElement per checked box.
function commitCheckboxes(filterName) {
var container = document.querySelector(
'.adj-hidden-inputs[data-adj-filter="' + filterName + '"]'
);
container.replaceChildren(); // wipe all previous hidden inputs
document.querySelectorAll(
'.adj-filter-cb[data-adj-filter="' + filterName + '"]:checked'
).forEach(function(cb) {
var inp = document.createElement('input');
inp.type = 'hidden';
inp.name = filterName;
inp.value = cb.value;
container.appendChild(inp);
});
renderPillLabel(filterName);
}
// --- Initial paint: set pill labels from the URL's hidden-input state ---
['type', 'worker', 'team'].forEach(renderPillLabel);
// --- Pill click: toggle its popover (and close any sibling popover) ---
document.querySelectorAll('.adj-filter-pill').forEach(function(pill) {
pill.addEventListener('click', function(ev) {
ev.stopPropagation();
var popoverId = pill.getAttribute('aria-controls');
var pop = document.getElementById(popoverId);
var isOpen = !pop.hidden;
if (isOpen) {
pop.hidden = true;
pill.setAttribute('aria-expanded', 'false');
} else {
closeAllAdjPopovers(popoverId);
pop.hidden = false;
pill.setAttribute('aria-expanded', 'true');
// Focus the search input so the user can type immediately
var search = pop.querySelector('[data-popover-search]');
if (search) setTimeout(function() { search.focus(); }, 0);
}
});
});
// --- Click outside any filter-pill-wrap closes all popovers ---
document.addEventListener('click', function(ev) {
if (!ev.target.closest('.filter-pill-wrap')) closeAllAdjPopovers();
});
// --- Esc closes all popovers (handy when focus is in the search box) ---
document.addEventListener('keydown', function(ev) {
if (ev.key === 'Escape') closeAllAdjPopovers();
});
// --- OK / Cancel buttons inside each popover ---
document.querySelectorAll('#adjustmentsFilters .filter-popover').forEach(function(pop) {
var filterName = pop.closest('.filter-pill-wrap')
.querySelector('.adj-filter-pill').getAttribute('data-adj-filter');
var okBtn = pop.querySelector('.popover-ok');
var cancelBtn = pop.querySelector('.popover-cancel');
okBtn.addEventListener('click', function() {
commitCheckboxes(filterName);
closeAllAdjPopovers();
});
cancelBtn.addEventListener('click', function() {
revertCheckboxes(filterName);
closeAllAdjPopovers();
});
});
// --- Select All / Clear / Invert buttons ---
// Important: these operate on VISIBLE rows only — so if the user has
// typed into the search box, "All" ticks only the matching entries.
document.querySelectorAll('#adjustmentsFilters [data-popover-action]').forEach(function(btn) {
btn.addEventListener('click', function() {
var action = btn.getAttribute('data-popover-action');
var pop = btn.closest('.filter-popover');
var visibleRows = Array.from(pop.querySelectorAll('.adj-cb-row'))
.filter(function(r) { return r.style.display !== 'none'; });
visibleRows.forEach(function(row) {
var cb = row.querySelector('.adj-filter-cb');
if (action === 'select-all') cb.checked = true;
else if (action === 'clear') cb.checked = false;
else if (action === 'invert') cb.checked = !cb.checked;
});
});
});
// --- Search input: live-filter visible checkbox rows ---
document.querySelectorAll('#adjustmentsFilters [data-popover-search]').forEach(function(search) {
search.addEventListener('input', function() {
var q = search.value.toLowerCase().trim();
var pop = search.closest('.filter-popover');
pop.querySelectorAll('.adj-cb-row').forEach(function(row) {
var label = row.querySelector('.adj-cb-label').textContent.toLowerCase();
row.style.display = (!q || label.indexOf(q) !== -1) ? '' : 'none';
});
});
});
// --- Direct delete buttons on each unpaid row ---
// Short-circuits the edit modal's usual 2-step delete flow by opening
// #deleteConfirmModal directly with the correct form action + labels.

View File

@ -2018,3 +2018,39 @@ th.sortable .sort-arrow {
}
th.sortable:hover .sort-arrow,
th.sortable.sorted .sort-arrow { opacity: 1; }
/* =============================================================================
* ADJUSTMENTS TAB pill-popover checkbox list
* The Type / Workers / Teams filters each open a popover that reuses the
* shared .filter-popover styles (see "Inline Filters" block above). This
* section only adds the bits specific to the checkbox-list body the rest
* of the visual vocabulary (pill button, popover chrome, sticky footer)
* is inherited.
* ============================================================================= */
/* --- Scrollable checkbox list inside each Adjustments popover --- */
.adj-checkbox-list {
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border-subtle);
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
}
/* Each row is a full-width <label> so clicking the text toggles the checkbox */
.adj-cb-row {
cursor: pointer;
margin: 0;
padding: 0.2rem 0.25rem;
border-radius: 0.25rem;
transition: background-color 120ms;
}
.adj-cb-row:hover { background: var(--bg-card-hover); }
.adj-cb-label { user-select: none; }
/* --- Count badge shown on the pill when 2+ options are selected --- */
/* (For 0 or 1 selected the label text carries the info; the badge stays hidden.) */
.filter-pill__count {
font-size: 0.75em;
opacity: 0.75;
font-weight: 600;
}