mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"common": {
|
||||
"loading": "در حال بارگذاری...",
|
||||
"all": "همه",
|
||||
@@ -703,7 +703,12 @@
|
||||
"riskLow": "کم",
|
||||
"riskMedium": "متوسط",
|
||||
"riskHigh": "بالا",
|
||||
"riskCritical": "بحرانی"
|
||||
"riskCritical": "بحرانی",
|
||||
"connected": "متصل",
|
||||
"totalSpent": "هزینه",
|
||||
"subStart": "شروع",
|
||||
"subEnd": "پایان",
|
||||
"lastNode": "آخرین نود"
|
||||
},
|
||||
"emailTemplates": {
|
||||
"title": "قالبهای ایمیل",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"common": {
|
||||
"all": "Все",
|
||||
"loading": "Загрузка...",
|
||||
@@ -843,7 +843,12 @@
|
||||
"riskLow": "Низкий",
|
||||
"riskMedium": "Средний",
|
||||
"riskHigh": "Высокий",
|
||||
"riskCritical": "Критический"
|
||||
"riskCritical": "Критический",
|
||||
"connected": "Подкл.",
|
||||
"totalSpent": "Траты",
|
||||
"subStart": "Начало",
|
||||
"subEnd": "Конец",
|
||||
"lastNode": "Посл. нода"
|
||||
},
|
||||
"emailTemplates": {
|
||||
"title": "Email-шаблоны",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"common": {
|
||||
"loading": "加载中...",
|
||||
"error": "错误",
|
||||
@@ -703,7 +703,12 @@
|
||||
"riskLow": "低",
|
||||
"riskMedium": "中",
|
||||
"riskHigh": "高",
|
||||
"riskCritical": "严重"
|
||||
"riskCritical": "严重",
|
||||
"connected": "连接",
|
||||
"totalSpent": "消费",
|
||||
"subStart": "开始",
|
||||
"subEnd": "结束",
|
||||
"lastNode": "最近节点"
|
||||
},
|
||||
"paymentMethods": {
|
||||
"title": "支付方法",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user