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>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Dashboard — Google Sheets</title>
<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;
--red:#ff4757;--green:#2ed573;--orange:#ffa502;--blue:#3742fa;
}
*{box-sizing:border-box;margin:0;padding:0}
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;
}
/* Header */
.topbar{
background:var(--ink);color:var(--white);
padding:16px 24px;display:flex;align-items:center;
justify-content:space-between;flex-wrap:wrap;gap:12px;
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:12px}
.badge{background:var(--green);color:var(--ink);font-size:13px;
font-weight:600;padding:4px 12px;border-radius:20px}
.badge.offline{background:var(--red);color:var(--white)}
.btn-refresh{
.topbar .controls{display:flex;align-items:center;gap:14px}
.btn{
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;
transition:opacity .2s;
}
.btn-refresh:hover{opacity:.85}
.btn:hover{opacity:.85}
.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 */
.tabs{
display:flex;background:var(--white);border-bottom:2px solid var(--gray-100);
overflow-x:auto;-webkit-overflow-scrolling:touch;
padding:0 24px;
/* 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;
}
.tab{
padding:14px 20px;font-size:15px;font-weight:600;
color:var(--gray-500);border:none;background:none;
border-bottom:3px solid transparent;cursor:pointer;
white-space:nowrap;transition:all .2s;
.stat-card{
background:var(--white);border-radius:12px;padding:20px;
box-shadow:0 1px 3px rgba(0,0,0,.06);
}
.tab:hover{color:var(--ink)}
.tab.active{color:var(--ink);border-bottom-color:var(--cyan)}
.tab .count{font-size:12px;background:var(--gray-100);color:var(--gray-500);
padding:2px 8px;border-radius:10px;margin-left:6px}
.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:24px;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)}
.panel-header{
.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;
}
.panel-header h2{font-size:18px;font-weight:700}
.panel-header a{color:var(--cyan);font-size:14px;font-weight:600;
text-decoration:none}
.panel-header a:hover{text-decoration:underline}
.iframe-wrap{position:relative;width:100%;height:0;
padding-bottom:75vh;overflow:hidden}
.iframe-wrap iframe{
position:absolute;top:0;left:0;width:100%;height:100%;
border:none}
.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}
/* Grid (all view) */
.grid{
display:grid;grid-template-columns:repeat(3,1fr);
gap:20px;padding:24px
/* 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;
}
@media(max-width:1000px){.grid{grid-template-columns:repeat(2,1fr)}}
@media(max-width:640px){.grid{grid-template-columns:1fr}}
td{padding:9px 14px;border-bottom:1px solid var(--gray-100)}
tr:hover td{background:var(--cyan-50)}
.card{
background:var(--white);border-radius:16px;overflow:hidden;
box-shadow:0 1px 3px rgba(0,0,0,.08);
transition:transform .2s,box-shadow .2s
/* 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;
}
.card:hover{transform:translateY(-2px);box-shadow:0 4px 12px rgba(0,0,0,.12)}
.card-header{
padding:14px 18px;border-bottom:1px solid var(--gray-100);
display:flex;align-items:center;justify-content:space-between
@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);
}
.card-header h3{font-size:15px;font-weight:700}
.card-header a{font-size:13px;color:var(--cyan);text-decoration:none;font-weight:600}
.card .mini-iframe{width:100%;height:320px;border:none}
.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}
/* Empty state */
.empty-state{
text-align:center;padding:80px 24px;color:var(--gray-500)
}
.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}
/* Expand arrow */
.arrow{display:inline-block;transition:transform .2s;font-size:13px}
.arrow.open{transform:rotate(90deg)}
/* Status bar */
.statusbar{
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)}
/* 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}
.tabs{padding:0 8px}
.tab{padding:12px 14px;font-size:14px}
.content{padding:12px}
.panel-header{padding:12px 16px}
.panel-header h2{font-size:16px}
.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>📊 Google Sheets Dashboard</h1>
<h1>📊 Сводный Dashboard</h1>
<div class="controls">
<span id="statusBadge" class="badge">
<span class="dot online" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;background:var(--green)"></span>
Обновлено
</span>
<button class="btn-refresh" onclick="refreshAll()" title="Обновить все таблицы">↻ Обновить</button>
<span id="statusBadge" class="badge ok">Загрузка...</span>
<button class="btn" onclick="loadAll()">↻ Обновить</button>
<span class="timer" id="timerLabel">60 сек</span>
</div>
</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">
<span id="lastRefresh">Последнее обновление: —</span>
<span>Автообновление: каждые 60 секунд</span>
<footer class="footer">
<span id="lastRefresh"></span> &nbsp;|&nbsp; Автообновление каждые 60 секунд
</footer>
<script>
const sheets = [
{ id: "150YZTbzkbYosICYfrR6K_od_1pOm6u64lkotQXIZw78", gid: "684524029", label: "Таблица 1", emoji: "📋" },
{ id: "1U1u3ZOVQCNLqiYJyId-DSlhENr3ZgNz-7xmwxePp3I4", gid: "684524029", label: "Таблица 2", emoji: "📋" },
{ id: "1Ksb1KN-SEO74a2To0qEsWP17P6wpzv44V2S4tDPWjs4", gid: "1492525306", label: "Таблица 3", emoji: "📋" },
{ id: "1kRQdo-H-seMQ0JkoLQ_q9izQvWnQ3DVwLoS6qOdP0rI", gid: "684524029", label: "Таблица 4", emoji: "📋" },
{ id: "1APZKFlxOPmgY82Te2JisGr8xE7njWjhxShZU_jiIQxU", gid: "684524029", label: "Таблица 5", emoji: "📋" },
{ id: "1BkLoaLY3PvS80pw-nSTz_au4HwuOgvMVTo0tBBYjIIQ", gid: "885466660", label: "Таблица 6", emoji: "📋" },
{ 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" },
];
const REFRESH_SEC = 60;
let activeTab = "all";
let countdown = REFRESH_SEC;
let sheetStatus = sheets.map(() => "pending");
let countdown = 60;
let allData = [];
function buildTabs() {
const bar = document.getElementById("tabBar");
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 csvUrl(s) {
return `https://docs.google.com/spreadsheets/d/${s.id}/pub?gid=${s.gid}&single=true&output=csv`;
}
function switchTab(idx) {
activeTab = idx;
document.querySelectorAll(".tab").forEach(t => t.classList.remove("active"));
document.querySelector(`.tab[data-idx="${idx}"]`).classList.add("active");
renderContent();
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 renderContent() {
const main = document.getElementById("mainContent");
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);
});
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 {
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>`;
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 markStatus(i, st) {
sheetStatus[i] = st;
updateBadge();
}
async function loadAll() {
document.getElementById("mainContent").innerHTML =
'<div class="loading"><div class="spinner"></div><div>Загружаю данные...</div></div>';
function updateBadge() {
const badge = document.getElementById("statusBadge");
const errors = sheetStatus.filter(s => s === "error").length;
const all = sheetStatus.every(s => s === "ok" || s === "pending");
if (errors > 0) {
badge.className = "badge offline";
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 {
badge.className = "badge";
badge.innerHTML = `<span class="dot online" style="display:inline-block;width:6px;height:6px;border-radius:50%;margin-right:4px;background:var(--green)"></span>Онлайн`;
}
}
function refreshAll() {
const iframes = document.querySelectorAll("iframe");
iframes.forEach(f => {
const src = f.src;
f.src = "";
setTimeout(() => { f.src = src; }, 100);
});
countdown = REFRESH_SEC;
const results = await Promise.all(sheets.map(fetchSheet));
allData = results;
countdown = 60;
updateTimer();
document.getElementById("lastRefresh").textContent =
"Последнее обновление: " + new Date().toLocaleTimeString("ru-RU");
sheetStatus = sheets.map(() => "pending");
updateBadge();
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" 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() {
document.getElementById("timerLabel").textContent = countdown + " сек";
}
function startTimer() {
setInterval(() => {
countdown--;
updateTimer();
if (countdown <= 0) {
refreshAll();
}
if (countdown <= 0) loadAll();
}, 1000);
}
function init() {
buildTabs();
renderContent();
document.getElementById("lastRefresh").textContent =
"Последнее обновление: " + new Date().toLocaleTimeString("ru-RU");
startTimer();
}
init();
loadAll();
</script>
</body>