feat: enhance panel type filter with group selection, checkboxes, and bulk management controls.

This commit is contained in:
kdeterme 2026-09-11 18:18:18 +02:00
parent 1b38a8059e
commit 82e20f648e
3 changed files with 444 additions and 88 deletions

View file

@ -1657,6 +1657,8 @@ document.addEventListener('DOMContentLoaded', function() {
Array.from(panelTypeSelect.options).forEach(opt => opt.selected = false); Array.from(panelTypeSelect.options).forEach(opt => opt.selected = false);
Array.from(organizationSelect.options).forEach(opt => opt.selected = false); Array.from(organizationSelect.options).forEach(opt => opt.selected = false);
panelTypeSelect.dispatchEvent(new Event('change'));
document.getElementById('installation_year').value = ''; document.getElementById('installation_year').value = '';
currentFilters = { currentFilters = {

View file

@ -40,6 +40,35 @@
box-shadow: 0 4px 12px rgba(47, 62, 139, 0.06) !important; box-shadow: 0 4px 12px rgba(47, 62, 139, 0.06) !important;
} }
.accordion-header {
transition: background-color 0.2s ease;
border-radius: 8px 8px 0 0;
}
.accordion-header:hover {
background-color: #f8f9fa !important;
}
.accordion-header:hover .accordion-button {
background-color: transparent !important;
color: #0d6efd !important;
}
.accordion-header.is-open,
.accordion-header:has(.accordion-button:not(.collapsed)) {
background-color: rgba(13, 110, 253, 0.05) !important;
border-bottom: 1px solid rgba(13, 110, 253, 0.1) !important;
}
.accordion-header.is-open .accordion-button,
.accordion-header:has(.accordion-button:not(.collapsed)) .accordion-button {
background-color: transparent !important;
}
.accordion-header .accordion-button {
background-color: transparent !important;
}
.accordion-button { .accordion-button {
padding: 10px 16px !important; padding: 10px 16px !important;
font-size: 0.9rem !important; font-size: 0.9rem !important;
@ -66,6 +95,23 @@
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important; transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
} }
.group-select-all {
cursor: pointer;
width: 1.15rem;
height: 1.15rem;
transition: all 0.15s ease-in-out;
}
.group-select-all:hover {
transform: scale(1.1);
}
.group-select-all:indeterminate {
background-color: #0d6efd;
border-color: #0d6efd;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e");
}
.accordion-body { .accordion-body {
background-color: #fbfbfc !important; background-color: #fbfbfc !important;
border-top: none !important; border-top: none !important;
@ -215,20 +261,42 @@
<!-- Accordion for panel categories --> <!-- Accordion for panel categories -->
<div class="accordion mb-3 shadow-sm border rounded" id="panel-type-accordion" style="max-height: 500px; overflow-y: auto;"> <div class="accordion mb-3 shadow-sm border rounded" id="panel-type-accordion" style="max-height: 500px; overflow-y: auto;">
{% for group_key, group_label in panel_types_groups %} {% for group_key, group_label in panel_types_groups %}
<div class="accordion-item border-0 border-bottom"> <div class="accordion-item border-0 border-bottom" data-group-key="{{ group_key }}">
<h2 class="accordion-header" id="heading-{{ group_key }}"> <div class="accordion-header d-flex align-items-center bg-white" id="heading-{{ group_key }}">
<button class="accordion-button collapsed py-2 px-3 fw-bold bg-white text-dark shadow-none" <div class="ps-2 pe-1 py-1 d-flex align-items-center" title="{% translate 'Sélectionner / désélectionner tout le groupe' %}">
<input class="form-check-input group-select-all m-0"
type="checkbox"
id="check-group-{{ group_key }}"
data-group="{{ group_key }}"
aria-label="{% translate 'Sélectionner tous les' %} {{ group_label }}">
</div>
<button class="accordion-button collapsed py-2 ps-2 pe-3 fw-bold text-dark shadow-none flex-grow-1"
type="button" type="button"
data-bs-toggle="collapse" data-bs-toggle="collapse"
data-bs-target="#collapse-{{ group_key }}" data-bs-target="#collapse-{{ group_key }}"
aria-expanded="false" aria-expanded="false"
aria-controls="collapse-{{ group_key }}" aria-controls="collapse-{{ group_key }}"
data-group="{{ group_key }}"> data-group="{{ group_key }}">
{{ group_label }} <span class="group-title me-auto">{{ group_label }}</span>
<span class="badge rounded-pill bg-light text-muted border group-count-badge me-2" id="badge-{{ group_key }}" style="font-size: 0.72rem; font-weight: 500;">0</span>
</button> </button>
</h2> </div>
<div id="collapse-{{ group_key }}" class="accordion-collapse collapse" aria-labelledby="heading-{{ group_key }}" data-bs-parent="#panel-type-accordion"> <div id="collapse-{{ group_key }}" class="accordion-collapse collapse" aria-labelledby="heading-{{ group_key }}" data-bs-parent="#panel-type-accordion">
<div class="accordion-body p-2 bg-light border-top"> <div class="accordion-body p-2 bg-light border-top">
<!-- Category Action Toolbar -->
<div class="d-flex justify-content-between align-items-center mb-2 pb-1 border-bottom">
<span class="small text-muted group-status-text" id="status-{{ group_key }}" style="font-size: 0.75rem;">
0 sélectionné(s)
</span>
<div class="btn-group btn-group-sm">
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2 select-all-btn" data-group="{{ group_key }}" style="font-size: 0.72rem;">
<i class="bi bi-check-all me-1"></i>{% translate "Tout cocher" %}
</button>
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2 deselect-all-btn" data-group="{{ group_key }}" style="font-size: 0.72rem;">
<i class="bi bi-x me-1"></i>{% translate "Tout décocher" %}
</button>
</div>
</div>
<div class="d-flex flex-wrap gap-2 justify-content-start"> <div class="d-flex flex-wrap gap-2 justify-content-start">
{% for group, panels in panel_types_grouped.items %} {% for group, panels in panel_types_grouped.items %}
{% if group == group_key %} {% if group == group_key %}
@ -236,6 +304,7 @@
<div class="panel-item text-center p-1 border rounded bg-white shadow-sm hover-grow" <div class="panel-item text-center p-1 border rounded bg-white shadow-sm hover-grow"
data-id="{{ panel.id }}" data-id="{{ panel.id }}"
data-code="{{ panel.code }}" data-code="{{ panel.code }}"
data-group="{{ group_key }}"
data-image-url="{{ panel.image_url|default:'' }}" data-image-url="{{ panel.image_url|default:'' }}"
style="cursor: pointer; width: 68px; transition: transform 0.15s ease;" style="cursor: pointer; width: 68px; transition: transform 0.15s ease;"
title="{{ panel.code }} - {{ panel.name }}"> title="{{ panel.code }} - {{ panel.name }}">
@ -255,7 +324,7 @@
</div> </div>
<!-- Selected panels list --> <!-- Selected panels list -->
<div id="selected-panels-list" class="border rounded p-2 bg-light" style="max-height: 150px; overflow-y: auto;"> <div id="selected-panels-list" class="border rounded p-2 bg-light" style="max-height: 180px; overflow-y: auto;">
<div class="text-muted small fst-italic empty-message text-center">{% translate "Aucun type sélectionné" %}</div> <div class="text-muted small fst-italic empty-message text-center">{% translate "Aucun type sélectionné" %}</div>
<!-- Selected items will be added here via JS --> <!-- Selected items will be added here via JS -->
</div> </div>
@ -1933,109 +2002,323 @@ document.addEventListener('DOMContentLoaded', function() {
const panelTypeSelect = document.getElementById('panel_type'); const panelTypeSelect = document.getElementById('panel_type');
if (!panelTypeSelect) return; if (!panelTypeSelect) return;
const accordionButtons = document.querySelectorAll('#panel-type-accordion .accordion-button'); const accordion = document.getElementById('panel-type-accordion');
const selectedList = document.getElementById('selected-panels-list'); const selectedList = document.getElementById('selected-panels-list');
const emptyMessage = selectedList.querySelector('.empty-message'); const emptyMessage = selectedList ? selectedList.querySelector('.empty-message') : null;
if (!selectedList) return;
// Helper to update selected list UI // Helper to load images for a category
function loadGroupImages(group) {
const collapseDiv = document.querySelector(`#collapse-${group}`);
if (!collapseDiv) return;
collapseDiv.querySelectorAll('.panel-item').forEach(item => {
const placeholder = item.querySelector('.panel-img-placeholder');
if (placeholder && placeholder.children.length === 0) {
const imageUrl = item.dataset.imageUrl;
const code = item.dataset.code;
if (imageUrl) {
const img = document.createElement('img');
img.src = imageUrl;
img.style.maxHeight = '100%';
img.style.maxWidth = '100%';
placeholder.appendChild(img);
} else if (typeof buildPanelIconElement === 'function') {
const img = buildPanelIconElement(code);
img.style.height = '40px';
img.style.width = '40px';
placeholder.appendChild(img);
} else {
placeholder.textContent = code;
}
}
});
}
// Map option values to option elements for fast lookup
function getOptionMap() {
const map = new Map();
Array.from(panelTypeSelect.options).forEach(opt => {
map.set(String(opt.value), opt);
});
return map;
}
// Helper to update selected list UI & Group states
function updateSelectedList() { function updateSelectedList() {
const selectedOptions = Array.from(panelTypeSelect.options).filter(opt => opt.selected); const optionMap = getOptionMap();
const selectedIds = new Set(
Array.from(panelTypeSelect.options)
.filter(opt => opt.selected)
.map(opt => String(opt.value))
);
// Clear list (except empty message) // 1. Update active states on grid items
document.querySelectorAll('.panel-item').forEach(item => {
if (selectedIds.has(String(item.dataset.id))) {
item.classList.add('selected');
} else {
item.classList.remove('selected');
}
});
// 2. Clear selected list container (except empty message)
Array.from(selectedList.children).forEach(child => { Array.from(selectedList.children).forEach(child => {
if (!child.classList.contains('empty-message')) { if (!child.classList.contains('empty-message')) {
child.remove(); child.remove();
} }
}); });
if (selectedOptions.length === 0) { // 3. Process groups to calculate counts, update checkboxes, and build selection items
emptyMessage.style.display = 'block'; const groupItems = document.querySelectorAll('#panel-type-accordion .accordion-item');
} else { const fullySelectedGroups = new Set();
emptyMessage.style.display = 'none'; const groupSelectedPanelsMap = new Map();
selectedOptions.forEach(opt => {
const id = opt.value;
const code = opt.text.trim();
const item = document.createElement('div'); groupItems.forEach(groupEl => {
item.className = 'd-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1 shadow-sm'; const groupKey = groupEl.dataset.groupKey;
item.innerHTML = ` if (!groupKey) return;
<span class="small fw-bold ms-1 text-primary">${code}</span>
<button type="button" class="btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type" data-id="${id}"> const panelsInGroup = Array.from(groupEl.querySelectorAll('.panel-item'));
<i class="bi bi-x"></i> const totalInGroup = panelsInGroup.length;
</button> const selectedPanels = panelsInGroup.filter(p => selectedIds.has(String(p.dataset.id)));
`; const count = selectedPanels.length;
selectedList.appendChild(item);
groupSelectedPanelsMap.set(groupKey, selectedPanels);
// Update group checkbox state (checked / indeterminate / unchecked)
const checkbox = document.getElementById(`check-group-${groupKey}`);
if (checkbox) {
if (count === 0) {
checkbox.checked = false;
checkbox.indeterminate = false;
} else if (count === totalInGroup && totalInGroup > 0) {
checkbox.checked = true;
checkbox.indeterminate = false;
fullySelectedGroups.add(groupKey);
} else {
checkbox.checked = false;
checkbox.indeterminate = true;
}
}
// Update header badge
const badge = document.getElementById(`badge-${groupKey}`);
if (badge) {
if (count > 0) {
badge.textContent = `${count}/${totalInGroup}`;
badge.className = 'badge rounded-pill bg-primary text-white border-0 group-count-badge me-2';
} else {
badge.textContent = '0';
badge.className = 'badge rounded-pill bg-light text-muted border group-count-badge me-2';
}
}
// Update status text inside accordion body
const statusText = document.getElementById(`status-${groupKey}`);
if (statusText) {
if (count === totalInGroup && totalInGroup > 0) {
statusText.innerHTML = `<span class="text-primary fw-bold"><i class="bi bi-check-circle-fill me-1"></i>Tous sélectionnés (${totalInGroup})</span>`;
} else if (count > 0) {
statusText.textContent = `${count} / ${totalInGroup} sélectionné(s)`;
} else {
statusText.textContent = `0 / ${totalInGroup} sélectionné(s)`;
}
}
});
// 4. Render selected list
if (selectedIds.size === 0) {
if (emptyMessage) emptyMessage.style.display = 'block';
} else {
if (emptyMessage) emptyMessage.style.display = 'none';
// Selection header with total count and "Tout effacer" button
const headerRow = document.createElement('div');
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';
headerRow.innerHTML = `
<span class="small fw-bold text-dark">${selectedIds.size} sélectionné(s)</span>
<button type="button" class="btn btn-link btn-sm text-danger p-0 text-decoration-none" id="clear-all-panel-types" style="font-size: 0.75rem;">
<i class="bi bi-trash3 me-1"></i>Tout effacer
</button>
`;
selectedList.appendChild(headerRow);
const handledPanelIds = new Set();
// Display consolidated cards for fully selected groups first
groupItems.forEach(groupEl => {
const groupKey = groupEl.dataset.groupKey;
if (!groupKey) return;
const groupLabel = groupEl.querySelector('.group-title')?.textContent?.trim() || groupKey;
const selectedPanels = groupSelectedPanelsMap.get(groupKey) || [];
if (fullySelectedGroups.has(groupKey)) {
// Entire group selected
const groupItem = document.createElement('div');
groupItem.className = 'd-flex justify-content-between align-items-center bg-primary-subtle border border-primary-subtle rounded p-1 mb-1 shadow-sm';
groupItem.innerHTML = `
<div class="d-flex align-items-center text-truncate me-2">
<i class="bi bi-folder-check text-primary me-1 ms-1"></i>
<span class="small fw-bold text-primary text-truncate">${groupLabel}</span>
<span class="badge bg-primary text-white ms-2" style="font-size: 0.65rem;">Tous (${selectedPanels.length})</span>
</div>
<button type="button" class="btn btn-sm btn-link text-danger p-0 me-1 remove-group-type" data-group="${groupKey}" title="Désélectionner tout le groupe">
<i class="bi bi-x-lg"></i>
</button>
`;
selectedList.appendChild(groupItem);
selectedPanels.forEach(p => handledPanelIds.add(String(p.dataset.id)));
} else if (selectedPanels.length > 0) {
// Partially selected group: list individual items
selectedPanels.forEach(p => {
const id = String(p.dataset.id);
const code = p.dataset.code || optionMap.get(id)?.text?.trim() || id;
handledPanelIds.add(id);
const item = document.createElement('div');
item.className = 'd-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1 shadow-sm';
item.innerHTML = `
<span class="small fw-bold ms-1 text-primary">${code}</span>
<button type="button" class="btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type" data-id="${id}" title="Retirer">
<i class="bi bi-x"></i>
</button>
`;
selectedList.appendChild(item);
});
}
});
// Any remaining panels not in groups (fallback)
selectedIds.forEach(id => {
if (!handledPanelIds.has(id)) {
const opt = optionMap.get(id);
const code = opt ? opt.text.trim() : id;
const item = document.createElement('div');
item.className = 'd-flex justify-content-between align-items-center bg-white border rounded p-1 mb-1 shadow-sm';
item.innerHTML = `
<span class="small fw-bold ms-1 text-primary">${code}</span>
<button type="button" class="btn btn-sm btn-link text-danger p-0 me-1 remove-panel-type" data-id="${id}" title="Retirer">
<i class="bi bi-x"></i>
</button>
`;
selectedList.appendChild(item);
}
}); });
} }
// Update active state in grid
document.querySelectorAll('.panel-item').forEach(item => {
const id = item.dataset.id;
if (Array.from(panelTypeSelect.options).find(o => o.value === id && o.selected)) {
item.classList.add('selected');
} else {
item.classList.remove('selected');
}
});
} }
// Handle accordion toggle to load images // Set group selection helper
accordionButtons.forEach(btn => { function setGroupSelection(groupKey, isSelected) {
const collapseDiv = document.querySelector(`#collapse-${groupKey}`);
if (!collapseDiv) return;
const optionMap = getOptionMap();
collapseDiv.querySelectorAll('.panel-item').forEach(item => {
const opt = optionMap.get(String(item.dataset.id));
if (opt) {
opt.selected = isSelected;
}
});
if (isSelected) {
loadGroupImages(groupKey);
}
updateSelectedList();
}
// Group checkbox change handler
document.addEventListener('change', function(e) {
if (e.target && e.target.classList.contains('group-select-all')) {
const groupKey = e.target.dataset.group;
setGroupSelection(groupKey, e.target.checked);
}
});
// Toolbar and action button handlers via event delegation
document.addEventListener('click', function(e) {
const selectAllBtn = e.target.closest('.select-all-btn');
if (selectAllBtn) {
const groupKey = selectAllBtn.dataset.group;
setGroupSelection(groupKey, true);
return;
}
const deselectAllBtn = e.target.closest('.deselect-all-btn');
if (deselectAllBtn) {
const groupKey = deselectAllBtn.dataset.group;
setGroupSelection(groupKey, false);
return;
}
// Remove entire group from selected list
const removeGroupBtn = e.target.closest('.remove-group-type');
if (removeGroupBtn) {
const groupKey = removeGroupBtn.dataset.group;
setGroupSelection(groupKey, false);
return;
}
// Remove single panel from selected list
const removeBtn = e.target.closest('.remove-panel-type');
if (removeBtn) {
const id = String(removeBtn.dataset.id);
const optionMap = getOptionMap();
const opt = optionMap.get(id);
if (opt) {
opt.selected = false;
updateSelectedList();
}
return;
}
// Clear all panel types
const clearAllBtn = e.target.closest('#clear-all-panel-types');
if (clearAllBtn) {
Array.from(panelTypeSelect.options).forEach(opt => opt.selected = false);
updateSelectedList();
return;
}
// Panel item click in grid
const item = e.target.closest('.panel-item');
if (item) {
const id = String(item.dataset.id);
const optionMap = getOptionMap();
const opt = optionMap.get(id);
if (opt) {
opt.selected = !opt.selected;
updateSelectedList();
}
return;
}
});
// Handle accordion toggle to load images & sync visual open class
document.querySelectorAll('#panel-type-accordion .accordion-button').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const group = btn.dataset.group; const group = btn.dataset.group;
const collapseDiv = document.querySelector(`#collapse-${group}`); loadGroupImages(group);
if (collapseDiv) {
collapseDiv.querySelectorAll('.panel-item').forEach(item => {
const placeholder = item.querySelector('.panel-img-placeholder');
if (placeholder && placeholder.children.length === 0) {
const imageUrl = item.dataset.imageUrl;
const code = item.dataset.code;
if (imageUrl) {
const img = document.createElement('img');
img.src = imageUrl;
img.style.maxHeight = '100%';
img.style.maxWidth = '100%';
placeholder.appendChild(img);
} else if (typeof buildPanelIconElement === 'function') {
const img = buildPanelIconElement(code);
img.style.height = '40px';
img.style.width = '40px';
placeholder.appendChild(img);
} else {
placeholder.textContent = code;
}
}
});
}
}); });
}); });
// Handle panel item click document.querySelectorAll('#panel-type-accordion .accordion-collapse').forEach(collapse => {
document.addEventListener('click', function(e) { collapse.addEventListener('show.bs.collapse', () => {
const item = e.target.closest('.panel-item'); const header = collapse.previousElementSibling;
if (item) { if (header) header.classList.add('is-open');
const id = item.dataset.id; });
const option = Array.from(panelTypeSelect.options).find(o => o.value === id); collapse.addEventListener('hide.bs.collapse', () => {
if (option) { const header = collapse.previousElementSibling;
option.selected = !option.selected; if (header) header.classList.remove('is-open');
updateSelectedList(); });
}
}
}); });
// Handle remove click // Listen to panelTypeSelect changes and reset button
document.addEventListener('click', function(e) { panelTypeSelect.addEventListener('change', updateSelectedList);
const btn = e.target.closest('.remove-panel-type'); const resetFiltersBtn = document.getElementById('reset-filters');
if (btn) { if (resetFiltersBtn) {
const id = btn.dataset.id; resetFiltersBtn.addEventListener('click', function() {
const option = Array.from(panelTypeSelect.options).find(o => o.value === id); setTimeout(updateSelectedList, 0);
if (option) { });
option.selected = false; }
updateSelectedList();
}
}
});
// Initial update // Initial update
updateSelectedList(); updateSelectedList();

View file

@ -1202,3 +1202,74 @@ class SupportRepresentationAndModelTests(TestCase):
self.assertTrue(tl_support['properties'].get('is_standalone_support')) self.assertTrue(tl_support['properties'].get('is_standalone_support'))
class SignPanelCategoryFilterTest(TestCase):
"""Tests for panel type category grouping and filter controls."""
def setUp(self):
from django.contrib.auth.models import User
from common.models import UserConfig, AppView
from sign.models import SignPanelType
self.user = User.objects.create_superuser(username="filter_test_user", password="password123", email="filter@test.com")
self.sign_view, _ = AppView.objects.get_or_create(
code='sign',
defaults={
'name_fr': 'Signalisation',
'name_nl': 'Signalisatie',
'url_name': 'sign:index',
}
)
self.user_config = UserConfig.objects.create(user=self.user)
self.user_config.accessible_views.add(self.sign_view)
# Create panel types across different categories
self.type_danger_1 = SignPanelType.objects.create(code="A1a", name_fr="Virage à droite", name_nl="Bocht rechts")
self.type_danger_2 = SignPanelType.objects.create(code="A1b", name_fr="Virage à gauche", name_nl="Bocht links")
self.type_priority = SignPanelType.objects.create(code="B1", name_fr="Céder le passage", name_nl="Voorrang verlenen")
self.type_prohibition = SignPanelType.objects.create(code="C1", name_fr="Sens interdit", name_nl="Verboden richting")
def test_get_grouped_panel_types(self):
"""Verify get_grouped_panel_types categorizes panel types according to Belgian traffic code."""
from sign.views import get_grouped_panel_types
grouped, groups = get_grouped_panel_types()
# Check group labels exist
group_keys = [g[0] for g in groups]
self.assertIn("danger", group_keys)
self.assertIn("priority", group_keys)
self.assertIn("prohibition", group_keys)
# Check danger group contains A1a and A1b
danger_codes = [p['code'] for p in grouped['danger']]
self.assertIn("A1a", danger_codes)
self.assertIn("A1b", danger_codes)
# Check priority contains B1
priority_codes = [p['code'] for p in grouped['priority']]
self.assertIn("B1", priority_codes)
def test_index_view_renders_category_filter_controls(self):
"""Verify the sign index view renders the category select-all checkboxes and quick-action toolbar."""
self.client.force_login(self.user)
response = self.client.get(reverse('sign:index'))
self.assertEqual(response.status_code, 200)
content = response.content.decode('utf-8')
# Verify accordion item with group key
self.assertIn('data-group-key="danger"', content)
# Verify group select-all checkbox
self.assertIn('class="form-check-input group-select-all m-0"', content)
self.assertIn('id="check-group-danger"', content)
self.assertIn('data-group="danger"', content)
# Verify count badge
self.assertIn('id="badge-danger"', content)
# Verify toolbar buttons: Tout cocher / Tout décocher
self.assertIn('select-all-btn', content)
self.assertIn('deselect-all-btn', content)
# Verify data-group attribute on panel item
self.assertIn('data-group="danger"', content)