snp-report/index.html

430 lines
19 KiB
HTML
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.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>СНП — отбор по КАТО для расчётов</title>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;--green:#27AE60;--green-bg:#E8F8F0;--orange:#E67E22;--orange-bg:#FEF5E7}
*{box-sizing:border-box;margin:0;padding:0}
body{font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--white)}
.container{max-width:1200px;margin:0 auto;padding:60px 24px}
.hero{background:var(--ink);color:var(--white)}
.hero h1{font-size:48px;font-weight:800;line-height:1.05;margin-bottom:12px}
.hero p{font-size:18px;color:#9aa3b2;max-width:650px}
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:12px 24px;border-radius:8px;font-weight:700;font-size:15px;border:none;cursor:pointer;transition:background .15s}
.btn:hover{background:#1be5ff}
.btn-sm{padding:8px 16px;font-size:13px}
.btn-outline{background:transparent;border:2px solid var(--ink);padding:10px 22px}
.btn-outline:hover{background:var(--gray-100)}
.btn-green{background:var(--green);color:var(--white)}
.btn-green:hover{background:#219A52}
.card{background:var(--gray-100);border-radius:16px;padding:28px;margin-bottom:20px}
.section h2{font-size:30px;font-weight:700;margin-bottom:4px}
.section .subtitle{color:var(--gray-500);margin-bottom:24px}
.drop-zone{border:2px dashed var(--gray-500);border-radius:16px;padding:48px 24px;text-align:center;cursor:pointer;transition:border-color .2s,background .2s}
.drop-zone:hover,.drop-zone.dragover{border-color:var(--cyan);background:var(--cyan-50)}
.drop-zone.has-file{padding:20px 24px}
.drop-zone input{display:none}
.drop-zone-icon{font-size:40px}
.filename{font-weight:700;font-size:17px}
.filename small{font-weight:400;color:var(--gray-500)}
/* filters */
.filter-bar{display:flex;flex-wrap:wrap;gap:12px;align-items:end;margin-bottom:20px}
.filter-group{flex:1;min-width:180px}
.filter-group label{display:block;font-size:12px;font-weight:600;color:var(--gray-500);margin-bottom:4px;text-transform:uppercase;letter-spacing:.5px}
.filter-group select,.filter-group input[type=text]{width:100%;padding:10px 14px;border:2px solid var(--gray-100);border-radius:8px;font-size:15px;background:var(--white)}
.filter-group select:focus,.filter-group input:focus{outline:none;border-color:var(--cyan)}
.check-filter{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:14px;font-weight:600;padding:10px 0;white-space:nowrap}
.check-filter input{width:18px;height:18px;cursor:pointer}
/* summary */
.summary-bar{display:flex;flex-wrap:wrap;gap:12px;justify-content:space-between;align-items:center;margin-bottom:16px;padding:14px 20px;background:var(--white);border-radius:12px;border:1px solid var(--gray-100)}
.summary-count{font-size:17px;font-weight:700}
.summary-count .num{color:var(--cyan)}
.summary-count .marked{color:var(--green)}
.actions{display:flex;flex-wrap:wrap;gap:8px}
/* table */
.table-wrap{overflow-x:auto;border-radius:12px;border:1px solid var(--gray-100)}
table{width:100%;border-collapse:collapse;font-size:14px}
thead{background:var(--ink);color:var(--white)}
thead th{padding:12px 14px;text-align:left;white-space:nowrap;font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.4px;position:sticky;top:0}
th.chk{width:44px;text-align:center}
th.chk input{width:16px;height:16px;cursor:pointer}
tbody td{padding:10px 14px;border-bottom:1px solid var(--gray-100);vertical-align:middle}
td.chk{text-align:center}
td.chk input{width:16px;height:16px;cursor:pointer}
tbody tr:hover{background:var(--cyan-50)}
tbody tr.marked{background:var(--green-bg)}
tbody tr.marked:hover{background:var(--green-bg)}
.district-header{background:#E8ECF0!important;font-weight:700;font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:var(--gray-500)}
.district-header td{padding:8px 14px!important}
.hidden{display:none!important}
.empty-state{text-align:center;padding:60px 24px;color:var(--gray-500);font-size:18px}
@media (max-width:640px){
.hero h1{font-size:32px}.container{padding:40px 16px}
.filter-bar{flex-direction:column}.filter-group{min-width:100%}
}
</style>
</head>
<body>
<section class="hero">
<div class="container" style="padding:48px 24px">
<h1>📋 Отбор СНП для расчётов</h1>
<p>Загрузите справочник населённых пунктов, отметьте какие учитываются в расчётах, и скачайте готовую выборку.</p>
</div>
</section>
<section class="section">
<div class="container">
<div class="card">
<h2>📁 Загрузить файл</h2>
<p class="subtitle">Excel с колонками: КАТО, область, район, округ, название СНП</p>
<div class="drop-zone" id="dropZone">
<input type="file" id="fileInput" accept=".xlsx,.xls,.csv">
<div><span class="drop-zone-icon">📂</span></div>
<div style="font-size:17px">Перетащите файл сюда или <strong>выберите</strong></div>
<div style="color:var(--gray-500);font-size:14px;margin-top:4px">.xlsx, .xls, .csv</div>
</div>
</div>
<div id="resultArea" class="hidden">
<div class="card">
<div style="display:flex;flex-wrap:wrap;justify-content:space-between;align-items:start;gap:12px;margin-bottom:16px">
<div>
<h2>🔍 Фильтр и отбор</h2>
<p class="subtitle" style="margin-bottom:0">Выберите регион, отметьте нужные населённые пункты</p>
</div>
<div style="display:flex;gap:8px;align-items:center;font-size:14px;color:var(--gray-500);background:var(--white);padding:8px 16px;border-radius:8px;border:1px solid var(--gray-100)">
<span></span> Отмечено: <strong id="markedCount" style="color:var(--green)">0</strong>
<span style="margin:0 4px;color:var(--gray-100)">|</span>
Всего: <strong id="totalCount">0</strong>
</div>
</div>
<div class="filter-bar">
<div class="filter-group">
<label>Область</label>
<select id="filterOblast"><option value="">Все</option></select>
</div>
<div class="filter-group">
<label>Район</label>
<select id="filterRayon"><option value="">Все</option></select>
</div>
<div class="filter-group">
<label>Округ</label>
<select id="filterOkrug"><option value="">Все</option></select>
</div>
<label class="check-filter">
<input type="checkbox" id="showMarkedOnly"> Только отмеченные
</label>
</div>
<div class="actions" id="batchActions" style="margin-bottom:16px">
<button class="btn btn-sm" id="markAllBtn">✅ Отметить все в фильтре</button>
<button class="btn btn-sm btn-outline" id="unmarkAllBtn">✕ Снять все</button>
<span style="flex:1"></span>
<button class="btn btn-sm btn-green" id="exportBtn">⬇ Скачать только отмеченные</button>
</div>
<div id="tableContainer">
<div class="table-wrap"><table><thead id="tableHead"></thead><tbody id="tableBody"></tbody></table></div>
</div>
</div>
</div>
</div>
</section>
<script>
let allData = [];
let filteredData = [];
let dataColumns = [];
let hierarchy = {};
let markedIds = new Set();
let showMarkedOnly = false;
const COL_KEYWORDS = {
kato: ['като', 'код', 'katо', 'kato'],
oblast: ['област', 'регион', 'oblast', 'region'],
rayon: ['район', 'rayon', 'district'],
okrug: ['округ', 'okrug', 'сельск'],
snp: ['снп', 'населен', 'село', 'поселок', 'аул', 'snp', 'settlement', 'name']
};
function detectColumn(name) {
const n = name.toLowerCase().trim();
for (const [key, keywords] of Object.entries(COL_KEYWORDS)) {
if (keywords.some(k => n.includes(k))) return key;
}
return null;
}
function rowId(row, idx) {
const katoCol = dataColumns.indexOf('kato');
if (katoCol !== -1 && row[dataColumns[katoCol]]) return String(row[dataColumns[katoCol]]).trim();
return 'row_' + idx;
}
function initHierarchy(data) {
const obIx = dataColumns.indexOf('oblast');
const raIx = dataColumns.indexOf('rayon');
const okIx = dataColumns.indexOf('okrug');
hierarchy = { oblasts: {}, rayons: {}, okrugs: {} };
data.forEach(row => {
const ob = obIx !== -1 && row[obIx] ? String(row[obIx]).trim() : '';
const ra = raIx !== -1 && row[raIx] ? String(row[raIx]).trim() : '';
const ok = okIx !== -1 && row[okIx] ? String(row[okIx]).trim() : '';
if (ob && !hierarchy.oblasts[ob]) hierarchy.oblasts[ob] = new Set();
if (ob && ra) {
if (!hierarchy.rayons[ob]) hierarchy.rayons[ob] = new Set();
hierarchy.rayons[ob].add(ra);
}
if (ob && ra && ok) {
const key = ob + '|' + ra;
if (!hierarchy.okrugs[key]) hierarchy.okrugs[key] = new Set();
hierarchy.okrugs[key].add(ok);
}
});
hierarchy.oblasts = Object.keys(hierarchy.oblasts).sort();
Object.keys(hierarchy.rayons).forEach(k => hierarchy.rayons[k] = [...hierarchy.rayons[k]].sort());
Object.keys(hierarchy.okrugs).forEach(k => hierarchy.okrugs[k] = [...hierarchy.okrugs[k]].sort());
}
function renderFilters() {
const selO = document.getElementById('filterOblast');
selO.innerHTML = '<option value="">— Все —</option>' + hierarchy.oblasts.map(o => `<option>${o}</option>`).join('');
document.getElementById('filterOblast').onchange = () => {
const ob = selO.value;
const selR = document.getElementById('filterRayon');
selR.innerHTML = '<option value="">— Все —</option>' + (ob && hierarchy.rayons[ob] ? hierarchy.rayons[ob].map(r => `<option>${r}</option>`).join('') : '');
document.getElementById('filterOkrug').innerHTML = '<option value="">— Все —</option>';
applyFilter();
};
document.getElementById('filterRayon').onchange = () => {
const ob = selO.value, ra = document.getElementById('filterRayon').value;
const selK = document.getElementById('filterOkrug');
const key = ob + '|' + ra;
selK.innerHTML = '<option value="">— Все —</option>' + (ob && ra && hierarchy.okrugs[key] ? hierarchy.okrugs[key].map(o => `<option>${o}</option>`).join('') : '');
applyFilter();
};
document.getElementById('filterOkrug').onchange = applyFilter;
document.getElementById('showMarkedOnly').onchange = () => {
showMarkedOnly = document.getElementById('showMarkedOnly').checked;
applyFilter();
};
}
function applyFilter() {
const ob = document.getElementById('filterOblast').value;
const ra = document.getElementById('filterRayon').value;
const ok = document.getElementById('filterOkrug').value;
const obIx = dataColumns.indexOf('oblast');
const raIx = dataColumns.indexOf('rayon');
const okIx = dataColumns.indexOf('okrug');
let data = allData;
if (ob || ra || ok) {
data = allData.filter(row => {
const rOb = obIx !== -1 ? String(row[obIx] || '').trim() : '';
const rRa = raIx !== -1 ? String(row[raIx] || '').trim() : '';
const rOk = okIx !== -1 ? String(row[okIx] || '').trim() : '';
if (ob && rOb !== ob) return false;
if (ra && rRa !== ra) return false;
if (ok && rOk !== ok) return false;
return true;
});
}
if (showMarkedOnly) {
data = data.filter((row, i) => markedIds.has(row._id));
}
filteredData = data;
renderTable();
}
function renderTable() {
const headers = allData.columns || [];
const head = document.getElementById('tableHead');
const body = document.getElementById('tableBody');
const total = document.getElementById('totalCount');
const markedSpan = document.getElementById('markedCount');
const markedCount = allData.filter(r => markedIds.has(r._id)).length;
markedSpan.textContent = markedCount;
if (filteredData.length === 0) {
head.innerHTML = '<tr><th class="chk"></th>' + headers.map(h => `<th>${h}</th>`).join('') + '</tr>';
body.innerHTML = '<tr><td colspan="99"><div class="empty-state">🔍 Нет данных по выбранным фильтрам</div></td></tr>';
total.textContent = '0';
document.getElementById('markAllBtn').textContent = '✅ Отметить все';
return;
}
total.textContent = filteredData.length;
// Group by district
const raIx = dataColumns.indexOf('rayon');
const okIx = dataColumns.indexOf('okrug');
const groups = {};
filteredData.forEach(row => {
const ra = raIx !== -1 ? String(row[raIx] || '').trim() : '';
const ok = okIx !== -1 ? String(row[okIx] || '').trim() : '';
const key = ra + ' → ' + ok;
if (!groups[key]) groups[key] = [];
groups[key].push(row);
});
let allMarkedInView = true;
let allUnmarkedInView = true;
filteredData.forEach(r => {
if (markedIds.has(r._id)) allUnmarkedInView = false;
else allMarkedInView = false;
});
const markAllBtn = document.getElementById('markAllBtn');
if (allMarkedInView) markAllBtn.textContent = '✕ Снять все в фильтре';
else markAllBtn.textContent = '✅ Отметить все в фильтре';
head.innerHTML = '<tr><th class="chk"><input type="checkbox" id="selectAll" ' + (allMarkedInView && filteredData.length > 0 ? 'checked' : '') + '></th>' + headers.map(h => `<th>${h}</th>`).join('') + '</tr>';
let html = '';
Object.entries(groups).forEach(([group, rows]) => {
const markedInGroup = rows.filter(r => markedIds.has(r._id)).length;
html += `<tr class="district-header"><td style="text-align:center;cursor:pointer" onclick="toggleGroup('${group.replace(/'/g, "\\'")}')">${markedInGroup === rows.length ? '✅' : markedInGroup > 0 ? '◐' : '○'}</td><td colspan="${headers.length}">📌 ${group}${rows.length} нас. пунктов (отмечено ${markedInGroup})</td></tr>`;
rows.forEach(row => {
const isMarked = markedIds.has(row._id);
html += `<tr class="${isMarked ? 'marked' : ''}">
<td class="chk"><input type="checkbox" ${isMarked ? 'checked' : ''} data-id="${row._id}" class="row-check"></td>
${headers.map(h => `<td>${row[h] != null ? row[h] : ''}</td>`).join('')}
</tr>`;
});
});
body.innerHTML = html;
// event listeners
document.getElementById('selectAll').onchange = function() {
filteredData.forEach(r => {
if (this.checked) markedIds.add(r._id);
else markedIds.delete(r._id);
});
renderTable();
};
document.querySelectorAll('.row-check').forEach(cb => {
cb.onchange = function() {
if (this.checked) markedIds.add(this.dataset.id);
else markedIds.delete(this.dataset.id);
renderTable();
};
});
}
function toggleGroup(groupName) {
const raIx = dataColumns.indexOf('rayon');
const okIx = dataColumns.indexOf('okrug');
const parts = groupName.split(' → ');
const ra = parts[0], ok = parts[1];
const rows = filteredData.filter(r => {
const rRa = raIx !== -1 ? String(r[raIx] || '').trim() : '';
const rOk = okIx !== -1 ? String(r[okIx] || '').trim() : '';
return rRa === ra && rOk === ok;
});
const allMarked = rows.every(r => markedIds.has(r._id));
rows.forEach(r => {
if (allMarked) markedIds.delete(r._id);
else markedIds.add(r._id);
});
renderTable();
}
function handleFile(file) {
const reader = new FileReader();
reader.onload = function(e) {
try {
const wb = XLSX.read(e.target.result, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const json = XLSX.utils.sheet_to_json(ws, { header: 1 });
if (json.length < 2) { alert('Файл пуст или содержит только заголовки'); return; }
const headers = json[0].map(h => String(h).trim());
const rows = json.slice(1).filter(r => r.some(c => c != null && c !== ''));
dataColumns = headers.map((h, i) => detectColumn(h) || ('col_' + i));
allData = rows.map((r, idx) => {
const obj = {};
headers.forEach((h, i) => { obj[h] = r[i] != null ? r[i] : ''; });
obj._id = rowId(obj, idx);
return obj;
});
allData.columns = headers;
// init marked from localStorage
markedIds = new Set();
const zone = document.getElementById('dropZone');
zone.classList.add('has-file');
zone.innerHTML = `<div><span class="drop-zone-icon">✅</span></div><div class="filename">${file.name} <small>(${allData.length} записей)</small></div>`;
initHierarchy(allData);
renderFilters();
applyFilter();
document.getElementById('resultArea').classList.remove('hidden');
} catch(err) {
alert('Ошибка: ' + err.message);
}
};
reader.readAsArrayBuffer(file);
}
document.getElementById('fileInput').onchange = e => { if (e.target.files[0]) handleFile(e.target.files[0]); };
const dz = document.getElementById('dropZone');
dz.onclick = () => document.getElementById('fileInput').click();
dz.ondragover = e => { e.preventDefault(); dz.classList.add('dragover'); };
dz.ondragleave = () => dz.classList.remove('dragover');
dz.ondrop = e => { e.preventDefault(); dz.classList.remove('dragover'); if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]); };
document.getElementById('markAllBtn').onclick = () => {
const btn = document.getElementById('markAllBtn');
const allMarked = filteredData.length > 0 && filteredData.every(r => markedIds.has(r._id));
filteredData.forEach(r => {
if (allMarked) markedIds.delete(r._id);
else markedIds.add(r._id);
});
renderTable();
};
document.getElementById('unmarkAllBtn').onclick = () => {
allData.forEach(r => markedIds.delete(r._id));
renderTable();
};
document.getElementById('exportBtn').onclick = () => {
const marked = allData.filter(r => markedIds.has(r._id));
if (marked.length === 0) { alert('Нет отмеченных населённых пунктов. Отметьте хотя бы один.'); return; }
const headers = ['Учитывается в расчётах', ...allData.columns];
const wsData = [headers];
allData.forEach(row => {
wsData.push([markedIds.has(row._id) ? 'Да' : 'Нет', ...allData.columns.map(h => row[h] != null ? row[h] : '')]);
});
const ws = XLSX.utils.aoa_to_sheet(wsData);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'СНП');
XLSX.writeFile(wb, 'snp-otbor.xlsx');
};
</script>
</body>
</html>