// pages-overview.jsx — Home (2 variants) + Stats const { useState: useStateO, useMemo: useMemoO, useRef: useRefO, useEffect: useEffectO } = React; /* ============================ HOME — variant A (current) ============================ */ function useOverviewData() { /* Общий хук для HomeA/HomeB/StatsPage — грузит accounts + dashboard + events + status, обновляется каждые 15 сек. */ const [accounts, setAccounts] = useStateO([]); const [dashboard, setDashboard] = useStateO(null); const [events, setEvents] = useStateO([]); const [statusPills, setStatusPills] = useStateO(STATUS_PILLS); const [loading, setLoading] = useStateO(true); useEffectO(() => { let stopped = false; const refresh = async () => { const [a, d, e, s] = await Promise.all([ fetchAccounts(), fetchDashboard(), fetchEvents(30), fetchStatus(), ]); if (stopped) return; setAccounts(a); setDashboard(d); setEvents(e); setStatusPills(s); setLoading(false); }; refresh(); const id = setInterval(refresh, 15_000); return () => { stopped = true; clearInterval(id); }; }, []); // Принудительный refresh статуса — для дёргания после bot start/stop, // чтобы UI не ждал 15-секундного polling-тика. const refreshStatus = React.useCallback(async () => { try { const s = await fetchStatus(); setStatusPills(s); } catch {} }, []); return { accounts, dashboard, events, statusPills, loading, refreshStatus }; } function HomeA() { const { accounts, dashboard, events, loading } = useOverviewData(); const clients = dashboard?.clients || {}; const totalToday = clients.clientsToday || 0; const totalYesterday = clients.clientsYesterday || 0; const totalWeek = clients.clients7d || 0; const diff = totalToday - totalYesterday; const active = accounts.filter((a) => a.today > 0).length; const messagesOutgoing = dashboard?.messagesTodayOutgoing || 0; return (
= 0 ? "+" : "") + diff} deltaPositive={diff >= 0} sublabel={"вчера: " + totalYesterday} />
Клиенты по дням
последние 7 дней
Последние события
{loading && Array.from({ length: 5 }).map((_, i) => (
))} {!loading && events.length === 0 && (
Событий пока нет
)} {!loading && events.slice(0, 8).map((e, i) => (
{e.text}
{e.time}
))}
); } /* ============================ HOME — variant B (live-feed focused) ============================ */ function HomeB() { const { accounts, dashboard, events, statusPills, loading, refreshStatus } = useOverviewData(); const clients = dashboard?.clients || {}; const totalToday = clients.clientsToday || 0; const diff = (clients.clientsToday || 0) - (clients.clientsYesterday || 0); const top5 = [...accounts].sort((a, b) => b.today - a.today).slice(0, 5); const receiptSummary = dashboard?.receipts || {}; const pendingCount = receiptSummary.pending || 0; const approvedToday = receiptSummary.approvedTodayByCurrency || {}; const sumRub = approvedToday["Rub"] || 0; return (
{/* Hero band */}
Сегодня
{totalToday}
клиентов написали
= 0 ? "emerald" : "rose"}> = 0 ? "up" : "down"} className="w-3 h-3"/> {(diff >= 0 ? "+" : "") + diff} к вчера 0 ? "amber" : "zinc"}>{pendingCount} чеков на проверке {sumRub.toLocaleString("ru")} ₽ за день
Топ аккаунтов сегодня
обновлено 2 мин назад
{loading && Array.from({ length: 5 }).map((_, i) => (
{i + 1}
))} {!loading && top5.length === 0 && (
Активных аккаунтов сегодня нет
)} {!loading && top5.length > 0 && top5.map((a, i) => { const max = Math.max(top5[0].today, 1); const pct = (a.today / max) * 100; return (
{i + 1}
{a.name}
{a.today}
); })}
Активность за час
сообщения, чеки, диалоги
{}} options={[ { id: "hour", label: "Час" }, { id: "day", label: "День" }, { id: "week", label: "Неделя" }, ]} size="sm" />
Лента
live
{loading && Array.from({ length: 6 }).map((_, i) => (
))} {!loading && events.length === 0 && (
Событий пока нет
)} {!loading && events.map((e, i) => (
{e.text}
{e.time}
))}
); } function HomePage({ variant = "A" }) { return variant === "B" ? : ; } function TrafficChart({ compact = false, data }) { // data ожидается в формате [{day:"Пн", value:N, isToday:bool}] (7 элементов). // Если не пришло — рисуем placeholder с нулями. const series = Array.isArray(data) && data.length === 7 ? data : ["Пн","Вт","Ср","Чт","Пт","Сб","Вс"].map((day, i) => ({ day, value: 0, isToday: i === 6 })); const max = Math.max(...series.map((s) => s.value), 1); return (
{series.map((s, i) => { const h = (s.value / max) * 100; return (
{s.value}
{s.day}
); })}
); } function HourlyChart({ data }) { // data: [{hour:0, messages:N, checks:N}, ...] (24 элемента). // Если не пришло — нулевая полоса. const series = Array.isArray(data) && data.length === 24 ? data : Array.from({ length: 24 }, (_, h) => ({ hour: h, messages: 0, checks: 0 })); const hours = series.map((s) => s.hour); const msgs = series.map((s) => s.messages); const checks = series.map((s) => s.checks); const max = Math.max(...msgs, 1); const H = 128; // px const nowHour = new Date().getHours(); return (
{hours.map((h) => { const mh = (msgs[h] / max) * H; const ch = (checks[h] / max) * H; const isNow = h === nowHour; return (
{h}:00 · {msgs[h]} / {checks[h]}
); })}
00:00 06:00 12:00 18:00 23:00
сообщения
чеки
); } /* ============================ STATS ============================ */ function StatsPage() { const { accounts, dashboard, loading } = useOverviewData(); const [period, setPeriod] = useStateO("today"); const metric = period === "today" ? "today" : period === "yesterday" ? "yesterday" : "week"; const clients = dashboard?.clients || {}; const totalToday = clients.clientsToday || 0; const totalYesterday = clients.clientsYesterday || 0; const totalWeek = clients.clients7d || 0; const diff = totalToday - totalYesterday; const receiptSummary = dashboard?.receipts || {}; const approvedToday = receiptSummary.approvedTodayByCurrency || {}; // ── Парето-раскладка: активные аккаунты за выбранный период ── const active = accounts .filter((a) => (a[metric] || 0) > 0) .sort((a, b) => (b[metric] || 0) - (a[metric] || 0)); const total = active.reduce((s, a) => s + (a[metric] || 0), 0); // сколько топ-аккаунтов суммарно дают ≥80% клиентов let vital = 0; if (total > 0) { let cum = 0; for (const a of active) { cum += a[metric] || 0; vital += 1; if (cum >= total * 0.8) break; } } // Столбцы: ВСЕ активные аккаунты тонкими барами — честный Парето. // (Агрегат «ещё K» убран: при ровном распределении хвост из 80+ аккаунтов // становился гигантским столбом, ломал масштаб и плющил реальные столбцы.) let cumSum = 0; const bars = active.map((a) => { const v = a[metric] || 0; cumSum += v; return { id: a.id, name: a.name, v, cum: total > 0 ? (cumSum / total) * 100 : 0 }; }); const leader = active[0]; const periodLabel = period === "today" ? "сегодня" : period === "yesterday" ? "вчера" : "7 дней"; return (
= 0 ? "+" : "") + diff} deltaPositive={diff >= 0} sublabel="клиентов написало" />
Клиенты по аккаунтам
диаграмма Парето — кто делает кассу, а кто простаивает
{active.length === 0 ? (
{loading ? "Загрузка…" : "Пока нет активности за период"}
) : ( <> {/* Insight + метрики */}
{vital} /{active.length}
аккаунтов приносят
80% клиентов за {periodLabel}
Всего клиентов {total.toLocaleString("ru")}
Активных аккаунтов {active.length}
Лидер {leader.name} {leader[metric]}
{/* Легенда */}
Ключевые (80%) Остальные Накопительный %
)}
{[ { label: "RUB", key: "Rub", symbol: "₽" }, { label: "UAH", key: "Uah", symbol: "₴" }, { label: "USD", key: "Usd", symbol: "$" }, ].map(({ label, key, symbol }) => { const todayAmt = approvedToday[key] || 0; return (
{label}
0 ? "emerald" : "zinc"}>{symbol}
{todayAmt.toLocaleString("ru")}
подтверждено сегодня
); })}
); } /* ============================ ОПЛАТЫ — чеки по аккаунтам за период ============================ */ function _localISODate(offsetDays) { const d = new Date(); if (offsetDays) d.setDate(d.getDate() + offsetDays); const p = (n) => String(n).padStart(2, "0"); return d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()); } function PaymentsPage() { const [period, setPeriod] = useStateO("today"); const [start, setStart] = useStateO(_localISODate(-2)); const [end, setEnd] = useStateO(_localISODate(0)); const [data, setData] = useStateO(null); const [loading, setLoading] = useStateO(true); useEffectO(() => { let stopped = false; setLoading(true); fetchPaymentsStats(period, start, end).then((d) => { if (!stopped) { setData(d); setLoading(false); } }); return () => { stopped = true; }; }, [period, start, end]); const accounts = (data && data.accounts) || []; const total = (data && data.total) || 0; const days = (data && data.days) || 1; const label = (data && data.label) || ""; const top = accounts[0]; const max = (top && top.count) || 1; const inputCls = "bg-zinc-800/60 border border-zinc-700/60 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-indigo-500/50"; return (
{period === "custom" && (
с setStart(e.target.value)} className={inputCls} /> по setEnd(e.target.value)} className={inputCls} />
)}
Топ аккаунт
{top ? (
{top.name}
{top.count} оплат
) : (
)}
Аккаунт Оплат
{loading && !data ? (
Загрузка…
) : accounts.length === 0 ? (
Нет оплат за период
) : (
{accounts.map((a) => (
{a.name}
{a.count}
))}
)}
); } /** * Парето-диаграмма «клиенты по аккаунтам» (SVG-презентация, данные готовит StatsPage). * bars: [{id, name, v, cum}] — ВСЕ активные аккаунты по убыванию (тонкие бары), * cum — накопительный % (0..100). vital — сколько первых входят в «ключевые 80%». * Имя/значение — в hover-tooltip (столбцов ~100, подписи не влезают). */ function AccountsPareto({ bars, vital }) { const W = 1400, H = 330; const padL = 48, padR = 58, padT = 30, padB = 40; const plotW = W - padL - padR; const plotH = H - padT - padB; const n = bars.length; const max = Math.max(...bars.map((b) => b.v), 1); // потолок — кратен 4 (целые деления оси) и близок к максимуму: 12 → 12 const niceMax = Math.max(4, Math.ceil(max / 4) * 4); const slot = plotW / n; const barW = Math.max(Math.min(slot * 0.7, 40), 1.5); const baseY = padT + plotH; const ticks = 4; const yL = (v) => padT + plotH - (v / niceMax) * plotH; const yR = (pct) => padT + plotH - (pct / 100) * plotH; const cx = (i) => padL + slot * i + slot / 2; const pts = bars.map((b, i) => [cx(i), yR(Math.min(b.cum, 100))]); const linePath = pts.map((p, i) => (i === 0 ? "M " : "L ") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" "); const areaPath = n >= 2 ? linePath + " L " + pts[n - 1][0].toFixed(1) + " " + baseY + " L " + pts[0][0].toFixed(1) + " " + baseY + " Z" : ""; const y80 = yR(80); const splitX = padL + slot * Math.min(vital, n); // точек на линии не больше ~14 (+ последняя); подписи значений — только // когда столбцам просторно; ось X — номера аккаунтов (1, 10, 20, …, N) const dotStep = Math.max(1, Math.ceil(n / 14)); const showVals = slot >= 26; const xTicks = []; for (let i = 0; i < n; i++) { const num = i + 1; if (num === 1 || num % 10 === 0 || num === n) xTicks.push(i); } const LINE = "#c1762e"; const MUTED = "#8a8272"; return (
{/* зона «ключевых» аккаунтов */} {/* сетка + левая ось (клиенты) */} {Array.from({ length: ticks + 1 }, (_, i) => { const v = (niceMax * (ticks - i)) / ticks; const y = padT + (plotH * i) / ticks; return ( {paretoFmt(v)} ); })} {/* правая ось (%) */} {[0, 50, 80, 100].map((pct) => ( {pct}% ))} {/* референс 80% */} 80% {/* вертикальная граница «ключевых»: на этом аккаунте накопилось 80% */} {vital > 0 && vital < n && ( {vital} акк. → 80% )} {/* столбцы — тонкие, имя/цифры в hover-tooltip */} {bars.map((b, i) => { const bh = Math.max((b.v / niceMax) * plotH, 1); const isVital = i < vital; return ( {b.name}: {b.v} клиентов ({b.cum.toFixed(1)}% накопительно) {showVals && ( {paretoFmt(b.v)} )} ); })} {/* ось X: номера аккаунтов */} {xTicks.map((i) => ( {i + 1} ))} {/* накопительная линия + область под ней */} {n >= 2 && } {n >= 2 && ( )} {pts.map((p, i) => ((i % dotStep === 0 || i === n - 1) ? ( {bars[i].cum.toFixed(1)}% накопительно ) : null))}
); } // компактный формат значений (1200 → «1,2k») function paretoFmt(v) { if (v >= 1000) return (v / 1000).toFixed(v % 1000 === 0 ? 0 : 1).replace(".", ",") + "k"; return Math.round(v).toString(); } /** * Управление ботом: статус-pill + кнопки Запустить/Остановить. * Статус берётся из statusPills (приходит из /api/status). После каждого * действия дёргаем refreshStatus() чтобы UI обновился сразу, не ждал * 10-сек polling-а. refreshStatus — prop из App, который зовёт fetchStatus * и setStatusPills. */ function BotControls({ statusPills, refreshStatus }) { const botPill = statusPills.find((p) => p.label === "Бот") || { value: "…", color: "zinc" }; const running = botPill.color === "emerald"; const dotColor = { emerald: "bg-emerald-400", rose: "bg-rose-400", amber: "bg-amber-400", zinc: "bg-zinc-500", }[botPill.color] || "bg-zinc-500"; const [busy, setBusy] = useStateO(""); // 'start' | 'stop' | 'restart' | '' const [err, setErr] = useStateO(""); const action = async (kind) => { setBusy(kind); setErr(""); try { if (kind === "start") { await botStart(); if (window.__toast) window.__toast.push({ kind: "ok", text: "Бот запущен" }); } else if (kind === "stop") { await botStop(); if (window.__toast) window.__toast.push({ kind: "info", text: "Бот остановлен" }); } else if (kind === "restart") { await botRestart(); if (window.__toast) window.__toast.push({ kind: "ok", text: "Бот перезапущен" }); } // Force-refresh статуса чтобы UI отразил новое состояние сразу, // не ждал следующего polling-tick. if (refreshStatus) { try { await refreshStatus(); } catch {} } } catch (e) { const msg = String(e.message || e).slice(0, 120); setErr(msg); if (window.__toast) window.__toast.push({ kind: "err", text: "Ошибка: " + msg }); } finally { setBusy(""); } }; return (
{botPill.value === "запущен" ? "онлайн" : botPill.value} {!running && ( )} {running && ( <> )} {err && (
{err}
)}
); } Object.assign(window, { HomePage, StatsPage, TrafficChart, AccountsPareto, HourlyChart, BotControls });