sdelat-slayd-po-postupleniya/index.html

409 lines
21 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>Безналичные поступления — динамика за 3 года</title>
<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">
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<style>
.kt-ai-app { min-height: 100vh; display: flex; flex-direction: column; }
.kt-ai-app-top { position: sticky; top: 0; z-index: 5; }
.kt-ai-app-main { flex: 1; padding: var(--kt-ai-space-7) 0 var(--kt-ai-space-9); }
.kt-ai-drop {
border: 2px dashed var(--kt-ai-border); border-radius: var(--kt-ai-radius-2xl);
padding: var(--kt-ai-space-8) var(--kt-ai-space-7); text-align: center;
cursor: pointer; transition: border-color .15s, background .15s;
}
.kt-ai-drop:hover, .kt-ai-drop.over { border-color: var(--kt-ai-primary); background: var(--kt-ai-bg-soft); }
.kt-ai-drop-ic { display: inline-flex; margin-bottom: var(--kt-ai-space-3); color: var(--kt-ai-primary); }
.kt-ai-drop-ic svg { width: 34px; height: 34px; }
.kt-ai-drop-title { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); display: block; margin-bottom: 4px; }
.kt-ai-drop-hint { color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm); display: block; line-height: 1.5; }
.kt-ai-files { display: flex; flex-wrap: wrap; gap: var(--kt-ai-space-2); margin-top: var(--kt-ai-space-4); }
.kt-ai-filechip {
display: inline-flex; align-items: center; gap: var(--kt-ai-space-2);
background: var(--kt-ai-bg-soft); border: 1px solid var(--kt-ai-border);
border-radius: var(--kt-ai-radius-full); padding: 5px 8px 5px 12px;
font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-medium);
}
.kt-ai-filechip button {
border: 0; background: transparent; color: var(--kt-ai-fg-muted); cursor: pointer;
display: inline-flex; padding: 2px; border-radius: 50%; line-height: 0;
}
.kt-ai-filechip button:hover { color: var(--kt-ai-danger); }
.kt-ai-filechip svg { width: 14px; height: 14px; }
.kt-ai-stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--kt-ai-space-4); margin-bottom: var(--kt-ai-space-6); }
.kt-ai-stat { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); }
.kt-ai-stat-label { color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm); display: block; margin-bottom: var(--kt-ai-space-2); }
.kt-ai-stat-value { font-size: var(--kt-ai-text-2xl); font-weight: var(--kt-ai-weight-bold); }
.kt-ai-stat-delta { display: block; margin-top: var(--kt-ai-space-1); font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-medium); }
.delta-up { color: var(--kt-ai-primary); }
.delta-down { color: var(--kt-ai-danger); }
.kt-ai-charts { display: grid; grid-template-columns: 3fr 2fr; gap: var(--kt-ai-space-5); margin-bottom: var(--kt-ai-space-6); }
@media (max-width: 900px) { .kt-ai-charts { grid-template-columns: 1fr; } }
.kt-ai-chart-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-6); }
.kt-ai-chart-card h3 { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); margin: 0 0 var(--kt-ai-space-5); }
.kt-ai-chart svg { width: 100%; height: auto; display: block; }
.kt-ai-legend { display: flex; flex-wrap: wrap; gap: var(--kt-ai-space-4); margin-top: var(--kt-ai-space-4); font-size: var(--kt-ai-text-sm); }
.kt-ai-legend span { display: inline-flex; align-items: center; gap: 6px; color: var(--kt-ai-fg-muted); }
.kt-ai-legend i { width: 12px; height: 12px; border-radius: 3px; display: inline-block; }
.kt-ai-tbl-wrap { overflow-x: auto; border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); background: var(--kt-ai-card-bg); }
table.kt-ai-table { width: 100%; border-collapse: collapse; font-size: var(--kt-ai-text-sm); }
.kt-ai-table th, .kt-ai-table td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--kt-ai-divider); white-space: nowrap; }
.kt-ai-table thead th { background: var(--kt-ai-bg-soft); color: var(--kt-ai-fg-muted); font-weight: var(--kt-ai-weight-medium); }
.kt-ai-table tbody tr:last-child td { border-bottom: 0; }
.kt-ai-table td.num, .kt-ai-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
.kt-ai-empty { padding: var(--kt-ai-space-10) var(--kt-ai-space-7); text-align: center; color: var(--kt-ai-fg-muted); }
.kt-ai-empty svg { width: 40px; height: 40px; margin-bottom: var(--kt-ai-space-3); opacity: .5; }
.kt-ai-actions { margin-top: var(--kt-ai-space-4); display: flex; align-items: center; gap: var(--kt-ai-space-3); flex-wrap: wrap; }
@media print {
.kt-ai-app-top, .kt-ai-drop, .kt-ai-actions { display: none; }
.kt-ai-app-main { padding: 0; }
.kt-ai-charts { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="kt-ai-app">
<div class="kt-ai-app-top">
<header class="kt-ai-pagehead">
<div class="kt-ai-wrap kt-ai-pagehead-in">
<strong>Безналичные поступления</strong>
<span>Динамика за 3 года · сравнение</span>
<span class="kt-ai-spacer"></span>
<label class="kt-ai-btn" data-variant="primary" style="cursor:pointer">
<input type="file" id="fileInput" accept=".xlsx,.xls" multiple style="display:none" onchange="handleFiles(this.files)">
Загрузить Excel
</label>
<button class="kt-ai-btn" data-variant="ghost" onclick="resetData()">Сбросить</button>
</div>
</header>
</div>
<main class="kt-ai-app-main">
<div class="kt-ai-wrap">
<div class="kt-ai-drop" id="drop" onclick="document.getElementById('fileInput').click()">
<span class="kt-ai-drop-ic">
<svg class="kt-icon" aria-hidden="true"><use href="#upload"></use></svg>
</span>
<span class="kt-ai-drop-title">Перетащите файлы .xlsx сюда или нажмите, чтобы выбрать</span>
<span class="kt-ai-drop-hint" id="dropHint">
Формат: столбцы «Год», «Наименование контрагента», «Сумма» (в любой последовательности, с 2023 по 2025).
</span>
<div class="kt-ai-files" id="fileList"></div>
</div>
<div class="kt-ai-actions" id="demoBar" style="margin-top:var(--kt-ai-space-4)">
<button class="kt-ai-btn" data-variant="ghost" onclick="loadDemo()">Показать пример данных</button>
<button class="kt-ai-btn" data-variant="ghost" style="display:none" id="demoClear" onclick="clearDemo()">Очистить пример</button>
<span style="color:var(--kt-ai-fg-muted);font-size:var(--kt-ai-text-sm)">или загрузите свои файлы .xlsx</span>
</div>
<div id="results"></div>
</div>
</main>
</div>
<script>
var YEAR_COLORS = ["8B95A5", "3E9BBF", "00B8CC"];
var COUNTERPARTY_COLORS = ["00B8CC", "F4B942", "7C6FE8", "4CC38A", "F472A0", "5B8DEF", "D96C4F", "8A8A92"];
var files = [];
var rows = [];
var demoOn = false;
var DEMO = [
{ year: 2023, name: "KT Bank", amount: 12500000 },
{ year: 2023, name: "Halyk Pay", amount: 8400000 },
{ year: 2023, name: "Kaspi Online", amount: 15200000 },
{ year: 2023, name: "Beeline Payments", amount: 3600000 },
{ year: 2023, name: "Qysqali", amount: 2100000 },
{ year: 2024, name: "KT Bank", amount: 14800000 },
{ year: 2024, name: "Halyk Pay", amount: 9900000 },
{ year: 2024, name: "Kaspi Online", amount: 18700000 },
{ year: 2024, name: "Beeline Payments", amount: 4100000 },
{ year: 2024, name: "Qysqali", amount: 2450000 },
{ year: 2025, name: "KT Bank", amount: 17300000 },
{ year: 2025, name: "Halyk Pay", amount: 11200000 },
{ year: 2025, name: "Kaspi Online", amount: 22400000 },
{ year: 2025, name: "Beeline Payments", amount: 3800000 },
{ year: 2025, name: "Qysqali", amount: 2900000 },
{ year: 2025, name: "Paytech Almaty", amount: 1500000 }
];
function loadDemo() {
if (demoOn) return;
demoOn = true;
rows = rows.concat(DEMO);
render();
}
function clearDemo() {
demoOn = false;
rows = rows.filter(function (r) {
return !DEMO.some(function (d) { return d.year === r.year && d.name === r.name && d.amount === r.amount; });
});
render();
}
var fmt = new Intl.NumberFormat("ru-RU", { maximumFractionDigits: 0 });
function money(v) { return fmt.format(Math.round(v)); }
function kfmt(v) {
var a = Math.abs(v);
if (a >= 1e9) return (v / 1e9).toFixed(1).replace(".0","") + " млрд";
if (a >= 1e6) return (v / 1e6).toFixed(1).replace(".0","") + " млн";
if (a >= 1e3) return (v / 1e3).toFixed(0) + " тыс";
return String(Math.round(v));
}
function parseNumber(v) {
if (v == null) return 0;
if (typeof v === "number") return v;
var s = String(v).replace(/\s/g, "").replace(/\.(?=\d{3}(\D|$))/g, "").replace(",", ".");
var n = parseFloat(s);
return isNaN(n) ? 0 : n;
}
function detectSheetToRows(sheet, wb) {
var a = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: null });
if (!a.length) return null;
var hdr = a[0].map(function (h) { return String(h == null ? "" : h).trim(); });
var idx = { year: -1, name: -1, amount: -1 };
hdr.forEach(function (h, i) {
var l = h.toLowerCase();
if (idx.year < 0 && /год|year/.test(l)) idx.year = i;
if (idx.name < 0 && /контрагент|partner|наименование|назван|клиент|компан/.test(l)) idx.name = i;
if (idx.amount < 0 && /(сумм|amount|поступл|платеж)/.test(l)) idx.amount = i;
});
if (idx.year < 0 || idx.name < 0 || idx.amount < 0) return null;
var out = [];
for (var i = 1; i < a.length; i++) {
var r = a[i];
if (!r || r.every(function (c) { return c == null || String(c).trim() === ""; })) continue;
var y = parseInt(r[idx.year], 10);
var n = r[idx.name];
var amt = parseNumber(r[idx.amount]);
if (!y || !n || !amt) continue;
out.push({ year: y, name: String(n).trim(), amount: amt });
}
return out.length ? out : null;
}
function handleFiles(list) {
Array.prototype.forEach.call(list, function (f) {
if (!/\.(xlsx|xls)$/i.test(f.name)) return;
files.push({ name: f.name, file: f });
f.arrayBuffer().then(function (buf) {
var wb = XLSX.read(buf, { type: "array" });
for (var si = 0; si < wb.SheetNames.length; si++) {
var got = detectSheetToRows(wb.Sheets[wb.SheetNames[si]], wb);
if (got) { rows = rows.concat(got); break; }
}
render();
});
});
document.getElementById("fileInput").value = "";
renderFileList();
}
function removeFile(i) {
files.splice(i, 1);
rows = rows.filter(function (r) { return true; });
// пересобрать сложно, поэтому просто перечитаем оставшиеся файлы
rebuildRows();
}
function rebuildRows() {
rows = [];
var pending = 0;
files.forEach(function (f) {
pending++;
f.file.arrayBuffer().then(function (buf) {
var wb = XLSX.read(buf, { type: "array" });
for (var si = 0; si < wb.SheetNames.length; si++) {
var got = detectSheetToRows(wb.Sheets[wb.SheetNames[si]], wb);
if (got) { rows = rows.concat(got); break; }
}
pending--;
if (pending === 0) { renderFileList(); render(); }
});
});
if (pending === 0) { renderFileList(); render(); }
}
function resetData() {
files = []; rows = []; demoOn = false;
renderFileList(); render();
}
function renderFileList() {
var el = document.getElementById("fileList");
el.innerHTML = files.map(function (f, i) {
return '<span class="kt-ai-filechip">' + f.name +
' <button onclick="removeFile(' + i + ')" title="Убрать файл"><svg class="kt-icon" aria-hidden="true"><use href="#x"></use></svg></button></span>';
}).join("");
}
function aggregate() {
var byYear = {};
var byPair = {};
var byYearName = {};
rows.forEach(function (r) {
byYear[r.year] = (byYear[r.year] || 0) + r.amount;
var key = r.year + "|" + r.name;
byPair[key] = (byPair[key] || 0) + r.amount;
if (!byYearName[r.year]) byYearName[r.year] = {};
byYearName[r.year][r.name] = (byYearName[r.year][r.name] || 0) + r.amount;
});
var years = Object.keys(byYear).map(Number).sort();
return { byYear: byYear, byPair: byPair, byYearName: byYearName, years: years };
}
function chartGrouped(data) {
var years = data.years;
var names = [];
data.byYearName.forEach(function (m) {
Object.keys(m).forEach(function (n) { if (names.indexOf(n) < 0) names.push(n); });
});
var max = 0;
names.forEach(function (n) {
years.forEach(function (y) {
var v = (data.byYearName[y] && data.byYearName[y][n]) || 0;
if (v > max) max = v;
});
});
var W = 760, H = 380, padL = 60, padB = 80, padT = 20, padR = 20;
var plotW = W - padL - padR, plotH = H - padT - padB;
var band = plotW / Math.max(years.length, 1);
var bw = Math.min(60, (band - 10) / Math.max(names.length, 1));
var svg = ['<svg viewBox="0 0 ' + W + ' ' + H + '" class="kt-ai-chart" xmlns="http://www.w3.org/2000/svg">'];
for (var g = 0; g <= 4; g++) {
var gv = max * g / 4;
var gy = padT + plotH - plotH * g / 4;
svg.push('<line x1="' + padL + '" y1="' + gy + '" x2="' + (W - padR) + '" y2="' + gy + '" stroke="var(--kt-ai-divider)"/>');
svg.push('<text x="' + (padL - 8) + '" y="' + (gy + 4) + '" text-anchor="end" font-size="11" fill="var(--kt-ai-fg-muted)">' + kfmt(gv) + '</text>');
}
years.forEach(function (y, yi) {
var cx = padL + band * yi + band / 2;
names.forEach(function (n, ni) {
var v = (data.byYearName[y] && data.byYearName[y][n]) || 0;
var h = max ? (v / max) * plotH : 0;
var bx = cx - (names.length * bw + (names.length - 1) * 3) / 2 + ni * (bw + 3);
var by = padT + plotH - h;
svg.push('<rect x="' + bx.toFixed(1) + '" y="' + by.toFixed(1) + '" width="' + bw.toFixed(1) + '" height="' + h.toFixed(1) + '" rx="4" fill="' + COUNTERPARTY_COLORS[ni % COUNTERPARTY_COLORS.length] + '"/>');
});
svg.push('<text x="' + cx + '" y="' + (H - padB + 22) + '" text-anchor="middle" font-size="13" font-weight="600" fill="var(--kt-ai-fg)">' + y + '</text>');
});
svg.push('</svg>');
return svg;
}
function chartLine(data) {
var years = data.years;
var vals = years.map(function (y) { return data.byYear[y]; });
var max = Math.max.apply(null, vals.concat([1]));
var W = 520, H = 300, padL = 60, padB = 40, padT = 24, padR = 24;
var plotW = W - padL - padR, plotH = H - padT - padB;
var pt = function (i) {
var x = years.length > 1 ? padL + plotW * i / (years.length - 1) : padL + plotW / 2;
var y = padT + plotH - (vals[i] / max) * plotH;
return { x: x, y: y };
};
var n = years.length;
var svg = ['<svg viewBox="0 0 ' + W + ' ' + H + '" class="kt-ai-chart" xmlns="http://www.w3.org/2000/svg">'];
for (var g = 0; g <= 4; g++) {
var gv = max * g / 4;
var gy = padT + plotH - plotH * g / 4;
svg.push('<line x1="' + padL + '" y1="' + gy + '" x2="' + (W - padR) + '" y2="' + gy + '" stroke="var(--kt-ai-divider)"/>');
svg.push('<text x="' + (padL - 8) + '" y="' + (gy + 4) + '" text-anchor="end" font-size="11" fill="var(--kt-ai-fg-muted)">' + kfmt(gv) + '</text>');
}
if (n > 1) {
var area = vals.map(function (v, i) { var p = pt(i); return (i ? "L" : "M") + p.x.toFixed(1) + " " + p.y.toFixed(1); }).join(" ");
area += " L" + pt(n - 1).x.toFixed(1) + " " + (padT + plotH) + " L" + pt(0).x.toFixed(1) + " " + (padT + plotH) + " Z";
svg.push('<path d="' + area + '" fill="var(--kt-ai-primary)" opacity="0.12"/>');
svg.push('<path d="' + vals.map(function (v, i) { var p = pt(i); return (i ? "L" : "M") + p.x.toFixed(1) + " " + p.y.toFixed(1); }).join(" ") + '" fill="none" stroke="var(--kt-ai-primary)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>');
}
years.forEach(function (y, i) {
var p = pt(i);
svg.push('<circle cx="' + p.x.toFixed(1) + '" cy="' + p.y.toFixed(1) + '" r="5" fill="var(--kt-ai-primary)" stroke="var(--kt-ai-bg)" stroke-width="2"/>');
svg.push('<text x="' + p.x.toFixed(1) + '" y="' + (p.y - 12) + '" text-anchor="middle" font-size="11" font-weight="600" fill="var(--kt-ai-fg)">' + kfmt(vals[i]) + '</text>');
svg.push('<text x="' + p.x.toFixed(1) + '" y="' + (H - padB + 22) + '" text-anchor="middle" font-size="12" fill="var(--kt-ai-fg-muted)">' + y + '</text>');
});
svg.push('</svg>');
return svg;
}
function render() {
renderFileList();
var el = document.getElementById("results");
if (!rows.length) {
el.innerHTML = '<div class="kt-ai-empty"><svg class="kt-icon" aria-hidden="true"><use href="#chart"></use></svg><div>Загрузите файлы с данными — здесь появятся статистика, графики и таблица контрагентов.</div></div>';
return;
}
var d = aggregate();
var years = d.years;
var total = 0;
years.forEach(function (y) { total += d.byYear[y]; });
var firstY = years[0], lastY = years[years.length - 1];
var delta = d.byYear[firstY] ? ((d.byYear[lastY] - d.byYear[firstY]) / d.byYear[firstY] * 100) : null;
var names = [];
d.byYearName.forEach(function (m) { Object.keys(m).forEach(function (n) { if (names.indexOf(n) < 0) names.push(n); }); });
var stats = years.map(function (y) {
var prev = years.indexOf(y) > 0 ? d.byYear[years[years.indexOf(y) - 1]] : null;
var dl = prev ? ((d.byYear[y] - prev) / prev * 100) : null;
var dlHtml = dl == null ? '<span class="kt-ai-stat-delta">—</span>'
: '<span class="kt-ai-stat-delta ' + (dl >= 0 ? "delta-up" : "delta-down") + '">' + (dl >= 0 ? "↑ " : "↓ ") + Math.abs(dl).toFixed(1) + "% к пред. году</span>";
return '<div class="kt-ai-stat"><span class="kt-ai-stat-label">Поступления, ' + y + '</span><span class="kt-ai-stat-value">' + money(d.byYear[y]) + '</span>' + dlHtml + '</div>';
}).join("");
var tableRows = names.map(function (n, i) {
var cells = years.map(function (y) {
return '<td class="num">' + ((d.byYearName[y] && d.byYearName[y][n]) ? money(d.byYearName[y][n]) : "—") + '</td>';
}).join("");
var sum = 0, present = false;
years.forEach(function (y) { if (d.byYearName[y] && d.byYearName[y][n]) { sum += d.byYearName[y][n]; present = true; } });
var share = present && total ? (sum / total * 100).toFixed(1) + " %" : "—";
return '<tr><td>' + n + '</td>' + cells + '<td class="num"><strong>' + (present ? money(sum) : "—") + '</strong></td><td class="num">' + share + '</td></tr>';
}).join("");
var totalRow = '<tr><td><strong>Итого</strong></td>' + years.map(function (y) { return '<td class="num"><strong>' + money(d.byYear[y]) + '</strong></td>'; }).join("") + '<td class="num"><strong>' + money(total) + '</strong></td><td class="num">100%</td></tr>';
el.innerHTML =
'<div class="kt-ai-stat-grid">' + stats +
(delta != null ? '<div class="kt-ai-stat"><span class="kt-ai-stat-label">Смена за ' + firstY + ' → ' + lastY + '</span><span class="kt-ai-stat-value ' + (delta >= 0 ? "delta-up" : "delta-down") + '">' + (delta >= 0 ? "+" : "") + delta.toFixed(1) + '%</span></div>' : '') +
'</div>' +
'<div class="kt-ai-charts">' +
'<div class="kt-ai-chart-card"><h3>Поступления по годам</h3>' + chartLine(d) + '</div>' +
'<div class="kt-ai-chart-card"><h3>По контрагентам</h3>' + chartGrouped(d) +
'<div class="kt-ai-legend">' + names.map(function (n, i) { return '<span><i style="background:' + COUNTERPARTY_COLORS[i % COUNTERPARTY_COLORS.length] + '"></i>' + n + '</span>'; }).join("") + '</div>' +
'</div>' +
'</div>' +
'<div class="kt-ai-chart-card" style="margin-bottom:var(--kt-ai-space-4)"><h3 style="margin-bottom:var(--kt-ai-space-4)">Контрагенты и суммы, ' + years.join(" / ") + '</h3>' +
'<div class="kt-ai-tbl-wrap"><table class="kt-ai-table"><thead><tr><th>Контрагент</th>' + years.map(function (y) { return '<th class="num">' + y + '</th>'; }).join("") + '<th class="num">Итого</th><th class="num">Доля</th></tr></thead><tbody>' +
tableRows + totalRow + '</tbody></table></div>' +
'</div>' +
'<div class="kt-ai-actions">' +
'<button class="kt-ai-btn" data-variant="ghost" onclick="window.print()">Печать / экспортировать в PDF</button>' +
' '<span style="color:var(--kt-ai-fg-muted);font-size:var(--kt-ai-text-sm)">Итого контрагентов: ' + names.length + ' · записей: ' + rows.length +
(demoOn ? " · пример данных" : "") + '</span>' +
'</div>';
document.getElementById("demoClear").style.display = demoOn ? "" : "none";
}
document.getElementById("drop").addEventListener("dragover", function (e) { e.preventDefault(); this.classList.add("over"); });
document.getElementById("drop").addEventListener("dragleave", function () { this.classList.remove("over"); });
document.getElementById("drop").addEventListener("drop", function (e) {
e.preventDefault(); this.classList.remove("over");
if (e.dataTransfer && e.dataTransfer.files.length) handleFiles(e.dataTransfer.files);
});
render();
</script>
</body>
</html>