google-dashboard/index.html

394 lines
13 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>Dashboard — Сводная аналитика</title>
<style>
:root{
--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;
--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;
--gray-800:#1a1f2b;--gray-700:#252b38;
--red:#ff4757;--green:#2ed573;--orange:#ffa502;--blue:#3742fa;
}
*{box-sizing:border-box;margin:0;padding:0}
body{
font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
color:var(--ink);background:var(--gray-100);min-height:100vh;
}
.topbar{
background:var(--ink);color:var(--white);
padding:18px 28px;display:flex;align-items:center;
justify-content:space-between;flex-wrap:wrap;gap:14px;
position:sticky;top:0;z-index:100;
}
.topbar h1{font-size:22px;font-weight:700}
.topbar .controls{display:flex;align-items:center;gap:14px}
.btn{
background:var(--cyan);color:var(--ink);
border:none;padding:9px 20px;border-radius:8px;
font-weight:700;font-size:14px;cursor:pointer;
}
.btn:hover{opacity:.85}
.btn-sm{
background:var(--gray-100);color:var(--ink);
border:none;padding:5px 12px;border-radius:6px;
font-weight:600;font-size:12px;cursor:pointer;
white-space:nowrap;
}
.btn-sm:hover{background:var(--cyan)}
.timer{font-size:13px;color:var(--gray-500)}
.badge{padding:4px 14px;border-radius:20px;font-size:13px;font-weight:600}
.badge.ok{background:var(--green);color:var(--ink)}
.badge.warn{background:var(--orange);color:var(--white)}
.badge.err{background:var(--red);color:var(--white)}
/* Stats row */
.stats{
display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
gap:16px;padding:24px 28px 0;max-width:1400px;margin:0 auto;
}
.stat-card{
background:var(--white);border-radius:12px;padding:20px;
box-shadow:0 1px 3px rgba(0,0,0,.06);
}
.stat-card .value{font-size:32px;font-weight:800;line-height:1.1}
.stat-card .label{font-size:13px;color:var(--gray-500);margin-top:6px}
/* Content */
.content{padding:16px 28px 40px;max-width:1400px;margin:0 auto}
/* Sheet section */
.sheet-section{
background:var(--white);border-radius:16px;margin-bottom:20px;
overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06);
}
.sheet-header{
display:flex;align-items:center;justify-content:space-between;
padding:16px 24px;border-bottom:1px solid var(--gray-100);
cursor:pointer;
}
.sheet-header:hover{background:var(--gray-100)}
.sheet-header h3{font-size:17px;font-weight:700}
.sheet-header .sheet-meta{font-size:13px;color:var(--gray-500)}
.sheet-body{padding:0 24px 24px;overflow-x:auto}
.sheet-body.collapsed{display:none}
/* Table */
table{width:100%;border-collapse:collapse;font-size:14px}
th{
background:var(--gray-100);text-align:left;padding:10px 14px;
font-weight:700;font-size:13px;white-space:nowrap;
position:sticky;top:0;
}
td{padding:9px 14px;border-bottom:1px solid var(--gray-100)}
tr:hover td{background:var(--cyan-50)}
/* Loading / Empty */
.loading{text-align:center;padding:40px;color:var(--gray-500)}
.spinner{
width:32px;height:32px;border:3px solid var(--gray-100);
border-top:3px solid var(--cyan);border-radius:50%;
animation:spin .8s linear infinite;margin:0 auto 12px;
}
@keyframes spin{to{transform:rotate(360deg)}}
.error-box{
background:#fff5f5;border:1px solid #ffcccc;border-radius:12px;
padding:20px;text-align:center;color:var(--red);
}
.error-box code{font-size:13px;background:var(--gray-100);padding:2px 8px;border-radius:4px}
.nodata{text-align:center;padding:30px;color:var(--gray-500);font-size:15px}
/* Expand arrow */
.arrow{display:inline-block;transition:transform .2s;font-size:13px}
.arrow.open{transform:rotate(90deg)}
/* Footer */
.footer{text-align:center;padding:24px;font-size:13px;color:var(--gray-500)}
@media(max-width:640px){
.topbar{padding:14px 16px}
.topbar h1{font-size:18px}
.stats{padding:16px 16px 0}
.content{padding:12px 12px 40px}
.sheet-header{padding:12px 16px}
.sheet-header h3{font-size:15px}
}
</style>
</head>
<body>
<header class="topbar">
<h1>📊 Сводный Dashboard</h1>
<div class="controls">
<span id="statusBadge" class="badge ok">Загрузка...</span>
<button class="btn" onclick="loadAll()">↻ Обновить</button>
<button class="btn" onclick="downloadAll()" style="background:var(--gray-700);color:var(--white)">📥 Скачать всё</button>
<span class="timer" id="timerLabel">60 сек</span>
</div>
</header>
<div class="stats" id="statsRow"></div>
<main class="content" id="mainContent">
<div class="loading"><div class="spinner"></div><div>Загружаю данные...</div></div>
</main>
<footer class="footer">
<span id="lastRefresh"></span> &nbsp;|&nbsp; Автообновление каждые 60 секунд
</footer>
<script>
const sheets = [
{ id:"150YZTbzkbYosICYfrR6K_od_1pOm6u64lkotQXIZw78", gid:"684524029", label:"Таблица 1" },
{ id:"1U1u3ZOVQCNLqiYJyId-DSlhENr3ZgNz-7xmwxePp3I4", gid:"684524029", label:"Таблица 2" },
{ id:"1Ksb1KN-SEO74a2To0qEsWP17P6wpzv44V2S4tDPWjs4", gid:"1492525306", label:"Таблица 3" },
{ id:"1kRQdo-H-seMQ0JkoLQ_q9izQvWnQ3DVwLoS6qOdP0rI", gid:"684524029", label:"Таблица 4" },
{ id:"1APZKFlxOPmgY82Te2JisGr8xE7njWjhxShZU_jiIQxU", gid:"684524029", label:"Таблица 5" },
{ id:"1BkLoaLY3PvS80pw-nSTz_au4HwuOgvMVTo0tBBYjIIQ", gid:"885466660", label:"Таблица 6" },
];
let countdown = 60;
let allData = [];
function csvUrl(s) {
return `https://docs.google.com/spreadsheets/d/${s.id}/pub?gid=${s.gid}&single=true&output=csv`;
}
async function fetchSheet(sheet) {
try {
const r = await fetch(csvUrl(sheet), { mode:"cors" });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const text = await r.text();
const rows = parseCSV(text);
return { sheet, rows, error: null };
} catch(e) {
return { sheet, rows: [], error: e.message };
}
}
function parseCSV(text) {
const lines = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (ch === "\"") {
if (inQuotes && text[i+1] === "\"") { current += "\""; i++; }
else { inQuotes = !inQuotes; }
} else if (ch === "," && !inQuotes) {
lines.push(current); current = "";
} else if (ch === "\n" && !inQuotes) {
if (current || lines.length > 0) {
lines.push(current); current = "";
if (lines.length > 0) return [lines.map(c => c.trim())];
// Actually this is wrong, let me use a proper parser
}
} else {
current += ch;
}
}
// Fallback: simple split
return text.trim().split("\n").map(line => {
const cells = [];
let cell = "";
let inQ = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (c === "\"") {
if (inQ && line[i+1] === "\"") { cell += "\""; i++; }
else { inQ = !inQ; }
} else if (c === "," && !inQ) {
cells.push(cell.trim()); cell = "";
} else { cell += c; }
}
cells.push(cell.trim());
return cells;
}).filter(r => r.some(c => c !== ""));
}
async function loadAll() {
document.getElementById("mainContent").innerHTML =
'<div class="loading"><div class="spinner"></div><div>Загружаю данные...</div></div>';
const results = await Promise.all(sheets.map(fetchSheet));
allData = results;
countdown = 60;
updateTimer();
document.getElementById("lastRefresh").textContent =
"Последнее обновление: " + new Date().toLocaleTimeString("ru-RU");
render(results);
}
function render(results) {
const ok = results.filter(r => !r.error).length;
const err = results.filter(r => r.error).length;
const badge = document.getElementById("statusBadge");
if (err === 0) {
badge.className = "badge ok";
badge.textContent = `${ok} таблиц загружено`;
} else if (ok > 0) {
badge.className = "badge warn";
badge.textContent = `${ok} загружено, ${err} недоступны`;
} else {
badge.className = "badge err";
badge.textContent = "Нет данных";
}
renderStats(results);
renderMain(results);
}
function renderStats(results) {
let totalRows = 0, totalCols = 0;
results.forEach(r => {
if (r.rows.length > 0) {
totalRows += r.rows.length;
totalCols = Math.max(totalCols, r.rows[0]?.length || 0);
}
});
document.getElementById("statsRow").innerHTML = `
<div class="stat-card"><div class="value">${results.length}</div><div class="label">Всего таблиц</div></div>
<div class="stat-card"><div class="value">${results.filter(r => !r.error).length}</div><div class="label">Загружено</div></div>
<div class="stat-card"><div class="value">${totalRows}</div><div class="label">Всего строк данных</div></div>
<div class="stat-card"><div class="value">${results.filter(r => r.error).length}</div><div class="label">Недоступны</div></div>
`;
}
function renderMain(results) {
let html = "";
results.forEach((r, i) => {
const s = r.sheet;
const openLink = `https://docs.google.com/spreadsheets/d/${s.id}/edit?gid=${s.gid}`;
html += `<div class="sheet-section">`;
html += `<div class="sheet-header">
<h3 style="display:flex;align-items:center;gap:12px" onclick="toggleSheet(this.parentElement)">
<span class="arrow">▶</span> 📋 ${s.label}
</h3>
<span class="sheet-meta" style="display:flex;align-items:center;gap:10px">
${r.error ? "❌ " + r.error : r.rows.length + " строк"}
${!r.error && r.rows.length > 0 ? `<button class="btn-sm" onclick="event.stopPropagation();downloadCSV(${i})" title="Скачать CSV (Excel-совместимый)">📥 CSV</button>` : ""}
</span>
</div>`;
html += `<div class="sheet-body ${r.rows.length === 0 ? 'collapsed' : ''}">`;
if (r.error) {
html += `<div class="error-box">
<p>⚠️ Таблица не загрузилась</p>
<p style="font-size:13px;margin-top:8px">Убедись, что она опубликована:<br>
<code>Файл → Поделиться → Опубликовать в интернете</code></p>
<p style="margin-top:8px"><a href="${openLink}" target="_blank" rel="noopener" style="color:var(--cyan)">Открыть в Google Sheets ↗</a></p>
</div>`;
} else if (r.rows.length === 0) {
html += `<div class="nodata">Нет данных</div>`;
} else {
html += `<div style="overflow-x:auto;max-height:500px;overflow-y:auto">`;
html += `<table>`;
r.rows.forEach((row, ri) => {
html += "<tr>";
row.forEach(cell => {
html += ri === 0 ? `<th>${esc(cell)}</th>` : `<td>${esc(cell)}</td>`;
});
html += "</tr>";
});
html += `</table></div>`;
html += `<p style="margin-top:12px;font-size:13px;color:var(--gray-500)">
<a href="${openLink}" target="_blank" rel="noopener" style="color:var(--cyan)">Открыть в Google Sheets ↗</a>
</p>`;
}
html += `</div></div>`;
});
document.getElementById("mainContent").innerHTML = html;
}
function esc(s) {
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function toggleSheet(header) {
const body = header.nextElementSibling;
const arrow = header.querySelector(".arrow");
body.classList.toggle("collapsed");
arrow.classList.toggle("open");
}
function downloadCSV(sheetIndex) {
const r = allData[sheetIndex];
if (!r || r.error || r.rows.length === 0) return;
const rows = r.rows;
let csv = "\uFEFF"; // UTF-8 BOM — чтобы старый Excel сразу читал кириллицу
for (let i = 0; i < rows.length; i++) {
const cells = rows[i].map(c => {
const s = String(c);
if (s.includes(",") || s.includes("\"") || s.includes("\n")) {
return "\"" + s.replace(/\"/g, "\"\"") + "\"";
}
return s;
});
csv += cells.join(",") + "\n";
}
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const name = r.sheet.label.replace(/[^a-zA-Zа-яА-ЯёЁ0-9_\- ]/g, "").trim() || "export";
a.download = name + ".csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function downloadAll() {
const ok = allData.filter(r => !r.error && r.rows.length > 0);
if (ok.length === 0) { alert("Нет данных для скачивания"); return; }
let csv = "\uFEFF";
ok.forEach((r, idx) => {
csv += "\n" + r.sheet.label + "\n";
r.rows.forEach(row => {
const cells = row.map(c => {
const s = String(c);
if (s.includes(",") || s.includes("\"") || s.includes("\n")) {
return "\"" + s.replace(/\"/g, "\"\"") + "\"";
}
return s;
});
csv += cells.join(",") + "\n";
});
if (idx < ok.length - 1) csv += "\n";
});
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "svodnaya_tablitsa.csv";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function updateTimer() {
document.getElementById("timerLabel").textContent = countdown + " сек";
}
setInterval(() => {
countdown--;
updateTimer();
if (countdown <= 0) loadAll();
}, 1000);
loadAll();
</script>
</body>
</html>