privet/dashboard.html

433 lines
24 KiB
HTML
Raw Permalink 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 { border: 1px solid var(--kt-ai-card-border); border-radius: 12px; background: var(--kt-ai-card-bg); }
table.kfu-t { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 13px; }
.kfu-t th { text-align: left; padding: 8px 8px; border-bottom: 1px solid var(--kt-ai-card-border); font-weight: 600; font-size: 11.5px; color: var(--kt-ai-fg-muted); white-space: nowrap; background: var(--kt-ai-bg); overflow: hidden; text-overflow: ellipsis; }
.kfu-t td { padding: 8px 8px; border-bottom: 1px solid var(--kt-ai-card-border); vertical-align: top; overflow: hidden; text-overflow: clip; }
.kfu-t td.company-cell { white-space: normal; word-break: break-word; }
.kfu-sub { font-size: 12px; color: var(--kt-ai-fg-muted); margin-top: 2px; }
.kfu-toggle { background: none; border: 0; color: var(--kt-ai-primary); cursor: pointer; font-size: 12px; padding: 2px 0; margin-top: 4px; }
.kfu-toggle:hover { text-decoration: underline; }
.kfu-rowdetail { padding: 10px 14px 12px; background: var(--kt-ai-bg); }
.kfu-err { margin-top: 4px; font-size: 11.5px; color: var(--kt-ai-danger); max-width: 320px; }
.kfu-alem-note { font-size: 11px; color: var(--kt-ai-fg-faint, var(--kt-ai-fg-muted)); line-height: 1.55; margin-top: 10px; padding: 8px 12px; background: var(--kt-ai-card-bg); border: 1px dashed var(--kt-ai-card-border); border-radius: 10px; }
.kfu-alem-note code { font-size: 10.5px; color: var(--kt-ai-fg); }
.kfu-rowdetail .kfu-cell { margin-bottom: 6px; font-size: 12.5px; color: var(--kt-ai-fg); }
.kfu-rowdetail .lbl { font-size: 11.5px; color: var(--kt-ai-fg-muted); }
.kfu-t tr:last-child td { border-bottom: 0; }
.kfu-t td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: 12.5px; }
.kfu-t th.num { text-align: right; }
.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">
<colgroup>
<col style="width:19%"><col style="width:11%"><col style="width:9%"><col style="width:8%"><col style="width:9%">
<col style="width:10%"><col style="width:9%"><col style="width:6.5%"><col style="width:6.5%"><col style="width:6.5%"><col style="width:6%">
</colgroup>
<thead>
<tr>
<th class="th-company">Компания</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>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<p class="kfu-alem-note">Как считается: дочка загружает файл → приложение создаёт сессию в Alem: <code>POST /api/v1/open-api/conversation</code> (заголовок <code>Authorization: Bearer …</code>, body <code>{template_id, title, user_id}</code>) → затем текст отчёта уходит в <code>POST /api/v1/open-api/conversation/{session_id}/send</code> (поле <code>content</code>) → ответ приходит SSE-потоком, итог — в последнем событии <code>message_result.detail.full_content</code>, которое приложение разбирает как JSON. Ключи и <code>template_id</code> задаются в настройках приложения, в код не попадают.</p>
<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">Логин дочерней компании — её адрес в Aitu (например, <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 s = String(iso).trim();
var p2 = function (x) { return (x < 10 ? "0" : "") + x; };
var m = s.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/);
if (m) return p2(+m[1]) + "." + p2(+m[2]) + "." + m[3];
var parts = s.split("-");
if (parts.length === 3) return parts[2] + "." + parts[1] + "." + parts[0];
var d = new Date(s);
if (isNaN(d.getTime())) return s;
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 || row.status === "not_submitted") 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");
var ROW_DATA = [];
var ROW_IDX = 0;
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++;
});
var submitted = rows.filter(function (r) { return r.status === "done" || r.status === "error"; }).length;
document.getElementById("cnt").innerHTML =
esc(rows.length) + " компаний: <b>" + submitted + "</b> сдано (" + err + " с ошибкой расчёта), " + proc + " на расчёте";
document.getElementById("periodMeta").textContent = "период " + (b.period || "") + " · срок до " + fmtDateFull(b.deadline);
ROW_DATA = [];
ROW_IDX = 0;
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>");
}
var idx = ROW_IDX++;
ROW_DATA[idx] = r;
var hasDetail = !!(r.comment || (r.flags && r.flags.length) || r.agent_comment || r.error);
var actsStr = acts.length ? acts.join(" ") : "";
return "<tr>" +
"<td class='company company-cell' title='" + esc(r.company) + "'>" + esc(r.company) +
(hasDetail ? "<br><button class='kfu-toggle' onclick='kfToggle(" + idx + ")' id='tg" + idx + "'>подробнее</button>" : "") +
(r.error ? "<div class='kfu-err'>" + esc(r.error) + "</div>" : "") +
"</td>" +
"<td>" + stHtml(r) + "</td>" +
"<td>" + esc(fmtDate(r.uploadedAt)) + "</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-act'>" + actsStr + "</td>" +
"</tr>" +
(hasDetail ? "<tr class='kfu-detail-row' id='dt" + idx + "' hidden><td colspan='12' class='kfu-rowdetail'>" +
(r.comment ? "<div class='kfu-cell'><span class='lbl'>Комментарий дочки:</span> " + esc(r.comment) + "</div>" : "") +
(r.agent_comment ? "<div class='kfu-cell'><span class='lbl'>Замечания агента:</span> " + esc(r.agent_comment) + "</div>" : "") +
((r.flags && r.flags.length) ? "<div class='kfu-cell'><span class='lbl'>Признаки:</span><br>" + r.flags.map(esc).join("<br>") + "</div>" : "") +
(r.error ? "<div class='kfu-cell' style='color:var(--kt-ai-danger)'><span class='lbl'>Ошибка расчёта:</span> " + esc(r.error) + "</div>" : "") +
"</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='Логин для входа — передайте компании в Aitu'>" + 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;
return { id: c.id, name: data.name, person: data.person, phone: data.phone, active: c.active };
}) };
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 + " — передайте компании через Aitu.");
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 = fmtDateFull(s.deadline);
document.getElementById("setPhone").value = s.consolidatorPhone || "";
renderCompanies(s.companies || []);
});
}
window.kfToggle = function (i) {
var row = document.getElementById("dt" + i);
var btn = document.getElementById("tg" + i);
if (!row) return;
var open = row.hidden;
row.hidden = !open;
if (btn) btn.textContent = open ? "скрыть" : "подробнее";
};
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: function (v) {
var m = String(v || "").trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/);
if (!m) { var m2 = String(v || "").trim().match(/^(\d{4})-(\d{2})-(\d{2})/); if (m2) return m2[0]; return String(v || ""); }
return m[3] + "-" + m[2] + "-" + m[1];
}(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 + " — передайте через Aitu.";
document.getElementById("compList").after(el);
setTimeout(function () { el.remove(); }, 30000);
load();
});
});
document.getElementById("regeneratePw").addEventListener("click", function () {
var nw = prompt("Введите новый пароль сводящего (минимум 4 символа):");
if (!nw || nw.length < 4) { alert("Слишком короткий пароль"); return; }
if (nw !== window.prompt("Повторите новый пароль:")) { alert("Пароли не совпадают"); return; }
var cur = prompt("Введите текущий пароль сводящего:");
if (cur === null) return;
fetch("api/consolidator-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: cur })
}).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>