feat: add enrichment columns to admin traffic usage table

Add 5 new columns (connected devices, spending, start/end dates, last node)
with progressive loading shimmer placeholders and 4-language translations.
This commit is contained in:
Fringg
2026-02-08 21:49:45 +03:00
parent 2dfa520604
commit 893c69ab6f
6 changed files with 192 additions and 7 deletions

View File

@@ -35,6 +35,18 @@ export interface ExportCsvResponse {
message: string;
}
export interface TrafficEnrichmentData {
devices_connected: number;
total_spent_kopeks: number;
subscription_start_date: string | null;
subscription_end_date: string | null;
last_node_name: string | null;
}
export interface TrafficEnrichmentResponse {
data: Record<number, TrafficEnrichmentData>;
}
export type TrafficParams = {
period?: number;
limit?: number;
@@ -69,6 +81,11 @@ function buildCacheKey(params: TrafficParams): string {
});
}
const enrichmentCache: { data: TrafficEnrichmentResponse | null; timestamp: number } = {
data: null,
timestamp: 0,
};
export const adminTrafficApi = {
getTrafficUsage: async (
params: TrafficParams,
@@ -104,6 +121,22 @@ export const adminTrafficApi = {
trafficCache.clear();
},
getEnrichment: async (options?: { skipCache?: boolean }): Promise<TrafficEnrichmentResponse> => {
if (
!options?.skipCache &&
enrichmentCache.data &&
Date.now() - enrichmentCache.timestamp < CACHE_TTL
) {
return enrichmentCache.data;
}
const response = await apiClient.get('/cabinet/admin/traffic/enrichment');
const data: TrafficEnrichmentResponse = response.data;
enrichmentCache.data = data;
enrichmentCache.timestamp = Date.now();
return data;
},
exportCsv: async (data: {
period: number;
start_date?: string;

View File

@@ -822,7 +822,12 @@
"riskLow": "Low",
"riskMedium": "Medium",
"riskHigh": "High",
"riskCritical": "Critical"
"riskCritical": "Critical",
"connected": "Conn.",
"totalSpent": "Spent",
"subStart": "Start",
"subEnd": "End",
"lastNode": "Last Node"
},
"emailTemplates": {
"title": "Email Templates",

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"loading": "در حال بارگذاری...",
"all": "همه",
@@ -703,7 +703,12 @@
"riskLow": "کم",
"riskMedium": "متوسط",
"riskHigh": "بالا",
"riskCritical": "بحرانی"
"riskCritical": "بحرانی",
"connected": "متصل",
"totalSpent": "هزینه",
"subStart": "شروع",
"subEnd": "پایان",
"lastNode": "آخرین نود"
},
"emailTemplates": {
"title": "قالب‌های ایمیل",

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"all": "Все",
"loading": "Загрузка...",
@@ -843,7 +843,12 @@
"riskLow": "Низкий",
"riskMedium": "Средний",
"riskHigh": "Высокий",
"riskCritical": "Критический"
"riskCritical": "Критический",
"connected": "Подкл.",
"totalSpent": "Траты",
"subStart": "Начало",
"subEnd": "Конец",
"lastNode": "Посл. нода"
},
"emailTemplates": {
"title": "Email-шаблоны",

View File

@@ -1,4 +1,4 @@
{
{
"common": {
"loading": "加载中...",
"error": "错误",
@@ -703,7 +703,12 @@
"riskLow": "低",
"riskMedium": "中",
"riskHigh": "高",
"riskCritical": "严重"
"riskCritical": "严重",
"connected": "连接",
"totalSpent": "消费",
"subStart": "开始",
"subEnd": "结束",
"lastNode": "最近节点"
},
"paymentMethods": {
"title": "支付方法",

View File

@@ -15,6 +15,7 @@ import {
type TrafficNodeInfo,
type TrafficUsageResponse,
type TrafficParams,
type TrafficEnrichmentData,
} from '../api/adminTraffic';
import { usePlatform } from '../platform/hooks/usePlatform';
@@ -48,6 +49,21 @@ const getFlagEmoji = (countryCode: string): string => {
return String.fromCodePoint(...codePoints);
};
const formatCurrency = (kopeks: number): string => {
const rubles = kopeks / 100;
if (rubles === 0) return '0';
if (rubles < 10) return rubles.toFixed(2);
if (rubles < 1000) return Math.round(rubles).toString();
return `${(rubles / 1000).toFixed(1)}k`;
};
const formatShortDate = (iso: string | null): string => {
if (!iso) return '\u2014';
const d = new Date(iso);
if (isNaN(d.getTime())) return '\u2014';
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getFullYear()).slice(2)}`;
};
const toBackendSortField = (columnId: string): string => {
if (columnId === 'user') return 'full_name';
return columnId;
@@ -1049,6 +1065,8 @@ export default function AdminTrafficUsage() {
const [selectedCountries, setSelectedCountries] = useState<Set<string>>(new Set());
const [offset, setOffset] = useState(0);
const [total, setTotal] = useState(0);
const [enrichment, setEnrichment] = useState<Record<number, TrafficEnrichmentData> | null>(null);
const [enrichmentLoading, setEnrichmentLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]);
@@ -1158,6 +1176,27 @@ export default function AdminTrafficUsage() {
loadData();
}, [loadData]);
// Load enrichment after main data arrives
useEffect(() => {
if (initialLoading || items.length === 0) return;
let cancelled = false;
const load = async () => {
setEnrichmentLoading(true);
try {
const res = await adminTrafficApi.getEnrichment();
if (!cancelled) setEnrichment(res.data);
} catch {
// silently fail — enrichment is optional
} finally {
if (!cancelled) setEnrichmentLoading(false);
}
};
load();
return () => {
cancelled = true;
};
}, [initialLoading, items.length]);
// Prefetch adjacent periods in background (only in period mode)
useEffect(() => {
if (dateMode) return;
@@ -1286,6 +1325,13 @@ export default function AdminTrafficUsage() {
const handleRefresh = () => {
loadData(true);
setEnrichment(null);
setEnrichmentLoading(true);
adminTrafficApi
.getEnrichment({ skipCache: true })
.then((res) => setEnrichment(res.data))
.catch(() => {})
.finally(() => setEnrichmentLoading(false));
};
const availableCountries = useMemo(() => {
@@ -1381,6 +1427,90 @@ export default function AdminTrafficUsage() {
return <span className="text-xs text-dark-300">{gb > 0 ? `${gb} GB` : '\u221E'}</span>;
},
},
// ---- Enrichment columns ----
{
id: 'connected',
header: t('admin.trafficUsage.connected'),
size: 65,
minSize: 50,
enableSorting: false,
meta: { align: 'center' as const },
cell: ({ row }) => {
const e = enrichment?.[row.original.user_id];
if (enrichmentLoading && !enrichment)
return <div className="mx-auto h-4 w-8 animate-pulse rounded bg-dark-700" />;
return <span className="text-xs text-dark-300">{e?.devices_connected ?? '\u2014'}</span>;
},
},
{
id: 'total_spent',
header: t('admin.trafficUsage.totalSpent'),
size: 75,
minSize: 55,
enableSorting: false,
meta: { align: 'center' as const },
cell: ({ row }) => {
const e = enrichment?.[row.original.user_id];
if (enrichmentLoading && !enrichment)
return <div className="mx-auto h-4 w-12 animate-pulse rounded bg-dark-700" />;
if (!e || e.total_spent_kopeks === 0)
return <span className="text-xs text-dark-300">{'\u2014'}</span>;
return (
<span className="text-xs text-dark-300">{formatCurrency(e.total_spent_kopeks)}</span>
);
},
},
{
id: 'sub_start',
header: t('admin.trafficUsage.subStart'),
size: 80,
minSize: 65,
enableSorting: false,
meta: { align: 'center' as const },
cell: ({ row }) => {
const e = enrichment?.[row.original.user_id];
if (enrichmentLoading && !enrichment)
return <div className="mx-auto h-4 w-14 animate-pulse rounded bg-dark-700" />;
return (
<span className="text-xs text-dark-300">
{formatShortDate(e?.subscription_start_date ?? null)}
</span>
);
},
},
{
id: 'sub_end',
header: t('admin.trafficUsage.subEnd'),
size: 80,
minSize: 65,
enableSorting: false,
meta: { align: 'center' as const },
cell: ({ row }) => {
const e = enrichment?.[row.original.user_id];
if (enrichmentLoading && !enrichment)
return <div className="mx-auto h-4 w-14 animate-pulse rounded bg-dark-700" />;
return (
<span className="text-xs text-dark-300">
{formatShortDate(e?.subscription_end_date ?? null)}
</span>
);
},
},
{
id: 'last_node',
header: t('admin.trafficUsage.lastNode'),
size: 100,
minSize: 70,
enableSorting: false,
meta: { align: 'center' as const },
cell: ({ row }) => {
const e = enrichment?.[row.original.user_id];
if (enrichmentLoading && !enrichment)
return <div className="mx-auto h-4 w-16 animate-pulse rounded bg-dark-700" />;
return <span className="text-xs text-dark-300">{e?.last_node_name ?? '\u2014'}</span>;
},
},
// ---- Dynamic node columns ----
...displayNodes.map(
(node): ColumnDef<UserTrafficItem> => ({
id: `node_${node.node_uuid}`,
@@ -1486,6 +1616,8 @@ export default function AdminTrafficUsage() {
totalThresholdNum,
nodeThresholdNum,
periodDays,
enrichment,
enrichmentLoading,
]);
const table = useReactTable({