386 lines
20 KiB
HTML
386 lines
20 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" data-theme="light">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Сводный дашборд — ИИ-агент КФУ</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">
|
||
<link rel="stylesheet" href="design-system/vibe-theme.css">
|
||
<style>
|
||
a.kt-ai-btn { text-decoration: none; }
|
||
.kfu-wrap { max-width: 1240px; margin: 0 auto; padding: 20px 18px 70px; }
|
||
.kfu-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px 16px; margin-bottom: 16px; }
|
||
.kfu-head h1 { font-size: 22px; margin: 0; }
|
||
.kfu-head .meta { font-size: 13.5px; color: var(--kt-ai-fg-muted); }
|
||
.kfu-head .spacer { flex: 1; }
|
||
.kfu-cnt { font-size: 14px; font-weight: 600; }
|
||
.kfu-cnt b { color: var(--kt-ai-primary); }
|
||
.kfu-legend { display: flex; flex-wrap: wrap; gap: 8px 16px; margin: 0 0 14px; font-size: 12.5px; color: var(--kt-ai-fg-muted); }
|
||
.kfu-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||
.kfu-dot { width: 10px; height: 10px; border-radius: 50%; }
|
||
.kfu-dot.green { background: var(--kt-ai-success-token); }
|
||
.kfu-dot.yellow { background: var(--kt-ai-warning, var(--kt-ai-warn-token)); }
|
||
.kfu-dot.red { background: var(--kt-ai-danger); }
|
||
.kfu-dot.none { background: var(--kt-ai-fg-faint, var(--kt-ai-fg-faint)); }
|
||
.kfu-table-wrap { overflow-x: auto; border: 1px solid var(--kt-ai-card-border); border-radius: 12px; background: var(--kt-ai-card-bg); }
|
||
table.kfu-t { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 1180px; }
|
||
.kfu-t th { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--kt-ai-card-border); font-weight: 600; font-size: 12px; color: var(--kt-ai-fg-muted); white-space: nowrap; background: var(--kt-ai-bg); position: sticky; top: 0; }
|
||
.kfu-t td { padding: 10px 12px; border-bottom: 1px solid var(--kt-ai-card-border); vertical-align: top; }
|
||
.kfu-t tr:last-child td { border-bottom: 0; }
|
||
.kfu-t td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||
.kfu-t td.company { font-weight: 600; white-space: nowrap; max-width: 230px; overflow: hidden; text-overflow: ellipsis; }
|
||
.kfu-zone { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; white-space: nowrap; }
|
||
.kfu-flags { font-size: 12px; color: var(--kt-ai-fg-muted); max-width: 260px; }
|
||
.kfu-act { white-space: nowrap; }
|
||
.kfu-act a, .kfu-act button { background: none; border: 0; color: var(--kt-ai-primary); cursor: pointer; font-size: 12.5px; padding: 2px 4px; }
|
||
.kfu-note { font-size: 12px; color: var(--kt-ai-fg-muted); max-width: 240px; }
|
||
.kfu-status-pill { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; white-space: nowrap; }
|
||
.kfu-st-done { background: color-mix(in srgb, var(--kt-ai-success-token) 14%, transparent); color: var(--kt-ai-success, var(--kt-ai-success-token)); }
|
||
.kfu-st-processing { background: color-mix(in srgb, var(--kt-ai-warning, var(--kt-ai-warn-token)) 14%, transparent); color: var(--kt-ai-warning, var(--kt-ai-warn-token)); }
|
||
.kfu-st-error { background: color-mix(in srgb, var(--kt-ai-danger) 12%, transparent); color: var(--kt-ai-danger); }
|
||
.kfu-st-no { background: var(--kt-ai-bg-active, var(--kt-ai-bg-active)); color: var(--kt-ai-fg-muted); }
|
||
.kfu-section { margin-top: 30px; }
|
||
.kfu-section h2 { font-size: 17px; margin: 0 0 12px; }
|
||
.kfu-setcard { border: 1px solid var(--kt-ai-card-border); border-radius: 12px; background: var(--kt-ai-card-bg); padding: 18px; display: grid; gap: 16px; }
|
||
.kfu-setgrid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); }
|
||
.kfu-comp { display: grid; grid-template-columns: 1.3fr 1fr 0.8fr auto; gap: 10px; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--kt-ai-card-border); }
|
||
.kfu-comp:last-child { border-bottom: 0; }
|
||
.kfu-comp .in { width: 100%; }
|
||
.kfu-comp .name { font-weight: 600; font-size: 13px; }
|
||
.kfu-comp input.kt-ai-input { padding: 6px 10px; font-size: 13px; }
|
||
.kfu-actions { display: flex; gap: 6px; white-space: nowrap; }
|
||
.kfu-actions button { background: var(--kt-ai-bg-active, transparent); border: 1px solid var(--kt-ai-card-border); border-radius: 8px; padding: 5px 9px; font-size: 12px; cursor: pointer; color: var(--kt-ai-fg); }
|
||
.kfu-actions button:hover { border-color: var(--kt-ai-primary); color: var(--kt-ai-primary); }
|
||
.kfu-actions button.danger:hover { border-color: var(--kt-ai-danger); color: var(--kt-ai-danger); }
|
||
.kfu-addrow { display: grid; grid-template-columns: 1.3fr 1fr 0.8fr auto; gap: 10px; align-items: center; padding-top: 12px; }
|
||
.kfu-hidden-input { display: none; }
|
||
.kfu-pass { font-size: 12px; color: var(--kt-ai-fg-muted); margin-top: 6px; }
|
||
.kfu-pass code { background: var(--kt-ai-bg-active, var(--kt-ai-bg-active)); padding: 1px 5px; border-radius: 4px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<script src="design-system/icons/kt-ai-icons.js"></script>
|
||
<div class="kfu-wrap">
|
||
<div class="kfu-head">
|
||
<h1>Сводный дашборд</h1>
|
||
<span class="meta" id="periodMeta"></span>
|
||
<span class="spacer"></span>
|
||
<span class="kfu-cnt" id="cnt"></span>
|
||
<a class="kt-ai-btn" data-variant="primary" id="exportBtn" href="#">Скачать сводный отчёт</a>
|
||
<a class="kt-ai-btn" data-variant="ghost" id="logoutBtn" href="#">Выйти</a>
|
||
</div>
|
||
<div class="kfu-legend">
|
||
<span><i class="kfu-dot green"></i>зелёная</span>
|
||
<span><i class="kfu-dot yellow"></i>жёлтая</span>
|
||
<span><i class="kfu-dot red"></i>красная</span>
|
||
<span><i class="kfu-dot none"></i>без оценки</span>
|
||
<span style="margin-left:auto">Цифры и зоны — от агента Alem</span>
|
||
</div>
|
||
<div class="kfu-table-wrap">
|
||
<table class="kfu-t">
|
||
<thead>
|
||
<tr>
|
||
<th>Компания</th>
|
||
<th>Статус</th>
|
||
<th>Когда загружен</th>
|
||
<th>Комментарий дочки</th>
|
||
<th class="num">Долг</th>
|
||
<th class="num">EBITDA 12М</th>
|
||
<th class="num">Проц. расходы 12М</th>
|
||
<th class="num">Капитал, млрд ₸</th>
|
||
<th class="num">К1</th>
|
||
<th class="num">К2</th>
|
||
<th class="num">К3</th>
|
||
<th>Зона компании</th>
|
||
<th>Замечания агента</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
<section class="kfu-section">
|
||
<h2>Настройки</h2>
|
||
<div class="kfu-setcard">
|
||
<div class="kfu-setgrid">
|
||
<label class="kt-ai-field"><span>Период отчётности</span>
|
||
<input class="kt-ai-input" id="setPeriod" placeholder="Q2-2026"></label>
|
||
<label class="kt-ai-field"><span>Срок предоставления (день.месяц)</span>
|
||
<input class="kt-ai-input" id="setDeadline" placeholder="30.06.2026"></label>
|
||
<label class="kt-ai-field"><span>Телефон ведущего</span>
|
||
<input class="kt-ai-input" id="setPhone" placeholder="+7 (___) ___-__-__"></label>
|
||
</div>
|
||
<button class="kt-ai-btn" data-variant="primary" id="saveSettings">Сохранить настройки</button>
|
||
|
||
<div>
|
||
<h3 style="font-size:14px;margin:0 0 4px">Дочерние компании</h3>
|
||
<p style="font-size:12.5px;color:var(--kt-ai-fg-muted);margin:0 0 8px">Логин дочерней компании — её адрес в Аит (например, <code>jandamir_z</code>). Пароль генерируется системой.</p>
|
||
<div id="compList"></div>
|
||
<div class="kfu-addrow">
|
||
<input class="kt-ai-input" id="addName" placeholder="Название компании">
|
||
<input class="kt-ai-input" id="addPerson" placeholder="Ответственный">
|
||
<input class="kt-ai-input" id="addPhone" placeholder="+7 ___">
|
||
<span></span>
|
||
<button class="kt-ai-btn" data-variant="ghost" id="addCompany" type="button">Добавить</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<h3 style="font-size:14px;margin:0 0 4px">Смена пароля сводящего</h3>
|
||
<button class="kt-ai-btn" data-variant="ghost" id="regeneratePw" type="button">Сменить пароль</button>
|
||
<p class="kfu-pass" id="newPw"></p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
<script>
|
||
(function () {
|
||
function fmtDate(iso) {
|
||
if (!iso) return "";
|
||
var d = new Date(iso);
|
||
var p = function (x) { return (x < 10 ? "0" : "") + x; };
|
||
return p(d.getDate()) + "." + p(d.getMonth() + 1) + " " + p(d.getHours()) + ":" + p(d.getMinutes());
|
||
}
|
||
function fmtDateFull(iso) {
|
||
if (!iso) return "";
|
||
var d = new Date(iso);
|
||
var p2 = function (x) { return (x < 10 ? "0" : "") + x; };
|
||
var parts = String(iso).split("-");
|
||
if (parts.length === 3) return parts[2] + "." + parts[1] + "." + parts[0];
|
||
return p2(d.getDate()) + "." + p2(d.getMonth() + 1) + "." + d.getFullYear();
|
||
}
|
||
function fmtNum(v) {
|
||
if (v === null || v === undefined || v === "") return "—";
|
||
var n = Number(v);
|
||
if (isNaN(n)) return esc(v);
|
||
return n.toLocaleString("ru-RU", { maximumFractionDigits: 2 });
|
||
}
|
||
function esc(x) {
|
||
return String(x == null ? "" : x).replace(/[&<>"']/g, function (c) {
|
||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||
});
|
||
}
|
||
function zoneHtml(row) {
|
||
var z = row.zone_company;
|
||
var cls = { "зелёная": "green", "жёлтая": "yellow", "красная": "red" }[z];
|
||
var dot = "<i class='kfu-dot " + (cls || "none") + "'></i>";
|
||
var flags = (row.flags || []).join("\n");
|
||
return "<div class='kfu-zone'>" + dot + "<span>" + esc(z || "без оценки") + "</span></div>" +
|
||
(flags ? "<div class='kfu-flags'>" + esc(flags) + "</div>" : "");
|
||
}
|
||
function stHtml(row) {
|
||
if (!row || !row.status) return "<span class='kfu-status-pill kfu-st-no'>нет данных</span>";
|
||
var m = { done: ["kfu-st-done", "сдан"], processing: ["kfu-st-processing", "расчёт"], error: ["kfu-st-error", "ошибка"] };
|
||
var it = m[row.status] || ["kfu-st-no", esc(row.status)];
|
||
return "<span class='kfu-status-pill " + it[0] + "'>" + it[1] + "</span>";
|
||
}
|
||
|
||
var tbody = document.getElementById("tbody");
|
||
|
||
function render(rows, b) {
|
||
var done = 0, proc = 0, err = 0;
|
||
rows.forEach(function (r) {
|
||
if (r.status === "done") done++;
|
||
else if (r.status === "processing") proc++;
|
||
else if (r.status === "error") err++;
|
||
});
|
||
document.getElementById("cnt").innerHTML =
|
||
esc(rows.length) + " компаний: <b>" + done + "</b> сдано, " + proc + " на расчёте, " + err + " ошибок";
|
||
document.getElementById("periodMeta").textContent = "период " + (b.period || "") + " · срок до " + fmtDateFull(b.deadline);
|
||
var html = rows.map(function (r) {
|
||
var acts = [];
|
||
if (r.fileId) {
|
||
acts.push("<a href='api/file/" + r.fileId + "' target='_blank' download>файл</a>");
|
||
}
|
||
if (r.status === "processing" || r.status === "error") {
|
||
acts.push("<button onclick='kfRecalc(\"" + r.fileId + "\")'>пересчитать</button>");
|
||
}
|
||
return "<tr>" +
|
||
"<td class='company' title='" + esc(r.company) + "'>" + esc(r.company) + "</td>" +
|
||
"<td>" + stHtml(r) + "</td>" +
|
||
"<td>" + esc(fmtDate(r.uploadedAt)) + "</td>" +
|
||
"<td class='kfu-note'>" + esc(r.comment) + "</td>" +
|
||
"<td class='num'>" + fmtNum(r.debt) + "</td>" +
|
||
"<td class='num'>" + fmtNum(r.ebitda_ltm) + "</td>" +
|
||
"<td class='num'>" + fmtNum(r.interest_ltm) + "</td>" +
|
||
"<td class='num'>" + fmtNum(r.equity) + "</td>" +
|
||
"<td class='num'>" + fmtNum(r.k1) + "</td>" +
|
||
"<td class='num'>" + fmtNum(r.k2) + "</td>" +
|
||
"<td class='num'>" + fmtNum(r.k3) + "</td>" +
|
||
"<td>" + zoneHtml(r) + "</td>" +
|
||
"<td class='kfu-note'>" + esc(r.agent_comment) + "</td>" +
|
||
"<td class='kfu-act'>" + acts.join(" ") + "</td>" +
|
||
"</tr>";
|
||
}).join("");
|
||
tbody.innerHTML = html;
|
||
document.getElementById("exportBtn").setAttribute("href", "api/export?t=" + Date.now());
|
||
}
|
||
|
||
function renderCompanies(list) {
|
||
var box = document.getElementById("compList");
|
||
box.innerHTML = list.map(function (c) {
|
||
return "<div class='kfu-comp' data-id='" + esc(c.id) + "' data-login='" + esc(c.login) + "'>" +
|
||
"<input class='kt-ai-input in name' value='" + esc(c.name) + "' placeholder='Название'>" +
|
||
"<input class='kt-ai-input in person' value='" + esc(c.person) + "' placeholder='Ответственный'>" +
|
||
"<input class='kt-ai-input in phone' value='" + esc(c.phone) + "' placeholder='+7 ___'>" +
|
||
"<span style='font-size:12.5px' class='lg' title='Логин для входа — передайте компании в Аит'>" + esc(c.login) + "</span>" +
|
||
"<div class='kfu-actions'>" +
|
||
"<button class='act-save'>сохранить</button>" +
|
||
"<button class='act-pw'>пароль</button>" +
|
||
"<button class='act-toggle'>" + (c.active ? "отключить" : "включить") + "</button>" +
|
||
"</div>" +
|
||
"</div>";
|
||
}).join("");
|
||
Array.prototype.forEach.call(box.querySelectorAll(".kfu-comp"), function (rowEl) {
|
||
var id = rowEl.getAttribute("data-id");
|
||
var c_login = rowEl.getAttribute("data-login");
|
||
function collect() {
|
||
return {
|
||
name: rowEl.querySelector(".name").value,
|
||
person: rowEl.querySelector(".person").value,
|
||
phone: rowEl.querySelector(".phone").value,
|
||
login: c_login
|
||
};
|
||
}
|
||
function flash(msg) {
|
||
var el = document.createElement("p");
|
||
el.className = "kfu-pass";
|
||
el.textContent = msg;
|
||
box.after(el);
|
||
setTimeout(function () { el.remove(); }, 30000);
|
||
}
|
||
rowEl.querySelector(".act-save").addEventListener("click", function () {
|
||
var data = collect();
|
||
var body = { companies: window.__companies.map(function (c) {
|
||
if (c.id !== id) return c;
|
||
var m = { id: c.id, name: data.name, person: data.person, phone: data.phone, login: data.login, active: c.active };
|
||
return m;
|
||
}) };
|
||
fetch("api/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body)
|
||
}).then(function (r) { return r.json(); }).then(function (b) {
|
||
if (!b.ok) { alert(b.error || "Не удалось сохранить"); return; }
|
||
load();
|
||
});
|
||
});
|
||
rowEl.querySelector(".act-pw").addEventListener("click", function () {
|
||
fetch("api/user/" + id, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action: "set_password" })
|
||
}).then(function (r) { return r.json(); }).then(function (b) {
|
||
if (!b.ok) { alert(b.error || "Ошибка"); return; }
|
||
flash("Новый пароль: " + b.tempPassword + " — передайте компании через Аит.");
|
||
load();
|
||
});
|
||
});
|
||
rowEl.querySelector(".act-toggle").addEventListener("click", function () {
|
||
fetch("api/user/" + id, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ action: "toggle" })
|
||
}).then(function (r) { return r.json(); }).then(function (b) { load(); });
|
||
});
|
||
});
|
||
}
|
||
|
||
function load() {
|
||
Promise.all([
|
||
fetch("api/dashboard", { credentials: "same-origin" })
|
||
.then(function (r) { if (r.status === 401) { window.location.href = "index.html"; } return r.json(); }),
|
||
fetch("api/settings", { credentials: "same-origin" }).then(function (r) { return r.json(); })
|
||
]).then(function (pair) {
|
||
var b = pair[0], s = pair[1];
|
||
if (!b) return;
|
||
window.__companies = s.companies || [];
|
||
render(b.rows, b);
|
||
document.getElementById("setPeriod").value = s.period || "";
|
||
document.getElementById("setDeadline").value = s.deadline || "";
|
||
document.getElementById("setPhone").value = s.consolidatorPhone || "";
|
||
renderCompanies(s.companies || []);
|
||
});
|
||
}
|
||
|
||
window.kfRecalc = function (id) {
|
||
fetch("api/recalc?id=" + encodeURIComponent(id), { method: "POST" })
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (b) {
|
||
if (!b.ok) alert(b.error || "Не удалось пересчитать");
|
||
load();
|
||
});
|
||
};
|
||
|
||
document.getElementById("saveSettings").addEventListener("click", function () {
|
||
fetch("api/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
period: document.getElementById("setPeriod").value,
|
||
deadline: document.getElementById("setDeadline").value,
|
||
consolidatorPhone: document.getElementById("setPhone").value
|
||
})
|
||
}).then(function (r) { return r.json(); }).then(function (b) {
|
||
if (b.ok) load(); else alert(b.error || "Не удалось сохранить");
|
||
});
|
||
});
|
||
|
||
document.getElementById("addCompany").addEventListener("click", function () {
|
||
var name = document.getElementById("addName").value.trim();
|
||
var phone = document.getElementById("addPhone").value.trim();
|
||
if (!name) { alert("Укажите название компании"); return; }
|
||
fetch("api/add-company", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name: name, phone: phone, person: document.getElementById("addPerson").value.trim() })
|
||
}).then(function (r) { return r.json(); }).then(function (b) {
|
||
if (!b.ok) { alert(b.error || "Не удалось добавить компанию"); return; }
|
||
document.getElementById("addName").value = "";
|
||
document.getElementById("addPhone").value = "";
|
||
var el = document.createElement("p");
|
||
el.className = "kfu-pass";
|
||
el.textContent = "Компания добавлена. Логин: " + b.login + ", пароль: " + b.tempPassword + " — передайте через Аит.";
|
||
document.getElementById("compList").after(el);
|
||
setTimeout(function () { el.remove(); }, 30000);
|
||
load();
|
||
});
|
||
});
|
||
|
||
document.getElementById("regeneratePw").addEventListener("click", function () {
|
||
var cur = prompt("Введите текущий пароль сводящего:");
|
||
if (cur === null) return;
|
||
var nw = prompt("Новый пароль (минимум 4 символа):");
|
||
if (!nw || nw.length < 4) { alert("Слишком короткий пароль"); return; }
|
||
if (nw !== window.prompt("Повторите новый пароль:")) { alert("Пароли не совпадают"); return; }
|
||
fetch("api/consolidator-password", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ current: cur, next: nw })
|
||
}).then(function (r) { return r.json(); }).then(function (b) {
|
||
if (!b.ok) { alert(b.error || "Ошибка"); return; }
|
||
alert("Пароль изменён.");
|
||
});
|
||
});
|
||
|
||
document.getElementById("logoutBtn").addEventListener("click", function (e) {
|
||
e.preventDefault();
|
||
fetch("api/logout", { method: "POST" }).then(function () { window.location.href = "index.html"; });
|
||
});
|
||
|
||
fetch("api/me", { credentials: "same-origin" }).then(function (r) {
|
||
if (r.status === 401) { window.location.href = "index.html"; return; }
|
||
return r.json();
|
||
}).then(function (me) {
|
||
if (!me || me.role !== "consolidator") { window.location.href = "index.html"; return; }
|
||
load();
|
||
setInterval(function () { if (document.visibilityState === "visible") load(); }, 30000);
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
</body>
|
||
</html> |