// apps/web/src/app/(app)/admin/users/page.tsx // Admin 유저 목록 — profiles JOIN subscriptions, 검색/필터/페이지네이션 import { Box } from '@mui/material' import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { getSupabaseServerClient } from '@/lib/supabase-server' import Link from 'next/link' const PAGE_SIZE = 20 interface UserRow { id: string name: string | null email: string | null tier: string role: string created_at: string subscription_status: string | null payment_provider: string | null } async function loadUsers(page: number, search: string, tierFilter: string): Promise<{ users: UserRow[]; total: number }> { const supabase = await getSupabaseServerClient() // profiles 조회 (DB 타입에 role/조인이 미정의이므로 raw cast) const from = page * PAGE_SIZE const to = from + PAGE_SIZE - 1 // 단순 profiles 조회 + 별도 subscriptions 조회 const profileQuery = supabase .from('profiles') .select('id, name, tier, created_at', { count: 'exact' }) .order('created_at', { ascending: false }) .range(from, to) if (search) { profileQuery.ilike('name', `%${search}%`) } if (tierFilter && tierFilter !== 'all') { profileQuery.eq('tier', tierFilter as 'free' | 'pro' | 'pro_plus') } const { data: rawProfiles, count } = await profileQuery const profiles = (rawProfiles ?? []) as Array> // 해당 유저들의 구독 정보 조회 const userIds = profiles.map((p) => p.id as string) const { data: rawSubs } = userIds.length > 0 ? await supabase.from('subscriptions').select('user_id, status, payment_provider').in('user_id', userIds) : { data: [] } const subs = (rawSubs ?? []) as Array> const subMap = new Map(subs.map((s) => [s.user_id as string, s])) // role 조회 (별도 raw query — DB 타입에 role 미정의) const { data: rawRoles } = userIds.length > 0 ? await supabase.from('profiles').select('id, role').in('id', userIds) : { data: [] } const roles = (rawRoles ?? []) as Array> const roleMap = new Map(roles.map((r) => [r.id as string, (r.role as string) ?? 'user'])) const users: UserRow[] = profiles.map((row) => { const sub = subMap.get(row.id as string) return { id: row.id as string, name: row.name as string | null, email: null, tier: (row.tier as string) ?? 'free', role: roleMap.get(row.id as string) ?? 'user', created_at: row.created_at as string, subscription_status: (sub?.status as string) ?? null, payment_provider: (sub?.payment_provider as string) ?? null, } }) return { users, total: count ?? 0 } } interface PageProps { searchParams: Promise<{ page?: string; search?: string; tier?: string }> } export default async function AdminUsersPage({ searchParams }: PageProps): Promise { const params = await searchParams const page = parseInt(params.page ?? '0', 10) const search = params.search ?? '' const tierFilter = params.tier ?? 'all' const { users, total } = await loadUsers(page, search, tierFilter) const totalPages = Math.ceil(total / PAGE_SIZE) return ( {/* Filters */} {total} USERS {['all', 'free', 'pro', 'pro_plus'].map((t) => ( {t === 'all' ? 'ALL' : t === 'pro_plus' ? 'PRO+' : t.toUpperCase()} ))} {/* Table */} NAME TIER ROLE STATUS PROVIDER JOINED {users.map((u) => ( {u.name ?? u.id.substring(0, 8)} {u.role.toUpperCase()} {u.subscription_status?.toUpperCase() ?? '-'} {u.payment_provider ?? '-'} {new Date(u.created_at).toLocaleDateString()} ))} {users.length === 0 && ( No users found )} {/* Pagination */} {totalPages > 1 && ( {Array.from({ length: Math.min(totalPages, 10) }, (_, i) => ( {i + 1} ))} )} ) } function TierBadge({ tier }: { tier: string }): React.ReactElement { const color = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary const label = tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase() return {label} }