feat/admin-portal #17
@@ -27,6 +27,11 @@ model Session {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
|
country String?
|
||||||
|
city String?
|
||||||
|
countryCode String?
|
||||||
|
latitude Float?
|
||||||
|
longitude Float?
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "sessions" ADD COLUMN "city" TEXT,
|
||||||
|
ADD COLUMN "country" TEXT,
|
||||||
|
ADD COLUMN "countryCode" TEXT,
|
||||||
|
ADD COLUMN "latitude" DOUBLE PRECISION,
|
||||||
|
ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||||
@@ -148,6 +148,33 @@ export type DateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type FloatNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
export type UuidFilter<$PrismaModel = never> = {
|
export type UuidFilter<$PrismaModel = never> = {
|
||||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
@@ -495,6 +522,33 @@ export type NestedDateTimeWithAggregatesFilter<$PrismaModel = never> = {
|
|||||||
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
_max?: Prisma.NestedDateTimeFilter<$PrismaModel>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type NestedFloatNullableFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
|
||||||
|
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
|
||||||
|
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
|
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
|
||||||
|
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
|
||||||
|
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
|
||||||
|
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
|
||||||
|
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
|
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
|
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
|
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
|
||||||
|
}
|
||||||
|
|
||||||
export type NestedUuidFilter<$PrismaModel = never> = {
|
export type NestedUuidFilter<$PrismaModel = never> = {
|
||||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1668,6 +1668,11 @@ export const SessionScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
ipAddress: 'ipAddress',
|
ipAddress: 'ipAddress',
|
||||||
userAgent: 'userAgent',
|
userAgent: 'userAgent',
|
||||||
|
country: 'country',
|
||||||
|
city: 'city',
|
||||||
|
countryCode: 'countryCode',
|
||||||
|
latitude: 'latitude',
|
||||||
|
longitude: 'longitude',
|
||||||
userId: 'userId'
|
userId: 'userId'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -1960,6 +1965,20 @@ export type ListDateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaM
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to a field of type 'Float'
|
||||||
|
*/
|
||||||
|
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference to a field of type 'Float[]'
|
||||||
|
*/
|
||||||
|
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reference to a field of type 'ComplexUserRole'
|
* Reference to a field of type 'ComplexUserRole'
|
||||||
*/
|
*/
|
||||||
@@ -2043,20 +2062,6 @@ export type JsonFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'J
|
|||||||
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
|
export type EnumQueryModeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'QueryMode'>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reference to a field of type 'Float'
|
|
||||||
*/
|
|
||||||
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reference to a field of type 'Float[]'
|
|
||||||
*/
|
|
||||||
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch Payload for updateMany & deleteMany & createMany
|
* Batch Payload for updateMany & deleteMany & createMany
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ export const SessionScalarFieldEnum = {
|
|||||||
updatedAt: 'updatedAt',
|
updatedAt: 'updatedAt',
|
||||||
ipAddress: 'ipAddress',
|
ipAddress: 'ipAddress',
|
||||||
userAgent: 'userAgent',
|
userAgent: 'userAgent',
|
||||||
|
country: 'country',
|
||||||
|
city: 'city',
|
||||||
|
countryCode: 'countryCode',
|
||||||
|
latitude: 'latitude',
|
||||||
|
longitude: 'longitude',
|
||||||
userId: 'userId'
|
userId: 'userId'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ type IpGeoInfo = {
|
|||||||
city: string;
|
city: string;
|
||||||
country: string;
|
country: string;
|
||||||
countryCode: string;
|
countryCode: string;
|
||||||
|
lat: number | null;
|
||||||
|
lon: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cache = new Map<string, { data: IpGeoInfo; expiresAt: number }>();
|
const cache = new Map<string, { data: IpGeoInfo; expiresAt: number }>();
|
||||||
@@ -24,7 +26,14 @@ function setCache(ip: string, info: IpGeoInfo): void {
|
|||||||
|
|
||||||
async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
||||||
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
if (ip === '127.0.0.1' || ip === '::1' || ip.startsWith('192.168.') || ip.startsWith('10.')) {
|
||||||
return { ip, city: 'Red local', country: 'Red local', countryCode: 'LOCAL' };
|
return {
|
||||||
|
ip,
|
||||||
|
city: 'Red local',
|
||||||
|
country: 'Red local',
|
||||||
|
countryCode: 'LOCAL',
|
||||||
|
lat: null,
|
||||||
|
lon: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const cached = getCached(ip);
|
const cached = getCached(ip);
|
||||||
@@ -32,7 +41,7 @@ async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,query`
|
`http://ip-api.com/json/${ip}?fields=status,country,countryCode,city,lat,lon,query`
|
||||||
);
|
);
|
||||||
if (!response.ok) return null;
|
if (!response.ok) return null;
|
||||||
|
|
||||||
@@ -44,6 +53,8 @@ async function fetchGeoInfo(ip: string): Promise<IpGeoInfo | null> {
|
|||||||
city: data.city || 'Desconocida',
|
city: data.city || 'Desconocida',
|
||||||
country: data.country || 'Desconocido',
|
country: data.country || 'Desconocido',
|
||||||
countryCode: data.countryCode || '',
|
countryCode: data.countryCode || '',
|
||||||
|
lat: data.lat ?? null,
|
||||||
|
lon: data.lon ?? null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setCache(ip, info);
|
setCache(ip, info);
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { auth } from '@/lib/auth';
|
import { auth } from '@/lib/auth';
|
||||||
|
import { fetchGeoInfo } from '@/lib/geoip';
|
||||||
|
import { db } from '@/lib/prisma';
|
||||||
import type { AppEnv } from '@/types/hono';
|
import type { AppEnv } from '@/types/hono';
|
||||||
import { createMiddleware } from 'hono/factory';
|
import { createMiddleware } from 'hono/factory';
|
||||||
|
|
||||||
|
type SessionWithGeo = {
|
||||||
|
id: string;
|
||||||
|
ipAddress: string | null;
|
||||||
|
userAgent: string | null;
|
||||||
|
country: string | null;
|
||||||
|
city: string | null;
|
||||||
|
countryCode: string | null;
|
||||||
|
latitude: number | null;
|
||||||
|
longitude: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
|
export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: c.req.raw.headers,
|
headers: c.req.raw.headers,
|
||||||
@@ -23,8 +36,33 @@ export const requireAuth = createMiddleware<AppEnv>(async (c, next) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const s = session.session as unknown as SessionWithGeo;
|
||||||
|
|
||||||
c.set('user', session.user);
|
c.set('user', session.user);
|
||||||
c.set('session', session.session);
|
c.set('session', session.session);
|
||||||
|
|
||||||
|
if (s.ipAddress && !s.country) {
|
||||||
|
resolveSessionGeo(s.id, s.ipAddress);
|
||||||
|
}
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function resolveSessionGeo(sessionId: string, ipAddress: string) {
|
||||||
|
try {
|
||||||
|
const geo = await fetchGeoInfo(ipAddress);
|
||||||
|
if (!geo) return;
|
||||||
|
await db.session.update({
|
||||||
|
where: { id: sessionId },
|
||||||
|
data: {
|
||||||
|
country: geo.country,
|
||||||
|
city: geo.city,
|
||||||
|
countryCode: geo.countryCode,
|
||||||
|
latitude: geo.lat,
|
||||||
|
longitude: geo.lon,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Silently fail — geo enrichment is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { requireSuperAdmin } from '@/middlewares/require-super-admin.middleware'
|
|||||||
import { blockUserHandler } from '@/modules/admin/handlers/block-user.handler';
|
import { blockUserHandler } from '@/modules/admin/handlers/block-user.handler';
|
||||||
import { createPlanHandler } from '@/modules/admin/handlers/create-plan.handler';
|
import { createPlanHandler } from '@/modules/admin/handlers/create-plan.handler';
|
||||||
import { deletePlanHandler } from '@/modules/admin/handlers/delete-plan.handler';
|
import { deletePlanHandler } from '@/modules/admin/handlers/delete-plan.handler';
|
||||||
|
import { getGeoStatsHandler } from '@/modules/admin/handlers/get-geo-stats.handler';
|
||||||
import { getUserSessionsHandler } from '@/modules/admin/handlers/get-user-sessions.handler';
|
import { getUserSessionsHandler } from '@/modules/admin/handlers/get-user-sessions.handler';
|
||||||
import { listComplexesHandler } from '@/modules/admin/handlers/list-complexes.handler';
|
import { listComplexesHandler } from '@/modules/admin/handlers/list-complexes.handler';
|
||||||
import { listPlansAdminHandler } from '@/modules/admin/handlers/list-plans-admin.handler';
|
import { listPlansAdminHandler } from '@/modules/admin/handlers/list-plans-admin.handler';
|
||||||
@@ -40,6 +41,8 @@ adminRoutes.delete(
|
|||||||
deletePlanHandler
|
deletePlanHandler
|
||||||
);
|
);
|
||||||
|
|
||||||
|
adminRoutes.get('/geo-stats', getGeoStatsHandler);
|
||||||
|
|
||||||
adminRoutes.get('/users', listUsersHandler);
|
adminRoutes.get('/users', listUsersHandler);
|
||||||
adminRoutes.post(
|
adminRoutes.post(
|
||||||
'/users/:id/block',
|
'/users/:id/block',
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { getGeoStats } from '@/modules/admin/services/admin-geo.service';
|
||||||
|
import type { AppEnv } from '@/types/hono';
|
||||||
|
import type { Handler } from 'hono';
|
||||||
|
|
||||||
|
export const getGeoStatsHandler: Handler<AppEnv> = async (c) => {
|
||||||
|
const stats = await getGeoStats();
|
||||||
|
return c.json(stats);
|
||||||
|
};
|
||||||
67
apps/backend/src/modules/admin/services/admin-geo.service.ts
Normal file
67
apps/backend/src/modules/admin/services/admin-geo.service.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { db } from '@/lib/prisma';
|
||||||
|
|
||||||
|
type GeoStatsCountry = {
|
||||||
|
country: string;
|
||||||
|
countryCode: string;
|
||||||
|
totalUsers: number;
|
||||||
|
cities: { city: string; userCount: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getGeoStats(): Promise<{
|
||||||
|
countries: GeoStatsCountry[];
|
||||||
|
totalUniqueCountries: number;
|
||||||
|
}> {
|
||||||
|
const sessions = await db.session.findMany({
|
||||||
|
where: {
|
||||||
|
country: { not: null },
|
||||||
|
expiresAt: { gt: new Date() },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
country: true,
|
||||||
|
countryCode: true,
|
||||||
|
city: true,
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const countryMap = new Map<
|
||||||
|
string,
|
||||||
|
{ country: string; countryCode: string; users: Set<string>; cities: Map<string, Set<string>> }
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const s of sessions) {
|
||||||
|
const code = s.countryCode || 'XX';
|
||||||
|
let entry = countryMap.get(code);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {
|
||||||
|
country: s.country || 'Desconocido',
|
||||||
|
countryCode: code,
|
||||||
|
users: new Set(),
|
||||||
|
cities: new Map(),
|
||||||
|
};
|
||||||
|
countryMap.set(code, entry);
|
||||||
|
}
|
||||||
|
entry.users.add(s.userId);
|
||||||
|
|
||||||
|
const cityName = s.city || 'Desconocida';
|
||||||
|
let cityUsers = entry.cities.get(cityName);
|
||||||
|
if (!cityUsers) {
|
||||||
|
cityUsers = new Set();
|
||||||
|
entry.cities.set(cityName, cityUsers);
|
||||||
|
}
|
||||||
|
cityUsers.add(s.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const countries = Array.from(countryMap.values())
|
||||||
|
.map((entry) => ({
|
||||||
|
country: entry.country,
|
||||||
|
countryCode: entry.countryCode,
|
||||||
|
totalUsers: entry.users.size,
|
||||||
|
cities: Array.from(entry.cities.entries())
|
||||||
|
.map(([city, users]) => ({ city, userCount: users.size }))
|
||||||
|
.sort((a, b) => b.userCount - a.userCount),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.totalUsers - a.totalUsers);
|
||||||
|
|
||||||
|
return { countries, totalUniqueCountries: countries.length };
|
||||||
|
}
|
||||||
@@ -1,11 +1,30 @@
|
|||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link, useLocation } from '@tanstack/react-router';
|
import { Link, useLocation } from '@tanstack/react-router';
|
||||||
import { Building2, CalendarDays, Crosshair, Users } from 'lucide-react';
|
import {
|
||||||
|
Building2,
|
||||||
|
CalendarDays,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Crosshair,
|
||||||
|
Globe,
|
||||||
|
Users,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Fragment, useState } from 'react';
|
||||||
import { AdminLayout } from './admin-layout';
|
import { AdminLayout } from './admin-layout';
|
||||||
|
|
||||||
|
function countryFlag(countryCode: string): string {
|
||||||
|
if (countryCode === 'LOCAL' || countryCode === 'XX') return '';
|
||||||
|
const codePoints = countryCode
|
||||||
|
.toUpperCase()
|
||||||
|
.split('')
|
||||||
|
.map((c) => 0x1f1e6 + c.charCodeAt(0) - 65);
|
||||||
|
return String.fromCodePoint(...codePoints);
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminPage() {
|
export function AdminPage() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [expandedCountry, setExpandedCountry] = useState<string | null>(null);
|
||||||
|
|
||||||
const complexesQuery = useQuery({
|
const complexesQuery = useQuery({
|
||||||
queryKey: ['admin-complexes'],
|
queryKey: ['admin-complexes'],
|
||||||
@@ -17,6 +36,11 @@ export function AdminPage() {
|
|||||||
queryFn: () => apiClient.admin.listUsers(),
|
queryFn: () => apiClient.admin.listUsers(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const geoQuery = useQuery({
|
||||||
|
queryKey: ['admin-geo-stats'],
|
||||||
|
queryFn: () => apiClient.admin.getGeoStats(),
|
||||||
|
});
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
totalComplexes: complexesQuery.data?.length ?? 0,
|
totalComplexes: complexesQuery.data?.length ?? 0,
|
||||||
totalUsers: usersQuery.data?.length ?? 0,
|
totalUsers: usersQuery.data?.length ?? 0,
|
||||||
@@ -125,6 +149,106 @@ export function AdminPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold">Usuarios por país / ciudad</h2>
|
||||||
|
|
||||||
|
{geoQuery.isLoading && <p className="text-sm text-muted-foreground">Cargando...</p>}
|
||||||
|
|
||||||
|
{geoQuery.data && geoQuery.data.countries.length === 0 && (
|
||||||
|
<div className="rounded-2xl border border-border/60 bg-card p-8 text-center shadow-sm">
|
||||||
|
<Globe className="mx-auto mb-2 size-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Aún no hay datos de geolocalización. Aparecerán a medida que los usuarios inicien
|
||||||
|
sesión.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{geoQuery.data && geoQuery.data.countries.length > 0 && (
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border/60 bg-card shadow-sm">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border/60">
|
||||||
|
<th className="w-8 px-4 py-3" />
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-muted-foreground">País</th>
|
||||||
|
<th className="px-4 py-3 text-right font-medium text-muted-foreground">
|
||||||
|
Usuarios
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{geoQuery.data.countries.map((country) => {
|
||||||
|
const isExpanded = expandedCountry === country.countryCode;
|
||||||
|
const flag = countryFlag(country.countryCode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={country.countryCode}>
|
||||||
|
<tr
|
||||||
|
className={`cursor-pointer border-b border-border/40 transition-colors hover:bg-accent/50 ${isExpanded ? 'bg-accent/30' : ''}`}
|
||||||
|
onClick={() => setExpandedCountry(isExpanded ? null : country.countryCode)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
setExpandedCountry(isExpanded ? null : country.countryCode);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{country.cities.length > 1 ||
|
||||||
|
country.cities[0]?.city !== country.country ? (
|
||||||
|
<button type="button" className="text-muted-foreground">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="size-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="inline-block w-4" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="mr-2">{flag}</span>
|
||||||
|
{country.country}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium">{country.totalUsers}</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="bg-accent/20 p-0">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{country.cities.map((city) => (
|
||||||
|
<tr key={city.city} className="border-b border-border/20">
|
||||||
|
<td className="w-8 px-4 py-2" />
|
||||||
|
<td className="px-4 py-2 text-muted-foreground">{city.city}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium">
|
||||||
|
{city.userCount}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div className="border-t border-border/40 px-4 py-2 text-xs text-muted-foreground">
|
||||||
|
{geoQuery.data.totalUniqueCountries}{' '}
|
||||||
|
{geoQuery.data.totalUniqueCountries === 1 ? 'país' : 'países'} · solo sesiones activas
|
||||||
|
con geolocalización resuelta
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type {
|
|||||||
AdminBlockUserInput,
|
AdminBlockUserInput,
|
||||||
AdminComplexListItem,
|
AdminComplexListItem,
|
||||||
AdminCreatePlanInput,
|
AdminCreatePlanInput,
|
||||||
|
AdminGeoStats,
|
||||||
AdminGlobalStats,
|
AdminGlobalStats,
|
||||||
AdminUpdatePlanInput,
|
AdminUpdatePlanInput,
|
||||||
AdminUser,
|
AdminUser,
|
||||||
@@ -77,6 +78,11 @@ export async function getGlobalStats() {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getGeoStats() {
|
||||||
|
const response = await http.get<AdminGeoStats>('/api/admin/geo-stats');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserSessions(userId: string) {
|
export async function getUserSessions(userId: string) {
|
||||||
const response = await http.get<AdminUserSession[]>(`/api/admin/users/${userId}/sessions`);
|
const response = await http.get<AdminUserSession[]>(`/api/admin/users/${userId}/sessions`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -93,3 +93,24 @@ export type AdminBlockUserInput = z.infer<typeof adminBlockUserSchema>
|
|||||||
export type AdminGlobalStats = z.infer<typeof adminGlobalStatsSchema>
|
export type AdminGlobalStats = z.infer<typeof adminGlobalStatsSchema>
|
||||||
export type AdminPaymentStatus = z.infer<typeof adminPaymentStatusSchema>
|
export type AdminPaymentStatus = z.infer<typeof adminPaymentStatusSchema>
|
||||||
export type AdminUserSession = z.infer<typeof adminUserSessionSchema>
|
export type AdminUserSession = z.infer<typeof adminUserSessionSchema>
|
||||||
|
|
||||||
|
export const adminGeoCitySchema = z.object({
|
||||||
|
city: z.string(),
|
||||||
|
userCount: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const adminGeoCountrySchema = z.object({
|
||||||
|
country: z.string(),
|
||||||
|
countryCode: z.string(),
|
||||||
|
totalUsers: z.number().int().nonnegative(),
|
||||||
|
cities: z.array(adminGeoCitySchema),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const adminGeoStatsSchema = z.object({
|
||||||
|
countries: z.array(adminGeoCountrySchema),
|
||||||
|
totalUniqueCountries: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AdminGeoCity = z.infer<typeof adminGeoCitySchema>
|
||||||
|
export type AdminGeoCountry = z.infer<typeof adminGeoCountrySchema>
|
||||||
|
export type AdminGeoStats = z.infer<typeof adminGeoStatsSchema>
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ export {
|
|||||||
adminGlobalStatsSchema,
|
adminGlobalStatsSchema,
|
||||||
adminPaymentStatusSchema,
|
adminPaymentStatusSchema,
|
||||||
adminUserSessionSchema,
|
adminUserSessionSchema,
|
||||||
|
adminGeoCitySchema,
|
||||||
|
adminGeoCountrySchema,
|
||||||
|
adminGeoStatsSchema,
|
||||||
} from './admin'
|
} from './admin'
|
||||||
export type {
|
export type {
|
||||||
AdminComplexListItem,
|
AdminComplexListItem,
|
||||||
@@ -150,4 +153,7 @@ export type {
|
|||||||
AdminGlobalStats,
|
AdminGlobalStats,
|
||||||
AdminPaymentStatus,
|
AdminPaymentStatus,
|
||||||
AdminUserSession,
|
AdminUserSession,
|
||||||
|
AdminGeoCity,
|
||||||
|
AdminGeoCountry,
|
||||||
|
AdminGeoStats,
|
||||||
} from './admin'
|
} from './admin'
|
||||||
|
|||||||
Reference in New Issue
Block a user