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:
@@ -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