gg
Deploy Iddaai Frontend / build-and-deploy (push) Successful in 2m38s

This commit is contained in:
2026-05-20 21:59:52 +03:00
parent fc369db123
commit 5df5145104
6 changed files with 787 additions and 2 deletions
+6 -1
View File
@@ -40,8 +40,9 @@ import {
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { EditUserModal } from "./edit-user-modal";
import LeagueTiersContent from "./league-tiers-content";
type AdminTab = "overview" | "users";
type AdminTab = "overview" | "users" | "league-tiers";
// ========================
// Admin Stat Card
@@ -140,6 +141,7 @@ export default function AdminContent() {
const tabs: { key: AdminTab; label: string }[] = [
{ key: "overview", label: t("overview") },
{ key: "users", label: t("user-management") },
{ key: "league-tiers", label: "Lig Tier" },
];
const getUserDisplayName = (user: AdminUserDto) => {
@@ -548,6 +550,9 @@ export default function AdminContent() {
</VStack>
)}
{/* League Tiers Tab */}
{activeTab === "league-tiers" && <LeagueTiersContent />}
<EditUserModal
user={editingUser}
isOpen={!!editingUser}
@@ -0,0 +1,535 @@
"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 { useColorModeValue } from "@/components/ui/color-mode";
import { AnimatedCounter } from "@/components/motion";
import {
useLeagueTiers,
useLeagueTierStats,
useAddLeagueTier,
useUpdateLeagueTier,
useDeactivateLeagueTier,
useDeleteLeagueTier,
useSyncLeagueTiers,
useTriggerRetrain,
} from "@/lib/api/admin/use-hooks";
import { useLeagues } from "@/lib/api/leagues/use-hooks";
import type { LeagueTierDto } from "@/lib/api/admin/types";
import {
LuDiamond,
LuMedal,
LuShield,
LuPlus,
LuTrash2,
LuRefreshCw,
LuBrain,
LuSearch,
LuX,
LuChevronDown,
LuChevronUp,
} from "react-icons/lu";
import { useState, useMemo } from "react";
const TIER_CONFIG: Record<
number,
{ label: string; color: string; icon: React.ReactNode }
> = {
1: { label: "Elmas", color: "cyan", icon: <LuDiamond /> },
2: { label: "Altin", color: "yellow", icon: <LuMedal /> },
3: { label: "Gumus", color: "gray", icon: <LuShield /> },
};
export default function LeagueTiersContent() {
const [searchQuery, setSearchQuery] = useState("");
const [filterTier, setFilterTier] = useState<string>("");
const [showAddForm, setShowAddForm] = useState(false);
const [addLeagueId, setAddLeagueId] = useState("");
const [addTier, setAddTier] = useState("2");
const [addNotes, setAddNotes] = useState("");
const [expandedTier, setExpandedTier] = useState<number | null>(null);
const cardBg = useColorModeValue("white", "gray.800");
const borderColor = useColorModeValue("gray.100", "gray.700");
const { data: tiersData, isLoading: tiersLoading } = useLeagueTiers();
const { data: statsData, isLoading: statsLoading } = useLeagueTierStats();
const { data: leaguesData } = useLeagues();
const addMutation = useAddLeagueTier();
const updateMutation = useUpdateLeagueTier();
const deactivateMutation = useDeactivateLeagueTier();
const deleteMutation = useDeleteLeagueTier();
const syncMutation = useSyncLeagueTiers();
const retrainMutation = useTriggerRetrain();
const tiers = (tiersData?.data as LeagueTierDto[] | undefined) ?? [];
const stats = statsData?.data;
const leagues = leaguesData?.data ?? [];
// Group tiers by tier level
const tierGroups = useMemo(() => {
const groups: Record<number, LeagueTierDto[]> = { 1: [], 2: [], 3: [] };
tiers.forEach((t) => {
if (!groups[t.tier]) groups[t.tier] = [];
groups[t.tier].push(t);
});
return groups;
}, [tiers]);
// Filter tiers
const filteredTiers = useMemo(() => {
let result = tiers;
if (filterTier) {
result = result.filter((t) => t.tier === parseInt(filterTier));
}
if (searchQuery) {
const q = searchQuery.toLowerCase();
result = result.filter(
(t) =>
t.league?.name?.toLowerCase().includes(q) ||
t.league?.country?.name?.toLowerCase().includes(q) ||
t.leagueId.toLowerCase().includes(q),
);
}
return result;
}, [tiers, filterTier, searchQuery]);
// Leagues not yet in tiers (for add form)
const availableLeagues = useMemo(() => {
const tierLeagueIds = new Set(tiers.map((t) => t.leagueId));
return (leagues as Array<{ id: string; name: string }>).filter(
(l) => !tierLeagueIds.has(l.id),
);
}, [leagues, tiers]);
const handleAdd = async () => {
if (!addLeagueId) return;
await addMutation.mutateAsync({
leagueId: addLeagueId,
tier: parseInt(addTier),
notes: addNotes || undefined,
addedBy: "admin",
});
setAddLeagueId("");
setAddNotes("");
setShowAddForm(false);
};
const handleTierChange = async (leagueId: string, newTier: number) => {
await updateMutation.mutateAsync({
leagueId,
dto: { tier: newTier },
});
};
const handleDeactivate = async (leagueId: string) => {
await deactivateMutation.mutateAsync(leagueId);
};
const handleDelete = async (leagueId: string) => {
await deleteMutation.mutateAsync(leagueId);
};
if (tiersLoading) {
return (
<Flex justify="center" py={16}>
<Spinner size="lg" color="primary.500" />
</Flex>
);
}
return (
<VStack gap={6} align="stretch">
{/* Stats Cards */}
<SimpleGrid columns={{ base: 2, md: 4 }} gap={4}>
{[1, 2, 3].map((tier) => {
const config = TIER_CONFIG[tier];
const count =
stats?.tiers?.find((t: { tier: number }) => t.tier === tier)
?.count ?? 0;
return (
<Card.Root
key={tier}
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Body>
<HStack gap={3}>
<Flex
boxSize="40px"
bg={`${config.color}.subtle`}
borderRadius="lg"
align="center"
justify="center"
color={`${config.color}.fg`}
fontSize="lg"
>
{config.icon}
</Flex>
<VStack gap={0} align="flex-start">
<Text fontSize="xl" fontWeight="900" lineHeight="1">
<AnimatedCounter value={count} />
</Text>
<Text fontSize="xs" color="fg.muted">
{config.label}
</Text>
</VStack>
</HStack>
</Card.Body>
</Card.Root>
);
})}
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body>
<HStack gap={3}>
<Flex
boxSize="40px"
bg="green.subtle"
borderRadius="lg"
align="center"
justify="center"
color="green.fg"
fontSize="lg"
>
<LuBrain />
</Flex>
<VStack gap={0} align="flex-start">
<Text fontSize="xl" fontWeight="900" lineHeight="1">
<AnimatedCounter
value={stats?.totalQualifiedMatches ?? 0}
/>
</Text>
<Text fontSize="xs" color="fg.muted">
Toplam Mac
</Text>
</VStack>
</HStack>
</Card.Body>
</Card.Root>
</SimpleGrid>
{/* Action Buttons */}
<HStack gap={2} flexWrap="wrap">
<Button
size="sm"
colorPalette="primary"
borderRadius="full"
onClick={() => setShowAddForm(!showAddForm)}
>
<LuPlus />
Lig Ekle
</Button>
<Button
size="sm"
variant="outline"
borderRadius="full"
onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending}
>
<LuRefreshCw />
{syncMutation.isPending ? "Senkronize ediliyor..." : "Sync JSON"}
</Button>
<Button
size="sm"
variant="outline"
colorPalette="purple"
borderRadius="full"
onClick={() => retrainMutation.mutate()}
disabled={retrainMutation.isPending}
>
<LuBrain />
{retrainMutation.isPending ? "Baslatiliyor..." : "Model Egit"}
</Button>
{(syncMutation.isSuccess || retrainMutation.isSuccess) && (
<Badge colorPalette="green" variant="subtle" borderRadius="full">
Basarili
</Badge>
)}
</HStack>
{/* Add League Form */}
{showAddForm && (
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body>
<VStack gap={3} align="stretch">
<Heading size="sm">Yeni Lig Ekle</Heading>
<SimpleGrid columns={{ base: 1, md: 3 }} gap={3}>
<Input
placeholder="League ID gir..."
value={addLeagueId}
onChange={(e) => setAddLeagueId(e.target.value)}
size="sm"
/>
<NativeSelectRoot size="sm">
<NativeSelectField
value={addTier}
onChange={(e) => setAddTier(e.target.value)}
items={[
{ label: "Tier 1 - Elmas", value: "1" },
{ label: "Tier 2 - Altin", value: "2" },
{ label: "Tier 3 - Gumus", value: "3" },
]}
/>
</NativeSelectRoot>
<Input
placeholder="Not (opsiyonel)"
value={addNotes}
onChange={(e) => setAddNotes(e.target.value)}
size="sm"
/>
</SimpleGrid>
<HStack>
<Button
size="sm"
colorPalette="primary"
onClick={handleAdd}
disabled={!addLeagueId || addMutation.isPending}
>
{addMutation.isPending ? "Ekleniyor..." : "Ekle"}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setShowAddForm(false)}
>
Iptal
</Button>
</HStack>
</VStack>
</Card.Body>
</Card.Root>
)}
{/* Filters */}
<Card.Root bg={cardBg} borderColor={borderColor} borderRadius="xl">
<Card.Body py={3}>
<SimpleGrid columns={{ base: 1, md: 2 }} gap={3}>
<HStack>
<LuSearch />
<Input
placeholder="Lig veya ulke ara..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
size="sm"
/>
{searchQuery && (
<Button
size="xs"
variant="ghost"
onClick={() => setSearchQuery("")}
>
<LuX />
</Button>
)}
</HStack>
<NativeSelectRoot size="sm">
<NativeSelectField
placeholder="Tum Tierler"
value={filterTier}
onChange={(e) => setFilterTier(e.target.value)}
items={[
{ label: "Tier 1 - Elmas", value: "1" },
{ label: "Tier 2 - Altin", value: "2" },
{ label: "Tier 3 - Gumus", value: "3" },
]}
/>
</NativeSelectRoot>
</SimpleGrid>
</Card.Body>
</Card.Root>
{/* Tier Groups */}
{[1, 2, 3].map((tier) => {
const config = TIER_CONFIG[tier];
const tierItems = filterTier
? filteredTiers.filter((t) => t.tier === tier)
: tierGroups[tier]?.filter((t) => {
if (!searchQuery) return true;
const q = searchQuery.toLowerCase();
return (
t.league?.name?.toLowerCase().includes(q) ||
t.league?.country?.name?.toLowerCase().includes(q)
);
}) ?? [];
if (filterTier && parseInt(filterTier) !== tier) return null;
const isExpanded = expandedTier === tier || expandedTier === null;
return (
<Card.Root
key={tier}
bg={cardBg}
borderColor={borderColor}
borderRadius="xl"
>
<Card.Body>
<Flex
justify="space-between"
align="center"
mb={isExpanded ? 3 : 0}
cursor="pointer"
onClick={() =>
setExpandedTier(expandedTier === tier ? null : tier)
}
>
<HStack gap={2}>
<Badge
colorPalette={config.color}
variant="subtle"
borderRadius="full"
px={3}
py={1}
>
{config.icon}
Tier {tier} - {config.label}
</Badge>
<Text fontSize="sm" color="fg.muted">
({tierItems.length} lig)
</Text>
</HStack>
{isExpanded ? <LuChevronUp /> : <LuChevronDown />}
</Flex>
{isExpanded && (
<VStack gap={0} align="stretch">
{/* Header */}
<Flex
px={4}
py={2}
bg="bg.muted"
borderRadius="lg"
mb={1}
fontWeight="semibold"
fontSize="xs"
color="fg.muted"
>
<Text flex={3}>Lig</Text>
<Text flex={2}>Ulke</Text>
<Text flex={1} textAlign="center">
Tier
</Text>
<Text flex={1} textAlign="center">
Durum
</Text>
<Text width="80px" textAlign="center">
Islem
</Text>
</Flex>
{tierItems.length === 0 ? (
<Text
py={4}
textAlign="center"
color="fg.muted"
fontSize="sm"
>
Bu tier&apos;da lig yok
</Text>
) : (
tierItems.map((item, idx) => (
<Box key={item.id}>
{idx > 0 && <Separator />}
<Flex
px={4}
py={2}
align="center"
_hover={{ bg: "bg.muted" }}
borderRadius="lg"
>
<Text
flex={3}
fontSize="sm"
fontWeight="medium"
truncate
>
{item.league?.name ?? item.leagueId}
</Text>
<Text flex={2} fontSize="sm" color="fg.muted" truncate>
{item.league?.country?.name ?? "-"}
</Text>
<Flex flex={1} justify="center">
<NativeSelectRoot size="xs">
<NativeSelectField
value={String(item.tier)}
onChange={(e) =>
handleTierChange(
item.leagueId,
parseInt(e.target.value),
)
}
items={[
{ label: "1", value: "1" },
{ label: "2", value: "2" },
{ label: "3", value: "3" },
]}
/>
</NativeSelectRoot>
</Flex>
<Flex flex={1} justify="center">
<Badge
colorPalette={item.isActive ? "green" : "gray"}
variant="subtle"
fontSize="2xs"
borderRadius="full"
>
{item.isActive ? "Aktif" : "Pasif"}
</Badge>
</Flex>
<Flex width="80px" justify="center" gap={1}>
{item.isActive && (
<Button
size="xs"
variant="ghost"
colorPalette="orange"
onClick={() =>
handleDeactivate(item.leagueId)
}
title="Pasif yap"
>
<LuX />
</Button>
)}
<Button
size="xs"
variant="ghost"
colorPalette="red"
onClick={() => handleDelete(item.leagueId)}
title="Sil"
>
<LuTrash2 />
</Button>
</Flex>
</Flex>
</Box>
))
)}
</VStack>
)}
</Card.Body>
</Card.Root>
);
})}
</VStack>
);
}
+91
View File
@@ -3,8 +3,13 @@ import { ApiResponse, PaginatedData } from "@/types/api-response";
import type {
AdminPaginationParams,
AdminUserDto,
AddLeagueTierDto,
AnalyticsOverviewDto,
LeagueTierDto,
LeagueTierStatsDto,
RetrainStatusDto,
SettingDto,
UpdateLeagueTierDto,
UpdateSettingDto,
UpdateUserRoleDto,
UpdateUserSubscriptionDto,
@@ -121,6 +126,82 @@ const toggleUserActive = (id: string) => {
});
};
// League Tiers
const getLeagueTiers = (active?: boolean) => {
return apiRequest<ApiResponse<LeagueTierDto[]>>({
url: "/admin/league-tiers",
client: "admin",
method: "get",
params: active !== undefined ? { active: String(active) } : undefined,
});
};
const getLeagueTierStats = () => {
return apiRequest<ApiResponse<LeagueTierStatsDto>>({
url: "/admin/league-tiers/stats",
client: "admin",
method: "get",
});
};
const addLeagueTier = (dto: AddLeagueTierDto) => {
return apiRequest<ApiResponse<LeagueTierDto>>({
url: "/admin/league-tiers",
client: "admin",
method: "post",
data: dto,
});
};
const updateLeagueTier = (leagueId: string, dto: UpdateLeagueTierDto) => {
return apiRequest<ApiResponse<LeagueTierDto>>({
url: `/admin/league-tiers/${leagueId}/tier`,
client: "admin",
method: "put",
data: dto,
});
};
const deactivateLeagueTier = (leagueId: string) => {
return apiRequest<ApiResponse<LeagueTierDto>>({
url: `/admin/league-tiers/${leagueId}/deactivate`,
client: "admin",
method: "put",
});
};
const deleteLeagueTier = (leagueId: string) => {
return apiRequest<ApiResponse<null>>({
url: `/admin/league-tiers/${leagueId}`,
client: "admin",
method: "delete",
});
};
const syncLeagueTiers = () => {
return apiRequest<ApiResponse<{ count: number; path: string }>>({
url: "/admin/league-tiers/sync",
client: "admin",
method: "post",
});
};
const triggerRetrain = () => {
return apiRequest<ApiResponse<{ status: string; message: string }>>({
url: "/admin/league-tiers/retrain",
client: "admin",
method: "post",
});
};
const getRetrainStatus = () => {
return apiRequest<ApiResponse<RetrainStatusDto>>({
url: "/admin/league-tiers/retrain/status",
client: "admin",
method: "get",
});
};
export const adminService = {
getAnalyticsOverview,
getAllSettings,
@@ -134,4 +215,14 @@ export const adminService = {
updateUserRole,
updateUserSubscription,
toggleUserActive,
// League Tiers
getLeagueTiers,
getLeagueTierStats,
addLeagueTier,
updateLeagueTier,
deactivateLeagueTier,
deleteLeagueTier,
syncLeagueTiers,
triggerRetrain,
getRetrainStatus,
};
+49
View File
@@ -66,6 +66,55 @@ export interface UpdateUserSubscriptionDto {
expiresAt?: string | null;
}
// ========================
// League Tiers
// ========================
export interface LeagueTierDto {
id: number;
leagueId: string;
tier: number;
isActive: boolean;
addedBy?: string;
notes?: string;
createdAt: string;
updatedAt: string;
league: {
id: string;
name: string;
country?: {
id: string;
name: string;
code?: string;
};
};
}
export interface LeagueTierStatsDto {
tiers: { tier: number; count: number }[];
totalActiveLeagues: number;
totalQualifiedMatches: number;
}
export interface AddLeagueTierDto {
leagueId: string;
tier?: number;
notes?: string;
addedBy?: string;
}
export interface UpdateLeagueTierDto {
tier: number;
}
export interface RetrainStatusDto {
running: boolean;
last_started?: string;
last_completed?: string;
last_status?: string;
last_error?: string;
}
// ========================
// Analytics
// ========================
+105
View File
@@ -1,7 +1,9 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { adminService } from "./service";
import type {
AddLeagueTierDto,
AdminPaginationParams,
UpdateLeagueTierDto,
UpdateSettingDto,
UpdateUserRoleDto,
UpdateUserSubscriptionDto,
@@ -17,6 +19,8 @@ export const AdminQueryKeys = {
users: (params?: AdminPaginationParams) =>
[...AdminQueryKeys.usersList(), params] as const,
user: (id: string) => [...AdminQueryKeys.all, "user", id] as const,
leagueTiers: () => [...AdminQueryKeys.all, "leagueTiers"] as const,
leagueTierStats: () => [...AdminQueryKeys.all, "leagueTierStats"] as const,
};
// Analytics
@@ -144,3 +148,104 @@ export const useToggleUserActive = () => {
},
});
};
// ========================
// League Tiers
// ========================
export const useLeagueTiers = (enabled = true) => {
return useQuery({
queryKey: AdminQueryKeys.leagueTiers(),
queryFn: () => adminService.getLeagueTiers(),
enabled,
});
};
export const useLeagueTierStats = (enabled = true) => {
return useQuery({
queryKey: AdminQueryKeys.leagueTierStats(),
queryFn: () => adminService.getLeagueTierStats(),
enabled,
});
};
export const useAddLeagueTier = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: AddLeagueTierDto) => adminService.addLeagueTier(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: AdminQueryKeys.leagueTiers() });
queryClient.invalidateQueries({
queryKey: AdminQueryKeys.leagueTierStats(),
});
},
});
};
export const useUpdateLeagueTier = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
leagueId,
dto,
}: {
leagueId: string;
dto: UpdateLeagueTierDto;
}) => adminService.updateLeagueTier(leagueId, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: AdminQueryKeys.leagueTiers() });
queryClient.invalidateQueries({
queryKey: AdminQueryKeys.leagueTierStats(),
});
},
});
};
export const useDeactivateLeagueTier = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (leagueId: string) =>
adminService.deactivateLeagueTier(leagueId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: AdminQueryKeys.leagueTiers() });
queryClient.invalidateQueries({
queryKey: AdminQueryKeys.leagueTierStats(),
});
},
});
};
export const useDeleteLeagueTier = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (leagueId: string) =>
adminService.deleteLeagueTier(leagueId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: AdminQueryKeys.leagueTiers() });
queryClient.invalidateQueries({
queryKey: AdminQueryKeys.leagueTierStats(),
});
},
});
};
export const useSyncLeagueTiers = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => adminService.syncLeagueTiers(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: AdminQueryKeys.leagueTiers() });
},
});
};
export const useTriggerRetrain = () => {
return useMutation({
mutationFn: () => adminService.triggerRetrain(),
});
};
+1 -1
View File
File diff suppressed because one or more lines are too long