547 lines
21 KiB
JavaScript
547 lines
21 KiB
JavaScript
// Глобальные данные
|
||
let allObjects = [];
|
||
let filteredObjects = [];
|
||
let map = null;
|
||
let markers = [];
|
||
|
||
// Загрузка данных
|
||
async function loadData() {
|
||
try {
|
||
const response = await fetch('data.json');
|
||
allObjects = await response.json();
|
||
filteredObjects = [...allObjects];
|
||
|
||
initDashboard();
|
||
initMap();
|
||
populateFilters();
|
||
renderObjects();
|
||
renderProblems();
|
||
renderAnalytics();
|
||
updateNotifications();
|
||
} catch (error) {
|
||
console.error('Ошибка загрузки данных:', error);
|
||
}
|
||
}
|
||
|
||
// Инициализация панели
|
||
function initDashboard() {
|
||
const total = allObjects.length;
|
||
const active = allObjects.filter(o => ['План', 'Обследование', 'СМР'].includes(o.status)).length;
|
||
const completed = allObjects.filter(o => ['Готов', 'Сдан'].includes(o.status)).length;
|
||
const problems = allObjects.filter(o => o.has_problem).length;
|
||
const overdue = allObjects.filter(o => o.is_overdue).length;
|
||
|
||
const portsPlan = allObjects.reduce((sum, o) => sum + (o.ports || 0), 0);
|
||
const portsFact = allObjects.filter(o => ['Готов', 'Сдан'].includes(o.status))
|
||
.reduce((sum, o) => sum + (o.ports || 0), 0);
|
||
|
||
const percent = total > 0 ? ((completed / total) * 100).toFixed(1) : 0;
|
||
|
||
document.getElementById('kpi-total').textContent = total;
|
||
document.getElementById('kpi-active').textContent = active;
|
||
document.getElementById('kpi-completed').textContent = completed;
|
||
document.getElementById('kpi-problems').textContent = problems;
|
||
document.getElementById('kpi-overdue').textContent = overdue;
|
||
document.getElementById('kpi-ports-plan').textContent = portsPlan.toLocaleString();
|
||
document.getElementById('kpi-ports-fact').textContent = portsFact.toLocaleString();
|
||
document.getElementById('kpi-percent').textContent = percent + '%';
|
||
|
||
// График статусов
|
||
const statusCounts = {};
|
||
allObjects.forEach(o => {
|
||
statusCounts[o.status] = (statusCounts[o.status] || 0) + 1;
|
||
});
|
||
|
||
const ctx = document.getElementById('statusChart').getContext('2d');
|
||
new Chart(ctx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: Object.keys(statusCounts),
|
||
datasets: [{
|
||
data: Object.values(statusCounts),
|
||
backgroundColor: [
|
||
'#6b7280', // План
|
||
'#f59e0b', // Обследование
|
||
'#0ea5e9', // СМР
|
||
'#ef4444', // Проблема
|
||
'#10b981', // Готов
|
||
'#10b981' // Сдан
|
||
]
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
position: 'bottom'
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Инициализация карты
|
||
function initMap() {
|
||
map = L.map('map').setView([48.0196, 66.9237], 5);
|
||
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
attribution: '© OpenStreetMap contributors'
|
||
}).addTo(map);
|
||
|
||
// Маркеры объектов
|
||
allObjects.forEach(obj => {
|
||
const [lat, lng] = obj.coordinates;
|
||
|
||
let color = '#1e40af';
|
||
if (obj.is_overdue) color = '#ef4444';
|
||
else if (obj.is_approaching) color = '#f59e0b';
|
||
else if (obj.status === 'Сдан') color = '#10b981';
|
||
else if (obj.status === 'Проблема') color = '#ef4444';
|
||
|
||
const marker = L.circleMarker([lat, lng], {
|
||
radius: 8,
|
||
fillColor: color,
|
||
color: '#fff',
|
||
weight: 2,
|
||
opacity: 1,
|
||
fillOpacity: 0.8
|
||
});
|
||
|
||
marker.bindPopup(`
|
||
<div class="popup-content">
|
||
<h3>${obj.city}</h3>
|
||
<p>${obj.address}</p>
|
||
<p><strong>Портов:</strong> ${obj.ports}</p>
|
||
<p><strong>Статус:</strong> ${obj.status}</p>
|
||
<p><strong>Ответственный:</strong> ${obj.manager}</p>
|
||
</div>
|
||
`);
|
||
|
||
marker.on('click', () => openModal(obj));
|
||
|
||
markers.push(marker);
|
||
marker.addTo(map);
|
||
});
|
||
}
|
||
|
||
// Заполнение фильтров
|
||
function populateFilters() {
|
||
const regions = [...new Set(allObjects.map(o => o.region).filter(Boolean))].sort();
|
||
const cities = [...new Set(allObjects.map(o => o.city).filter(Boolean))].sort();
|
||
const managers = [...new Set(allObjects.map(o => o.manager).filter(Boolean))].sort();
|
||
|
||
const regionSelect = document.getElementById('filter-region');
|
||
const citySelect = document.getElementById('filter-city');
|
||
const managerSelect = document.getElementById('filter-manager');
|
||
|
||
regions.forEach(r => {
|
||
regionSelect.innerHTML += `<option value="${r}">${r}</option>`;
|
||
});
|
||
|
||
cities.forEach(c => {
|
||
citySelect.innerHTML += `<option value="${c}">${c}</option>`;
|
||
});
|
||
|
||
managers.forEach(m => {
|
||
managerSelect.innerHTML += `<option value="${m}">${m}</option>`;
|
||
});
|
||
}
|
||
|
||
// Фильтрация объектов
|
||
function filterObjects() {
|
||
const region = document.getElementById('filter-region').value;
|
||
const city = document.getElementById('filter-city').value;
|
||
const status = document.getElementById('filter-status').value;
|
||
const manager = document.getElementById('filter-manager').value;
|
||
const search = document.getElementById('search-input').value.toLowerCase();
|
||
|
||
filteredObjects = allObjects.filter(o => {
|
||
if (region && o.region !== region) return false;
|
||
if (city && o.city !== city) return false;
|
||
if (status && o.status !== status) return false;
|
||
if (manager && o.manager !== manager) return false;
|
||
if (search && !o.address.toLowerCase().includes(search) &&
|
||
!o.city.toLowerCase().includes(search)) return false;
|
||
return true;
|
||
});
|
||
|
||
renderObjects();
|
||
}
|
||
|
||
// Рендер объектов
|
||
function renderObjects() {
|
||
const container = document.getElementById('objects-list');
|
||
container.innerHTML = '';
|
||
|
||
filteredObjects.forEach(obj => {
|
||
const card = createObjectCard(obj);
|
||
container.innerHTML += card;
|
||
});
|
||
}
|
||
|
||
// Создание карточки объекта
|
||
function createObjectCard(obj) {
|
||
const overdueClass = obj.is_overdue ? 'overdue' : '';
|
||
const approachingClass = obj.is_approaching && !obj.is_overdue ? 'approaching' : '';
|
||
const statusClass = `status-${obj.status.toLowerCase().replace(/ /g, '-')}`;
|
||
|
||
return `
|
||
<div class="object-card ${overdueClass} ${approachingClass} ${statusClass}" onclick="openModal(${JSON.stringify(obj).replace(/"/g, '"')})">
|
||
<div class="flex justify-between items-start mb-3">
|
||
<h3 class="font-bold text-lg text-dark">${obj.city}</h3>
|
||
<span class="status-badge ${obj.status.toLowerCase().replace(/ /g, '-')}">${obj.status}</span>
|
||
</div>
|
||
<p class="text-gray-600 text-sm mb-2">${obj.address}</p>
|
||
<div class="space-y-1 text-sm">
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">Тип:</span>
|
||
<span class="font-medium">${obj.object_type}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">Портов:</span>
|
||
<span class="font-medium">${obj.ports}</span>
|
||
</div>
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">План:</span>
|
||
<span class="font-medium">${formatDate(obj.plan_end_date)}</span>
|
||
</div>
|
||
${obj.fact_end_date ? `
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">Факт:</span>
|
||
<span class="font-medium text-green-600">${formatDate(obj.fact_end_date)}</span>
|
||
</div>
|
||
` : ''}
|
||
<div class="flex justify-between">
|
||
<span class="text-gray-500">Ответственный:</span>
|
||
<span class="font-medium">${obj.manager}</span>
|
||
</div>
|
||
</div>
|
||
${obj.is_overdue ? `
|
||
<div class="mt-3 p-2 bg-red-50 rounded text-red-700 text-xs font-semibold">
|
||
⚠️ Просрочено на ${getDaysOverdue(obj.plan_end_date)} дн.
|
||
</div>
|
||
` : ''}
|
||
${obj.is_approaching && !obj.is_overdue ? `
|
||
<div class="mt-3 p-2 bg-yellow-50 rounded text-yellow-700 text-xs font-semibold">
|
||
⏰ Срок истекает через ${getDaysUntil(obj.plan_end_date)} дн.
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Рендер проблемных объектов
|
||
function renderProblems() {
|
||
const problems = allObjects.filter(o => o.has_problem);
|
||
const container = document.getElementById('problems-list');
|
||
container.innerHTML = '';
|
||
|
||
problems.forEach(obj => {
|
||
container.innerHTML += createObjectCard(obj);
|
||
});
|
||
}
|
||
|
||
// Аналитика
|
||
function renderAnalytics() {
|
||
const regions = {};
|
||
|
||
allObjects.forEach(obj => {
|
||
if (!regions[obj.region]) {
|
||
regions[obj.region] = {
|
||
plan: 0,
|
||
fact: 0,
|
||
portsPlan: 0,
|
||
portsFact: 0
|
||
};
|
||
}
|
||
|
||
regions[obj.region].plan++;
|
||
if (['Готов', 'Сдан'].includes(obj.status)) {
|
||
regions[obj.region].fact++;
|
||
regions[obj.region].portsFact += obj.ports || 0;
|
||
}
|
||
regions[obj.region].portsPlan += obj.ports || 0;
|
||
});
|
||
|
||
const regionNames = Object.keys(regions);
|
||
const planData = regionNames.map(r => regions[r].plan);
|
||
const factData = regionNames.map(r => regions[r].fact);
|
||
const percentData = regionNames.map(r =>
|
||
regions[r].plan > 0 ? ((regions[r].fact / regions[r].plan) * 100).toFixed(1) : 0
|
||
);
|
||
const portsPlanData = regionNames.map(r => regions[r].portsPlan);
|
||
const portsFactData = regionNames.map(r => regions[r].portsFact);
|
||
|
||
// План vs Факт
|
||
new Chart(document.getElementById('regionsChart').getContext('2d'), {
|
||
type: 'bar',
|
||
data: {
|
||
labels: regionNames,
|
||
datasets: [
|
||
{
|
||
label: 'План',
|
||
data: planData,
|
||
backgroundColor: '#1e40af'
|
||
},
|
||
{
|
||
label: 'Факт',
|
||
data: factData,
|
||
backgroundColor: '#10b981'
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
scales: {
|
||
y: { beginAtZero: true }
|
||
}
|
||
}
|
||
});
|
||
|
||
// Процент выполнения
|
||
new Chart(document.getElementById('percentChart').getContext('2d'), {
|
||
type: 'bar',
|
||
data: {
|
||
labels: regionNames,
|
||
datasets: [{
|
||
label: '% выполнения',
|
||
data: percentData,
|
||
backgroundColor: percentData.map(p => p >= 100 ? '#10b981' : p >= 50 ? '#f59e0b' : '#ef4444')
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
scales: {
|
||
y: { beginAtZero: true, max: 100 }
|
||
}
|
||
}
|
||
});
|
||
|
||
// Порты
|
||
new Chart(document.getElementById('portsChart').getContext('2d'), {
|
||
type: 'bar',
|
||
data: {
|
||
labels: regionNames,
|
||
datasets: [
|
||
{
|
||
label: 'Портов по плану',
|
||
data: portsPlanData,
|
||
backgroundColor: '#6366f1'
|
||
},
|
||
{
|
||
label: 'Портов факт',
|
||
data: portsFactData,
|
||
backgroundColor: '#10b981'
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
scales: {
|
||
y: { beginAtZero: true }
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Уведомления
|
||
function updateNotifications() {
|
||
const overdue = allObjects.filter(o => o.is_overdue);
|
||
const approaching = allObjects.filter(o => o.is_approaching && !o.is_overdue);
|
||
|
||
const badge = document.getElementById('notif-badge');
|
||
badge.textContent = overdue.length + approaching.length;
|
||
|
||
const list = document.getElementById('notif-list');
|
||
list.innerHTML = '';
|
||
|
||
overdue.forEach(obj => {
|
||
list.innerHTML += `
|
||
<div class="notif-item danger" onclick="showObject(${obj.id}); toggleNotifications();">
|
||
<p class="font-semibold text-red-800">Просрочен: ${obj.city}</p>
|
||
<p class="text-sm text-red-600">${obj.address}</p>
|
||
<p class="text-xs text-red-500 mt-1">План: ${formatDate(obj.plan_end_date)}</p>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
approaching.forEach(obj => {
|
||
list.innerHTML += `
|
||
<div class="notif-item warning" onclick="showObject(${obj.id}); toggleNotifications();">
|
||
<p class="font-semibold text-yellow-800">Срок подходит: ${obj.city}</p>
|
||
<p class="text-sm text-yellow-600">${obj.address}</p>
|
||
<p class="text-xs text-yellow-500 mt-1">План: ${formatDate(obj.plan_end_date)}</p>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
if (overdue.length === 0 && approaching.length === 0) {
|
||
list.innerHTML = '<p class="text-gray-500 text-center py-4">Нет уведомлений</p>';
|
||
}
|
||
}
|
||
|
||
function toggleNotifications() {
|
||
const notif = document.getElementById('notifications');
|
||
notif.classList.toggle('hidden');
|
||
}
|
||
|
||
// Модальное окно
|
||
function openModal(obj) {
|
||
document.getElementById('modal-title').textContent = `${obj.city} - ${obj.address}`;
|
||
|
||
const content = document.getElementById('modal-content');
|
||
content.innerHTML = `
|
||
<div class="space-y-4">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="bg-gray-50 p-3 rounded-lg">
|
||
<p class="text-gray-500 text-sm">Тип объекта</p>
|
||
<p class="font-semibold">${obj.object_type}</p>
|
||
</div>
|
||
<div class="bg-gray-50 p-3 rounded-lg">
|
||
<p class="text-gray-500 text-sm">Статус</p>
|
||
<p class="font-semibold">${obj.status}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="bg-gray-50 p-3 rounded-lg">
|
||
<p class="text-gray-500 text-sm">Количество портов</p>
|
||
<p class="font-semibold text-2xl">${obj.ports}</p>
|
||
</div>
|
||
<div class="bg-gray-50 p-3 rounded-lg">
|
||
<p class="text-gray-500 text-sm">Проникновение</p>
|
||
<p class="font-semibold text-2xl">${obj.penetration || 0}%</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">Регион:</span>
|
||
<span class="font-medium">${obj.region}</span>
|
||
</div>
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">Адрес:</span>
|
||
<span class="font-medium">${obj.address}</span>
|
||
</div>
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">Ответственный:</span>
|
||
<span class="font-medium">${obj.manager}</span>
|
||
</div>
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">Дата начала:</span>
|
||
<span class="font-medium">${formatDate(obj.start_date)}</span>
|
||
</div>
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">Плановая дата:</span>
|
||
<span class="font-medium">${formatDate(obj.plan_end_date)}</span>
|
||
</div>
|
||
${obj.fact_end_date ? `
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">Фактическая дата:</span>
|
||
<span class="font-medium text-green-600">${formatDate(obj.fact_end_date)}</span>
|
||
</div>
|
||
` : ''}
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">Домов:</span>
|
||
<span class="font-medium">${obj.houses || 0}</span>
|
||
</div>
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">Квартир:</span>
|
||
<span class="font-medium">${obj.apartments || 0}</span>
|
||
</div>
|
||
</div>
|
||
|
||
${obj.problem_description ? `
|
||
<div class="bg-red-50 p-4 rounded-lg">
|
||
<p class="font-semibold text-red-800 mb-2">⚠️ Описание проблемы</p>
|
||
<p class="text-red-700">${obj.problem_description}</p>
|
||
</div>
|
||
` : ''}
|
||
|
||
${obj.comment ? `
|
||
<div class="bg-blue-50 p-4 rounded-lg">
|
||
<p class="font-semibold text-blue-800 mb-2">💬 Комментарий</p>
|
||
<p class="text-blue-700">${obj.comment}</p>
|
||
</div>
|
||
` : ''}
|
||
|
||
${obj.is_overdue ? `
|
||
<div class="bg-red-100 p-4 rounded-lg border-2 border-red-300">
|
||
<p class="font-bold text-red-800">🚨 ОБЪЕКТ ПРОСРОЧЕН</p>
|
||
<p class="text-red-700 mt-1">Просрочка: ${getDaysOverdue(obj.plan_end_date)} дн.</p>
|
||
</div>
|
||
` : ''}
|
||
|
||
${obj.is_approaching && !obj.is_overdue ? `
|
||
<div class="bg-yellow-100 p-4 rounded-lg border-2 border-yellow-300">
|
||
<p class="font-bold text-yellow-800">⏰ ВНИМАНИЕ: СРОК ПОДХОДИТ</p>
|
||
<p class="text-yellow-700 mt-1">Осталось дней: ${getDaysUntil(obj.plan_end_date)}</p>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('object-modal').classList.remove('hidden');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('object-modal').classList.add('hidden');
|
||
}
|
||
|
||
function showObject(id) {
|
||
const obj = allObjects.find(o => o.id === id);
|
||
if (obj) openModal(obj);
|
||
}
|
||
|
||
// Переключение секций
|
||
function showSection(sectionId) {
|
||
document.querySelectorAll('.section').forEach(s => s.classList.add('hidden'));
|
||
document.getElementById(sectionId).classList.remove('hidden');
|
||
|
||
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
||
event.target.classList.add('active');
|
||
|
||
if (sectionId === 'objects') {
|
||
setTimeout(() => map.invalidateSize(), 300);
|
||
}
|
||
}
|
||
|
||
// Вспомогательные функции
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '—';
|
||
const date = new Date(dateStr);
|
||
return date.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
}
|
||
|
||
function getDaysOverdue(dateStr) {
|
||
const today = new Date('2026-08-17');
|
||
const planDate = new Date(dateStr);
|
||
const diff = Math.floor((today - planDate) / (1000 * 60 * 60 * 24));
|
||
return diff > 0 ? diff : 0;
|
||
}
|
||
|
||
function getDaysUntil(dateStr) {
|
||
const today = new Date('2026-08-17');
|
||
const planDate = new Date(dateStr);
|
||
const diff = Math.floor((planDate - today) / (1000 * 60 * 60 * 24));
|
||
return diff > 0 ? diff : 0;
|
||
}
|
||
|
||
// Закрытие модального окна по клику вне
|
||
document.getElementById('object-modal').addEventListener('click', (e) => {
|
||
if (e.target.id === 'object-modal') closeModal();
|
||
});
|
||
|
||
// Закрытие уведомлений по клику вне
|
||
document.addEventListener('click', (e) => {
|
||
const notif = document.getElementById('notifications');
|
||
const btn = e.target.closest('button[onclick*="toggleNotifications"]');
|
||
if (!notif.contains(e.target) && !btn && !notif.classList.contains('hidden')) {
|
||
notif.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// Инициализация при загрузке
|
||
document.addEventListener('DOMContentLoaded', loadData);
|