feat: enhance panel type filter with group selection, checkboxes, and bulk management controls.
This commit is contained in:
parent
1b38a8059e
commit
82e20f648e
3 changed files with 444 additions and 88 deletions
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue