feat(adjustments): filter bar v2 — unify all 5 filters as pills + density pass

Konrad's feedback on the shipped Adjustments tab: "this interface
layout is very ugly. And the selection dropdown menus text is a bit
large." Plus: the 'Show as' toggle sits too close to the filter bar.

Design doc: docs/plans/2026-04-23-adjustments-filter-bar-v2-design.md

Changes:

1. All 5 filters become pill-popovers of identical shape
   - Type / Workers / Teams: unchanged (already pills)
   - Status: was <select> + <label>, now pill → popover with 3 radios
   - Date: was inline inputs + preset links + '...' toggle, now pill →
     popover with Single/Range mode toggle + picker(s) + presets + OK/Cancel
   - Pill labels update to 'Status: Unpaid' / 'Date: 24 Apr 2026' /
     'Date: 20 Apr – 26 Apr 2026' for at-a-glance state
   - Apply + Clear pushed to right end via .adj-apply-group (margin-left: auto)

2. Popover density pass
   - .adj-checkbox-list / .adj-radio-list font-size 0.8rem (~12.8px)
   - .adj-cb-row padding trimmed to 0.15rem 0.25rem
   - Checkbox visual size 0.9em
   - Popover footer buttons 0.75rem font, 0.25rem 0.6rem padding
   - Popover max-width 360px (was ~420px)
   - 7-type popover drops from ~320px tall to ~240px

3. Spacing fix above 'Show as:' toggle
   - .adj-groupby-toggle now has margin-top: 1rem + margin-bottom: 0.75rem
   - Clear visual separation from the sticky filter bar

4. Filter-bar alignment
   - align-items: center (was end, now all children are same height)
   - Gap tightened to 0.5rem

Backend contract unchanged (query params identical). No test changes
(65/65 still pass). Committed popover JS uses the same
.adj-hidden-inputs pattern as the checkbox filters — Status + Date
each have their own commit/revert logic that rewrites their hidden
inputs on OK. XSS-safe throughout (replaceChildren() + textContent,
no innerHTML with user data).

Gated the generic checkbox-popover OK/Cancel handler to
['type', 'worker', 'team'] so the new Status/Date popovers aren't
accidentally re-committed via commitCheckboxes.
This commit is contained in:
Konrad du Plessis 2026-04-23 22:00:27 +02:00
parent 620f433d06
commit 6f66faf06a
2 changed files with 340 additions and 89 deletions

View File

@ -706,41 +706,114 @@
</div>
</div>
{# --- Status single-select (Unpaid / Paid / All) --- #}
<div style="min-width: 120px;">
<label for="adjStatusSelect" class="form-label small mb-1">Status</label>
<select id="adjStatusSelect" name="adj_status" class="form-select form-select-sm">
<option value="" {% if not adj_filter_values.adj_status %}selected{% endif %}>All</option>
<option value="unpaid" {% if adj_filter_values.adj_status == 'unpaid' %}selected{% endif %}>Unpaid</option>
<option value="paid" {% if adj_filter_values.adj_status == 'paid' %}selected{% endif %}>Paid</option>
</select>
{# --- Status filter: pill-button opens popover with 3 radios --- #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjStatusPill" data-adj-filter="adj_status"
aria-expanded="false" aria-controls="adjStatusPopover">
<i class="fas fa-filter me-1"></i>
<span class="filter-pill__label" data-pill-label>Status</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjStatusPopover" role="dialog"
aria-label="Filter by status" hidden>
<div class="filter-popover__body">
<div class="adj-radio-list" data-popover-list>
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="radio" class="form-check-input adj-status-radio"
name="adj_status_pending" value=""
{% if not adj_filter_values.adj_status %}checked{% endif %}>
<span class="adj-cb-label">All</span>
</label>
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="radio" class="form-check-input adj-status-radio"
name="adj_status_pending" value="unpaid"
{% if adj_filter_values.adj_status == 'unpaid' %}checked{% endif %}>
<span class="adj-cb-label">Unpaid</span>
</label>
<label class="d-flex align-items-center gap-2 py-1 adj-cb-row">
<input type="radio" class="form-check-input adj-status-radio"
name="adj_status_pending" value="paid"
{% if adj_filter_values.adj_status == 'paid' %}checked{% endif %}>
<span class="adj-cb-label">Paid</span>
</label>
</div>
</div>
<div class="filter-popover__footer d-flex gap-1 justify-content-end">
<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>
{# Actual submit value — rewritten by the Status popover OK handler #}
<div class="adj-hidden-inputs" data-adj-filter="adj_status">
{% if adj_filter_values.adj_status %}
<input type="hidden" name="adj_status" value="{{ adj_filter_values.adj_status }}">
{% endif %}
</div>
</div>
{# --- Date picker: single by default, "..." toggles range mode --- #}
{# - Single mode (default): pick ONE date; JS mirrors it into the hidden #}
{# "To" field on submit so the backend's adj_date_from/adj_date_to #}
{# contract stays the same. #}
{# - Range mode: both pickers visible, JS leaves the "To" alone. #}
{# - Presets below: Today / This week / This month / Clear. #}
<div id="adjDateWrap" style="min-width: 180px;">
<label class="form-label small mb-1 d-flex align-items-center gap-2">
<span id="adjDateLabel">Date</span>
<button type="button" id="adjDateRangeToggle" class="btn btn-link btn-sm p-0 ms-auto"
title="Toggle range mode" aria-label="Toggle date range mode">
<i class="fas fa-ellipsis-h"></i>
</button>
</label>
<input type="date" name="adj_date_from" id="adjDateFrom"
class="form-control form-control-sm"
value="{{ adj_filter_values.adj_date_from }}">
<input type="date" name="adj_date_to" id="adjDateTo"
class="form-control form-control-sm mt-1"
value="{{ adj_filter_values.adj_date_to }}" hidden>
<div class="d-flex gap-2 mt-1 small" id="adjDatePresets">
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="today">Today</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="week">This week</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="month">This month</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="clear">Clear</button>
{# --- Date filter: pill-button opens popover with mode + pickers + presets --- #}
{# Mode toggle (Single / Range) sits inside the popover. Presets live inside too. #}
{# Backend contract unchanged: hidden inputs adj_date_from + adj_date_to. #}
<div class="filter-pill-wrap position-relative">
<button type="button"
class="filter-pill filter-pill--editable adj-filter-pill"
id="adjDatePill" data-adj-filter="date"
aria-expanded="false" aria-controls="adjDatePopover">
<i class="fas fa-calendar me-1"></i>
<span class="filter-pill__label" data-pill-label>Date</span>
<i class="fas fa-chevron-down ms-2 small filter-pill__chevron"></i>
</button>
<div class="filter-popover" id="adjDatePopover" role="dialog"
aria-label="Filter by date" hidden>
<div class="filter-popover__body">
{# Mode toggle #}
<div class="btn-group w-100 mb-2" role="group" aria-label="Date mode">
<input type="radio" class="btn-check" name="adj_date_mode_pending"
id="adjDateModeSingle" value="single" checked>
<label class="btn btn-outline-secondary btn-sm" for="adjDateModeSingle">Single</label>
<input type="radio" class="btn-check" name="adj_date_mode_pending"
id="adjDateModeRange" value="range">
<label class="btn btn-outline-secondary btn-sm" for="adjDateModeRange">Range</label>
</div>
{# Single-mode picker #}
<div id="adjDateSingleFields">
<label class="form-label small mb-1" for="adjDateSingle">Date</label>
<input type="date" class="form-control form-control-sm" id="adjDateSingle">
</div>
{# Range-mode pickers #}
<div id="adjDateRangeFields" class="row g-2 d-none">
<div class="col-6">
<label class="form-label small mb-1" for="adjDateFrom">From</label>
<input type="date" class="form-control form-control-sm" id="adjDateFrom">
</div>
<div class="col-6">
<label class="form-label small mb-1" for="adjDateTo">To</label>
<input type="date" class="form-control form-control-sm" id="adjDateTo">
</div>
</div>
{# Preset quick-links #}
<div class="d-flex gap-2 mt-2 small" id="adjDatePresets">
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="today">Today</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="week">Week</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="month">Month</button>
<button type="button" class="btn btn-link btn-sm p-0 text-muted" data-preset="clear">Clear</button>
</div>
</div>
<div class="filter-popover__footer d-flex gap-1 justify-content-end">
<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>
{# Actual submit values — rewritten by the Date popover OK handler #}
<div class="adj-hidden-inputs" data-adj-filter="date">
{% if adj_filter_values.adj_date_from %}
<input type="hidden" name="adj_date_from" value="{{ adj_filter_values.adj_date_from }}">
{% endif %}
{% if adj_filter_values.adj_date_to %}
<input type="hidden" name="adj_date_to" value="{{ adj_filter_values.adj_date_to }}">
{% endif %}
</div>
</div>
@ -750,8 +823,8 @@
{# --- Group-by state (keeps Flat/By Type/By Worker across Apply) --- #}
<input type="hidden" name="group_by" value="{{ adj_filter_values.group_by }}">
{# --- Apply / Clear buttons --- #}
<div class="d-flex gap-2">
{# --- Apply / Clear (pushed to the right end via .adj-apply-group) --- #}
<div class="adj-apply-group">
<button type="submit" class="btn btn-sm btn-accent">
<i class="fas fa-filter me-1"></i>Apply
</button>
@ -3585,10 +3658,15 @@ document.addEventListener('DOMContentLoaded', function() {
if (ev.key === 'Escape') closeAllAdjPopovers();
});
// --- OK / Cancel buttons inside each popover ---
// --- OK / Cancel buttons inside each CHECKBOX-style popover ---
// The Status and Date popovers below use radios / date inputs instead
// of checkboxes, so they register their own OK/Cancel handlers and are
// skipped here by gating on data-adj-filter in ['type', 'worker', 'team'].
var checkboxFilters = ['type', 'worker', 'team'];
document.querySelectorAll('#adjustmentsFilters .filter-popover').forEach(function(pop) {
var filterName = pop.closest('.filter-pill-wrap')
.querySelector('.adj-filter-pill').getAttribute('data-adj-filter');
if (checkboxFilters.indexOf(filterName) === -1) return;
var okBtn = pop.querySelector('.popover-ok');
var cancelBtn = pop.querySelector('.popover-cancel');
okBtn.addEventListener('click', function() {
@ -3719,72 +3797,210 @@ document.addEventListener('DOMContentLoaded', function() {
applyWorkerCrossFilter();
}
// === ADJUSTMENTS TAB — Date picker single/range toggle + presets ===
// Single mode (default): one <input> visible. On form submit, JS mirrors
// the From value into the hidden To so backend gets from==to (exact-day
// filter). Range mode: both inputs visible, user picks independently.
var dateWrap = document.getElementById('adjDateWrap');
if (dateWrap) {
var dateFrom = document.getElementById('adjDateFrom');
var dateTo = document.getElementById('adjDateTo');
var toggleBtn = document.getElementById('adjDateRangeToggle');
var dateLabel = document.getElementById('adjDateLabel');
// === ADJUSTMENTS TAB — Status pill popover ===
// Single hidden input adj_status, rewritten by the popover OK handler.
// The 3 radios inside the popover hold PENDING state; committed state
// lives in the .adj-hidden-inputs[data-adj-filter="adj_status"] div.
var statusPill = document.getElementById('adjStatusPill');
if (statusPill) {
var statusPopover = document.getElementById('adjStatusPopover');
var statusLabel = statusPill.querySelector('[data-pill-label]');
var statusHiddenContainer = document.querySelector(
'.adj-hidden-inputs[data-adj-filter="adj_status"]'
);
function applyMode(rangeMode) {
dateTo.hidden = !rangeMode;
dateLabel.textContent = rangeMode ? 'Date range' : 'Date';
// In single mode mirror dateFrom into dateTo so form submit
// sends equal values for an exact-day match.
if (!rangeMode) dateTo.value = dateFrom.value;
function currentStatusValue() {
var hidden = statusHiddenContainer.querySelector('input[type="hidden"]');
return hidden ? hidden.value : '';
}
function statusValueToLabel(v) {
if (v === 'unpaid') return 'Status: Unpaid';
if (v === 'paid') return 'Status: Paid';
return 'Status';
}
function renderStatusPillLabel() {
statusLabel.textContent = statusValueToLabel(currentStatusValue());
}
function revertStatusRadios() {
// Match radio state to committed hidden input
var v = currentStatusValue();
document.querySelectorAll('input[name="adj_status_pending"]').forEach(function(r) {
r.checked = (r.value === v);
});
}
function commitStatus() {
// Rewrite hidden input from whichever radio is checked
var checked = document.querySelector('input[name="adj_status_pending"]:checked');
var newVal = checked ? checked.value : '';
statusHiddenContainer.replaceChildren(); // XSS-safe clear
if (newVal) {
var inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'adj_status';
inp.value = newVal;
statusHiddenContainer.appendChild(inp);
}
renderStatusPillLabel();
}
// Start in range mode if URL already has different From and To
var urlFrom = dateFrom.value || '';
var urlTo = dateTo.value || '';
var initialRange = !!urlFrom && !!urlTo && urlFrom !== urlTo;
applyMode(initialRange);
toggleBtn.addEventListener('click', function() {
// Toggle: if hidden was the 'To' then we're switching to range
applyMode(dateTo.hidden);
});
// Keep the mirror in sync while in single mode
dateFrom.addEventListener('change', function() {
if (dateTo.hidden) dateTo.value = dateFrom.value;
});
// Initial pill label reflects committed state
renderStatusPillLabel();
// Preset quick-buttons
function iso(d) { return d.toISOString().slice(0, 10); }
// OK + Cancel wiring
statusPopover.querySelector('.popover-ok').addEventListener('click', function() {
commitStatus();
closeAllAdjPopovers();
});
statusPopover.querySelector('.popover-cancel').addEventListener('click', function() {
revertStatusRadios();
closeAllAdjPopovers();
});
}
// === ADJUSTMENTS TAB — Date pill popover (mode toggle + pickers + presets) ===
// Three inputs inside the popover hold PENDING state:
// - adjDateSingle (single-mode date)
// - adjDateFrom / adjDateTo (range-mode dates)
// On OK the selected mode decides which pair of values gets written
// into the hidden inputs adj_date_from + adj_date_to. Cancel reverts.
var datePill = document.getElementById('adjDatePill');
if (datePill) {
var datePopover = document.getElementById('adjDatePopover');
var datePillLabel = datePill.querySelector('[data-pill-label]');
var dateHiddenContainer = document.querySelector(
'.adj-hidden-inputs[data-adj-filter="date"]'
);
var adjDateSingle = document.getElementById('adjDateSingle');
var adjDateFrom = document.getElementById('adjDateFrom');
var adjDateTo = document.getElementById('adjDateTo');
var modeSingleRadio = document.getElementById('adjDateModeSingle');
var modeRangeRadio = document.getElementById('adjDateModeRange');
var singleFields = document.getElementById('adjDateSingleFields');
var rangeFields = document.getElementById('adjDateRangeFields');
function committedDateFrom() {
var f = dateHiddenContainer.querySelector('input[name="adj_date_from"]');
return f ? f.value : '';
}
function committedDateTo() {
var t = dateHiddenContainer.querySelector('input[name="adj_date_to"]');
return t ? t.value : '';
}
function humanDate(ymd) {
// "2026-04-24" -> "24 Apr 2026"
if (!ymd) return '';
var parts = ymd.split('-');
var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return parseInt(parts[2], 10) + ' ' + months[parseInt(parts[1], 10) - 1] + ' ' + parts[0];
}
function renderDatePillLabel() {
var f = committedDateFrom();
var t = committedDateTo();
if (!f && !t) { datePillLabel.textContent = 'Date'; return; }
if (f && t && f === t) {
datePillLabel.textContent = 'Date: ' + humanDate(f);
} else if (f && t) {
datePillLabel.textContent = 'Date: ' + humanDate(f) + ' ' + humanDate(t);
} else {
datePillLabel.textContent = 'Date: ' + humanDate(f || t);
}
}
function applyDateMode(rangeMode) {
modeRangeRadio.checked = rangeMode;
modeSingleRadio.checked = !rangeMode;
singleFields.classList.toggle('d-none', rangeMode);
rangeFields.classList.toggle('d-none', !rangeMode);
}
function populatePendingFromCommitted() {
// Seed the popover's input values from the committed hidden inputs
var f = committedDateFrom();
var t = committedDateTo();
if (f && t && f !== t) {
// Range
adjDateFrom.value = f;
adjDateTo.value = t;
adjDateSingle.value = '';
applyDateMode(true);
} else {
// Single (or empty)
adjDateSingle.value = f || t || '';
adjDateFrom.value = '';
adjDateTo.value = '';
applyDateMode(false);
}
}
function commitDate() {
var newFrom = '', newTo = '';
if (modeRangeRadio.checked) {
newFrom = adjDateFrom.value || '';
newTo = adjDateTo.value || '';
} else {
newFrom = newTo = adjDateSingle.value || '';
}
dateHiddenContainer.replaceChildren();
if (newFrom) {
var f = document.createElement('input');
f.type = 'hidden'; f.name = 'adj_date_from'; f.value = newFrom;
dateHiddenContainer.appendChild(f);
}
if (newTo) {
var t = document.createElement('input');
t.type = 'hidden'; t.name = 'adj_date_to'; t.value = newTo;
dateHiddenContainer.appendChild(t);
}
renderDatePillLabel();
}
// Initial state
populatePendingFromCommitted();
renderDatePillLabel();
// Mode toggle syncs the visible section
modeSingleRadio.addEventListener('change', function() { applyDateMode(false); });
modeRangeRadio.addEventListener('change', function() { applyDateMode(true); });
// Preset buttons populate the pending fields (do NOT commit)
function isoDate(d) { return d.toISOString().slice(0, 10); }
document.querySelectorAll('#adjDatePresets [data-preset]').forEach(function(btn) {
btn.addEventListener('click', function() {
var preset = btn.getAttribute('data-preset');
var today = new Date();
if (preset === 'today') {
dateFrom.value = iso(today);
dateTo.value = iso(today);
applyMode(false);
adjDateSingle.value = isoDate(today);
applyDateMode(false);
} else if (preset === 'week') {
// ISO-week: Mon -> Sun
var dayOffset = (today.getDay() + 6) % 7; // 0 = Monday
var dayOffset = (today.getDay() + 6) % 7;
var weekStart = new Date(today);
weekStart.setDate(today.getDate() - dayOffset);
var weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
dateFrom.value = iso(weekStart);
dateTo.value = iso(weekEnd);
applyMode(true);
adjDateFrom.value = isoDate(weekStart);
adjDateTo.value = isoDate(weekEnd);
applyDateMode(true);
} else if (preset === 'month') {
var monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
var monthEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
dateFrom.value = iso(monthStart);
dateTo.value = iso(monthEnd);
applyMode(true);
adjDateFrom.value = isoDate(monthStart);
adjDateTo.value = isoDate(monthEnd);
applyDateMode(true);
} else if (preset === 'clear') {
dateFrom.value = '';
dateTo.value = '';
applyMode(false);
adjDateSingle.value = '';
adjDateFrom.value = '';
adjDateTo.value = '';
applyDateMode(false);
}
});
});
// OK + Cancel wiring
datePopover.querySelector('.popover-ok').addEventListener('click', function() {
commitDate();
closeAllAdjPopovers();
});
datePopover.querySelector('.popover-cancel').addEventListener('click', function() {
populatePendingFromCommitted(); // revert
closeAllAdjPopovers();
});
}
// === ADJUSTMENTS TAB — Sortable column headers ===

View File

@ -1946,13 +1946,26 @@ body, .card, .modal-content, .form-control, .form-select,
top: 0;
z-index: 10;
background: var(--bg-card);
padding: 0.75rem 1rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border-default);
border-radius: 0.5rem 0.5rem 0 0;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
gap: 0.5rem;
/* All children are now same-height pills — center-align them */
align-items: center;
}
/* Apply / Clear push to the right end of the bar */
.adjustments-filter-bar > form {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.adjustments-filter-bar .adj-apply-group {
margin-left: auto;
display: flex;
gap: 0.35rem;
}
/* --- Group header (collapsible section divider for group-by mode) --- */
@ -2025,6 +2038,12 @@ body, .card, .modal-content, .form-control, .form-select,
.adj-empty-state .adj-empty-icon { font-size: 2.5rem; opacity: 0.35; margin-bottom: 1rem; }
/* --- Group-by toggle pill buttons (Flat / By Type / By Worker) --- */
/* margin-top: 1rem gives breathing room between the sticky filter bar and
this row they used to read as one cramped block. */
.adj-groupby-toggle {
margin-top: 1rem;
margin-bottom: 0.75rem;
}
.adj-groupby-toggle .btn { font-size: 0.8rem; padding: 0.3rem 0.75rem; }
/* --- Sort header arrows --- */
@ -2047,24 +2066,40 @@ th.sortable.sorted .sort-arrow { opacity: 1; }
* is inherited.
* ============================================================================= */
/* --- Scrollable checkbox list inside each Adjustments popover --- */
.adj-checkbox-list {
max-height: 280px;
/* --- Adjustments popover tighter density for the checkbox/radio list ---
Font-size drop (~14px 12.8px) + tighter row padding cuts the
7-type Type popover from ~320px tall to ~240px without losing
readability. Radios in the Status popover and date fields in the
Date popover inherit the same size via .adj-radio-list. */
.adj-checkbox-list,
.adj-radio-list {
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--border-subtle);
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
/* Each row is a full-width <label> so clicking the text toggles the checkbox */
/* Each row is a full-width <label> so clicking the text toggles the input */
.adj-cb-row {
cursor: pointer;
margin: 0;
padding: 0.2rem 0.25rem;
padding: 0.15rem 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; }
.adj-cb-row .form-check-input { width: 0.9em; height: 0.9em; }
/* Popover footer buttons (OK / Cancel / All / Invert / Clear) match the
same compact typography reads as one cohesive strip. */
.filter-popover__footer .btn { font-size: 0.75rem; padding: 0.25rem 0.6rem; }
/* The Adjustments popover is also slightly narrower now (less visual weight
once the filter bar is all pills of the same size). */
.adj-filter-pill + .filter-popover,
#adjustmentsFilters .filter-popover { max-width: 360px; }
/* --- 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.) */