privet/dashboard.html

386 lines
20 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" 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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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>