gpon-control-kz/app.js

547 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Глобальные данные
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, '&quot;')})">
<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);