// 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) => (
))}
);
}
/* ============================
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) => (
))}
{!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 (
);
})}
Активность за час
сообщения, чеки, диалоги
{}}
options={[
{ id: "hour", label: "Час" },
{ id: "day", label: "День" },
{ id: "week", label: "Неделя" },
]}
size="sm"
/>
{loading && Array.from({ length: 6 }).map((_, i) => (
))}
{!loading && events.length === 0 && (
Событий пока нет
)}
{!loading && events.map((e, i) => (
))}
);
}
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 (
);
})}
);
}
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) => (
))}
)}
);
}
/**
* Парето-диаграмма «клиенты по аккаунтам» (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 (
);
}
// компактный формат значений (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 });