dashboard v2 — fetch CSV data directly

This commit is contained in:
Dauren777 2026-06-10 11:30:17 +00:00
parent 2d5c2cb9f6
commit f8883205aa

View File

@ -3,286 +3,317 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard — Google Sheets</title> <title>Dashboard — Сводная аналитика</title>
<style> <style>
:root{ :root{
--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF; --ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;
--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7; --white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;
--gray-800:#1a1f2b;--gray-700:#252b38; --gray-800:#1a1f2b;--gray-700:#252b38;
--red:#ff4757;--green:#2ed573; --red:#ff4757;--green:#2ed573;--orange:#ffa502;--blue:#3742fa;
} }
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{ body{
font:17px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif; font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
color:var(--ink);background:var(--gray-100);min-height:100vh; color:var(--ink);background:var(--gray-100);min-height:100vh;
} }
/* Header */
.topbar{ .topbar{
background:var(--ink);color:var(--white); background:var(--ink);color:var(--white);
padding:16px 24px;display:flex;align-items:center; padding:18px 28px;display:flex;align-items:center;
justify-content:space-between;flex-wrap:wrap;gap:12px; justify-content:space-between;flex-wrap:wrap;gap:14px;
position:sticky;top:0;z-index:100; position:sticky;top:0;z-index:100;
} }
.topbar h1{font-size:22px;font-weight:700} .topbar h1{font-size:22px;font-weight:700}
.topbar .controls{display:flex;align-items:center;gap:12px} .topbar .controls{display:flex;align-items:center;gap:14px}
.badge{background:var(--green);color:var(--ink);font-size:13px; .btn{
font-weight:600;padding:4px 12px;border-radius:20px}
.badge.offline{background:var(--red);color:var(--white)}
.btn-refresh{
background:var(--cyan);color:var(--ink); background:var(--cyan);color:var(--ink);
border:none;padding:8px 18px;border-radius:8px; border:none;padding:9px 20px;border-radius:8px;
font-weight:700;font-size:14px;cursor:pointer; font-weight:700;font-size:14px;cursor:pointer;
transition:opacity .2s;
} }
.btn-refresh:hover{opacity:.85} .btn:hover{opacity:.85}
.timer{font-size:13px;color:var(--gray-500)} .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)}
/* Tabs */ /* Stats row */
.tabs{ .stats{
display:flex;background:var(--white);border-bottom:2px solid var(--gray-100); display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
overflow-x:auto;-webkit-overflow-scrolling:touch; gap:16px;padding:24px 28px 0;max-width:1400px;margin:0 auto;
padding:0 24px;
} }
.tab{ .stat-card{
padding:14px 20px;font-size:15px;font-weight:600; background:var(--white);border-radius:12px;padding:20px;
color:var(--gray-500);border:none;background:none; box-shadow:0 1px 3px rgba(0,0,0,.06);
border-bottom:3px solid transparent;cursor:pointer;
white-space:nowrap;transition:all .2s;
} }
.tab:hover{color:var(--ink)} .stat-card .value{font-size:32px;font-weight:800;line-height:1.1}
.tab.active{color:var(--ink);border-bottom-color:var(--cyan)} .stat-card .label{font-size:13px;color:var(--gray-500);margin-top:6px}
.tab .count{font-size:12px;background:var(--gray-100);color:var(--gray-500);
padding:2px 8px;border-radius:10px;margin-left:6px}
/* Content */ /* Content */
.content{padding:24px;max-width:1400px;margin:0 auto} .content{padding:16px 28px 40px;max-width:1400px;margin:0 auto}
.panel{background:var(--white);border-radius:16px;overflow:hidden;
box-shadow:0 1px 3px rgba(0,0,0,.08)} /* Sheet section */
.panel-header{ .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; display:flex;align-items:center;justify-content:space-between;
padding:16px 24px;border-bottom:1px solid var(--gray-100); padding:16px 24px;border-bottom:1px solid var(--gray-100);
cursor:pointer;
} }
.panel-header h2{font-size:18px;font-weight:700} .sheet-header:hover{background:var(--gray-100)}
.panel-header a{color:var(--cyan);font-size:14px;font-weight:600; .sheet-header h3{font-size:17px;font-weight:700}
text-decoration:none} .sheet-header .sheet-meta{font-size:13px;color:var(--gray-500)}
.panel-header a:hover{text-decoration:underline} .sheet-body{padding:0 24px 24px;overflow-x:auto}
.iframe-wrap{position:relative;width:100%;height:0; .sheet-body.collapsed{display:none}
padding-bottom:75vh;overflow:hidden}
.iframe-wrap iframe{
position:absolute;top:0;left:0;width:100%;height:100%;
border:none}
/* Grid (all view) */ /* Table */
.grid{ table{width:100%;border-collapse:collapse;font-size:14px}
display:grid;grid-template-columns:repeat(3,1fr); th{
gap:20px;padding:24px background:var(--gray-100);text-align:left;padding:10px 14px;
font-weight:700;font-size:13px;white-space:nowrap;
position:sticky;top:0;
} }
@media(max-width:1000px){.grid{grid-template-columns:repeat(2,1fr)}} td{padding:9px 14px;border-bottom:1px solid var(--gray-100)}
@media(max-width:640px){.grid{grid-template-columns:1fr}} tr:hover td{background:var(--cyan-50)}
.card{ /* Loading / Empty */
background:var(--white);border-radius:16px;overflow:hidden; .loading{text-align:center;padding:40px;color:var(--gray-500)}
box-shadow:0 1px 3px rgba(0,0,0,.08); .spinner{
transition:transform .2s,box-shadow .2s 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;
} }
.card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.12)} @keyframes spin{to{transform:rotate(360deg)}}
.card-header{ .error-box{
padding:14px 18px;border-bottom:1px solid var(--gray-100); background:#fff5f5;border:1px solid #ffcccc;border-radius:12px;
display:flex;align-items:center;justify-content:space-between padding:20px;text-align:center;color:var(--red);
} }
.card-header h3{font-size:15px;font-weight:700} .error-box code{font-size:13px;background:var(--gray-100);padding:2px 8px;border-radius:4px}
.card-header a{font-size:13px;color:var(--cyan);text-decoration:none;font-weight:600} .nodata{text-align:center;padding:30px;color:var(--gray-500);font-size:15px}
.card .mini-iframe{width:100%;height:320px;border:none}
/* Empty state */ /* Expand arrow */
.empty-state{ .arrow{display:inline-block;transition:transform .2s;font-size:13px}
text-align:center;padding:80px 24px;color:var(--gray-500) .arrow.open{transform:rotate(90deg)}
}
.empty-state .icon{font-size:48px;margin-bottom:16px}
.empty-state h3{font-size:22px;color:var(--ink);margin-bottom:8px}
.empty-state p{font-size:16px;max-width:500px;margin:0 auto 24px}
.empty-state .steps{
text-align:left;max-width:500px;margin:0 auto;
background:var(--gray-100);border-radius:12px;padding:20px 24px
}
.empty-state .steps li{margin-bottom:8px;font-size:14px}
/* Status bar */ /* Footer */
.statusbar{ .footer{text-align:center;padding:24px;font-size:13px;color:var(--gray-500)}
background:var(--white);padding:12px 24px;font-size:13px;
color:var(--gray-500);display:flex;align-items:center;
justify-content:space-between;flex-wrap:wrap;gap:8px;
border-top:1px solid var(--gray-100)
}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px}
.dot.online{background:var(--green)}
.dot.error{background:var(--red)}
@media(max-width:640px){ @media(max-width:640px){
.topbar{padding:14px 16px}
.topbar h1{font-size:18px} .topbar h1{font-size:18px}
.tabs{padding:0 8px} .stats{padding:16px 16px 0}
.tab{padding:12px 14px;font-size:14px} .content{padding:12px 12px 40px}
.content{padding:12px} .sheet-header{padding:12px 16px}
.panel-header{padding:12px 16px} .sheet-header h3{font-size:15px}
.panel-header h2{font-size:16px}
} }
</style> </style>
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<h1>📊 Google Sheets Dashboard</h1> <h1>📊 Сводный Dashboard</h1>
<div class="controls"> <div class="controls">
<span id="statusBadge" class="badge"> <span id="statusBadge" class="badge ok">Загрузка...</span>
<span class="dot online" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;background:var(--green)"></span> <button class="btn" onclick="loadAll()">↻ Обновить</button>
Обновлено
</span>
<button class="btn-refresh" onclick="refreshAll()" title="Обновить все таблицы">↻ Обновить</button>
<span class="timer" id="timerLabel">60 сек</span> <span class="timer" id="timerLabel">60 сек</span>
</div> </div>
</header> </header>
<nav class="tabs" id="tabBar"></nav> <div class="stats" id="statsRow"></div>
<main class="content" id="mainContent"></main> <main class="content" id="mainContent">
<div class="loading"><div class="spinner"></div><div>Загружаю данные...</div></div>
</main>
<footer class="statusbar"> <footer class="footer">
<span id="lastRefresh">Последнее обновление: —</span> <span id="lastRefresh"></span> &nbsp;|&nbsp; Автообновление каждые 60 секунд
<span>Автообновление: каждые 60 секунд</span>
</footer> </footer>
<script> <script>
const sheets = [ const sheets = [
{ id: "150YZTbzkbYosICYfrR6K_od_1pOm6u64lkotQXIZw78", gid: "684524029", label: "Таблица 1", emoji: "📋" }, { id:"150YZTbzkbYosICYfrR6K_od_1pOm6u64lkotQXIZw78", gid:"684524029", label:"Таблица 1" },
{ id: "1U1u3ZOVQCNLqiYJyId-DSlhENr3ZgNz-7xmwxePp3I4", gid: "684524029", label: "Таблица 2", emoji: "📋" }, { id:"1U1u3ZOVQCNLqiYJyId-DSlhENr3ZgNz-7xmwxePp3I4", gid:"684524029", label:"Таблица 2" },
{ id: "1Ksb1KN-SEO74a2To0qEsWP17P6wpzv44V2S4tDPWjs4", gid: "1492525306", label: "Таблица 3", emoji: "📋" }, { id:"1Ksb1KN-SEO74a2To0qEsWP17P6wpzv44V2S4tDPWjs4", gid:"1492525306", label:"Таблица 3" },
{ id: "1kRQdo-H-seMQ0JkoLQ_q9izQvWnQ3DVwLoS6qOdP0rI", gid: "684524029", label: "Таблица 4", emoji: "📋" }, { id:"1kRQdo-H-seMQ0JkoLQ_q9izQvWnQ3DVwLoS6qOdP0rI", gid:"684524029", label:"Таблица 4" },
{ id: "1APZKFlxOPmgY82Te2JisGr8xE7njWjhxShZU_jiIQxU", gid: "684524029", label: "Таблица 5", emoji: "📋" }, { id:"1APZKFlxOPmgY82Te2JisGr8xE7njWjhxShZU_jiIQxU", gid:"684524029", label:"Таблица 5" },
{ id: "1BkLoaLY3PvS80pw-nSTz_au4HwuOgvMVTo0tBBYjIIQ", gid: "885466660", label: "Таблица 6", emoji: "📋" }, { id:"1BkLoaLY3PvS80pw-nSTz_au4HwuOgvMVTo0tBBYjIIQ", gid:"885466660", label:"Таблица 6" },
]; ];
const REFRESH_SEC = 60; let countdown = 60;
let activeTab = "all"; let allData = [];
let countdown = REFRESH_SEC;
let sheetStatus = sheets.map(() => "pending");
function buildTabs() { function csvUrl(s) {
const bar = document.getElementById("tabBar"); return `https://docs.google.com/spreadsheets/d/${s.id}/pub?gid=${s.gid}&single=true&output=csv`;
bar.innerHTML = sheets.map((s, i) =>
`<button class="tab" data-idx="${i}" onclick="switchTab(${i})">${s.emoji} ${s.label}</button>`
).join("");
bar.insertAdjacentHTML("afterbegin",
`<button class="tab active" data-idx="all" onclick="switchTab('all')">📊 Все таблицы</button>`
);
} }
function switchTab(idx) { async function fetchSheet(sheet) {
activeTab = idx; try {
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active")); const r = await fetch(csvUrl(sheet), { mode:"cors" });
document.querySelector(`.tab[data-idx="${idx}"]`).classList.add("active"); if (!r.ok) throw new Error(`HTTP ${r.status}`);
renderContent(); const text = await r.text();
} const rows = parseCSV(text);
return { sheet, rows, error: null };
function renderContent() { } catch(e) {
const main = document.getElementById("mainContent"); return { sheet, rows: [], error: e.message };
if (activeTab === "all") {
main.innerHTML = `<div class="grid" id="grid"></div>`;
const grid = document.getElementById("grid");
sheets.forEach((s, i) => {
const card = document.createElement("div");
card.className = "card";
card.innerHTML = `
<div class="card-header">
<h3>${s.emoji} ${s.label}</h3>
<a href="https://docs.google.com/spreadsheets/d/${s.id}/edit?gid=${s.gid}" target="_blank" rel="noopener">Открыть ↗</a>
</div>
<iframe class="mini-iframe"
src="https://docs.google.com/spreadsheets/d/${s.id}/htmlembed?gid=${s.gid}&single=true&widget=true&headers=false"
onerror="markStatus(${i},'error')"
onload="markStatus(${i},'ok')"
sandbox="allow-scripts allow-same-origin allow-popups"
loading="lazy">
</iframe>`;
grid.appendChild(card);
});
} else {
const s = sheets[activeTab];
main.innerHTML = `
<div class="panel">
<div class="panel-header">
<h2>${s.emoji} ${s.label}</h2>
<a href="https://docs.google.com/spreadsheets/d/${s.id}/edit?gid=${s.gid}" target="_blank" rel="noopener">Открыть в Google Sheets ↗</a>
</div>
<div class="iframe-wrap">
<iframe
src="https://docs.google.com/spreadsheets/d/${s.id}/htmlembed?gid=${s.gid}&single=true&widget=true&headers=false"
onerror="markStatus(${activeTab},'error')"
onload="markStatus(${activeTab},'ok')"
sandbox="allow-scripts allow-same-origin allow-popups"
loading="lazy">
</iframe>
</div>
</div>`;
} }
} }
function markStatus(i, st) { function parseCSV(text) {
sheetStatus[i] = st; const lines = [];
updateBadge(); let current = "";
} let inQuotes = false;
for (let i = 0; i < text.length; i++) {
function updateBadge() { const ch = text[i];
const badge = document.getElementById("statusBadge"); if (ch === "\"") {
const errors = sheetStatus.filter(s => s === "error").length; if (inQuotes && text[i+1] === "\"") { current += "\""; i++; }
const all = sheetStatus.every(s => s === "ok" || s === "pending"); else { inQuotes = !inQuotes; }
if (errors > 0) { } else if (ch === "," && !inQuotes) {
badge.className = "badge offline"; lines.push(current); current = "";
badge.innerHTML = `<span class="dot error" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;background:var(--red)"></span>${errors} недоступны`; } else if (ch === "\n" && !inQuotes) {
} else { if (current || lines.length > 0) {
badge.className = "badge"; lines.push(current); current = "";
badge.innerHTML = `<span class="dot online" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;background:var(--green)"></span>Онлайн`; 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 !== ""));
} }
function refreshAll() { async function loadAll() {
const iframes = document.querySelectorAll("iframe"); document.getElementById("mainContent").innerHTML =
iframes.forEach(f => { '<div class="loading"><div class="spinner"></div><div>Загружаю данные...</div></div>';
const src = f.src;
f.src = ""; const results = await Promise.all(sheets.map(fetchSheet));
setTimeout(() => { f.src = src; }, 100); allData = results;
}); countdown = 60;
countdown = REFRESH_SEC;
updateTimer(); updateTimer();
document.getElementById("lastRefresh").textContent = document.getElementById("lastRefresh").textContent =
"Последнее обновление: " + new Date().toLocaleTimeString("ru-RU"); "Последнее обновление: " + new Date().toLocaleTimeString("ru-RU");
sheetStatus = sheets.map(() => "pending"); render(results);
updateBadge(); }
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" onclick="toggleSheet(this)">
<h3>📋 ${s.label} <span class="arrow"></span></h3>
<span class="sheet-meta">${r.error ? "❌ " + r.error : r.rows.length + " строк"}</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 updateTimer() { function updateTimer() {
document.getElementById("timerLabel").textContent = countdown + " сек"; document.getElementById("timerLabel").textContent = countdown + " сек";
} }
function startTimer() { setInterval(() => {
setInterval(() => { countdown--;
countdown--; updateTimer();
updateTimer(); if (countdown <= 0) loadAll();
if (countdown <= 0) { }, 1000);
refreshAll();
}
}, 1000);
}
function init() { loadAll();
buildTabs();
renderContent();
document.getElementById("lastRefresh").textContent =
"Последнее обновление: " + new Date().toLocaleTimeString("ru-RU");
startTimer();
}
init();
</script> </script>
</body> </body>