From 893c69ab6fc05ddc4bb64d229ae20376471a4f07 Mon Sep 17 00:00:00 2001 From: Fringg Date: Sun, 8 Feb 2026 21:49:45 +0300 Subject: [PATCH] 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. --- src/api/adminTraffic.ts | 33 ++++++++ src/locales/en.json | 7 +- src/locales/fa.json | 9 ++- src/locales/ru.json | 9 ++- src/locales/zh.json | 9 ++- src/pages/AdminTrafficUsage.tsx | 132 ++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 7 deletions(-) diff --git a/src/api/adminTraffic.ts b/src/api/adminTraffic.ts index 8af4dcd..c8e4f7e 100644 --- a/src/api/adminTraffic.ts +++ b/src/api/adminTraffic.ts @@ -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; +} + 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 => { + 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; diff --git a/src/locales/en.json b/src/locales/en.json index 7ccfa11..bef4532 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index 0e56e32..a010f2c 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "در حال بارگذاری...", "all": "همه", @@ -703,7 +703,12 @@ "riskLow": "کم", "riskMedium": "متوسط", "riskHigh": "بالا", - "riskCritical": "بحرانی" + "riskCritical": "بحرانی", + "connected": "متصل", + "totalSpent": "هزینه", + "subStart": "شروع", + "subEnd": "پایان", + "lastNode": "آخرین نود" }, "emailTemplates": { "title": "قالب‌های ایمیل", diff --git a/src/locales/ru.json b/src/locales/ru.json index f69ed6e..490a61b 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1,4 +1,4 @@ -{ +{ "common": { "all": "Все", "loading": "Загрузка...", @@ -843,7 +843,12 @@ "riskLow": "Низкий", "riskMedium": "Средний", "riskHigh": "Высокий", - "riskCritical": "Критический" + "riskCritical": "Критический", + "connected": "Подкл.", + "totalSpent": "Траты", + "subStart": "Начало", + "subEnd": "Конец", + "lastNode": "Посл. нода" }, "emailTemplates": { "title": "Email-шаблоны", diff --git a/src/locales/zh.json b/src/locales/zh.json index a05d235..5a33f00 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1,4 +1,4 @@ -{ +{ "common": { "loading": "加载中...", "error": "错误", @@ -703,7 +703,12 @@ "riskLow": "低", "riskMedium": "中", "riskHigh": "高", - "riskCritical": "严重" + "riskCritical": "严重", + "connected": "连接", + "totalSpent": "消费", + "subStart": "开始", + "subEnd": "结束", + "lastNode": "最近节点" }, "paymentMethods": { "title": "支付方法", diff --git a/src/pages/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 33ff729..e8cb26b 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -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>(new Set()); const [offset, setOffset] = useState(0); const [total, setTotal] = useState(0); + const [enrichment, setEnrichment] = useState | 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([{ 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 {gb > 0 ? `${gb} GB` : '\u221E'}; }, }, + // ---- 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
; + return {e?.devices_connected ?? '\u2014'}; + }, + }, + { + 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
; + if (!e || e.total_spent_kopeks === 0) + return {'\u2014'}; + return ( + {formatCurrency(e.total_spent_kopeks)} + ); + }, + }, + { + 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
; + return ( + + {formatShortDate(e?.subscription_start_date ?? null)} + + ); + }, + }, + { + 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
; + return ( + + {formatShortDate(e?.subscription_end_date ?? null)} + + ); + }, + }, + { + 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
; + return {e?.last_node_name ?? '\u2014'}; + }, + }, + // ---- Dynamic node columns ---- ...displayNodes.map( (node): ColumnDef => ({ id: `node_${node.node_uuid}`, @@ -1486,6 +1616,8 @@ export default function AdminTrafficUsage() { totalThresholdNum, nodeThresholdNum, periodDays, + enrichment, + enrichmentLoading, ]); const table = useReactTable({