From 9f338c3416f460a2736ab9c00852270d465d0515 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Mon, 11 May 2026 23:06:16 +0300 Subject: [PATCH] feat: tune visual of web app admin panel --- .../web/frontend/src/admin/AdminPanel.svelte | 7 +- .../src/admin/sections/AdsSection.svelte | 51 +- .../src/admin/sections/PromosSection.svelte | 13 +- .../src/admin/sections/StatsSection.svelte | 549 ++++++++++++++++-- .../src/lib/components/shadcn/badge.svelte | 22 + .../components/shadcn/card/card-action.svelte | 10 + .../shadcn/card/card-content.svelte | 10 + .../shadcn/card/card-description.svelte | 10 + .../components/shadcn/card/card-footer.svelte | 10 + .../components/shadcn/card/card-header.svelte | 10 + .../components/shadcn/card/card-title.svelte | 10 + .../lib/components/shadcn/card/card.svelte | 10 + .../src/lib/components/shadcn/card/index.js | 9 + bot/app/web/frontend/src/styles/admin.css | 445 +++++++++++++- db/dal/payment_dal.py | 44 +- locales/en.json | 47 +- locales/ru.json | 47 +- 17 files changed, 1202 insertions(+), 102 deletions(-) create mode 100644 bot/app/web/frontend/src/lib/components/shadcn/badge.svelte create mode 100644 bot/app/web/frontend/src/lib/components/shadcn/card/card-action.svelte create mode 100644 bot/app/web/frontend/src/lib/components/shadcn/card/card-content.svelte create mode 100644 bot/app/web/frontend/src/lib/components/shadcn/card/card-description.svelte create mode 100644 bot/app/web/frontend/src/lib/components/shadcn/card/card-footer.svelte create mode 100644 bot/app/web/frontend/src/lib/components/shadcn/card/card-header.svelte create mode 100644 bot/app/web/frontend/src/lib/components/shadcn/card/card-title.svelte create mode 100644 bot/app/web/frontend/src/lib/components/shadcn/card/card.svelte create mode 100644 bot/app/web/frontend/src/lib/components/shadcn/card/index.js diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte index 5fa2e9e..baeebe3 100644 --- a/bot/app/web/frontend/src/admin/AdminPanel.svelte +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -117,7 +117,10 @@ ]; $: 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", {}, "Поиск, баны и действия над аккаунтами") }, payments: { title: at("section_payments_title", {}, "Платежи"), subtitle: at("section_payments_subtitle", {}, "История транзакций и экспорт") }, promos: { title: at("section_promos_title", {}, "Промокоды"), subtitle: at("section_promos_subtitle", {}, "Создание и управление кодами") }, @@ -385,7 +388,7 @@
{#if active === "stats"} - + {/if} {#if active === "users"} diff --git a/bot/app/web/frontend/src/admin/sections/AdsSection.svelte b/bot/app/web/frontend/src/admin/sections/AdsSection.svelte index 1fba3a5..fc044dd 100644 --- a/bot/app/web/frontend/src/admin/sections/AdsSection.svelte +++ b/bot/app/web/frontend/src/admin/sections/AdsSection.svelte @@ -1,5 +1,5 @@ {#if statsError} -
{at("stats_error", { error: statsError }, "Не удалось загрузить статистику: " + statsError)}
-{:else if statsLoading || !stats} -
{at("loading", {}, "Загрузка…")}
-{:else} -
-
- {at("stats_label_users", {}, "Пользователи")} - {stats.users?.total_users ?? 0} - {at("stats_trend_banned", { count: stats.users?.banned_users ?? 0 }, "В бане: " + (stats.users?.banned_users ?? 0))} -
-
- {at("stats_label_paid_subs", {}, "Платные подписки")} - {stats.users?.paid_subscriptions ?? 0} - {at("stats_trend_trials", { count: stats.users?.trial_users ?? 0 }, "Триалы: " + (stats.users?.trial_users ?? 0))} -
-
- {at("stats_label_today_rev", {}, "Доход за день")} - {fmtMoney(stats.financial?.today_revenue, stats.currency_symbol)} - {at("stats_trend_payments", { count: stats.financial?.today_payments_count ?? 0 }, (stats.financial?.today_payments_count ?? 0) + " платежей")} -
-
- {at("stats_label_week", {}, "За неделю")} - {fmtMoney(stats.financial?.week_revenue, stats.currency_symbol)} - {at("stats_trend_month", { value: fmtMoney(stats.financial?.month_revenue, stats.currency_symbol) }, "Месяц: " + fmtMoney(stats.financial?.month_revenue, stats.currency_symbol))} -
-
- {at("stats_label_all_time", {}, "Всё время")} - {fmtMoney(stats.financial?.all_time_revenue, stats.currency_symbol)} - {at("stats_sync_label", {}, "Sync")}: {stats.panel_sync?.status ?? "—"} -
- {#if stats.queue} -
- {at("stats_label_queue", {}, "Очередь")} - {stats.queue.user_queue_size ?? 0} - {at("stats_trend_groups", { count: stats.queue.group_queue_size ?? 0 }, "Группы: " + (stats.queue.group_queue_size ?? 0))} -
- {/if} -
- -
-
-

{at("stats_recent_payments", {}, "Последние платежи")}

- {at("stats_records_count", { count: (stats.recent_payments || []).length }, (stats.recent_payments || []).length + " записей")} -
- {#if (stats.recent_payments || []).length} - +
{at("stats_error", { error: statsError }, "")}
+{:else if showSkeleton} +
+
+

{at("stats_section_audience", {}, "")}

+
+
+ {#each Array(3) as _, i (i)} + + + + + + + + + + + {/each} +
+ + + + + + +
+
+
+
+
+
+

{at("stats_recent_payments", {}, "")}

+
+
+
- - - - - - + + + + + + - {#each stats.recent_payments as p} + {#each Array(6) as _, k (k)} - - - - - - + + + + + + {/each} - {:else} -
{at("no_data", {}, "Нет данных")}
+
+ +{:else if stats} +
+
+

{at("stats_section_audience", {}, "")}

+ {at("stats_section_audience_hint", {}, "")} +
+ +
+ + + {at("stats_label_users", {}, "")} + {users.total_users ?? 0} + + +{users.active_today ?? 0} + + + + + + + + + + + {at("stats_label_paid_subs", {}, "")} + {users.paid_subscriptions ?? 0} + + {users.trial_users ?? 0} + + + + + + + + + + + {at("stats_label_inactive", {}, "")} + {users.inactive_users ?? 0} + + {users.total_users ? Math.round(((users.inactive_users ?? 0) / (users.total_users || 1)) * 100) : 0}% + + + + + + + +
+ +
+

{at("stats_section_revenue", {}, "")}

+ {at("stats_section_revenue_hint", {}, "")} +
+ + + + {at("stats_label_today_rev", {}, "")} + {fmtMoney(fin.today_revenue, currency)} + + {#if revenueKpis.growthPct != null} + + {#if revenueKpis.growthPct >= 0} + + {:else} + + {/if} + {revenueKpis.growthPct >= 0 ? "+" : ""}{revenueKpis.growthPct.toFixed(1)}% + + {:else} + + {/if} + + + +
+
+
{at("stats_trend_payments", { count: fin.today_payments_count ?? 0 }, "")}
+
{fin.today_payments_count ?? 0}
+
+
+
{at("stats_revenue_avg_ticket_label", {}, "")}
+
+ {revenueKpis.avgToday != null ? fmtMoney(revenueKpis.avgToday, currency) : "—"} +
+ {#if revenueKpis.avgToday == null} +
{at("stats_revenue_avg_none", {}, "")}
+ {/if} +
+
+
{at("stats_revenue_rolling_week", {}, "")}
+
{fmtMoney(fin.week_revenue, currency)}
+
+
+
{at("stats_revenue_rolling_month", {}, "")}
+
{fmtMoney(fin.month_revenue, currency)}
+
+
+
{at("stats_revenue_last_7_calendar", {}, "")}
+
{fmtMoney(revenueKpis.last7, currency)}
+
+
+
{at("stats_label_all_time", {}, "")}
+
{fmtMoney(fin.all_time_revenue, currency)}
+
+
+
{at("stats_revenue_total_14", {}, "")}
+
{fmtMoney(revenueKpis.total14, currency)}
+
+ {#if revenueKpis.growthPct != null} + = 0} + class:is-down={revenueKpis.growthPct < 0} + > + {at("stats_revenue_growth", { value: revenueKpis.growthPct.toFixed(1) }, "")} + + {:else} + {at("stats_revenue_growth_na", {}, "")} + {/if} +
+
+
+ +
+
{at("stats_revenue_chart_title", {}, "")}
+ {#if chartModel} + +
+ {#each chartModel.ticks as tk} + {tk.label} + {/each} +
+ {:else} +

{at("stats_revenue_no_chart", {}, "")}

+ {/if} +
+
+
+ +
+

{at("stats_section_panel", {}, "")}

+ {#if panelPayload?.error} + {at("stats_panel_unavailable", {}, "")} + {:else if panelMetrics} + {at("stats_section_panel_hint", {}, "")} + {/if} +
+ + {#if panelPayload?.error} +

{at("stats_panel_unavailable_detail", {}, "")}

+ {:else if panelMetrics} + + +
+
+ {at("stats_panel_online", {}, "")} + {panelMetrics.onlineNow} +
+
+ {at("stats_panel_active", {}, "")} + {panelMetrics.active} +
+
+ {at("stats_panel_expired", {}, "")} + {panelMetrics.expired} +
+
+ {at("stats_panel_disabled", {}, "")} + {panelMetrics.disabled} +
+
+ {at("stats_panel_limited", {}, "")} + {panelMetrics.limited} +
+
+ {at("stats_panel_total_users", {}, "")} + {panelMetrics.totalPanelUsers} +
+ {#if panelMetrics.nodesOnline != null} +
+ {at("stats_panel_nodes_online", {}, "")} + {panelMetrics.nodesOnline} +
+ {/if} + {#if panelMetrics.memPct != null} +
+ {at("stats_panel_memory", {}, "")} + {panelMetrics.memPct.toFixed(1)}% +
+ {/if} +
+ {#if panelBw && (panelBw.week != null || panelBw.month != null)} +
+ {#if panelBw.week != null} +
+
{at("stats_panel_bw_week", {}, "")}
+
{panelBw.week}
+
+ {/if} + {#if panelBw.month != null} +
+
{at("stats_panel_bw_month", {}, "")}
+
{panelBw.month}
+
+ {/if} +
+ {/if} +
+
{/if} + + + +
+ {at("stats_sync_label", {}, "")}: + {stats.panel_sync?.status ?? "—"}{#if stats.panel_sync?.last_sync_time} + · {at("stats_sync_last", {}, "")}: {fmtDateShort(stats.panel_sync.last_sync_time)}{/if} + {#if stats.panel_sync && (stats.panel_sync.users_processed > 0 || stats.panel_sync.subscriptions_synced > 0)} + {at("stats_sync_processed", { users: stats.panel_sync.users_processed, subs: stats.panel_sync.subscriptions_synced }, "")} + {/if} + {#if stats.queue} + {at("stats_label_queue", {}, "")}: + {stats.queue.user_queue_size ?? 0}{at("stats_queue_users", {}, "")}, {stats.queue.group_queue_size ?? 0}{at("stats_queue_groups", {}, "")} + {/if} +
+
+
+ + + + {at("stats_recent_payments", {}, "")} + {at("stats_records_count", { count: (stats.recent_payments || []).length }, "")} + + +
+ {#if statsLoading} + + + + + + + + + + + + + {#each Array(5) as _, r (r)} + + + + + + + + + {/each} + + + {:else if (stats.recent_payments || []).length} + + + + + + + + + + + + + {#each stats.recent_payments as p} + + + + + + + + + {/each} + +
{at("id", {}, "")}{at("user", {}, "")}{at("amount", {}, "")}{at("provider", {}, "")}{at("status", {}, "")}{at("date", {}, "")}
#{p.payment_id}{p.user_label || p.user_id}{fmtMoney(p.amount, p.currency)}{p.provider} + {p.status} + {fmtDate(p.created_at)}
+ {:else} +
{at("no_data", {}, "")}
+ {/if} +
+
+
{/if} diff --git a/bot/app/web/frontend/src/lib/components/shadcn/badge.svelte b/bot/app/web/frontend/src/lib/components/shadcn/badge.svelte new file mode 100644 index 0000000..b8b0a39 --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/shadcn/badge.svelte @@ -0,0 +1,22 @@ + + + + + diff --git a/bot/app/web/frontend/src/lib/components/shadcn/card/card-action.svelte b/bot/app/web/frontend/src/lib/components/shadcn/card/card-action.svelte new file mode 100644 index 0000000..9c12751 --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/shadcn/card/card-action.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/bot/app/web/frontend/src/lib/components/shadcn/card/card-content.svelte b/bot/app/web/frontend/src/lib/components/shadcn/card/card-content.svelte new file mode 100644 index 0000000..2882864 --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/shadcn/card/card-content.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/bot/app/web/frontend/src/lib/components/shadcn/card/card-description.svelte b/bot/app/web/frontend/src/lib/components/shadcn/card/card-description.svelte new file mode 100644 index 0000000..d34074d --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/shadcn/card/card-description.svelte @@ -0,0 +1,10 @@ + + +

+ +

diff --git a/bot/app/web/frontend/src/lib/components/shadcn/card/card-footer.svelte b/bot/app/web/frontend/src/lib/components/shadcn/card/card-footer.svelte new file mode 100644 index 0000000..584a1fc --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/shadcn/card/card-footer.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/bot/app/web/frontend/src/lib/components/shadcn/card/card-header.svelte b/bot/app/web/frontend/src/lib/components/shadcn/card/card-header.svelte new file mode 100644 index 0000000..18773d1 --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/shadcn/card/card-header.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/bot/app/web/frontend/src/lib/components/shadcn/card/card-title.svelte b/bot/app/web/frontend/src/lib/components/shadcn/card/card-title.svelte new file mode 100644 index 0000000..df51521 --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/shadcn/card/card-title.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/bot/app/web/frontend/src/lib/components/shadcn/card/card.svelte b/bot/app/web/frontend/src/lib/components/shadcn/card/card.svelte new file mode 100644 index 0000000..0fa1d8f --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/shadcn/card/card.svelte @@ -0,0 +1,10 @@ + + +
+ +
diff --git a/bot/app/web/frontend/src/lib/components/shadcn/card/index.js b/bot/app/web/frontend/src/lib/components/shadcn/card/index.js new file mode 100644 index 0000000..9e5ab21 --- /dev/null +++ b/bot/app/web/frontend/src/lib/components/shadcn/card/index.js @@ -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 }; diff --git a/bot/app/web/frontend/src/styles/admin.css b/bot/app/web/frontend/src/styles/admin.css index e324d6d..e7b0de3 100644 --- a/bot/app/web/frontend/src/styles/admin.css +++ b/bot/app/web/frontend/src/styles/admin.css @@ -412,6 +412,430 @@ font-size: 12px; } +/* shadcn-svelte–style 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 { display: flex; flex-wrap: wrap; @@ -1225,12 +1649,31 @@ .admin-dialog { 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; -webkit-overflow-scrolling: touch; 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 sidebar (profile facts) + main content (tabs). On mobile it stacks. */ diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index 0ae6ec3..e63342c 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -2,7 +2,7 @@ import logging from typing import Optional, List, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession 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 db.models import Payment, User @@ -176,6 +176,41 @@ async def update_provider_payment_and_status( 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]: """Get comprehensive financial statistics.""" from datetime import datetime, timedelta @@ -230,13 +265,16 @@ async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]: ) today_count = await session.execute(stmt_count_today) today_payments_count = today_count.scalar() or 0 - + + daily_series = await _daily_revenue_series_utc(session, days=14) + return { "today_revenue": float(today_amount), "week_revenue": float(week_amount), "month_revenue": float(month_amount), "all_time_revenue": float(all_amount), - "today_payments_count": today_payments_count + "today_payments_count": today_payments_count, + "daily_series": daily_series, } diff --git a/locales/en.json b/locales/en.json index ff3f723..3090f75 100644 --- a/locales/en.json +++ b/locales/en.json @@ -805,7 +805,7 @@ "admin_nav_tariffs": "Tariffs", "admin_nav_settings": "Settings", "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_subtitle": "Search, bans, and account actions", "admin_section_payments_title": "Payments", @@ -900,11 +900,52 @@ "admin_stats_label_week": "This Week", "admin_stats_trend_month": "Month: {value}", "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_trend_groups": "Groups: {count}", "admin_stats_recent_payments": "Recent Payments", "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_status_default": "Default", "admin_tariff_squads": "Squads", @@ -1013,7 +1054,7 @@ "admin_nav_tariffs": "Tariffs", "admin_nav_settings": "Settings", "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_subtitle": "Search, bans, and account actions", "admin_section_payments_title": "Payments", diff --git a/locales/ru.json b/locales/ru.json index 6b13470..7fd34cf 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -805,7 +805,7 @@ "admin_nav_tariffs": "Тарифы", "admin_nav_settings": "Настройки", "admin_section_stats_title": "Дашборд", - "admin_section_stats_subtitle": "Сводка по магазину и панели", + "admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи", "admin_section_users_title": "Пользователи", "admin_section_users_subtitle": "Поиск, баны, действия над аккаунтами", "admin_section_payments_title": "Платежи", @@ -900,11 +900,52 @@ "admin_stats_label_week": "За неделю", "admin_stats_trend_month": "Месяц: {value}", "admin_stats_label_all_time": "Всё время", - "admin_stats_sync_label": "Sync", + "admin_stats_sync_label": "Синхронизация", "admin_stats_label_queue": "Очередь", "admin_stats_trend_groups": "Группы: {count}", "admin_stats_recent_payments": "Последние платежи", "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_status_default": "По умолчанию", "admin_tariff_squads": "Squads", @@ -1013,7 +1054,7 @@ "admin_nav_tariffs": "Тарифы", "admin_nav_settings": "Настройки", "admin_section_stats_title": "Дашборд", - "admin_section_stats_subtitle": "Сводка по магазину и панели", + "admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи", "admin_section_users_title": "Пользователи", "admin_section_users_subtitle": "Поиск, баны и действия над аккаунтами", "admin_section_payments_title": "Платежи",