570 lines
20 KiB
TypeScript
570 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Box,
|
||
Flex,
|
||
Heading,
|
||
Text,
|
||
SimpleGrid,
|
||
Card,
|
||
VStack,
|
||
HStack,
|
||
Badge,
|
||
Spinner,
|
||
Button,
|
||
Separator,
|
||
Input,
|
||
} from "@chakra-ui/react";
|
||
import {
|
||
NativeSelectRoot,
|
||
NativeSelectField,
|
||
} from "@/components/ui/forms/native-select";
|
||
import { useTranslations, useFormatter } from "next-intl";
|
||
import { useColorModeValue } from "@/components/ui/color-mode";
|
||
import {
|
||
SlideUp,
|
||
StaggerContainer,
|
||
StaggerItem,
|
||
AnimatedCounter,
|
||
} from "@/components/motion";
|
||
import { useAdminAnalytics, useAdminUsers } from "@/lib/api/admin/use-hooks";
|
||
import type { AdminUserDto, AnalyticsOverviewDto } from "@/lib/api/admin/types";
|
||
import { formatRoleLabel, isAdminRole } from "@/lib/auth/roles";
|
||
import {
|
||
LuUsers,
|
||
LuChartBar,
|
||
LuActivity,
|
||
LuShield,
|
||
LuPencil,
|
||
} from "react-icons/lu";
|
||
import { useState, useEffect } from "react";
|
||
import { useSession } from "next-auth/react";
|
||
import { EditUserModal } from "./edit-user-modal";
|
||
import LeagueTiersContent from "./league-tiers-content";
|
||
import ModelPerformanceContent from "./model-performance-content";
|
||
|
||
type AdminTab = "overview" | "users" | "league-tiers" | "model-performance";
|
||
|
||
// ========================
|
||
// Admin Stat Card
|
||
// ========================
|
||
|
||
interface AdminStatProps {
|
||
label: string;
|
||
value: number;
|
||
icon: React.ReactNode;
|
||
colorPalette: string;
|
||
}
|
||
|
||
function AdminStat({ label, value, icon, colorPalette }: AdminStatProps) {
|
||
const cardBg = useColorModeValue("white", "gray.800");
|
||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||
|
||
return (
|
||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||
<Card.Body>
|
||
<HStack gap={4}>
|
||
<Flex
|
||
boxSize="48px"
|
||
bg={`${colorPalette}.subtle`}
|
||
borderRadius="xl"
|
||
align="center"
|
||
justify="center"
|
||
color={`${colorPalette}.fg`}
|
||
fontSize="xl"
|
||
>
|
||
{icon}
|
||
</Flex>
|
||
<VStack gap={0} align="flex-start">
|
||
<Text fontSize="2xl" fontWeight="900" lineHeight="1">
|
||
<AnimatedCounter value={value} />
|
||
</Text>
|
||
<Text fontSize="xs" color="fg.muted" fontWeight="medium">
|
||
{label}
|
||
</Text>
|
||
</VStack>
|
||
</HStack>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
);
|
||
}
|
||
|
||
// ========================
|
||
// Admin Content
|
||
// ========================
|
||
|
||
export default function AdminContent() {
|
||
const t = useTranslations("admin");
|
||
const tCommon = useTranslations("common");
|
||
const format = useFormatter();
|
||
const [activeTab, setActiveTab] = useState<AdminTab>("overview");
|
||
const [editingUser, setEditingUser] = useState<AdminUserDto | null>(null);
|
||
const [searchParams, setSearchParams] = useState({
|
||
search: "",
|
||
role: "",
|
||
subscriptionStatus: "",
|
||
page: 1,
|
||
limit: 10,
|
||
});
|
||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||
|
||
useEffect(() => {
|
||
const handler = setTimeout(() => {
|
||
setDebouncedSearch(searchParams.search);
|
||
setSearchParams((prev) => ({ ...prev, page: 1 }));
|
||
}, 500);
|
||
return () => clearTimeout(handler);
|
||
}, [searchParams.search]);
|
||
|
||
const { data: session, status } = useSession();
|
||
|
||
const cardBg = useColorModeValue("white", "gray.800");
|
||
const borderColor = useColorModeValue("gray.100", "gray.700");
|
||
const canAccessAdmin = isAdminRole(session?.user?.roles);
|
||
|
||
const { data: analyticsData, isLoading: analyticsLoading } =
|
||
useAdminAnalytics(canAccessAdmin);
|
||
const { data: usersData, isLoading: usersLoading } = useAdminUsers(
|
||
{
|
||
search: debouncedSearch,
|
||
role: searchParams.role,
|
||
subscriptionStatus: searchParams.subscriptionStatus,
|
||
page: searchParams.page,
|
||
limit: searchParams.limit,
|
||
},
|
||
canAccessAdmin,
|
||
);
|
||
|
||
const analytics = analyticsData?.data as AnalyticsOverviewDto | undefined;
|
||
const users = usersData?.data?.items ?? [];
|
||
const meta = usersData?.data?.meta;
|
||
|
||
const tabs: { key: AdminTab; label: string }[] = [
|
||
{ key: "overview", label: t("overview") },
|
||
{ key: "users", label: t("user-management") },
|
||
{ key: "league-tiers", label: "Lig Tier" },
|
||
{ key: "model-performance", label: "Model Performansı" },
|
||
];
|
||
|
||
const getUserDisplayName = (user: AdminUserDto) => {
|
||
if (user.firstName && user.lastName)
|
||
return `${user.firstName} ${user.lastName}`;
|
||
if (user.firstName) return user.firstName;
|
||
return user.email.split("@")[0];
|
||
};
|
||
|
||
if (status === "loading") {
|
||
return (
|
||
<Flex justify="center" py={16}>
|
||
<Spinner size="lg" color="primary.500" />
|
||
</Flex>
|
||
);
|
||
}
|
||
|
||
if (!canAccessAdmin) {
|
||
return (
|
||
<SlideUp>
|
||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||
<Card.Body py={10}>
|
||
<VStack gap={3}>
|
||
<Badge colorPalette="red" variant="subtle" borderRadius="full">
|
||
<LuShield />
|
||
{t("restricted")}
|
||
</Badge>
|
||
<Heading as="h2" size="md">
|
||
{t("admin-access-required")}
|
||
</Heading>
|
||
<Text color="fg.muted" textAlign="center" maxW="md">
|
||
{t("admin-access-description")}
|
||
</Text>
|
||
</VStack>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
</SlideUp>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<SlideUp>
|
||
<Box>
|
||
<Flex justify="space-between" align="center" mb={6}>
|
||
<VStack gap={1} align="flex-start">
|
||
<Heading as="h1" size="xl" fontWeight="bold">
|
||
{t("title")}
|
||
</Heading>
|
||
<Text color="fg.muted" fontSize="sm">
|
||
{t("subtitle")}
|
||
</Text>
|
||
</VStack>
|
||
<Badge
|
||
colorPalette="red"
|
||
variant="solid"
|
||
px={3}
|
||
py={1}
|
||
borderRadius="full"
|
||
>
|
||
<LuShield />
|
||
{t("admin-badge")}
|
||
</Badge>
|
||
</Flex>
|
||
|
||
{/* Tabs */}
|
||
<HStack gap={2} mb={6}>
|
||
{tabs.map((tab) => (
|
||
<Button
|
||
key={tab.key}
|
||
variant={activeTab === tab.key ? "solid" : "outline"}
|
||
colorPalette={activeTab === tab.key ? "primary" : "gray"}
|
||
size="sm"
|
||
borderRadius="full"
|
||
onClick={() => setActiveTab(tab.key)}
|
||
>
|
||
{tab.label}
|
||
</Button>
|
||
))}
|
||
</HStack>
|
||
|
||
{/* Overview Tab */}
|
||
{activeTab === "overview" &&
|
||
(analyticsLoading ? (
|
||
<Flex justify="center" py={16}>
|
||
<Spinner size="lg" color="primary.500" />
|
||
</Flex>
|
||
) : (
|
||
<StaggerContainer>
|
||
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4} mb={8}>
|
||
<StaggerItem>
|
||
<AdminStat
|
||
label={t("total-users")}
|
||
value={
|
||
analytics?.totalUsers ?? analytics?.users?.total ?? 0
|
||
}
|
||
icon={<LuUsers />}
|
||
colorPalette="primary"
|
||
/>
|
||
</StaggerItem>
|
||
<StaggerItem>
|
||
<AdminStat
|
||
label={t("total-predictions")}
|
||
value={
|
||
analytics?.totalPredictions ?? analytics?.predictions ?? 0
|
||
}
|
||
icon={<LuChartBar />}
|
||
colorPalette="green"
|
||
/>
|
||
</StaggerItem>
|
||
<StaggerItem>
|
||
<AdminStat
|
||
label={t("premium-users")}
|
||
value={analytics?.users?.premium ?? 0}
|
||
icon={<LuShield />}
|
||
colorPalette="purple"
|
||
/>
|
||
</StaggerItem>
|
||
<StaggerItem>
|
||
<AdminStat
|
||
label={t("active-users")}
|
||
value={
|
||
analytics?.activeUsers ?? analytics?.users?.active ?? 0
|
||
}
|
||
icon={<LuActivity />}
|
||
colorPalette="orange"
|
||
/>
|
||
</StaggerItem>
|
||
<StaggerItem>
|
||
<AdminStat
|
||
label={t("total-coupons")}
|
||
value={analytics?.totalCoupons ?? 0}
|
||
icon={<LuShield />}
|
||
colorPalette="purple"
|
||
/>
|
||
</StaggerItem>
|
||
</SimpleGrid>
|
||
</StaggerContainer>
|
||
))}
|
||
|
||
{/* Users Tab */}
|
||
{activeTab === "users" && (
|
||
<VStack gap={4} align="stretch">
|
||
{/* Filters */}
|
||
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
|
||
<Card.Body py={4}>
|
||
<SimpleGrid columns={{ base: 1, md: 3 }} gap={4}>
|
||
<Input
|
||
placeholder={t("search-users-placeholder")}
|
||
value={searchParams.search}
|
||
onChange={(e) =>
|
||
setSearchParams({
|
||
...searchParams,
|
||
search: e.target.value,
|
||
})
|
||
}
|
||
/>
|
||
<NativeSelectRoot>
|
||
<NativeSelectField
|
||
placeholder={t("all-roles")}
|
||
value={searchParams.role}
|
||
onChange={(e) =>
|
||
setSearchParams({
|
||
...searchParams,
|
||
role: e.target.value,
|
||
page: 1,
|
||
})
|
||
}
|
||
items={[
|
||
{ label: t("standard-user"), value: "user" },
|
||
{ label: t("superadmin"), value: "superadmin" },
|
||
]}
|
||
/>
|
||
</NativeSelectRoot>
|
||
<NativeSelectRoot>
|
||
<NativeSelectField
|
||
placeholder={t("all-plans")}
|
||
value={searchParams.subscriptionStatus}
|
||
onChange={(e) =>
|
||
setSearchParams({
|
||
...searchParams,
|
||
subscriptionStatus: e.target.value,
|
||
page: 1,
|
||
})
|
||
}
|
||
items={[
|
||
{ label: t("plan-free"), value: "free" },
|
||
{ label: "Plus", value: "plus" },
|
||
{ label: "Premium", value: "premium" },
|
||
{ label: t("plan-past-due"), value: "past_due" },
|
||
{ label: t("plan-cancelled"), value: "cancelled" },
|
||
]}
|
||
/>
|
||
</NativeSelectRoot>
|
||
</SimpleGrid>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
|
||
{usersLoading ? (
|
||
<Flex justify="center" py={16}>
|
||
<Spinner size="lg" color="primary.500" />
|
||
</Flex>
|
||
) : users.length > 0 ? (
|
||
<Card.Root
|
||
bg={cardBg}
|
||
borderColor={borderColor}
|
||
borderRadius="xl"
|
||
>
|
||
<Card.Body>
|
||
<VStack gap={0} align="stretch">
|
||
{/* Table Header */}
|
||
<Flex
|
||
px={4}
|
||
py={2}
|
||
bg="bg.muted"
|
||
borderRadius="lg"
|
||
mb={2}
|
||
fontWeight="semibold"
|
||
fontSize="xs"
|
||
color="fg.muted"
|
||
>
|
||
<Text flex={2}>{t("user-name")}</Text>
|
||
<Text flex={2}>{t("user-email")}</Text>
|
||
<Text flex={1} textAlign="center">
|
||
{t("user-role")}
|
||
</Text>
|
||
<Text flex={1} textAlign="center">
|
||
{t("subscription")}
|
||
</Text>
|
||
<Text flex={1} textAlign="center">
|
||
{t("user-status")}
|
||
</Text>
|
||
<Text width="40px" textAlign="center"></Text>
|
||
</Flex>
|
||
|
||
{/* User Rows */}
|
||
{users.map((user: AdminUserDto, idx: number) => (
|
||
<Box key={user.id ?? idx}>
|
||
{idx > 0 && <Separator />}
|
||
<Flex
|
||
px={4}
|
||
py={3}
|
||
align="center"
|
||
_hover={{ bg: "bg.muted" }}
|
||
borderRadius="lg"
|
||
>
|
||
<Text
|
||
flex={2}
|
||
fontSize="sm"
|
||
fontWeight="medium"
|
||
truncate
|
||
>
|
||
{getUserDisplayName(user)}
|
||
</Text>
|
||
<Text
|
||
flex={2}
|
||
fontSize="sm"
|
||
color="fg.muted"
|
||
truncate
|
||
>
|
||
{user.email}
|
||
</Text>
|
||
<Flex flex={1} justify="center">
|
||
<Badge
|
||
colorPalette={
|
||
isAdminRole([user.role]) ? "red" : "gray"
|
||
}
|
||
variant="subtle"
|
||
fontSize="2xs"
|
||
borderRadius="full"
|
||
>
|
||
{formatRoleLabel(user.role)}
|
||
</Badge>
|
||
</Flex>
|
||
<Flex
|
||
flex={1}
|
||
justify="center"
|
||
direction="column"
|
||
align="center"
|
||
gap={1}
|
||
>
|
||
<Badge
|
||
colorPalette={
|
||
user.subscriptionStatus === "premium" ||
|
||
user.subscriptionStatus === "plus"
|
||
? "purple"
|
||
: "gray"
|
||
}
|
||
variant="subtle"
|
||
fontSize="2xs"
|
||
borderRadius="full"
|
||
textTransform="capitalize"
|
||
>
|
||
{user.subscriptionStatus || "free"}
|
||
</Badge>
|
||
{user.subscriptionExpiresAt ? (
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
{format.dateTime(
|
||
new Date(user.subscriptionExpiresAt),
|
||
{
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
},
|
||
)}
|
||
</Text>
|
||
) : (
|
||
<Text fontSize="2xs" color="fg.muted">
|
||
-
|
||
</Text>
|
||
)}
|
||
</Flex>
|
||
<Flex flex={1} justify="center">
|
||
<Badge
|
||
colorPalette={
|
||
user.subscriptionStatus === "premium"
|
||
? "purple"
|
||
: user.subscriptionStatus === "plus"
|
||
? "blue"
|
||
: "gray"
|
||
}
|
||
variant="subtle"
|
||
fontSize="2xs"
|
||
borderRadius="full"
|
||
textTransform="capitalize"
|
||
>
|
||
{user.subscriptionStatus || "free"}
|
||
</Badge>
|
||
</Flex>
|
||
<Flex flex={1} justify="center">
|
||
<Badge
|
||
colorPalette={user.isActive ? "green" : "gray"}
|
||
variant="subtle"
|
||
fontSize="2xs"
|
||
borderRadius="full"
|
||
>
|
||
{user.isActive
|
||
? tCommon("active")
|
||
: tCommon("inactive")}
|
||
</Badge>
|
||
</Flex>
|
||
<Flex width="40px" justify="center">
|
||
<Button
|
||
size="sm"
|
||
variant="ghost"
|
||
onClick={() => setEditingUser(user)}
|
||
>
|
||
<LuPencil />
|
||
</Button>
|
||
</Flex>
|
||
</Flex>
|
||
</Box>
|
||
))}
|
||
|
||
{/* Pagination */}
|
||
{meta && meta.totalPages > 1 && (
|
||
<Flex
|
||
justify="center"
|
||
pt={4}
|
||
pb={2}
|
||
gap={2}
|
||
borderTopWidth="1px"
|
||
borderColor={borderColor}
|
||
mt={2}
|
||
>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!meta.hasPreviousPage}
|
||
onClick={() =>
|
||
setSearchParams({
|
||
...searchParams,
|
||
page: meta.page - 1,
|
||
})
|
||
}
|
||
>
|
||
{tCommon("previous")}
|
||
</Button>
|
||
<Flex align="center" gap={2} fontSize="sm">
|
||
<Text>
|
||
{tCommon("page")} {meta.page} / {meta.totalPages}
|
||
</Text>
|
||
</Flex>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!meta.hasNextPage}
|
||
onClick={() =>
|
||
setSearchParams({
|
||
...searchParams,
|
||
page: meta.page + 1,
|
||
})
|
||
}
|
||
>
|
||
{tCommon("next")}
|
||
</Button>
|
||
</Flex>
|
||
)}
|
||
</VStack>
|
||
</Card.Body>
|
||
</Card.Root>
|
||
) : (
|
||
<Flex justify="center" py={16}>
|
||
<Text color="fg.muted">{t("no-users")}</Text>
|
||
</Flex>
|
||
)}
|
||
</VStack>
|
||
)}
|
||
|
||
{/* League Tiers Tab */}
|
||
{activeTab === "league-tiers" && <LeagueTiersContent />}
|
||
|
||
{/* Model Performance Tab */}
|
||
{activeTab === "model-performance" && <ModelPerformanceContent />}
|
||
|
||
<EditUserModal
|
||
user={editingUser}
|
||
isOpen={!!editingUser}
|
||
onClose={() => setEditingUser(null)}
|
||
/>
|
||
</Box>
|
||
</SlideUp>
|
||
);
|
||
}
|