Виджет с усреднёнными точками на карте

This commit is contained in:
2025-11-14 16:58:13 +03:00
parent d61236dee2
commit bc226bfc1a
16 changed files with 2268 additions and 14 deletions

View File

@@ -16,6 +16,9 @@
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:objitem_list' %}">Объекты</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:transponder_list' %}">Транспондеры</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{% url 'mainapp:actions' %}">Действия</a>
</li>

View File

@@ -0,0 +1,237 @@
{% extends "mainapp/base.html" %}
{% load static %}
{% block title %}Визуализация усреднения источника #{{ source_id }}{% endblock title %}
{% block extra_css %}
<!-- Leaflet CSS -->
<link href="{% static 'leaflet/leaflet.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-measure/leaflet-measure.css' %}" rel="stylesheet">
<link href="{% static 'leaflet-tree/L.Control.Layers.Tree.css' %}" rel="stylesheet">
<style>
body {
overflow: hidden;
}
#map {
position: fixed;
top: 56px; /* Высота navbar */
bottom: 0;
left: 0;
right: 0;
z-index: 1;
}
.legend {
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 0 10px rgba(0,0,0,0.2);
font-size: 11px;
max-height: 80vh;
overflow-y: auto;
}
.legend h6 {
font-size: 13px;
margin: 0 0 8px 0;
font-weight: bold;
}
.legend-item {
margin: 4px 0;
display: flex;
align-items: center;
}
.legend-marker {
width: 18px;
height: 30px;
margin-right: 6px;
background-size: contain;
background-repeat: no-repeat;
flex-shrink: 0;
}
.legend-info {
background: #f8f9fa;
padding: 6px;
border-radius: 3px;
margin-bottom: 8px;
font-size: 11px;
}
</style>
{% endblock %}
{% block content %}
<div id="map"></div>
{% endblock content %}
{% block extra_js %}
<!-- Leaflet JavaScript -->
<script src="{% static 'leaflet/leaflet.js' %}"></script>
<script src="{% static 'leaflet-measure/leaflet-measure.ru.js' %}"></script>
<script src="{% static 'leaflet-tree/L.Control.Layers.Tree.js' %}"></script>
<script>
// Инициализация карты
let map = L.map('map').setView([55.75, 37.62], 10);
L.control.scale({
imperial: false,
metric: true
}).addTo(map);
map.attributionControl.setPrefix(false);
const street = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
});
street.addTo(map);
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: 'Tiles &copy; Esri'
});
const street_local = L.tileLayer('http://127.0.0.1:8080/styles/basic-preview/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: 'Local Tiles'
});
const baseLayers = {
"Улицы": street,
"Спутник": satellite,
"Локально": street_local
};
L.control.layers(baseLayers).addTo(map);
map.setMaxZoom(18);
map.setMinZoom(0);
L.control.measure({ primaryLengthUnit: 'kilometers' }).addTo(map);
// Цвета для маркеров
var markerColors = {
'red': 'red',
'orange': 'orange',
'blue': 'blue',
'green': 'green',
'purple': 'violet',
'cyan': 'yellow',
'violet': 'violet'
};
var getColorIcon = function(color) {
var iconColor = markerColors[color] || color;
return L.icon({
iconUrl: '{% static "leaflet-markers/img/marker-icon-" %}' + iconColor + '.png',
shadowUrl: '{% static "leaflet-markers/img/marker-shadow.png" %}',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
});
};
var overlays = [];
// Создаём слои для каждой группы
{% for group in groups %}
var groupName = '{{ group.name|escapejs }}';
var colorName = '{{ group.color }}';
var groupIcon = getColorIcon(colorName);
var groupLayer = L.layerGroup();
var subgroup = [];
{% for point_data in group.points %}
var popupContent = '';
{% if point_data.name %}
popupContent += '<strong>{{ point_data.name|escapejs }}</strong><br>';
{% endif %}
{% if point_data.frequency %}
popupContent += 'Частота: {{ point_data.frequency|escapejs }}<br>';
{% endif %}
{% if point_data.objitem_id %}
popupContent += 'ObjItem ID: {{ point_data.objitem_id }}<br>';
{% endif %}
{% if point_data.step %}
popupContent += '{{ point_data.step|escapejs }}<br>';
{% endif %}
{% if point_data.distance %}
popupContent += 'Расстояние: {{ point_data.distance|escapejs }}<br>';
{% endif %}
{% if point_data.source_id %}
popupContent += '{{ point_data.source_id|escapejs }}<br>';
{% endif %}
var marker = L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}], {
icon: groupIcon
}).bindPopup(popupContent || 'Точка {{ forloop.counter }}');
groupLayer.addLayer(marker);
var label = "{{ forloop.counter }}";
{% if point_data.name %}
label += " - {{ point_data.name|escapejs|truncatechars:30 }}";
{% elif point_data.step %}
label += " - {{ point_data.step|escapejs }}";
{% elif point_data.source_id %}
label += " - {{ point_data.source_id|escapejs }}";
{% endif %}
subgroup.push({
label: label,
layer: marker
});
{% endfor %}
overlays.push({
label: groupName + ' ({{ group.points|length }})',
selectAllCheckbox: true,
children: subgroup,
layer: groupLayer
});
{% endfor %}
// Корневая группа
const rootGroup = {
label: "Все точки",
selectAllCheckbox: true,
children: overlays,
layer: L.layerGroup()
};
// Создаём tree control
const layerControl = L.control.layers.tree(baseLayers, [rootGroup], {
collapsed: false,
autoZIndex: true
});
layerControl.addTo(map);
// Подгоняем карту под все маркеры
{% if groups %}
var groupBounds = L.featureGroup([]);
{% for group in groups %}
{% for point_data in group.points %}
groupBounds.addLayer(L.marker([{{ point_data.point.1|safe }}, {{ point_data.point.0|safe }}]));
{% endfor %}
{% endfor %}
map.fitBounds(groupBounds.getBounds().pad(0.1));
{% endif %}
// Добавляем легенду в левый нижний угол
var legend = L.control({ position: 'bottomleft' });
legend.onAdd = function(map) {
var div = L.DomUtil.create('div', 'legend');
div.innerHTML = '<h6>Визуализация усреднения</h6>';
div.innerHTML += '<div class="legend-info">';
div.innerHTML += 'Источник: #{{ source_id }}<br>';
div.innerHTML += 'Всего точек: {{ total_points }}<br>';
div.innerHTML += 'Шагов усреднения: {{ total_steps }}';
div.innerHTML += '</div>';
{% for group in groups %}
div.innerHTML += `
<div class="legend-item">
<div class="legend-marker" style="background-image: url('{% static "leaflet-markers/img/marker-icon-" %}${markerColors['{{ group.color }}'] || '{{ group.color }}'}.png');"></div>
<span>{{ group.name|escapejs }} ({{ group.points|length }})</span>
</div>
`;
{% endfor %}
return div;
};
legend.addTo(map);
</script>
{% endblock extra_js %}

View File

@@ -0,0 +1,137 @@
{% extends 'mainapp/base.html' %}
{% block title %}Подтверждение удаления источников{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="bi bi-exclamation-triangle"></i> Подтверждение удаления источников
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading">
<i class="bi bi-exclamation-circle"></i> Внимание!
</h5>
<p class="mb-0">
Вы собираетесь удалить <strong>{{ total_sources }}</strong> источник(ов).
Это действие также удалит <strong>{{ total_objitems }}</strong> связанных точек.
</p>
<hr>
<p class="mb-0">
<strong>Это действие необратимо!</strong> Все данные будут безвозвратно удалены.
</p>
</div>
<h5 class="mt-4 mb-3">Детали удаления:</h5>
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-striped table-hover table-sm">
<thead class="table-dark sticky-top">
<tr>
<th class="text-center" style="width: 15%;">ID источника</th>
<th class="text-center" style="width: 20%;">Кол-во точек</th>
<th style="width: 65%;">Спутники</th>
</tr>
</thead>
<tbody>
{% for source in sources_info %}
<tr>
<td class="text-center">{{ source.id }}</td>
<td class="text-center">
<span class="badge bg-primary">{{ source.objitem_count }}</span>
</td>
<td>{{ source.satellites }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="alert alert-info mt-4" role="alert">
<h6 class="alert-heading">Что будет удалено:</h6>
<ul class="mb-0">
<li><strong>{{ total_sources }}</strong> источник(ов)</li>
<li><strong>{{ total_objitems }}</strong> точек ГЛ</li>
<li>Все связанные геолокационные данные</li>
<li>Все связанные параметры</li>
</ul>
</div>
<form method="post" id="deleteForm">
{% csrf_token %}
<input type="hidden" name="ids" value="{{ ids }}">
<div class="d-flex justify-content-between mt-4">
<a href="{% url 'mainapp:home' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Отмена
</a>
<button type="submit" class="btn btn-danger" id="confirmDeleteBtn">
<i class="bi bi-trash"></i> Подтвердить удаление
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('deleteForm').addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('confirmDeleteBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Удаление...';
const formData = new FormData(this);
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
fetch('{% url "mainapp:delete_selected_sources" %}', {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
window.location.href = '{% url "mainapp:home" %}';
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Подтвердить удаление';
}
})
.catch(error => {
console.error('Error:', error);
alert('Произошла ошибка при удалении источников');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Подтвердить удаление';
});
});
</script>
{% endblock %}

View File

@@ -67,6 +67,12 @@
<!-- Action buttons -->
<div class="d-flex gap-2">
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<button type="button" class="btn btn-danger btn-sm" title="Удалить"
onclick="deleteSelectedSources()">
<i class="bi bi-trash"></i> Удалить
</button>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm" title="Показать на карте"
onclick="showSelectedOnMap()">
<i class="bi bi-map"></i> Карта
@@ -100,6 +106,24 @@
</div>
<div class="offcanvas-body">
<form method="get" id="filter-form">
<!-- Satellite Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Спутник:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', false)">Снять</button>
</div>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Coordinates Average Filter -->
<div class="mb-2">
<label class="form-label">Усредненные координаты:</label>
@@ -168,9 +192,9 @@
</div>
</div>
<!-- ObjItem Count Filter -->
<!-- Point Count Filter -->
<div class="mb-2">
<label class="form-label">Количество ObjItem:</label>
<label class="form-label">Количество точек:</label>
<input type="number" name="objitem_count_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ objitem_count_min|default:'' }}">
<input type="number" name="objitem_count_max" class="form-control form-control-sm"
@@ -217,13 +241,14 @@
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">Спутник</th>
<th scope="col" style="min-width: 150px;">Усредненные координаты</th>
<th scope="col" style="min-width: 150px;">Координаты Кубсата</th>
<th scope="col" style="min-width: 150px;">Координаты оперативников</th>
<th scope="col" style="min-width: 150px;">Координаты справочные</th>
<th scope="col" class="text-center" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('objitem_count')" class="text-white text-decoration-none">
Кол-во ObjItem
Кол-во точек
{% if sort == 'objitem_count' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-objitem_count' %}
@@ -262,6 +287,7 @@
value="{{ source.id }}">
</td>
<td class="text-center">{{ source.id }}</td>
<td>{{ source.satellite }}</td>
<td>{{ source.coords_average }}</td>
<td>{{ source.coords_kupsat }}</td>
<td>{{ source.coords_valid }}</td>
@@ -285,6 +311,19 @@
</button>
{% endif %}
{% if source.objitem_count > 1 %}
<a href="{% url 'mainapp:show_source_averaging_map' source.id %}"
target="_blank"
class="btn btn-sm btn-outline-info"
title="Визуализация усреднения">
<i class="bi bi-diagram-3"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="Недостаточно точек для усреднения">
<i class="bi bi-diagram-3"></i>
</button>
{% endif %}
<button type="button" class="btn btn-sm btn-outline-primary"
onclick="showSourceDetails({{ source.id }})"
title="Показать детали">
@@ -307,7 +346,7 @@
</tr>
{% empty %}
<tr>
<td colspan="10" class="text-center text-muted">Нет данных для отображения</td>
<td colspan="11" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
@@ -335,7 +374,7 @@
</div>
<div id="modalErrorMessage" class="alert alert-danger" style="display: none;"></div>
<div id="modalContent" style="display: none;">
<h6>Связанные объекты (<span id="objitemCount">0</span>):</h6>
<h6>Связанные точки (<span id="objitemCount">0</span>):</h6>
<div class="table-responsive" style="max-height: 60vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm">
<thead class="table-light sticky-top">
@@ -364,7 +403,7 @@
</div>
</div>
<div id="modalNoData" class="text-center text-muted py-4" style="display: none;">
Нет связанных объектов
Нет связанных точек
</div>
</div>
<div class="modal-footer">
@@ -427,6 +466,27 @@ function showSelectedOnMap() {
window.open(url, '_blank'); // Open in a new tab
}
// Function to delete selected sources
function deleteSelectedSources() {
// Get all checked checkboxes
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один источник для удаления');
return;
}
// Extract IDs from checked checkboxes
const selectedIds = [];
checkedCheckboxes.forEach(checkbox => {
selectedIds.push(checkbox.value);
});
// Redirect to confirmation page
const url = '{% url "mainapp:delete_selected_sources" %}' + '?ids=' + selectedIds.join(',');
window.location.href = url;
}
// Search functionality
function performSearch() {
const searchValue = document.getElementById('toolbar-search').value.trim();
@@ -501,6 +561,16 @@ function setupRadioLikeCheckboxes(name) {
});
}
// Function to select/deselect all options in a select element
function selectAllOptions(selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let i = 0; i < selectElement.options.length; i++) {
selectElement.options[i].selected = selectAll;
}
}
}
// Filter counter functionality
function updateFilterCounter() {
const form = document.getElementById('filter-form');
@@ -510,6 +580,19 @@ function updateFilterCounter() {
// Count non-empty form fields
for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') {
// For multi-select fields, skip counting individual selections
if (key === 'satellite_id') {
continue;
}
filterCount++;
}
}
// Count selected options in satellite multi-select field
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
if (satelliteSelect) {
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(opt => opt.selected);
if (selectedOptions.length > 0) {
filterCount++;
}
}
@@ -569,6 +652,11 @@ document.addEventListener('DOMContentLoaded', function() {
input.addEventListener('change', updateFilterCounter);
});
const selectFields = form.querySelectorAll('select');
selectFields.forEach(select => {
select.addEventListener('change', updateFilterCounter);
});
const checkboxFields = form.querySelectorAll('input[type="checkbox"]');
checkboxFields.forEach(checkbox => {
checkbox.addEventListener('change', updateFilterCounter);

View File

@@ -0,0 +1,143 @@
{% extends 'mainapp/base.html' %}
{% block title %}Подтверждение удаления транспондеров{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card border-danger">
<div class="card-header bg-danger text-white">
<h4 class="mb-0">
<i class="bi bi-exclamation-triangle"></i> Подтверждение удаления транспондеров
</h4>
</div>
<div class="card-body">
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading">
<i class="bi bi-exclamation-circle"></i> Внимание!
</h5>
<p class="mb-0">
Вы собираетесь удалить <strong>{{ total_transponders }}</strong> транспондер(ов).
Это действие также удалит <strong>{{ total_objitems }}</strong> связанных точек.
</p>
<hr>
<p class="mb-0">
<strong>Это действие необратимо!</strong> Все данные будут безвозвратно удалены.
</p>
</div>
<h5 class="mt-4 mb-3">Детали удаления:</h5>
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;">
<table class="table table-striped table-hover table-sm">
<thead class="table-dark sticky-top">
<tr>
<th class="text-center" style="width: 10%;">ID</th>
<th style="width: 20%;">Название</th>
<th style="width: 20%;">Спутник</th>
<th class="text-center" style="width: 15%;">Downlink, МГц</th>
<th class="text-center" style="width: 15%;">Полоса, МГц</th>
<th class="text-center" style="width: 20%;">Кол-во точек</th>
</tr>
</thead>
<tbody>
{% for transponder in transponders_info %}
<tr>
<td class="text-center">{{ transponder.id }}</td>
<td>{{ transponder.name }}</td>
<td>{{ transponder.satellite }}</td>
<td class="text-center">{{ transponder.downlink }}</td>
<td class="text-center">{{ transponder.frequency_range }}</td>
<td class="text-center">
<span class="badge bg-primary">{{ transponder.objitem_count }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="alert alert-info mt-4" role="alert">
<h6 class="alert-heading">Что будет удалено:</h6>
<ul class="mb-0">
<li><strong>{{ total_transponders }}</strong> транспондер(ов)</li>
<li><strong>{{ total_objitems }}</strong> точек ГЛ</li>
<li>Все связанные геолокационные данные</li>
<li>Все связанные параметры</li>
</ul>
</div>
<form method="post" id="deleteForm">
{% csrf_token %}
<input type="hidden" name="ids" value="{{ ids }}">
<div class="d-flex justify-content-between mt-4">
<a href="{% url 'mainapp:transponder_list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Отмена
</a>
<button type="submit" class="btn btn-danger" id="confirmDeleteBtn">
<i class="bi bi-trash"></i> Подтвердить удаление
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('deleteForm').addEventListener('submit', function(e) {
e.preventDefault();
const btn = document.getElementById('confirmDeleteBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Удаление...';
const formData = new FormData(this);
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
const csrftoken = getCookie('csrftoken');
fetch('{% url "mainapp:delete_selected_transponders" %}', {
method: 'POST',
headers: {
'X-CSRFToken': csrftoken,
},
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert(data.message);
window.location.href = '{% url "mainapp:transponder_list" %}';
} else {
alert('Ошибка: ' + (data.error || 'Неизвестная ошибка'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Подтвердить удаление';
}
})
.catch(error => {
console.error('Error:', error);
alert('Произошла ошибка при удалении транспондеров');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-trash"></i> Подтвердить удаление';
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,280 @@
{% extends 'mainapp/base.html' %}
{% block title %}{{ title }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-10">
<div class="card">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">{{ title }}</h4>
</div>
<div class="card-body">
{% if action == 'update' and objitem_count > 0 %}
<div class="alert alert-info" role="alert">
<i class="bi bi-info-circle"></i>
С этим транспондером связано <strong>{{ objitem_count }}</strong> точек ГЛ.
</div>
{% endif %}
<form method="post" id="transponderForm">
{% csrf_token %}
<!-- Основная информация -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Основная информация</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.name.id_for_label }}" class="form-label">
{{ form.name.label }}
</label>
{{ form.name }}
{% if form.name.errors %}
<div class="invalid-feedback d-block">
{{ form.name.errors }}
</div>
{% endif %}
{% if form.name.help_text %}
<small class="form-text text-muted">{{ form.name.help_text }}</small>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.sat_id.id_for_label }}" class="form-label">
{{ form.sat_id.label }} <span class="text-danger">*</span>
</label>
{{ form.sat_id }}
{% if form.sat_id.errors %}
<div class="invalid-feedback d-block">
{{ form.sat_id.errors }}
</div>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.zone_name.id_for_label }}" class="form-label">
{{ form.zone_name.label }}
</label>
{{ form.zone_name }}
{% if form.zone_name.errors %}
<div class="invalid-feedback d-block">
{{ form.zone_name.errors }}
</div>
{% endif %}
{% if form.zone_name.help_text %}
<small class="form-text text-muted">{{ form.zone_name.help_text }}</small>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label for="{{ form.polarization.id_for_label }}" class="form-label">
{{ form.polarization.label }}
</label>
{{ form.polarization }}
{% if form.polarization.errors %}
<div class="invalid-feedback d-block">
{{ form.polarization.errors }}
</div>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Частотные параметры -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Частотные параметры</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<label for="{{ form.downlink.id_for_label }}" class="form-label">
{{ form.downlink.label }} <span class="text-danger">*</span>
</label>
{{ form.downlink }}
{% if form.downlink.errors %}
<div class="invalid-feedback d-block">
{{ form.downlink.errors }}
</div>
{% endif %}
{% if form.downlink.help_text %}
<small class="form-text text-muted">{{ form.downlink.help_text }}</small>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.uplink.id_for_label }}" class="form-label">
{{ form.uplink.label }}
</label>
{{ form.uplink }}
{% if form.uplink.errors %}
<div class="invalid-feedback d-block">
{{ form.uplink.errors }}
</div>
{% endif %}
{% if form.uplink.help_text %}
<small class="form-text text-muted">{{ form.uplink.help_text }}</small>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
<div class="col-md-4 mb-3">
<label for="{{ form.frequency_range.id_for_label }}" class="form-label">
{{ form.frequency_range.label }}
</label>
{{ form.frequency_range }}
{% if form.frequency_range.errors %}
<div class="invalid-feedback d-block">
{{ form.frequency_range.errors }}
</div>
{% endif %}
{% if form.frequency_range.help_text %}
<small class="form-text text-muted">{{ form.frequency_range.help_text }}</small>
{% endif %}
{% if action == 'update' %}
<small class="form-text text-muted">
<i class="bi bi-lock"></i> Поле только для чтения при редактировании
</small>
{% endif %}
</div>
</div>
{% if action == 'update' and object.transfer %}
<div class="row">
<div class="col-md-12 mb-3">
<label class="form-label">Перенос (вычисляемое поле)</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.transfer|floatformat:3 }} МГц
</div>
<small class="form-text text-muted">
Автоматически вычисляется как |Downlink - Uplink|
</small>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Дополнительные параметры -->
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Дополнительные параметры</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label for="{{ form.snr.id_for_label }}" class="form-label">
{{ form.snr.label }}
</label>
{{ form.snr }}
{% if form.snr.errors %}
<div class="invalid-feedback d-block">
{{ form.snr.errors }}
</div>
{% endif %}
{% if form.snr.help_text %}
<small class="form-text text-muted">{{ form.snr.help_text }}</small>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Метаданные (только при редактировании) -->
{% if action == 'update' %}
<div class="card mb-3">
<div class="card-header">
<h5 class="mb-0">Метаданные</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Создано</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.created_at|date:"d.m.Y H:i" }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Создал</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.created_by|default:"-" }}
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Обновлено</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.updated_at|date:"d.m.Y H:i" }}
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Обновил</label>
<div class="form-control" readonly style="background-color: #e9ecef;">
{{ object.updated_by|default:"-" }}
</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Кнопки действий -->
<div class="d-flex justify-content-between">
<a href="{% url 'mainapp:transponder_list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> Назад к списку
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save"></i> Сохранить
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('transponderForm');
// Disable readonly fields on submit to prevent them from being sent
form.addEventListener('submit', function(e) {
const readonlyFields = form.querySelectorAll('[readonly], [disabled]');
readonlyFields.forEach(field => {
// Don't disable if it's just a display field (div)
if (field.tagName === 'SELECT' || field.tagName === 'INPUT') {
field.disabled = false;
}
});
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,577 @@
{% extends 'mainapp/base.html' %}
{% block title %}Список транспондеров{% endblock %}
{% block extra_css %}
<style>
.table-responsive tr.selected {
background-color: #d4edff;
}
.sticky-top {
position: sticky;
top: 0;
z-index: 10;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-3">
<div class="row mb-3">
<div class="col-12">
<h2>Список транспондеров</h2>
</div>
</div>
<!-- Toolbar -->
<div class="row mb-3">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="d-flex flex-wrap align-items-center gap-3">
<!-- Search bar -->
<div style="min-width: 200px; flex-grow: 1; max-width: 400px;">
<div class="input-group">
<input type="text" id="toolbar-search" class="form-control" placeholder="Поиск..."
value="{{ search_query|default:'' }}">
<button type="button" class="btn btn-outline-primary"
onclick="performSearch()">Найти</button>
<button type="button" class="btn btn-outline-secondary"
onclick="clearSearch()">Очистить</button>
</div>
</div>
<!-- Items per page select -->
<div>
<label for="items-per-page" class="form-label mb-0">Показать:</label>
<select name="items_per_page" id="items-per-page"
class="form-select form-select-sm d-inline-block" style="width: auto;"
onchange="updateItemsPerPage()">
{% for option in available_items_per_page %}
<option value="{{ option }}" {% if option == items_per_page %}selected{% endif %}>
{{ option }}
</option>
{% endfor %}
</select>
</div>
<!-- Action buttons -->
<div class="d-flex gap-2">
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<a href="{% url 'mainapp:transponder_create' %}" class="btn btn-success btn-sm" title="Создать">
<i class="bi bi-plus-circle"></i> Создать
</a>
<button type="button" class="btn btn-danger btn-sm" title="Удалить"
onclick="deleteSelectedTransponders()">
<i class="bi bi-trash"></i> Удалить
</button>
{% endif %}
</div>
<!-- Filter Toggle Button -->
<div>
<button class="btn btn-outline-primary btn-sm" type="button" data-bs-toggle="offcanvas"
data-bs-target="#offcanvasFilters" aria-controls="offcanvasFilters">
<i class="bi bi-funnel"></i> Фильтры
<span id="filterCounter" class="badge bg-danger" style="display: none;">0</span>
</button>
</div>
<!-- Pagination -->
<div class="ms-auto">
{% include 'mainapp/components/_pagination.html' with page_obj=page_obj show_info=True %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Offcanvas Filter Panel -->
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasFilters" aria-labelledby="offcanvasFiltersLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="offcanvasFiltersLabel">Фильтры</h5>
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Закрыть"></button>
</div>
<div class="offcanvas-body">
<form method="get" id="filter-form">
<!-- Satellite Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Спутник:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('satellite_id', false)">Снять</button>
</div>
<select name="satellite_id" class="form-select form-select-sm mb-2" multiple size="6">
{% for satellite in satellites %}
<option value="{{ satellite.id }}" {% if satellite.id in selected_satellites %}selected{% endif %}>
{{ satellite.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Polarization Selection - Multi-select -->
<div class="mb-2">
<label class="form-label">Поляризация:</label>
<div class="d-flex justify-content-between mb-1">
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', true)">Выбрать</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
onclick="selectAllOptions('polarization', false)">Снять</button>
</div>
<select name="polarization" class="form-select form-select-sm mb-2" multiple size="4">
{% for pol in polarizations %}
<option value="{{ pol.id }}" {% if pol.id in selected_polarizations %}selected{% endif %}>
{{ pol.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Downlink Filter -->
<div class="mb-2">
<label class="form-label">Downlink, МГц:</label>
<input type="number" step="0.001" name="downlink_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ downlink_min|default:'' }}">
<input type="number" step="0.001" name="downlink_max" class="form-control form-control-sm"
placeholder="До" value="{{ downlink_max|default:'' }}">
</div>
<!-- Uplink Filter -->
<div class="mb-2">
<label class="form-label">Uplink, МГц:</label>
<input type="number" step="0.001" name="uplink_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ uplink_min|default:'' }}">
<input type="number" step="0.001" name="uplink_max" class="form-control form-control-sm"
placeholder="До" value="{{ uplink_max|default:'' }}">
</div>
<!-- Frequency Range Filter -->
<div class="mb-2">
<label class="form-label">Полоса, МГц:</label>
<input type="number" step="0.001" name="freq_range_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ freq_range_min|default:'' }}">
<input type="number" step="0.001" name="freq_range_max" class="form-control form-control-sm"
placeholder="До" value="{{ freq_range_max|default:'' }}">
</div>
<!-- SNR Filter -->
<div class="mb-2">
<label class="form-label">ОСШ, дБ:</label>
<input type="number" step="0.1" name="snr_min" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ snr_min|default:'' }}">
<input type="number" step="0.1" name="snr_max" class="form-control form-control-sm"
placeholder="До" value="{{ snr_max|default:'' }}">
</div>
<!-- Date Filter -->
<div class="mb-2">
<label class="form-label">Дата создания:</label>
<input type="date" name="date_from" id="date_from" class="form-control form-control-sm mb-1"
placeholder="От" value="{{ date_from|default:'' }}">
<input type="date" name="date_to" id="date_to" class="form-control form-control-sm"
placeholder="До" value="{{ date_to|default:'' }}">
</div>
<!-- Apply Filters and Reset Buttons -->
<div class="d-grid gap-2 mt-2">
<button type="submit" class="btn btn-primary btn-sm">Применить</button>
<a href="?" class="btn btn-secondary btn-sm">Сбросить</a>
</div>
</form>
</div>
</div>
<!-- Main Table -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 75vh; overflow-y: auto;">
<table class="table table-striped table-hover table-sm" style="font-size: 0.85rem;">
<thead class="table-dark sticky-top">
<tr>
<th scope="col" class="text-center" style="width: 3%;">
<input type="checkbox" id="select-all" class="form-check-input">
</th>
<th scope="col" class="text-center" style="min-width: 60px;">
<a href="javascript:void(0)" onclick="updateSort('id')" class="text-white text-decoration-none">
ID
{% if sort == 'id' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-id' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('name')" class="text-white text-decoration-none">
Название
{% if sort == 'name' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-name' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('sat_id__name')" class="text-white text-decoration-none">
Спутник
{% if sort == 'sat_id__name' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-sat_id__name' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('downlink')" class="text-white text-decoration-none">
Downlink, МГц
{% if sort == 'downlink' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-downlink' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('uplink')" class="text-white text-decoration-none">
Uplink, МГц
{% if sort == 'uplink' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-uplink' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">
<a href="javascript:void(0)" onclick="updateSort('frequency_range')" class="text-white text-decoration-none">
Полоса, МГц
{% if sort == 'frequency_range' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-frequency_range' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 100px;">Перенос, МГц</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('zone_name')" class="text-white text-decoration-none">
Зона покрытия
{% if sort == 'zone_name' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-zone_name' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 80px;">
<a href="javascript:void(0)" onclick="updateSort('polarization__name')" class="text-white text-decoration-none">
Поляризация
{% if sort == 'polarization__name' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-polarization__name' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 80px;">
<a href="javascript:void(0)" onclick="updateSort('snr')" class="text-white text-decoration-none">
ОСШ, дБ
{% if sort == 'snr' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-snr' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" class="text-center" style="min-width: 80px;">
<a href="javascript:void(0)" onclick="updateSort('objitem_count')" class="text-white text-decoration-none">
Кол-во точек
{% if sort == 'objitem_count' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-objitem_count' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('created_at')" class="text-white text-decoration-none">
Создано
{% if sort == 'created_at' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-created_at' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" style="min-width: 120px;">
<a href="javascript:void(0)" onclick="updateSort('updated_at')" class="text-white text-decoration-none">
Обновлено
{% if sort == 'updated_at' %}
<i class="bi bi-arrow-up"></i>
{% elif sort == '-updated_at' %}
<i class="bi bi-arrow-down"></i>
{% endif %}
</a>
</th>
<th scope="col" class="text-center" style="min-width: 100px;">Действия</th>
</tr>
</thead>
<tbody>
{% for transponder in processed_transponders %}
<tr>
<td class="text-center">
<input type="checkbox" class="form-check-input item-checkbox"
value="{{ transponder.id }}">
</td>
<td class="text-center">{{ transponder.id }}</td>
<td>{{ transponder.name }}</td>
<td>{{ transponder.satellite }}</td>
<td>{{ transponder.downlink }}</td>
<td>{{ transponder.uplink }}</td>
<td>{{ transponder.frequency_range }}</td>
<td>{{ transponder.transfer }}</td>
<td>{{ transponder.zone_name }}</td>
<td>{{ transponder.polarization }}</td>
<td>{{ transponder.snr }}</td>
<td class="text-center">{{ transponder.objitem_count }}</td>
<td>{{ transponder.created_at|date:"d.m.Y H:i" }}</td>
<td>{{ transponder.updated_at|date:"d.m.Y H:i" }}</td>
<td class="text-center">
{% if user.customuser.role == 'admin' or user.customuser.role == 'moderator' %}
<a href="{% url 'mainapp:transponder_update' transponder.id %}"
class="btn btn-sm btn-outline-warning"
title="Редактировать транспондер">
<i class="bi bi-pencil"></i>
</a>
{% else %}
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="Недостаточно прав">
<i class="bi bi-pencil"></i>
</button>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="15" class="text-center text-muted">Нет данных для отображения</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let lastCheckedIndex = null;
function updateRowHighlight(checkbox) {
const row = checkbox.closest('tr');
if (checkbox.checked) {
row.classList.add('selected');
} else {
row.classList.remove('selected');
}
}
function handleCheckboxClick(e) {
if (e.shiftKey && lastCheckedIndex !== null) {
const checkboxes = document.querySelectorAll('.item-checkbox');
const currentIndex = Array.from(checkboxes).indexOf(e.target);
const startIndex = Math.min(lastCheckedIndex, currentIndex);
const endIndex = Math.max(lastCheckedIndex, currentIndex);
for (let i = startIndex; i <= endIndex; i++) {
checkboxes[i].checked = e.target.checked;
updateRowHighlight(checkboxes[i]);
}
} else {
updateRowHighlight(e.target);
}
lastCheckedIndex = Array.from(document.querySelectorAll('.item-checkbox')).indexOf(e.target);
}
// Function to delete selected transponders
function deleteSelectedTransponders() {
const checkedCheckboxes = document.querySelectorAll('.item-checkbox:checked');
if (checkedCheckboxes.length === 0) {
alert('Пожалуйста, выберите хотя бы один транспондер для удаления');
return;
}
const selectedIds = [];
checkedCheckboxes.forEach(checkbox => {
selectedIds.push(checkbox.value);
});
const url = '{% url "mainapp:delete_selected_transponders" %}' + '?ids=' + selectedIds.join(',');
window.location.href = url;
}
// Search functionality
function performSearch() {
const searchValue = document.getElementById('toolbar-search').value.trim();
const urlParams = new URLSearchParams(window.location.search);
if (searchValue) {
urlParams.set('search', searchValue);
} else {
urlParams.delete('search');
}
urlParams.delete('page');
window.location.search = urlParams.toString();
}
function clearSearch() {
document.getElementById('toolbar-search').value = '';
const urlParams = new URLSearchParams(window.location.search);
urlParams.delete('search');
urlParams.delete('page');
window.location.search = urlParams.toString();
}
document.getElementById('toolbar-search').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
// Items per page functionality
function updateItemsPerPage() {
const itemsPerPage = document.getElementById('items-per-page').value;
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('items_per_page', itemsPerPage);
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Sorting functionality
function updateSort(field) {
const urlParams = new URLSearchParams(window.location.search);
const currentSort = urlParams.get('sort');
let newSort;
if (currentSort === field) {
newSort = '-' + field;
} else if (currentSort === '-' + field) {
newSort = field;
} else {
newSort = field;
}
urlParams.set('sort', newSort);
urlParams.delete('page');
window.location.search = urlParams.toString();
}
// Function to select/deselect all options in a select element
function selectAllOptions(selectName, selectAll) {
const selectElement = document.querySelector(`select[name="${selectName}"]`);
if (selectElement) {
for (let i = 0; i < selectElement.options.length; i++) {
selectElement.options[i].selected = selectAll;
}
}
}
// Filter counter functionality
function updateFilterCounter() {
const form = document.getElementById('filter-form');
const formData = new FormData(form);
let filterCount = 0;
for (const [key, value] of formData.entries()) {
if (value && value.trim() !== '') {
if (key === 'satellite_id' || key === 'polarization') {
continue;
}
filterCount++;
}
}
// Count selected options in multi-select fields
const satelliteSelect = document.querySelector('select[name="satellite_id"]');
if (satelliteSelect) {
const selectedOptions = Array.from(satelliteSelect.selectedOptions).filter(opt => opt.selected);
if (selectedOptions.length > 0) {
filterCount++;
}
}
const polarizationSelect = document.querySelector('select[name="polarization"]');
if (polarizationSelect) {
const selectedOptions = Array.from(polarizationSelect.selectedOptions).filter(opt => opt.selected);
if (selectedOptions.length > 0) {
filterCount++;
}
}
const counterElement = document.getElementById('filterCounter');
if (counterElement) {
if (filterCount > 0) {
counterElement.textContent = filterCount;
counterElement.style.display = 'inline';
} else {
counterElement.style.display = 'none';
}
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function() {
const selectAllCheckbox = document.getElementById('select-all');
const itemCheckboxes = document.querySelectorAll('.item-checkbox');
if (selectAllCheckbox && itemCheckboxes.length > 0) {
selectAllCheckbox.addEventListener('change', function () {
itemCheckboxes.forEach(checkbox => {
checkbox.checked = selectAllCheckbox.checked;
updateRowHighlight(checkbox);
});
});
itemCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', function () {
const allChecked = Array.from(itemCheckboxes).every(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
});
checkbox.addEventListener('click', handleCheckboxClick);
});
}
updateFilterCounter();
const form = document.getElementById('filter-form');
if (form) {
const inputFields = form.querySelectorAll('input[type="text"], input[type="number"], input[type="date"]');
inputFields.forEach(input => {
input.addEventListener('input', updateFilterCounter);
input.addEventListener('change', updateFilterCounter);
});
const selectFields = form.querySelectorAll('select');
selectFields.forEach(select => {
select.addEventListener('change', updateFilterCounter);
});
}
const offcanvasElement = document.getElementById('offcanvasFilters');
if (offcanvasElement) {
offcanvasElement.addEventListener('show.bs.offcanvas', updateFilterCounter);
}
});
</script>
{% endblock %}