mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23: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;
|
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 = {
|
export type TrafficParams = {
|
||||||
period?: number;
|
period?: number;
|
||||||
limit?: 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 = {
|
export const adminTrafficApi = {
|
||||||
getTrafficUsage: async (
|
getTrafficUsage: async (
|
||||||
params: TrafficParams,
|
params: TrafficParams,
|
||||||
@@ -104,6 +121,22 @@ export const adminTrafficApi = {
|
|||||||
trafficCache.clear();
|
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: {
|
exportCsv: async (data: {
|
||||||
period: number;
|
period: number;
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
|
|||||||
@@ -822,7 +822,12 @@
|
|||||||
"riskLow": "Low",
|
"riskLow": "Low",
|
||||||
"riskMedium": "Medium",
|
"riskMedium": "Medium",
|
||||||
"riskHigh": "High",
|
"riskHigh": "High",
|
||||||
"riskCritical": "Critical"
|
"riskCritical": "Critical",
|
||||||
|
"connected": "Conn.",
|
||||||
|
"totalSpent": "Spent",
|
||||||
|
"subStart": "Start",
|
||||||
|
"subEnd": "End",
|
||||||
|
"lastNode": "Last Node"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "Email Templates",
|
"title": "Email Templates",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "در حال بارگذاری...",
|
"loading": "در حال بارگذاری...",
|
||||||
"all": "همه",
|
"all": "همه",
|
||||||
@@ -703,7 +703,12 @@
|
|||||||
"riskLow": "کم",
|
"riskLow": "کم",
|
||||||
"riskMedium": "متوسط",
|
"riskMedium": "متوسط",
|
||||||
"riskHigh": "بالا",
|
"riskHigh": "بالا",
|
||||||
"riskCritical": "بحرانی"
|
"riskCritical": "بحرانی",
|
||||||
|
"connected": "متصل",
|
||||||
|
"totalSpent": "هزینه",
|
||||||
|
"subStart": "شروع",
|
||||||
|
"subEnd": "پایان",
|
||||||
|
"lastNode": "آخرین نود"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "قالبهای ایمیل",
|
"title": "قالبهای ایمیل",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"all": "Все",
|
"all": "Все",
|
||||||
"loading": "Загрузка...",
|
"loading": "Загрузка...",
|
||||||
@@ -843,7 +843,12 @@
|
|||||||
"riskLow": "Низкий",
|
"riskLow": "Низкий",
|
||||||
"riskMedium": "Средний",
|
"riskMedium": "Средний",
|
||||||
"riskHigh": "Высокий",
|
"riskHigh": "Высокий",
|
||||||
"riskCritical": "Критический"
|
"riskCritical": "Критический",
|
||||||
|
"connected": "Подкл.",
|
||||||
|
"totalSpent": "Траты",
|
||||||
|
"subStart": "Начало",
|
||||||
|
"subEnd": "Конец",
|
||||||
|
"lastNode": "Посл. нода"
|
||||||
},
|
},
|
||||||
"emailTemplates": {
|
"emailTemplates": {
|
||||||
"title": "Email-шаблоны",
|
"title": "Email-шаблоны",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
"error": "错误",
|
"error": "错误",
|
||||||
@@ -703,7 +703,12 @@
|
|||||||
"riskLow": "低",
|
"riskLow": "低",
|
||||||
"riskMedium": "中",
|
"riskMedium": "中",
|
||||||
"riskHigh": "高",
|
"riskHigh": "高",
|
||||||
"riskCritical": "严重"
|
"riskCritical": "严重",
|
||||||
|
"connected": "连接",
|
||||||
|
"totalSpent": "消费",
|
||||||
|
"subStart": "开始",
|
||||||
|
"subEnd": "结束",
|
||||||
|
"lastNode": "最近节点"
|
||||||
},
|
},
|
||||||
"paymentMethods": {
|
"paymentMethods": {
|
||||||
"title": "支付方法",
|
"title": "支付方法",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type TrafficNodeInfo,
|
type TrafficNodeInfo,
|
||||||
type TrafficUsageResponse,
|
type TrafficUsageResponse,
|
||||||
type TrafficParams,
|
type TrafficParams,
|
||||||
|
type TrafficEnrichmentData,
|
||||||
} from '../api/adminTraffic';
|
} from '../api/adminTraffic';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
@@ -48,6 +49,21 @@ const getFlagEmoji = (countryCode: string): string => {
|
|||||||
return String.fromCodePoint(...codePoints);
|
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 => {
|
const toBackendSortField = (columnId: string): string => {
|
||||||
if (columnId === 'user') return 'full_name';
|
if (columnId === 'user') return 'full_name';
|
||||||
return columnId;
|
return columnId;
|
||||||
@@ -1049,6 +1065,8 @@ export default function AdminTrafficUsage() {
|
|||||||
const [selectedCountries, setSelectedCountries] = useState<Set<string>>(new Set());
|
const [selectedCountries, setSelectedCountries] = useState<Set<string>>(new Set());
|
||||||
const [offset, setOffset] = useState(0);
|
const [offset, setOffset] = useState(0);
|
||||||
const [total, setTotal] = 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 [exporting, setExporting] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||||
const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]);
|
const [sorting, setSorting] = useState<SortingState>([{ id: 'total_bytes', desc: true }]);
|
||||||
@@ -1158,6 +1176,27 @@ export default function AdminTrafficUsage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [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)
|
// Prefetch adjacent periods in background (only in period mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dateMode) return;
|
if (dateMode) return;
|
||||||
@@ -1286,6 +1325,13 @@ export default function AdminTrafficUsage() {
|
|||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
loadData(true);
|
loadData(true);
|
||||||
|
setEnrichment(null);
|
||||||
|
setEnrichmentLoading(true);
|
||||||
|
adminTrafficApi
|
||||||
|
.getEnrichment({ skipCache: true })
|
||||||
|
.then((res) => setEnrichment(res.data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setEnrichmentLoading(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const availableCountries = useMemo(() => {
|
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>;
|
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(
|
...displayNodes.map(
|
||||||
(node): ColumnDef<UserTrafficItem> => ({
|
(node): ColumnDef<UserTrafficItem> => ({
|
||||||
id: `node_${node.node_uuid}`,
|
id: `node_${node.node_uuid}`,
|
||||||
@@ -1486,6 +1616,8 @@ export default function AdminTrafficUsage() {
|
|||||||
totalThresholdNum,
|
totalThresholdNum,
|
||||||
nodeThresholdNum,
|
nodeThresholdNum,
|
||||||
periodDays,
|
periodDays,
|
||||||
|
enrichment,
|
||||||
|
enrichmentLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
Reference in New Issue
Block a user