feat: tune visual of web app admin panel

This commit is contained in:
3252a8
2026-05-11 23:06:16 +03:00
parent a575cb057b
commit 9f338c3416
17 changed files with 1202 additions and 102 deletions
@@ -117,7 +117,10 @@
]; ];
$: SECTION_META = { $: SECTION_META = {
stats: { title: at("section_stats_title", {}, "Дашборд"), subtitle: at("section_stats_subtitle", {}, "Сводка по магазину и панели") }, stats: {
title: at("section_stats_title", {}, "Дашборд"),
subtitle: at("section_stats_subtitle", {}, "Аудитория, доходы, панель Remnawave и последние платежи"),
},
users: { title: at("section_users_title", {}, "Пользователи"), subtitle: at("section_users_subtitle", {}, "Поиск, баны и действия над аккаунтами") }, users: { title: at("section_users_title", {}, "Пользователи"), subtitle: at("section_users_subtitle", {}, "Поиск, баны и действия над аккаунтами") },
payments: { title: at("section_payments_title", {}, "Платежи"), subtitle: at("section_payments_subtitle", {}, "История транзакций и экспорт") }, payments: { title: at("section_payments_title", {}, "Платежи"), subtitle: at("section_payments_subtitle", {}, "История транзакций и экспорт") },
promos: { title: at("section_promos_title", {}, "Промокоды"), subtitle: at("section_promos_subtitle", {}, "Создание и управление кодами") }, promos: { title: at("section_promos_title", {}, "Промокоды"), subtitle: at("section_promos_subtitle", {}, "Создание и управление кодами") },
@@ -385,7 +388,7 @@
<main class="admin-main"> <main class="admin-main">
{#if active === "stats"} {#if active === "stats"}
<StatsSection {at} {fmtDate} {fmtMoney} {paymentStatusVariant} /> <StatsSection {at} {fmtDate} {fmtDateShort} {fmtMoney} {paymentStatusVariant} />
{/if} {/if}
{#if active === "users"} {#if active === "users"}
@@ -1,5 +1,5 @@
<script> <script>
import { Trash2, Check } from "lucide-svelte"; import { Trash2 } from "lucide-svelte";
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import Dialog from "../../lib/components/ui/dialog.svelte"; import Dialog from "../../lib/components/ui/dialog.svelte";
import { Label } from "bits-ui"; import { Label } from "bits-ui";
@@ -99,23 +99,36 @@
{/if} {/if}
</div> </div>
<Dialog open={adCreateOpen} title={at("ad_create_title", {}, "Новая кампания")} closeLabel={at("close", {}, "Закрыть")} onclose={() => adsStore.setCreateOpen(false)} class="admin-dialog"> <Dialog
<div class="admin-form"> open={adCreateOpen}
<Label.Root class="admin-field-label"> title={at("ad_create_title", {}, "Новая кампания")}
<span>{at("ad_label_source", {}, "Источник")}</span> closeLabel={at("close", {}, "Закрыть")}
<input class="input" type="text" placeholder="telegram_ads" value={adDraft.source} on:input={(e) => adsStore.updateDraft({ source: e.target.value })} /> onclose={() => adsStore.setCreateOpen(false)}
</Label.Root> class="admin-dialog admin-dialog-compact"
<Label.Root class="admin-field-label"> >
<span>{at("ad_label_param", {}, "start-параметр")}</span> <div class="admin-form" data-dialog-content>
<small>{at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}</small> <div class="admin-dialog-form-section">
<input class="input" type="text" placeholder="ads_summer25" value={adDraft.start_param} on:input={(e) => adsStore.updateDraft({ start_param: e.target.value })} /> <Label.Root class="admin-field-label">
</Label.Root> <span>{at("ad_label_source", {}, "Источник")}</span>
<Label.Root class="admin-field-label"> <input class="input" type="text" placeholder="telegram_ads" value={adDraft.source} on:input={(e) => adsStore.updateDraft({ source: e.target.value })} />
<span>{at("ad_label_cost", {}, "Стоимость, RUB")}</span> </Label.Root>
<input class="input" type="number" step="0.01" min="0" value={adDraft.cost} on:input={(e) => adsStore.updateDraft({ cost: Number(e.target.value) })} /> <Label.Root class="admin-field-label">
</Label.Root> <span>{at("ad_label_param", {}, "start-параметр")}</span>
<button type="button" class="admin-btn admin-btn-primary" on:click={adsStore.createAd} disabled={!adDraft.source.trim() || !adDraft.start_param.trim()}> <small>{at("ad_hint_param", {}, "Передаётся в /start, должен быть уникален")}</small>
<Check size={14} /> {at("btn_create", {}, "Создать")} <input class="input" type="text" placeholder="ads_summer25" value={adDraft.start_param} on:input={(e) => adsStore.updateDraft({ start_param: e.target.value })} />
</button> </Label.Root>
</div>
<div class="admin-dialog-form-section">
<Label.Root class="admin-field-label">
<span>{at("ad_label_cost", {}, "Стоимость, RUB")}</span>
<input class="input" type="number" step="0.01" min="0" value={adDraft.cost} on:input={(e) => adsStore.updateDraft({ cost: Number(e.target.value) })} />
</Label.Root>
</div>
<div class="admin-dialog-actions">
<button type="button" class="admin-btn" on:click={() => adsStore.setCreateOpen(false)}>{at("btn_cancel", {}, "Отмена")}</button>
<button type="button" class="admin-btn admin-btn-primary" on:click={adsStore.createAd} disabled={!adDraft.source.trim() || !adDraft.start_param.trim()}>
{at("btn_create", {}, "Создать")}
</button>
</div>
</div> </div>
</Dialog> </Dialog>
@@ -104,17 +104,16 @@
title={at("promo_create_title", {}, "Создать промокод")} title={at("promo_create_title", {}, "Создать промокод")}
closeLabel={at("close", {}, "Закрыть")} closeLabel={at("close", {}, "Закрыть")}
onclose={() => promosStore.setCreateOpen(false)} onclose={() => promosStore.setCreateOpen(false)}
class="admin-dialog" class="admin-dialog admin-dialog-compact"
> >
<div class="admin-modal" data-dialog-content> <div class="admin-form" data-dialog-content>
<div class="admin-modal-head"> <div class="admin-dialog-form-section">
<h3>{at("promo_create_title", {}, "Создать промокод")}</h3>
</div>
<div class="admin-modal-body admin-form">
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>{at("promo_label_code", {}, "Код")}</span> <span>{at("promo_label_code", {}, "Код")}</span>
<input type="text" class="input" value={promoDraft.code} on:input={(e) => promosStore.updateDraft({ code: e.target.value })} placeholder="FREE-7-DAYS" /> <input type="text" class="input" value={promoDraft.code} on:input={(e) => promosStore.updateDraft({ code: e.target.value })} placeholder="FREE-7-DAYS" />
</Label.Root> </Label.Root>
</div>
<div class="admin-dialog-form-section">
<div class="admin-form-row-2"> <div class="admin-form-row-2">
<Label.Root class="admin-field-label"> <Label.Root class="admin-field-label">
<span>{at("promo_label_bonus_days", {}, "Бонус (дней)")}</span> <span>{at("promo_label_bonus_days", {}, "Бонус (дней)")}</span>
@@ -130,7 +129,7 @@
<input type="number" class="input" min="1" value={promoDraft.valid_days} on:input={(e) => promosStore.updateDraft({ valid_days: Number(e.target.value) })} /> <input type="number" class="input" min="1" value={promoDraft.valid_days} on:input={(e) => promosStore.updateDraft({ valid_days: Number(e.target.value) })} />
</Label.Root> </Label.Root>
</div> </div>
<div class="admin-modal-footer"> <div class="admin-dialog-actions">
<button type="button" class="admin-btn" on:click={() => promosStore.setCreateOpen(false)}>{at("btn_cancel", {}, "Отмена")}</button> <button type="button" class="admin-btn" on:click={() => promosStore.setCreateOpen(false)}>{at("btn_cancel", {}, "Отмена")}</button>
<button type="button" class="admin-btn admin-btn-primary" on:click={promosStore.createPromo} disabled={!promoDraft.code.trim()}> <button type="button" class="admin-btn admin-btn-primary" on:click={promosStore.createPromo} disabled={!promoDraft.code.trim()}>
{at("btn_create", {}, "Создать")} {at("btn_create", {}, "Создать")}
@@ -1,9 +1,13 @@
<script> <script>
import { BarChart3, Coins, Database, Send, Shield, UsersRound } from "lucide-svelte"; import { Activity, Radio, Server, TrendingDown, TrendingUp } from "lucide-svelte";
import { getContext, onMount } from "svelte"; import { getContext, onMount } from "svelte";
import Badge from "../../lib/components/shadcn/badge.svelte";
import * as Card from "../../lib/components/shadcn/card/index.js";
export let at; export let at;
export let fmtDate = (value) => value; export let fmtDate = (value) => value;
export let fmtDateShort = (value) => value;
export let fmtMoney = (value) => value; export let fmtMoney = (value) => value;
export let paymentStatusVariant = () => "muted"; export let paymentStatusVariant = () => "muted";
@@ -15,85 +19,502 @@
statsLoading, statsLoading,
} = $statsStore); } = $statsStore);
$: showSkeleton = !stats && !statsError;
$: currency = stats?.currency_symbol || "RUB";
$: fin = stats?.financial || {};
$: users = stats?.users || {};
$: panelPayload = stats?.panel;
$: panelMetrics =
panelPayload && !panelPayload.error ? parsePanelSystem(panelPayload) : null;
$: panelBw =
panelPayload && !panelPayload.error ? parsePanelBandwidth(panelPayload) : null;
$: dailySeries = Array.isArray(fin.daily_series) ? fin.daily_series : [];
$: revenueKpis = computeRevenueKpis(fin, dailySeries);
$: chartModel = buildRevenueChartModel(dailySeries, fmtDateShort);
function parsePanelSystem(panel) {
const system = panel?.system;
if (!system || typeof system !== "object") return null;
const u = system.users || {};
const statusCounts = u.statusCounts || {};
const onlineStats = system.onlineStats || {};
const mem = system.memory || {};
const memTotal = Number(mem.total) || 0;
const memUsed = Number(mem.used) || 0;
const memPct = memTotal > 0 ? (memUsed / memTotal) * 100 : null;
const nodes = system.nodes || {};
return {
onlineNow: onlineStats.onlineNow ?? 0,
active: statusCounts.ACTIVE ?? 0,
disabled: statusCounts.DISABLED ?? 0,
expired: statusCounts.EXPIRED ?? 0,
limited: statusCounts.LIMITED ?? 0,
totalPanelUsers: u.totalUsers ?? 0,
nodesOnline: nodes.totalOnline != null ? nodes.totalOnline : null,
memPct,
};
}
function parsePanelBandwidth(panel) {
const bw = panel?.bandwidth;
if (!bw || typeof bw !== "object") return null;
const week = bw.bandwidthLastSevenDays?.current;
const month =
bw.bandwidthLast30Days?.current ?? bw.bandwidthLastThirtyDays?.current;
if (week == null && month == null) return null;
return { week, month };
}
function computeRevenueKpis(financial, series) {
const amounts = series.map((p) => Number(p.amount) || 0);
const n = amounts.length;
const last7 = n ? amounts.slice(-7).reduce((a, b) => a + b, 0) : 0;
const prev7 = n > 7 ? amounts.slice(-14, -7).reduce((a, b) => a + b, 0) : 0;
let growthPct = null;
if (n >= 14 && prev7 > 0) growthPct = ((last7 - prev7) / prev7) * 100;
const tc = Number(financial.today_payments_count) || 0;
const tr = Number(financial.today_revenue) || 0;
const avgToday = tc > 0 ? tr / tc : null;
const total14 = amounts.reduce((a, b) => a + b, 0);
const maxY = Math.max(...amounts, 1e-9);
return { last7, prev7, growthPct, avgToday, total14, maxY, amounts, n };
}
function buildRevenueChartModel(series, fmtShort) {
const W = 360;
const H = 168;
const pad = { t: 12, r: 10, b: 26, l: 8 };
const innerW = W - pad.l - pad.r;
const innerH = H - pad.t - pad.b;
const amounts = series.map((p) => Number(p.amount) || 0);
const n = amounts.length;
if (!n) return null;
const maxY = Math.max(...amounts, 1e-9);
const pts = amounts.map((amt, i) => {
const x = pad.l + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const y = pad.t + innerH - (amt / maxY) * innerH;
return { x, y, amt, date: series[i]?.date };
});
const lineD = pts.map((p, i) => `${i === 0 ? "M" : "L"}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join("");
const baseY = (pad.t + innerH).toFixed(1);
const areaD = `${lineD} L${pts[n - 1].x.toFixed(1)},${baseY} L${pts[0].x.toFixed(1)},${baseY} Z`;
const pickIdx = (() => {
if (n === 1) return [0];
const u = new Set([0, Math.floor((n - 1) / 3), Math.floor((2 * (n - 1)) / 3), n - 1]);
return [...u].sort((a, b) => a - b);
})();
const ticks = pickIdx.map((i) => ({
x: pts[i].x,
label: fmtShort(series[i]?.date),
}));
const gridYs = [0.33, 0.66].map((g) => pad.t + innerH * (1 - g));
return { lineD, areaD, ticks, W, H, maxY, gridYs, pad, innerW, innerH };
}
function growthBadgeVariant(pct) {
if (pct == null) return "outline";
if (pct >= 0) return "default";
return "destructive";
}
onMount(() => { onMount(() => {
statsStore.loadStats(); statsStore.loadStats();
}); });
</script> </script>
{#if statsError} {#if statsError}
<div class="admin-empty">{at("stats_error", { error: statsError }, "Не удалось загрузить статистику: " + statsError)}</div> <div class="admin-empty">{at("stats_error", { error: statsError }, "")}</div>
{:else if statsLoading || !stats} {:else if showSkeleton}
<div class="admin-empty">{at("loading", {}, "Загрузка…")}</div> <div class="admin-cn-dashboard-stack">
{:else} <div class="admin-dashboard-section-head">
<div class="admin-stat-grid"> <h3>{at("stats_section_audience", {}, "")}</h3>
<article class="admin-stat-card"> </div>
<span class="admin-stat-label"><UsersRound size={14} /> {at("stats_label_users", {}, "Пользователи")}</span> <div class="admin-cn-dashboard-grid admin-cn-dashboard-grid--3">
<span class="admin-stat-value">{stats.users?.total_users ?? 0}</span> {#each Array(3) as _, i (i)}
<span class="admin-stat-trend">{at("stats_trend_banned", { count: stats.users?.banned_users ?? 0 }, "В бане: " + (stats.users?.banned_users ?? 0))}</span> <Card.Root class="admin-cn-card-skeleton">
</article> <Card.Header>
<article class="admin-stat-card"> <span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
<span class="admin-stat-label"><Shield size={14} /> {at("stats_label_paid_subs", {}, "Платные подписки")}</span> <span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong" style="width:72%"></span>
<span class="admin-stat-value">{stats.users?.paid_subscriptions ?? 0}</span> </Card.Header>
<span class="admin-stat-trend">{at("stats_trend_trials", { count: stats.users?.trial_users ?? 0 }, "Триалы: " + (stats.users?.trial_users ?? 0))}</span> <Card.Footer class="admin-cn-card-footer--stack">
</article> <span class="admin-skeleton admin-skeleton-line" style="width:88%"></span>
<article class="admin-stat-card"> <span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny" style="width:60%"></span>
<span class="admin-stat-label"><Coins size={14} /> {at("stats_label_today_rev", {}, "Доход за день")}</span> </Card.Footer>
<span class="admin-stat-value">{fmtMoney(stats.financial?.today_revenue, stats.currency_symbol)}</span> </Card.Root>
<span class="admin-stat-trend">{at("stats_trend_payments", { count: stats.financial?.today_payments_count ?? 0 }, (stats.financial?.today_payments_count ?? 0) + " платежей")}</span> {/each}
</article> </div>
<article class="admin-stat-card"> <Card.Root class="admin-cn-card-skeleton">
<span class="admin-stat-label"><BarChart3 size={14} /> {at("stats_label_week", {}, "За неделю")}</span> <Card.Header>
<span class="admin-stat-value">{fmtMoney(stats.financial?.week_revenue, stats.currency_symbol)}</span> <span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span>
<span class="admin-stat-trend">{at("stats_trend_month", { value: fmtMoney(stats.financial?.month_revenue, stats.currency_symbol) }, "Месяц: " + fmtMoney(stats.financial?.month_revenue, stats.currency_symbol))}</span> <span class="admin-skeleton admin-skeleton-line admin-skeleton-line-strong" style="width:40%"></span>
</article> </Card.Header>
<article class="admin-stat-card"> <Card.Content class="admin-cn-card-content--flush">
<span class="admin-stat-label"><Database size={14} /> {at("stats_label_all_time", {}, "Всё время")}</span> <div class="admin-revenue-svg-frame">
<span class="admin-stat-value">{fmtMoney(stats.financial?.all_time_revenue, stats.currency_symbol)}</span> <div class="admin-skeleton" style="height:168px;border-radius:10px;"></div>
<span class="admin-stat-trend">{at("stats_sync_label", {}, "Sync")}: {stats.panel_sync?.status ?? "—"}</span> </div>
</article> </Card.Content>
{#if stats.queue} </Card.Root>
<article class="admin-stat-card"> <div class="admin-dashboard-section-head">
<span class="admin-stat-label"><Send size={14} /> {at("stats_label_queue", {}, "Очередь")}</span> <h3>{at("stats_recent_payments", {}, "")}</h3>
<span class="admin-stat-value">{stats.queue.user_queue_size ?? 0}</span> </div>
<span class="admin-stat-trend">{at("stats_trend_groups", { count: stats.queue.group_queue_size ?? 0 }, "Группы: " + (stats.queue.group_queue_size ?? 0))}</span> <div class="admin-table-wrap">
</article> <table class="admin-table admin-table-skeleton" aria-hidden="true">
{/if}
</div>
<div class="admin-table-wrap">
<header class="admin-card-head">
<h3>{at("stats_recent_payments", {}, "Последние платежи")}</h3>
<small>{at("stats_records_count", { count: (stats.recent_payments || []).length }, (stats.recent_payments || []).length + " записей")}</small>
</header>
{#if (stats.recent_payments || []).length}
<table class="admin-table">
<thead> <thead>
<tr> <tr>
<th>{at("id", {}, "ID")}</th> <th>{at("id", {}, "")}</th>
<th>{at("user", {}, "Пользователь")}</th> <th>{at("user", {}, "")}</th>
<th>{at("amount", {}, "Сумма")}</th> <th>{at("amount", {}, "")}</th>
<th>{at("provider", {}, "Провайдер")}</th> <th>{at("provider", {}, "")}</th>
<th>{at("status", {}, "Статус")}</th> <th>{at("status", {}, "")}</th>
<th>{at("date", {}, "Дата")}</th> <th>{at("date", {}, "")}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each stats.recent_payments as p} {#each Array(6) as _, k (k)}
<tr> <tr>
<td class="admin-cell-id" data-label="ID">#{p.payment_id}</td> <td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td data-label={at("user", {}, "Пользователь")}>{p.user_label || p.user_id}</td> <td><span class="admin-skeleton admin-skeleton-line"></span></td>
<td data-label={at("amount", {}, "Сумма")}>{fmtMoney(p.amount, p.currency)}</td> <td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td data-label={at("provider", {}, "Провайдер")}>{p.provider}</td> <td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td data-label={at("status", {}, "Статус")}> <td><span class="admin-skeleton admin-skeleton-badge"></span></td>
<span class="admin-badge admin-badge-{paymentStatusVariant(p.status)}">{p.status}</span> <td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
</td>
<td data-label={at("date", {}, "Дата")}>{fmtDate(p.created_at)}</td>
</tr> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
{:else} </div>
<div class="admin-card-body"><span class="admin-muted">{at("no_data", {}, "Нет данных")}</span></div> </div>
{:else if stats}
<div class="admin-cn-dashboard-stack">
<div class="admin-dashboard-section-head">
<h3>{at("stats_section_audience", {}, "")}</h3>
<small>{at("stats_section_audience_hint", {}, "")}</small>
</div>
<div class="admin-cn-dashboard-grid admin-cn-dashboard-grid--3">
<Card.Root>
<Card.Header>
<Card.Description>{at("stats_label_users", {}, "")}</Card.Description>
<Card.Title>{users.total_users ?? 0}</Card.Title>
<Card.Action>
<Badge variant="outline">+{users.active_today ?? 0}</Badge>
</Card.Action>
</Card.Header>
<Card.Footer class="admin-cn-card-footer--stack">
<div class="admin-cn-card-footer-primary">
{at("stats_trend_banned", { count: users.banned_users ?? 0 }, "")}
</div>
<div class="admin-cn-card-footer-muted">
{at("stats_trend_referrals", { count: users.referral_users ?? 0 }, "")}
</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Description>{at("stats_label_paid_subs", {}, "")}</Card.Description>
<Card.Title>{users.paid_subscriptions ?? 0}</Card.Title>
<Card.Action>
<Badge variant="outline">{users.trial_users ?? 0}</Badge>
</Card.Action>
</Card.Header>
<Card.Footer class="admin-cn-card-footer--stack">
<div class="admin-cn-card-footer-primary">
{at("stats_trend_trials", { count: users.trial_users ?? 0 }, "")}
</div>
<div class="admin-cn-card-footer-muted">{at("stats_card_paid_caption", {}, "")}</div>
</Card.Footer>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Description>{at("stats_label_inactive", {}, "")}</Card.Description>
<Card.Title>{users.inactive_users ?? 0}</Card.Title>
<Card.Action>
<Badge variant="outline">{users.total_users ? Math.round(((users.inactive_users ?? 0) / (users.total_users || 1)) * 100) : 0}%</Badge>
</Card.Action>
</Card.Header>
<Card.Footer class="admin-cn-card-footer--stack">
<div class="admin-cn-card-footer-primary">
{at("stats_trend_new_today", { count: users.active_today ?? 0 }, "")}
</div>
<div class="admin-cn-card-footer-muted">{at("stats_card_inactive_caption", {}, "")}</div>
</Card.Footer>
</Card.Root>
</div>
<div class="admin-dashboard-section-head">
<h3>{at("stats_section_revenue", {}, "")}</h3>
<small>{at("stats_section_revenue_hint", {}, "")}</small>
</div>
<Card.Root>
<Card.Header>
<Card.Description>{at("stats_label_today_rev", {}, "")}</Card.Description>
<Card.Title>{fmtMoney(fin.today_revenue, currency)}</Card.Title>
<Card.Action>
{#if revenueKpis.growthPct != null}
<Badge variant={growthBadgeVariant(revenueKpis.growthPct)}>
{#if revenueKpis.growthPct >= 0}
<TrendingUp />
{:else}
<TrendingDown />
{/if}
{revenueKpis.growthPct >= 0 ? "+" : ""}{revenueKpis.growthPct.toFixed(1)}%
</Badge>
{:else}
<Badge variant="outline"></Badge>
{/if}
</Card.Action>
</Card.Header>
<Card.Content>
<div class="admin-revenue-kpis">
<div class="admin-revenue-kpi">
<div class="admin-revenue-kpi-label">{at("stats_trend_payments", { count: fin.today_payments_count ?? 0 }, "")}</div>
<div class="admin-revenue-kpi-value">{fin.today_payments_count ?? 0}</div>
</div>
<div class="admin-revenue-kpi">
<div class="admin-revenue-kpi-label">{at("stats_revenue_avg_ticket_label", {}, "")}</div>
<div class="admin-revenue-kpi-value">
{revenueKpis.avgToday != null ? fmtMoney(revenueKpis.avgToday, currency) : "—"}
</div>
{#if revenueKpis.avgToday == null}
<div class="admin-revenue-kpi-sub">{at("stats_revenue_avg_none", {}, "")}</div>
{/if}
</div>
<div class="admin-revenue-kpi">
<div class="admin-revenue-kpi-label">{at("stats_revenue_rolling_week", {}, "")}</div>
<div class="admin-revenue-kpi-value">{fmtMoney(fin.week_revenue, currency)}</div>
</div>
<div class="admin-revenue-kpi">
<div class="admin-revenue-kpi-label">{at("stats_revenue_rolling_month", {}, "")}</div>
<div class="admin-revenue-kpi-value">{fmtMoney(fin.month_revenue, currency)}</div>
</div>
<div class="admin-revenue-kpi">
<div class="admin-revenue-kpi-label">{at("stats_revenue_last_7_calendar", {}, "")}</div>
<div class="admin-revenue-kpi-value">{fmtMoney(revenueKpis.last7, currency)}</div>
</div>
<div class="admin-revenue-kpi">
<div class="admin-revenue-kpi-label">{at("stats_label_all_time", {}, "")}</div>
<div class="admin-revenue-kpi-value">{fmtMoney(fin.all_time_revenue, currency)}</div>
</div>
<div class="admin-revenue-kpi admin-revenue-kpi--wide">
<div class="admin-revenue-kpi-label">{at("stats_revenue_total_14", {}, "")}</div>
<div class="admin-revenue-kpi-value">{fmtMoney(revenueKpis.total14, currency)}</div>
<div class="admin-revenue-kpi-sub">
{#if revenueKpis.growthPct != null}
<span
class="admin-revenue-kpi-growth"
class:is-up={revenueKpis.growthPct >= 0}
class:is-down={revenueKpis.growthPct < 0}
>
{at("stats_revenue_growth", { value: revenueKpis.growthPct.toFixed(1) }, "")}
</span>
{:else}
{at("stats_revenue_growth_na", {}, "")}
{/if}
</div>
</div>
</div>
<div class="admin-revenue-chart">
<div class="admin-revenue-chart-title">{at("stats_revenue_chart_title", {}, "")}</div>
{#if chartModel}
<div class="admin-revenue-svg-frame" role="img" aria-label={at("stats_revenue_chart_aria", {}, "")}>
<svg class="admin-revenue-svg" viewBox="0 0 {chartModel.W} {chartModel.H}" preserveAspectRatio="none">
<defs>
<linearGradient id="adminRevenueFillDashboard" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="var(--admin-muted)" stop-opacity="0.28" />
<stop offset="100%" stop-color="var(--admin-muted)" stop-opacity="0" />
</linearGradient>
</defs>
{#each chartModel.gridYs as gy}
<line x1={chartModel.pad.l} y1={gy} x2={chartModel.W - chartModel.pad.r} y2={gy} stroke="var(--admin-border)" stroke-opacity="0.45" stroke-width="1" />
{/each}
<path d={chartModel.areaD} fill="url(#adminRevenueFillDashboard)" stroke="none" />
<path
d={chartModel.lineD}
fill="none"
stroke="color-mix(in srgb, var(--admin-muted) 55%, var(--admin-text))"
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
</div>
<div class="admin-revenue-xlabels">
{#each chartModel.ticks as tk}
<span>{tk.label}</span>
{/each}
</div>
{:else}
<p class="admin-muted">{at("stats_revenue_no_chart", {}, "")}</p>
{/if}
</div>
</Card.Content>
</Card.Root>
<div class="admin-dashboard-section-head">
<h3>{at("stats_section_panel", {}, "")}</h3>
{#if panelPayload?.error}
<small>{at("stats_panel_unavailable", {}, "")}</small>
{:else if panelMetrics}
<small>{at("stats_section_panel_hint", {}, "")}</small>
{/if}
</div>
{#if panelPayload?.error}
<p class="admin-muted" style="margin:0;">{at("stats_panel_unavailable_detail", {}, "")}</p>
{:else if panelMetrics}
<Card.Root>
<Card.Content>
<div class="admin-dashboard-panel-metrics">
<div class="admin-dashboard-panel-metric">
<span><Radio size={12} /> {at("stats_panel_online", {}, "")}</span>
<strong>{panelMetrics.onlineNow}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_active", {}, "")}</span>
<strong>{panelMetrics.active}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_expired", {}, "")}</span>
<strong>{panelMetrics.expired}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_disabled", {}, "")}</span>
<strong>{panelMetrics.disabled}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_limited", {}, "")}</span>
<strong>{panelMetrics.limited}</strong>
</div>
<div class="admin-dashboard-panel-metric">
<span><Activity size={12} /> {at("stats_panel_total_users", {}, "")}</span>
<strong>{panelMetrics.totalPanelUsers}</strong>
</div>
{#if panelMetrics.nodesOnline != null}
<div class="admin-dashboard-panel-metric">
<span><Server size={12} /> {at("stats_panel_nodes_online", {}, "")}</span>
<strong>{panelMetrics.nodesOnline}</strong>
</div>
{/if}
{#if panelMetrics.memPct != null}
<div class="admin-dashboard-panel-metric">
<span>{at("stats_panel_memory", {}, "")}</span>
<strong>{panelMetrics.memPct.toFixed(1)}%</strong>
</div>
{/if}
</div>
{#if panelBw && (panelBw.week != null || panelBw.month != null)}
<dl class="admin-dashboard-panel-bandwidth">
{#if panelBw.week != null}
<div>
<dt>{at("stats_panel_bw_week", {}, "")}</dt>
<dd>{panelBw.week}</dd>
</div>
{/if}
{#if panelBw.month != null}
<div>
<dt>{at("stats_panel_bw_month", {}, "")}</dt>
<dd>{panelBw.month}</dd>
</div>
{/if}
</dl>
{/if}
</Card.Content>
</Card.Root>
{/if} {/if}
<Card.Root>
<Card.Content class="admin-cn-card-content--flush" style="padding-top:12px;padding-bottom:12px;">
<div class="admin-sync-strip" style="border:0;background:transparent;padding:0;">
<span
><strong>{at("stats_sync_label", {}, "")}:</strong>
{stats.panel_sync?.status ?? "—"}{#if stats.panel_sync?.last_sync_time}
· {at("stats_sync_last", {}, "")}: {fmtDateShort(stats.panel_sync.last_sync_time)}{/if}</span
>
{#if stats.panel_sync && (stats.panel_sync.users_processed > 0 || stats.panel_sync.subscriptions_synced > 0)}
<span
>{at("stats_sync_processed", { users: stats.panel_sync.users_processed, subs: stats.panel_sync.subscriptions_synced }, "")}</span
>
{/if}
{#if stats.queue}
<span
><strong>{at("stats_label_queue", {}, "")}:</strong>
{stats.queue.user_queue_size ?? 0}{at("stats_queue_users", {}, "")}, {stats.queue.group_queue_size ?? 0}{at("stats_queue_groups", {}, "")}</span
>
{/if}
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header class="admin-cn-card-header--lead">
<Card.Title class="admin-cn-card-title--section">{at("stats_recent_payments", {}, "")}</Card.Title>
<Card.Description>{at("stats_records_count", { count: (stats.recent_payments || []).length }, "")}</Card.Description>
</Card.Header>
<Card.Content class="admin-cn-card-content--flush">
<div class="admin-table-wrap">
{#if statsLoading}
<table class="admin-table admin-table-skeleton" aria-hidden="true">
<thead>
<tr>
<th>{at("id", {}, "")}</th>
<th>{at("user", {}, "")}</th>
<th>{at("amount", {}, "")}</th>
<th>{at("provider", {}, "")}</th>
<th>{at("status", {}, "")}</th>
<th>{at("date", {}, "")}</th>
</tr>
</thead>
<tbody>
{#each Array(5) as _, r (r)}
<tr>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-tiny"></span></td>
<td><span class="admin-skeleton admin-skeleton-line"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
<td><span class="admin-skeleton admin-skeleton-badge"></span></td>
<td><span class="admin-skeleton admin-skeleton-line admin-skeleton-line-short"></span></td>
</tr>
{/each}
</tbody>
</table>
{:else if (stats.recent_payments || []).length}
<table class="admin-table">
<thead>
<tr>
<th>{at("id", {}, "")}</th>
<th>{at("user", {}, "")}</th>
<th>{at("amount", {}, "")}</th>
<th>{at("provider", {}, "")}</th>
<th>{at("status", {}, "")}</th>
<th>{at("date", {}, "")}</th>
</tr>
</thead>
<tbody>
{#each stats.recent_payments as p}
<tr>
<td class="admin-cell-id" data-label={at("id", {}, "")}>#{p.payment_id}</td>
<td data-label={at("user", {}, "")}>{p.user_label || p.user_id}</td>
<td data-label={at("amount", {}, "")}>{fmtMoney(p.amount, p.currency)}</td>
<td data-label={at("provider", {}, "")}>{p.provider}</td>
<td data-label={at("status", {}, "")}>
<span class="admin-badge admin-badge-{paymentStatusVariant(p.status)}">{p.status}</span>
</td>
<td data-label={at("date", {}, "")}>{fmtDate(p.created_at)}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<div class="admin-card-body"><span class="admin-muted">{at("no_data", {}, "")}</span></div>
{/if}
</div>
</Card.Content>
</Card.Root>
</div> </div>
{/if} {/if}
@@ -0,0 +1,22 @@
<script>
import { cn } from "../../utils.js";
/** @type {'default' | 'outline' | 'destructive'} */
export let variant = "default";
let className = "";
export { className as class };
</script>
<span
data-slot="badge"
class={cn(
"admin-cn-badge",
variant === "outline" && "admin-cn-badge-outline",
variant === "destructive" && "admin-cn-badge-destructive",
className,
)}
{...$$restProps}
>
<slot />
</span>
@@ -0,0 +1,10 @@
<script>
import { cn } from "../../../utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-action" class={cn("admin-cn-card-action", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "../../../utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-content" class={cn("admin-cn-card-content", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "../../../utils.js";
let className = "";
export { className as class };
</script>
<p data-slot="card-description" class={cn("admin-cn-card-description", className)} {...$$restProps}>
<slot />
</p>
@@ -0,0 +1,10 @@
<script>
import { cn } from "../../../utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-footer" class={cn("admin-cn-card-footer", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "../../../utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-header" class={cn("admin-cn-card-header", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "../../../utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card-title" class={cn("admin-cn-card-title", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,10 @@
<script>
import { cn } from "../../../utils.js";
let className = "";
export { className as class };
</script>
<div data-slot="card" class={cn("admin-cn-card", className)} {...$$restProps}>
<slot />
</div>
@@ -0,0 +1,9 @@
import Root from "./card.svelte";
import Header from "./card-header.svelte";
import Title from "./card-title.svelte";
import Description from "./card-description.svelte";
import Action from "./card-action.svelte";
import Footer from "./card-footer.svelte";
import Content from "./card-content.svelte";
export { Root, Header, Title, Description, Action, Footer, Content };
+444 -1
View File
@@ -412,6 +412,430 @@
font-size: 12px; font-size: 12px;
} }
/* shadcn-sveltestyle primitives (dashboard example), themed for admin — no Tailwind */
.admin-cn-dashboard-stack {
display: flex;
flex-direction: column;
gap: 18px;
min-width: 0;
}
.admin-cn-dashboard-grid {
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
}
@media (min-width: 720px) {
.admin-cn-dashboard-grid--3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.admin-cn-card[data-slot="card"] {
display: flex;
flex-direction: column;
min-width: 0;
border-radius: 12px;
border: 1px solid var(--admin-border);
background: var(--admin-surface);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
position: relative;
overflow: hidden;
}
.admin-cn-card-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto;
align-items: flex-start;
gap: 4px 12px;
padding: 14px 16px 0;
}
.admin-cn-card-header > [data-slot="card-description"] {
grid-column: 1;
grid-row: 1;
}
.admin-cn-card-header > [data-slot="card-title"] {
grid-column: 1;
grid-row: 2;
}
.admin-cn-card-header > [data-slot="card-action"] {
grid-column: 2;
grid-row: 1 / span 2;
align-self: flex-start;
justify-self: end;
}
.admin-cn-card-header--lead > [data-slot="card-title"] {
grid-row: 1;
}
.admin-cn-card-header--lead > [data-slot="card-description"] {
grid-row: 2;
}
.admin-cn-card-description {
margin: 0;
font-size: 12px;
font-weight: 500;
color: var(--admin-muted);
}
.admin-cn-card-title {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--admin-text);
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.admin-cn-card-title--section {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.admin-cn-card-skeleton {
min-height: 132px;
}
@media (min-width: 900px) {
.admin-cn-card-title {
font-size: 1.65rem;
}
}
.admin-cn-card-footer {
padding: 12px 16px 14px;
margin-top: auto;
border-top: 1px solid color-mix(in srgb, var(--admin-border) 70%, transparent);
}
.admin-cn-card-footer--stack {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
font-size: 13px;
}
.admin-cn-card-footer-primary {
font-weight: 600;
color: var(--admin-text);
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.admin-cn-card-footer-muted {
color: var(--admin-muted);
font-size: 12px;
line-height: 1.35;
}
.admin-cn-card-content {
padding: 12px 16px 14px;
display: grid;
gap: 12px;
min-width: 0;
}
.admin-cn-card-content--flush {
padding-top: 4px;
}
.admin-cn-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1;
border: 1px solid var(--admin-border);
background: color-mix(in srgb, var(--admin-surface-2) 88%, var(--admin-bg));
color: var(--admin-text);
}
.admin-cn-badge :global(svg) {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.admin-cn-badge-outline {
background: transparent;
border-color: var(--admin-border);
color: var(--admin-muted);
}
.admin-cn-badge-destructive {
background: color-mix(in srgb, #ff6b6b 16%, var(--admin-surface));
border-color: color-mix(in srgb, #ff6b6b 35%, var(--admin-border));
color: color-mix(in srgb, #ff6b6b 92%, var(--admin-text));
}
.admin-dashboard-section {
margin-top: 20px;
}
.admin-dashboard-section:first-child {
margin-top: 0;
}
.admin-dashboard-section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin: 0 0 10px;
}
.admin-dashboard-section-head h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--admin-text);
letter-spacing: -0.01em;
}
.admin-dashboard-section-head small {
color: var(--admin-muted);
font-size: 12px;
}
.admin-revenue-panel {
border: 1px solid var(--admin-border);
background: var(--admin-surface);
border-radius: 12px;
padding: 16px 18px;
display: grid;
gap: 14px;
min-width: 0;
}
.admin-revenue-panel__body {
display: grid;
gap: 18px;
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
}
@media (min-width: 900px) {
.admin-revenue-panel__body {
grid-template-columns: minmax(240px, 340px) minmax(0, 1fr);
gap: 22px;
}
}
.admin-revenue-kpis {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
min-width: 0;
}
@media (min-width: 520px) {
.admin-revenue-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.admin-revenue-kpi {
border: 1px solid var(--admin-border);
border-radius: 10px;
background: color-mix(in srgb, var(--admin-surface-2) 72%, var(--admin-bg));
padding: 10px 12px;
display: grid;
gap: 4px;
min-width: 0;
}
.admin-revenue-kpi--wide {
grid-column: span 2;
}
.admin-revenue-kpi-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--admin-muted);
}
.admin-revenue-kpi-value {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--admin-text);
line-height: 1.15;
}
.admin-revenue-kpi-sub {
font-size: 12px;
color: var(--admin-muted);
line-height: 1.35;
}
.admin-revenue-kpi-growth {
font-size: 12px;
font-weight: 600;
}
.admin-revenue-kpi-growth.is-up {
color: color-mix(in srgb, #3ecf8e 92%, var(--admin-text));
}
.admin-revenue-kpi-growth.is-down {
color: color-mix(in srgb, #ff6b6b 88%, var(--admin-text));
}
.admin-revenue-chart {
display: grid;
gap: 8px;
min-width: 0;
}
.admin-revenue-chart-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--admin-muted);
}
.admin-revenue-svg-frame {
border-radius: 10px;
border: 1px solid var(--admin-border);
background: color-mix(in srgb, var(--admin-bg) 88%, var(--admin-surface-2));
overflow: hidden;
}
.admin-revenue-svg {
width: 100%;
height: 168px;
display: block;
}
.admin-revenue-xlabels {
display: flex;
justify-content: space-between;
gap: 6px;
font-size: 10px;
color: var(--admin-muted);
padding: 0 2px;
}
.admin-revenue-xlabels span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-dashboard-panel-card {
border: 1px solid var(--admin-border);
background: var(--admin-surface);
border-radius: 12px;
padding: 16px 18px;
display: grid;
gap: 14px;
min-width: 0;
}
.admin-dashboard-panel-metrics {
display: flex;
flex-wrap: wrap;
gap: 12px 20px;
}
.admin-dashboard-panel-metric {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.admin-dashboard-panel-metric strong {
font-size: 18px;
font-weight: 700;
color: var(--admin-text);
letter-spacing: -0.02em;
}
.admin-dashboard-panel-metric span {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--admin-muted);
}
.admin-dashboard-panel-bandwidth {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
font-size: 12px;
color: var(--admin-muted);
}
.admin-dashboard-panel-bandwidth dt {
font-weight: 600;
color: var(--admin-text);
margin: 0;
}
.admin-dashboard-panel-bandwidth dd {
margin: 0;
}
.admin-sync-strip {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 18px;
padding: 12px 14px;
border: 1px solid var(--admin-border);
border-radius: 12px;
background: color-mix(in srgb, var(--admin-surface-2) 70%, var(--admin-bg));
font-size: 12px;
color: var(--admin-muted);
}
.admin-sync-strip strong {
color: var(--admin-text);
font-weight: 600;
}
.admin-stat-skeleton-card {
border: 1px solid var(--admin-border);
background: var(--admin-surface);
border-radius: 12px;
padding: 16px 18px;
display: grid;
gap: 10px;
min-height: 96px;
}
.admin-stat-skeleton-wide {
grid-column: span 1;
}
@media (min-width: 560px) {
.admin-stat-skeleton-wide {
grid-column: span 2;
}
}
.admin-toolbar { .admin-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1225,12 +1649,31 @@
.admin-dialog { .admin-dialog {
max-height: 100%; max-height: 100%;
width: 100%; /* Keep admin dialogs readable on desktop: base .dialog-card uses min(100%, 520px);
this rule must not stretch the card to full viewport width. */
width: min(100%, 520px);
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
/* Short create/edit forms (promo, ad campaign, etc.): narrower card on large screens. */
.admin-dialog.admin-dialog-compact {
width: min(100%, 400px);
}
/* Optional grouping inside compact admin dialogs */
.admin-dialog-compact .admin-dialog-form-section {
display: grid;
gap: 12px;
}
.admin-dialog-compact .admin-dialog-form-section + .admin-dialog-form-section {
padding-top: 14px;
margin-top: 2px;
border-top: 1px solid var(--admin-border);
}
/* User-detail dialog: constrain on desktop and lay out as a two-column /* User-detail dialog: constrain on desktop and lay out as a two-column
sidebar (profile facts) + main content (tabs). On mobile it stacks. */ sidebar (profile facts) + main content (tabs). On mobile it stacks. */
+40 -2
View File
@@ -2,7 +2,7 @@ import logging
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy import update, func, and_ from sqlalchemy import update, func, and_, cast, Date
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from db.models import Payment, User from db.models import Payment, User
@@ -176,6 +176,41 @@ async def update_provider_payment_and_status(
return payment return payment
async def _daily_revenue_series_utc(session: AsyncSession, days: int = 14) -> List[Dict[str, Any]]:
"""Succeeded payment totals per calendar day (UTC) for the last `days` days."""
from datetime import date, datetime, timedelta, timezone
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
range_start = today_start - timedelta(days=days - 1)
day_col = cast(func.date_trunc("day", Payment.created_at), Date).label("d")
stmt = (
select(day_col, func.coalesce(func.sum(Payment.amount), 0.0))
.where(
and_(
Payment.status == "succeeded",
Payment.created_at >= range_start,
)
)
.group_by(day_col)
.order_by(day_col)
)
result = await session.execute(stmt)
by_day: Dict[date, float] = {}
for row in result.all():
d_key = row[0]
if isinstance(d_key, datetime):
d_key = d_key.date()
by_day[d_key] = float(row[1] or 0)
out: List[Dict[str, Any]] = []
for i in range(days):
d = (range_start + timedelta(days=i)).date()
out.append({"date": d.isoformat(), "amount": float(by_day.get(d, 0.0) or 0.0)})
return out
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]: async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive financial statistics.""" """Get comprehensive financial statistics."""
from datetime import datetime, timedelta from datetime import datetime, timedelta
@@ -231,12 +266,15 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
today_count = await session.execute(stmt_count_today) today_count = await session.execute(stmt_count_today)
today_payments_count = today_count.scalar() or 0 today_payments_count = today_count.scalar() or 0
daily_series = await _daily_revenue_series_utc(session, days=14)
return { return {
"today_revenue": float(today_amount), "today_revenue": float(today_amount),
"week_revenue": float(week_amount), "week_revenue": float(week_amount),
"month_revenue": float(month_amount), "month_revenue": float(month_amount),
"all_time_revenue": float(all_amount), "all_time_revenue": float(all_amount),
"today_payments_count": today_payments_count "today_payments_count": today_payments_count,
"daily_series": daily_series,
} }
+44 -3
View File
@@ -805,7 +805,7 @@
"admin_nav_tariffs": "Tariffs", "admin_nav_tariffs": "Tariffs",
"admin_nav_settings": "Settings", "admin_nav_settings": "Settings",
"admin_section_stats_title": "Dashboard", "admin_section_stats_title": "Dashboard",
"admin_section_stats_subtitle": "Shop and panel summary", "admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments",
"admin_section_users_title": "Users", "admin_section_users_title": "Users",
"admin_section_users_subtitle": "Search, bans, and account actions", "admin_section_users_subtitle": "Search, bans, and account actions",
"admin_section_payments_title": "Payments", "admin_section_payments_title": "Payments",
@@ -900,11 +900,52 @@
"admin_stats_label_week": "This Week", "admin_stats_label_week": "This Week",
"admin_stats_trend_month": "Month: {value}", "admin_stats_trend_month": "Month: {value}",
"admin_stats_label_all_time": "All Time", "admin_stats_label_all_time": "All Time",
"admin_stats_sync_label": "Sync", "admin_stats_sync_label": "Sync with panel",
"admin_stats_label_queue": "Queue", "admin_stats_label_queue": "Queue",
"admin_stats_trend_groups": "Groups: {count}", "admin_stats_trend_groups": "Groups: {count}",
"admin_stats_recent_payments": "Recent Payments", "admin_stats_recent_payments": "Recent Payments",
"admin_stats_records_count": "{count} records", "admin_stats_records_count": "{count} records",
"admin_stats_section_audience": "Audience",
"admin_stats_section_audience_hint": "Bot users and subscriptions",
"admin_stats_trend_referrals": "Referrals: {count}",
"admin_stats_label_inactive": "No active subscription",
"admin_stats_trend_new_today": "Registrations today: {count}",
"admin_stats_section_revenue": "Revenue",
"admin_stats_section_revenue_hint": "Succeeded payments, shop currency",
"admin_stats_revenue_chart_title": "Daily revenue (UTC, 14 days)",
"admin_stats_revenue_chart_aria": "Daily revenue chart for the last 14 days",
"admin_stats_revenue_avg_check": "Average ticket today: {value}",
"admin_stats_revenue_avg_none": "No successful payments today",
"admin_stats_revenue_avg_ticket_label": "Avg. ticket (today)",
"admin_stats_card_paid_caption": "Trials shown in the badge",
"admin_stats_card_inactive_caption": "Share of all users is in the badge",
"admin_stats_revenue_last_7_calendar": "Last 7 calendar days total",
"admin_stats_revenue_prev_7_calendar": "Previous 7 days",
"admin_stats_revenue_growth": "vs previous 7 days: {value}%",
"admin_stats_revenue_growth_na": "Not enough history for 7×7 comparison",
"admin_stats_revenue_total_14": "14-day chart total",
"admin_stats_revenue_rolling_week": "Rolling 7 days",
"admin_stats_revenue_rolling_month": "Rolling 30 days",
"admin_stats_revenue_all_time": "All time",
"admin_stats_revenue_no_chart": "No data for chart",
"admin_stats_section_panel": "Remnawave panel",
"admin_stats_panel_unavailable": "Panel unavailable",
"admin_stats_section_panel_hint": "Live data from panel API",
"admin_stats_panel_unavailable_detail": "Check REMNAWAVE_* settings and panel API reachability.",
"admin_stats_panel_online": "Online",
"admin_stats_panel_active": "Active",
"admin_stats_panel_expired": "Expired",
"admin_stats_panel_disabled": "Disabled",
"admin_stats_panel_limited": "Limited",
"admin_stats_panel_total_users": "Total in panel",
"admin_stats_panel_nodes_online": "Nodes online",
"admin_stats_panel_memory": "Memory",
"admin_stats_panel_bw_week": "Traffic 7 days",
"admin_stats_panel_bw_month": "Traffic 30 days",
"admin_stats_sync_last": "Last run",
"admin_stats_sync_processed": "Processed: {users} users, {subs} subscriptions",
"admin_stats_queue_users": " users",
"admin_stats_queue_groups": " groups",
"admin_id": "ID", "admin_id": "ID",
"admin_status_default": "Default", "admin_status_default": "Default",
"admin_tariff_squads": "Squads", "admin_tariff_squads": "Squads",
@@ -1013,7 +1054,7 @@
"admin_nav_tariffs": "Tariffs", "admin_nav_tariffs": "Tariffs",
"admin_nav_settings": "Settings", "admin_nav_settings": "Settings",
"admin_section_stats_title": "Dashboard", "admin_section_stats_title": "Dashboard",
"admin_section_stats_subtitle": "Shop and panel overview", "admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments",
"admin_section_users_title": "Users", "admin_section_users_title": "Users",
"admin_section_users_subtitle": "Search, bans, and account actions", "admin_section_users_subtitle": "Search, bans, and account actions",
"admin_section_payments_title": "Payments", "admin_section_payments_title": "Payments",
+44 -3
View File
@@ -805,7 +805,7 @@
"admin_nav_tariffs": "Тарифы", "admin_nav_tariffs": "Тарифы",
"admin_nav_settings": "Настройки", "admin_nav_settings": "Настройки",
"admin_section_stats_title": "Дашборд", "admin_section_stats_title": "Дашборд",
"admin_section_stats_subtitle": "Сводка по магазину и панели", "admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи",
"admin_section_users_title": "Пользователи", "admin_section_users_title": "Пользователи",
"admin_section_users_subtitle": "Поиск, баны, действия над аккаунтами", "admin_section_users_subtitle": "Поиск, баны, действия над аккаунтами",
"admin_section_payments_title": "Платежи", "admin_section_payments_title": "Платежи",
@@ -900,11 +900,52 @@
"admin_stats_label_week": "За неделю", "admin_stats_label_week": "За неделю",
"admin_stats_trend_month": "Месяц: {value}", "admin_stats_trend_month": "Месяц: {value}",
"admin_stats_label_all_time": "Всё время", "admin_stats_label_all_time": "Всё время",
"admin_stats_sync_label": "Sync", "admin_stats_sync_label": "Синхронизация",
"admin_stats_label_queue": "Очередь", "admin_stats_label_queue": "Очередь",
"admin_stats_trend_groups": "Группы: {count}", "admin_stats_trend_groups": "Группы: {count}",
"admin_stats_recent_payments": "Последние платежи", "admin_stats_recent_payments": "Последние платежи",
"admin_stats_records_count": "{count} записей", "admin_stats_records_count": "{count} записей",
"admin_stats_section_audience": "Аудитория",
"admin_stats_section_audience_hint": "Пользователи бота и подписки",
"admin_stats_trend_referrals": "Рефералы: {count}",
"admin_stats_label_inactive": "Без активной подписки",
"admin_stats_trend_new_today": "Регистраций сегодня: {count}",
"admin_stats_section_revenue": "Доходы",
"admin_stats_section_revenue_hint": "Успешные платежи, валюта магазина",
"admin_stats_revenue_chart_title": "Выручка по дням (UTC, 14 дней)",
"admin_stats_revenue_chart_aria": "График выручки по дням за последние 14 дней",
"admin_stats_revenue_avg_check": "Средний чек сегодня: {value}",
"admin_stats_revenue_avg_none": "Сегодня без успешных платежей",
"admin_stats_revenue_avg_ticket_label": "Средний чек (сегодня)",
"admin_stats_card_paid_caption": "Триалы — отдельно в бейдже",
"admin_stats_card_inactive_caption": "Доля от всех пользователей — в бейдже",
"admin_stats_revenue_last_7_calendar": "Сумма за последние 7 дн. (календарь)",
"admin_stats_revenue_prev_7_calendar": "Предыдущие 7 дн.",
"admin_stats_revenue_growth": "к прошлым 7 дн.: {value}%",
"admin_stats_revenue_growth_na": "Нет базы для сравнения 7×7",
"admin_stats_revenue_total_14": "За 14 дн. на графике",
"admin_stats_revenue_rolling_week": "Скользящие 7 дн.",
"admin_stats_revenue_rolling_month": "Скользящие 30 дн.",
"admin_stats_revenue_all_time": "Всё время",
"admin_stats_revenue_no_chart": "Нет данных для графика",
"admin_stats_section_panel": "Панель Remnawave",
"admin_stats_panel_unavailable": "Панель недоступна",
"admin_stats_section_panel_hint": "Состояние из API панели",
"admin_stats_panel_unavailable_detail": "Проверьте REMNAWAVE_* и доступность API панели.",
"admin_stats_panel_online": "Онлайн",
"admin_stats_panel_active": "Активных",
"admin_stats_panel_expired": "Истекла",
"admin_stats_panel_disabled": "Отключено",
"admin_stats_panel_limited": "Лимит",
"admin_stats_panel_total_users": "Всего в панели",
"admin_stats_panel_nodes_online": "Нод онлайн",
"admin_stats_panel_memory": "Память",
"admin_stats_panel_bw_week": "Трафик 7 дней",
"admin_stats_panel_bw_month": "Трафик 30 дней",
"admin_stats_sync_last": "Последняя",
"admin_stats_sync_processed": "Обработано: {users} польз., {subs} подписок",
"admin_stats_queue_users": " польз.",
"admin_stats_queue_groups": " групп",
"admin_id": "ID", "admin_id": "ID",
"admin_status_default": "По умолчанию", "admin_status_default": "По умолчанию",
"admin_tariff_squads": "Squads", "admin_tariff_squads": "Squads",
@@ -1013,7 +1054,7 @@
"admin_nav_tariffs": "Тарифы", "admin_nav_tariffs": "Тарифы",
"admin_nav_settings": "Настройки", "admin_nav_settings": "Настройки",
"admin_section_stats_title": "Дашборд", "admin_section_stats_title": "Дашборд",
"admin_section_stats_subtitle": "Сводка по магазину и панели", "admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи",
"admin_section_users_title": "Пользователи", "admin_section_users_title": "Пользователи",
"admin_section_users_subtitle": "Поиск, баны и действия над аккаунтами", "admin_section_users_subtitle": "Поиск, баны и действия над аккаунтами",
"admin_section_payments_title": "Платежи", "admin_section_payments_title": "Платежи",