Добавлен Excel редактор для сопоставления данных по номеру телефона/лицевому счету

This commit is contained in:
Nurs 2026-09-16 12:21:29 +00:00
parent cccf382818
commit cd32645377
3 changed files with 638 additions and 0 deletions

300
excel-editor.html Normal file
View File

@ -0,0 +1,300 @@
<!DOCTYPE html>
<html lang="ru" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Excel Редактор | Сопоставление данных</title>
<link rel="icon" href="design-system/brand/kt-ai-logo.png">
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
<link rel="stylesheet" href="design-system/kt-ai-components.css">
<link rel="stylesheet" href="design-system/kt-ai-page.css">
<style>
.kt-ai-btn { text-decoration: none; }
.upload-zone {
border: 2px dashed var(--kt-ai-border);
border-radius: var(--kt-ai-radius-xl);
padding: var(--kt-ai-space-8);
text-align: center;
cursor: pointer;
transition: all 0.2s;
background: var(--kt-ai-bg-soft);
}
.upload-zone:hover {
border-color: var(--kt-ai-primary);
background: var(--kt-ai-primary-bg);
}
.upload-zone.has-file {
border-color: var(--kt-ai-success);
background: var(--kt-ai-success-bg);
}
.file-info {
margin-top: var(--kt-ai-space-3);
font-size: var(--kt-ai-text-sm);
color: var(--kt-ai-fg-muted);
}
.mapping-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: var(--kt-ai-space-3);
align-items: center;
padding: var(--kt-ai-space-3);
border-radius: var(--kt-ai-radius-lg);
background: var(--kt-ai-bg-soft);
margin-bottom: var(--kt-ai-space-2);
}
.mapping-row.matched {
background: var(--kt-ai-success-bg);
}
.mapping-row.no-match {
background: var(--kt-ai-warning-bg);
}
.column-badge {
padding: var(--kt-ai-space-1) var(--kt-ai-space-2);
border-radius: var(--kt-ai-radius-md);
background: var(--kt-ai-card-bg);
border: 1px solid var(--kt-ai-border);
font-size: var(--kt-ai-text-xs);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-table {
width: 100%;
border-collapse: collapse;
font-size: var(--kt-ai-text-sm);
}
.preview-table th,
.preview-table td {
padding: var(--kt-ai-space-2) var(--kt-ai-space-3);
border: 1px solid var(--kt-ai-border);
text-align: left;
}
.preview-table th {
background: var(--kt-ai-bg-soft);
font-weight: var(--kt-ai-weight-semibold);
position: sticky;
top: 0;
}
.preview-table tr:nth-child(even) {
background: var(--kt-ai-bg-soft);
}
.preview-table tr:hover {
background: var(--kt-ai-primary-bg);
}
.table-container {
max-height: 400px;
overflow: auto;
border: 1px solid var(--kt-ai-border);
border-radius: var(--kt-ai-radius-lg);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: var(--kt-ai-space-4);
margin: var(--kt-ai-space-4) 0;
}
.stat-card {
padding: var(--kt-ai-space-4);
border-radius: var(--kt-ai-radius-lg);
background: var(--kt-ai-card-bg);
border: 1px solid var(--kt-ai-border);
}
.stat-value {
font-size: var(--kt-ai-text-2xl);
font-weight: var(--kt-ai-weight-bold);
color: var(--kt-ai-fg);
}
.stat-label {
font-size: var(--kt-ai-text-xs);
color: var(--kt-ai-fg-muted);
margin-top: var(--kt-ai-space-1);
}
.step-indicator {
display: flex;
gap: var(--kt-ai-space-2);
margin-bottom: var(--kt-ai-space-6);
}
.step {
flex: 1;
padding: var(--kt-ai-space-3);
border-radius: var(--kt-ai-radius-lg);
background: var(--kt-ai-bg-soft);
border: 2px solid var(--kt-ai-border);
text-align: center;
font-size: var(--kt-ai-text-sm);
font-weight: var(--kt-ai-weight-semibold);
}
.step.active {
border-color: var(--kt-ai-primary);
background: var(--kt-ai-primary-bg);
color: var(--kt-ai-primary-fg);
}
.step.completed {
border-color: var(--kt-ai-success);
background: var(--kt-ai-success-bg);
}
.hidden { display: none; }
</style>
</head>
<body>
<header class="kt-ai-pagehead">
<div class="kt-ai-wrap kt-ai-pagehead-in">
<div style="display: flex; align-items: center; gap: var(--kt-ai-space-3);">
<img src="design-system/brand/kt-ai-logo.png" alt="" height="24">
<strong>Excel Редактор</strong>
</div>
<span class="kt-ai-spacer"></span>
<a class="kt-ai-btn" data-variant="ghost" href="index.html">← Назад</a>
</div>
</header>
<main>
<section class="kt-ai-hero" data-align="center">
<div class="kt-ai-wrap">
<h1>Сопоставление Excel файлов</h1>
<p class="kt-ai-hero-sub">Заполните пустые столбцы шаблона данными из основного файла по номеру телефона или лицевому счету</p>
</div>
</section>
<div class="kt-ai-wrap">
<div class="step-indicator">
<div class="step active" id="step1">1. Загрузка файлов</div>
<div class="step" id="step2">2. Настройка полей</div>
<div class="step" id="step3">3. Результат</div>
</div>
<!-- STEP 1: UPLOAD -->
<div id="upload-section">
<div class="kt-ai-section">
<div class="kt-ai-wrap">
<div class="kt-ai-section-head">
<h2>Загрузите файлы</h2>
<p>Основной файл содержит данные для заполнения, шаблон — файл для заполнения</p>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--kt-ai-space-6); margin-top: var(--kt-ai-space-6);">
<div>
<h3 style="margin-bottom: var(--kt-ai-space-3);">Основной файл</h3>
<div class="upload-zone" id="main-upload" onclick="document.getElementById('main-file').click()">
<svg class="kt-icon kt-icon-lg" style="color: var(--kt-ai-primary);" aria-hidden="true"><use href="#file"></use></svg>
<p style="margin-top: var(--kt-ai-space-3); font-weight: var(--kt-ai-weight-medium);">Основной файл с данными</p>
<p style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted); margin-top: var(--kt-ai-space-1);">.xlsx, .xls, .csv</p>
<div class="file-info" id="main-file-info"></div>
</div>
<input type="file" id="main-file" accept=".xlsx,.xls,.csv" style="display: none;" onchange="handleFileSelect(event, 'main')">
</div>
<div>
<h3 style="margin-bottom: var(--kt-ai-space-3);">Файл-шаблон</h3>
<div class="upload-zone" id="template-upload" onclick="document.getElementById('template-file').click()">
<svg class="kt-icon kt-icon-lg" style="color: var(--kt-ai-primary);" aria-hidden="true"><use href="#file"></use></svg>
<p style="margin-top: var(--kt-ai-space-3); font-weight: var(--kt-ai-weight-medium);">Шаблон для заполнения</p>
<p style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted); margin-top: var(--kt-ai-space-1);">.xlsx, .xls, .csv</p>
<div class="file-info" id="template-file-info"></div>
</div>
<input type="file" id="template-file" accept=".xlsx,.xls,.csv" style="display: none;" onchange="handleFileSelect(event, 'template')">
</div>
</div>
<div style="margin-top: var(--kt-ai-space-6); text-align: center;">
<button class="kt-ai-btn" data-variant="primary" data-size="lg" id="btn-mapping" onclick="goToMapping()" disabled>
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#arrowRight"></use></svg>
Продолжить
</button>
</div>
</div>
</div>
</div>
<!-- STEP 2: MAPPING -->
<div id="mapping-section" class="hidden">
<div class="kt-ai-section">
<div class="kt-ai-wrap">
<div class="kt-ai-section-head">
<h2>Настройте поля для сопоставления</h2>
<p>Укажите, какие поля использовать для поиска соответствий</p>
</div>
<div class="kt-ai-card" style="padding: var(--kt-ai-space-5); margin-top: var(--kt-ai-space-4);">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--kt-ai-space-4); margin-bottom: var(--kt-ai-space-4);">
<div class="kt-ai-field">
<label>Поле из основного файла</label>
<select class="kt-ai-select" id="main-key-field" onchange="processMapping()"></select>
</div>
<div class="kt-ai-field">
<label>Поле из шаблона</label>
<select class="kt-ai-select" id="template-key-field" onchange="processMapping()"></select>
</div>
</div>
<div style="margin-bottom: var(--kt-ai-space-4);">
<label style="font-weight: var(--kt-ai-weight-semibold); display: block; margin-bottom: var(--kt-ai-space-2);">Поля для заполнения:</label>
<div id="fields-to-fill"></div>
</div>
<div id="mapping-stats" class="stats-grid"></div>
</div>
<div class="kt-ai-card" style="padding: var(--kt-ai-space-5); margin-top: var(--kt-ai-space-4);">
<h3 style="margin-bottom: var(--kt-ai-space-4);">Предпросмотр сопоставления</h3>
<div class="table-container">
<table class="preview-table" id="mapping-preview"></table>
</div>
</div>
<div style="margin-top: var(--kt-ai-space-6); display: flex; gap: var(--kt-ai-space-3);">
<button class="kt-ai-btn" data-variant="ghost" onclick="goBackToUpload()">Назад</button>
<button class="kt-ai-btn" data-variant="primary" data-size="lg" id="btn-process" onclick="processFiles()" disabled>
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#rocket"></use></svg>
Заполнить и скачать результат
</button>
</div>
</div>
</div>
</div>
<!-- STEP 3: RESULT -->
<div id="result-section" class="hidden">
<div class="kt-ai-section">
<div class="kt-ai-wrap">
<div class="kt-ai-section-head">
<h2>Готово!</h2>
<p>Файл успешно обработан</p>
</div>
<div class="kt-ai-card" style="padding: var(--kt-ai-space-6); text-align: center;">
<svg class="kt-icon kt-icon-lg" style="color: var(--kt-ai-success);" aria-hidden="true"><use href="#check"></use></svg>
<h3 style="margin: var(--kt-ai-space-4) 0;">Файл готов к скачиванию</h3>
<p style="color: var(--kt-ai-fg-muted); margin-bottom: var(--kt-ai-space-4);">Все пустые столбцы заполнены данными из основного файла</p>
<div style="display: flex; gap: var(--kt-ai-space-3); justify-content: center; margin-top: var(--kt-ai-space-4);">
<a class="kt-ai-btn" data-variant="primary" data-size="lg" id="download-link" href="#">
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#download"></use></svg>
Скачать Excel
</a>
<button class="kt-ai-btn" data-variant="ghost" onclick="location.reload()">
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#refresh"></use></svg>
Обработать ещё
</button>
</div>
</div>
<div class="stats-grid" id="result-stats"></div>
</div>
</div>
</div>
</div>
</main>
<footer class="kt-ai-wrap kt-ai-pagefoot">
<span>© 2026 Excel Редактор</span>
<span class="kt-ai-spacer"></span>
<span>Сопоставление данных по ключевым полям</span>
</footer>
<script src="https://cdn.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js"></script>
<script src="excel-editor.js"></script>
</body>
</html>

334
excel-editor.js Normal file
View File

@ -0,0 +1,334 @@
const XLSX = window.XLSX;
let mainData = null;
let templateData = null;
let mainFileName = '';
let templateFileName = '';
let mappedResult = null;
// Обработка выбора файла
function handleFileSelect(event, type) {
const file = event.target.files[0];
if (!file) return;
const zone = document.getElementById(type + '-upload');
const info = document.getElementById(type + '-file-info');
zone.classList.add('has-file');
info.textContent = file.name + ' (' + formatFileSize(file.size) + ')';
if (type === 'main') {
mainFileName = file.name;
readExcel(file, (data) => {
mainData = data;
checkFilesReady();
});
} else {
templateFileName = file.name;
readExcel(file, (data) => {
templateData = data;
checkFilesReady();
});
}
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' Б';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' КБ';
return (bytes / (1024 * 1024)).toFixed(1) + ' МБ';
}
function readExcel(file, callback) {
const reader = new FileReader();
reader.onload = (e) => {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(worksheet);
callback(json);
};
reader.readAsArrayBuffer(file);
}
function checkFilesReady() {
const btn = document.getElementById('btn-mapping');
if (mainData && mainData.length > 0 && templateData && templateData.length > 0) {
btn.disabled = false;
}
}
function goToMapping() {
if (!mainData || !templateData) return;
document.getElementById('upload-section').classList.add('hidden');
document.getElementById('mapping-section').classList.remove('hidden');
document.getElementById('step1').classList.remove('active');
document.getElementById('step1').classList.add('completed');
document.getElementById('step2').classList.add('active');
populateFieldSelectors();
}
function goBackToUpload() {
document.getElementById('upload-section').classList.remove('hidden');
document.getElementById('mapping-section').classList.add('hidden');
document.getElementById('step1').classList.add('active');
document.getElementById('step2').classList.remove('active', 'completed');
}
function populateFieldSelectors() {
const mainFields = Object.keys(mainData[0] || {});
const templateFields = Object.keys(templateData[0] || {});
const mainSelect = document.getElementById('main-key-field');
const templateSelect = document.getElementById('template-key-field');
mainSelect.innerHTML = '';
templateSelect.innerHTML = '';
// Поиск полей с номером телефона и лицевым счетом
const phoneFields = ['телефон', 'phone', 'tel', 'номер', 'number', 'лицевой', 'account', 'лс', 'флс'];
phoneFields.forEach(field => {
const mainMatch = mainFields.find(f => f.toLowerCase().includes(field));
const templateMatch = templateFields.find(f => f.toLowerCase().includes(field));
if (mainMatch && !mainSelect.querySelector(`[value="${mainMatch}"]`)) {
mainSelect.add(new Option(mainMatch, mainMatch));
}
if (templateMatch && !templateSelect.querySelector(`[value="${templateMatch}"]`)) {
templateSelect.add(new Option(templateMatch, templateMatch));
}
});
// Добавляем все остальные поля
mainFields.forEach(field => {
if (!mainSelect.querySelector(`[value="${field}"]`)) {
mainSelect.add(new Option(field, field));
}
});
templateFields.forEach(field => {
if (!templateSelect.querySelector(`[value="${field}"]`)) {
templateSelect.add(new Option(field, field));
}
});
// Заполняем чекбоксы полей для заполнения
const fieldsContainer = document.getElementById('fields-to-fill');
fieldsContainer.innerHTML = '';
templateFields.forEach(field => {
const div = document.createElement('div');
div.style.cssText = 'display: inline-flex; align-items: center; gap: var(--kt-ai-space-2); margin-right: var(--kt-ai-space-3); margin-bottom: var(--kt-ai-space-2);';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'field-' + field;
checkbox.value = field;
checkbox.checked = true; // По умолчанию все поля выбраны
checkbox.onchange = processMapping;
const label = document.createElement('label');
label.htmlFor = 'field-' + field;
label.textContent = field;
label.style.fontSize = 'var(--kt-ai-text-sm)';
div.appendChild(checkbox);
div.appendChild(label);
fieldsContainer.appendChild(div);
});
processMapping();
}
function processMapping() {
const mainKeyField = document.getElementById('main-key-field').value;
const templateKeyField = document.getElementById('template-key-field').value;
if (!mainKeyField || !templateKeyField) return;
// Собираем поля для заполнения
const fieldsToFill = [];
document.querySelectorAll('#fields-to-fill input[type="checkbox"]:checked').forEach(cb => {
fieldsToFill.push(cb.value);
});
// Создаём карту соответствий
const mainMap = new Map();
mainData.forEach(row => {
const keyValue = normalizeValue(row[mainKeyField]);
if (keyValue) {
mainMap.set(keyValue, row);
}
});
// Сопоставляем данные
let matched = 0;
let notMatched = 0;
let filled = 0;
const previewRows = templateData.slice(0, 10).map((templateRow, index) => {
const keyValue = normalizeValue(templateRow[templateKeyField]);
const mainRow = mainMap.get(keyValue);
if (mainRow) {
matched++;
// Заполняем пустые поля
fieldsToFill.forEach(field => {
if (!templateRow[field] && mainRow[field]) {
filled++;
templateRow[field] = mainRow[field];
}
});
} else {
notMatched++;
}
return {
templateRow,
mainRow,
matched: !!mainRow
};
});
// Статистика
const total = templateData.length;
const matchPercent = total > 0 ? Math.round((matched / total) * 100) : 0;
document.getElementById('mapping-stats').innerHTML = `
<div class="stat-card">
<div class="stat-value">${total}</div>
<div class="stat-label">Всего записей в шаблоне</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-success);">${matched}</div>
<div class="stat-label">Сопоставлено (${matchPercent}%)</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-warning);">${notMatched}</div>
<div class="stat-label">Не найдено совпадений</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-primary);">${filled}</div>
<div class="stat-label">Полей заполнено (в предпросмотре)</div>
</div>
`;
// Предпросмотр
const table = document.getElementById('mapping-preview');
const allFields = new Set([...Object.keys(templateData[0] || {}), ...fieldsToFill]);
let headerHtml = '<thead><tr>';
allFields.forEach(field => {
headerHtml += '<th>' + escapeHtml(field) + '</th>';
});
headerHtml += '<th>Статус</th></tr></thead>';
let bodyHtml = '<tbody>';
previewRows.forEach(row => {
bodyHtml += '<tr class="' + (row.matched ? 'matched' : 'no-match') + '">';
allFields.forEach(field => {
const value = row.templateRow[field] !== undefined ? row.templateRow[field] : '';
const fromMain = row.mainRow && row.mainRow[field] && !row.templateRow[field];
const cellStyle = fromMain ? 'background: var(--kt-ai-success-bg); font-weight: 600;' : '';
bodyHtml += '<td style="' + cellStyle + '">' + escapeHtml(value) + '</td>';
});
bodyHtml += '<td>' + (row.matched ? '✓' : '✗') + '</td></tr>';
});
bodyHtml += '</tbody>';
table.innerHTML = headerHtml + bodyHtml;
// Сохраняем результат для дальнейшей обработки
mappedResult = { templateData, fieldsToFill, mainMap, templateKeyField };
document.getElementById('btn-process').disabled = matched === 0;
}
function processFiles() {
if (!mappedResult) return;
const { templateData, fieldsToFill, mainMap, templateKeyField } = mappedResult;
// Полная обработка всех данных
let totalFilled = 0;
const resultData = templateData.map(row => {
const keyValue = normalizeValue(row[templateKeyField]);
const mainRow = mainMap.get(keyValue);
if (mainRow) {
fieldsToFill.forEach(field => {
if (!row[field] && mainRow[field]) {
row[field] = mainRow[field];
totalFilled++;
}
});
}
return row;
});
// Создаём Excel файл
const worksheet = XLSX.utils.json_to_sheet(resultData);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, 'Результат');
// Генерируем имя файла
const baseName = templateFileName.replace(/\.(xlsx|xls|csv)$/i, '');
const outputFileName = baseName + '_заполненный.xlsx';
// Создаём ссылку для скачивания
const wbout = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
const blob = new Blob([wbout], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const downloadLink = document.getElementById('download-link');
downloadLink.href = url;
downloadLink.download = outputFileName;
// Показываем результат
document.getElementById('mapping-section').classList.add('hidden');
document.getElementById('result-section').classList.remove('hidden');
document.getElementById('step2').classList.remove('active');
document.getElementById('step2').classList.add('completed');
document.getElementById('step3').classList.add('active');
// Итоговая статистика
const matchedCount = resultData.filter(row => {
const keyValue = normalizeValue(row[templateKeyField]);
return mainMap.has(keyValue);
}).length;
document.getElementById('result-stats').innerHTML = `
<div class="stat-card">
<div class="stat-value">${templateData.length}</div>
<div class="stat-label">Всего записей обработано</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-success);">${matchedCount}</div>
<div class="stat-label">Найдено совпадений</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-primary);">${totalFilled}</div>
<div class="stat-label">Полей заполнено</div>
</div>
<div class="stat-card">
<div class="stat-value" style="color: var(--kt-ai-fg-muted);">${outputFileName}</div>
<div class="stat-label">Файл готов</div>
</div>
`;
}
function normalizeValue(value) {
if (value === null || value === undefined) return '';
const str = String(value).trim();
// Удаляем все нецифровые символы для номеров и счетов
return str.replace(/\D/g, '');
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}

View File

@ -68,6 +68,10 @@
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#plus"></use></svg>
Добавить оборудование
</a>
<a class="kt-ai-nav-item" href="excel-editor.html" target="_blank">
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#table"></use></svg>
Excel Редактор
</a>
<a class="kt-ai-nav-item" href="#" data-tab="reports">
<svg class="kt-icon kt-icon-sm" aria-hidden="true"><use href="#chart"></use></svg>
Отчёты