snp-report/index.html

542 lines
24 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 style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;padding:12px 16px;background:var(--white);border-radius:12px;border:2px solid var(--cyan)">
<label class="check-filter" style="padding:0">
<input type="checkbox" id="autoMode"> 🤖 Авто-режим (применить шаблон и скачать)
</label>
<span style="flex:1"></span>
<button class="btn btn-sm btn-outline" id="saveTemplateBtn">💾 Сохранить шаблон</button>
<button class="btn btn-sm btn-outline" id="loadTemplateBtn">📂 Применить шаблон</button>
<span id="templateStatus" style="font-size:13px;color:var(--gray-500);align-self:center"></span>
</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>
<button class="btn btn-sm btn-outline" id="fillOblastBtn">📋 Проставить области</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();
}
const TEMPLATE_KEY = 'snp_marked_template';
function saveTemplate() {
if (markedIds.size === 0) { alert('Нет отмеченных пунктов. Сначала отметьте.'); return; }
const arr = [...markedIds].filter(id => !id.startsWith('row_'));
if (arr.length === 0) { alert('Не найдены КАТО-коды. Отметка работает только если в файле есть колонка КАТО.'); return; }
localStorage.setItem(TEMPLATE_KEY, JSON.stringify(arr));
document.getElementById('templateStatus').textContent = `✅ Шаблон сохранён (${arr.length} кодов)`;
setTimeout(() => document.getElementById('templateStatus').textContent = '', 3000);
}
function loadTemplate() {
const raw = localStorage.getItem(TEMPLATE_KEY);
if (!raw) { alert('Нет сохранённого шаблона. Сначала отметьте пункты и сохраните.'); return; }
const saved = JSON.parse(raw);
let found = 0;
allData.forEach(r => {
if (saved.includes(r._id)) { markedIds.add(r._id); found++; }
});
renderTable();
document.getElementById('templateStatus').textContent = `📂 Применено: ${found} совпадений`;
setTimeout(() => document.getElementById('templateStatus').textContent = '', 3000);
}
const KATO_REGIONS = {
'01': 'Акмолинская область', '02': 'Актюбинская область',
'03': 'Алматинская область', '04': 'Атырауская область',
'05': 'Западно-Казахстанская область', '06': 'Жамбылская область',
'07': 'Карагандинская область', '08': 'Костанайская область',
'09': 'Кызылординская область', '10': 'Мангистауская область',
'11': 'Туркестанская область', '12': 'Павлодарская область',
'13': 'Северо-Казахстанская область', '14': 'Восточно-Казахстанская область',
'15': 'Г.Алматы', '16': 'Г.Астана', '17': 'Г.Шымкент',
'18': 'Улытауская область', '19': 'Абайская область',
'20': 'Жетысуйская область', '21': 'Кызылординская область'
};
function fillOblast() {
const katoIx = dataColumns.indexOf('kato');
const obIx = dataColumns.indexOf('oblast');
if (katoIx === -1) { alert('Не найдена колонка КАТО'); return; }
if (obIx === -1) { alert('Не найдена колонка области. Добавьте колонку "Область" в файл.'); return; }
let filled = 0;
allData.forEach(row => {
const kato = String(row[dataColumns[katoIx]] || '').trim();
const code = kato.length >= 2 ? kato.substring(0, 2) : '';
if (code && KATO_REGIONS[code] && !String(row[dataColumns[obIx]] || '').trim()) {
row[dataColumns[obIx]] = KATO_REGIONS[code];
filled++;
}
});
if (filled > 0) {
initHierarchy(allData);
renderFilters();
applyFilter();
alert(`✅ Области проставлены для ${filled} строк`);
} else {
alert('Области уже заполнены или нет строк с пустыми областями');
}
}
function autoProcess() {
const raw = localStorage.getItem(TEMPLATE_KEY);
if (!raw) return;
const saved = JSON.parse(raw);
let found = 0;
allData.forEach(r => {
if (saved.includes(r._id)) { markedIds.add(r._id); found++; }
});
renderTable();
if (found > 0) {
setTimeout(() => {
document.getElementById('exportBtn').click();
document.getElementById('templateStatus').textContent = `🤖 Авто: отмечено ${found}, файл скачан`;
setTimeout(() => document.getElementById('templateStatus').textContent = '', 4000);
}, 500);
}
}
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;
markedIds = new Set();
updateTemplateStatus();
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');
if (document.getElementById('autoMode').checked) {
autoProcess();
}
} catch(err) {
alert('Ошибка: ' + err.message);
}
};
reader.readAsArrayBuffer(file);
}
function updateTemplateStatus() {
const raw = localStorage.getItem(TEMPLATE_KEY);
const el = document.getElementById('templateStatus');
if (raw) {
const saved = JSON.parse(raw);
el.textContent = `📁 Шаблон: ${saved.length} кодов`;
} else {
el.textContent = '';
}
}
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('saveTemplateBtn').onclick = saveTemplate;
document.getElementById('loadTemplateBtn').onclick = loadTemplate;
document.getElementById('fillOblastBtn').onclick = fillOblast;
updateTemplateStatus();
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>