- migration: profiles.role 컬럼 + admin RLS 정책 - admin-guard.ts: RSC용 admin 권한 체크 - /admin: CRM 대시보드 (총 유저, 유료 구독자, API 호출, 만료 예정) - /admin/users: 유저 목록 (검색/필터/페이지네이션) - /admin/users/[id]: 유저 상세 (프로필+구독+30일 사용량) - /admin/subscriptions: 구독 목록 (상태 필터) - /admin/usage: 사용량 집계 (7/14/30일) - Sidebar에 Admin 네비게이션 추가
61 lines
2.3 KiB
TypeScript
61 lines
2.3 KiB
TypeScript
// apps/web/src/app/(app)/admin/page.tsx
|
|
// Admin CRM 대시보드 — 요약 카드 4개
|
|
|
|
import { Box, Grid } from '@mui/material'
|
|
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
|
import { d3roPalette } from '@d3ro/ui/theme'
|
|
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
|
|
|
interface StatCard {
|
|
label: string
|
|
value: string | number
|
|
color?: string
|
|
}
|
|
|
|
async function loadStats(): Promise<StatCard[]> {
|
|
const supabase = await getSupabaseServerClient()
|
|
|
|
const [profilesRes, paidRes, usageRes, expiringRes] = await Promise.all([
|
|
supabase.from('profiles').select('id', { count: 'exact', head: true }),
|
|
supabase.from('subscriptions').select('id', { count: 'exact', head: true })
|
|
.neq('tier', 'free').eq('status', 'active'),
|
|
supabase.from('daily_usage').select('count')
|
|
.eq('date', new Date().toISOString().split('T')[0]),
|
|
supabase.from('subscriptions').select('id', { count: 'exact', head: true })
|
|
.eq('status', 'active').eq('payment_provider', 'payple')
|
|
.lte('current_period_end', new Date(Date.now() + 7 * 86400000).toISOString()),
|
|
])
|
|
|
|
const todayUsage = (usageRes.data as Array<{ count: number }> | null)
|
|
?.reduce((sum, r) => sum + (r.count ?? 0), 0) ?? 0
|
|
|
|
return [
|
|
{ label: 'TOTAL USERS', value: profilesRes.count ?? 0 },
|
|
{ label: 'PAID SUBSCRIBERS', value: paidRes.count ?? 0, color: d3roPalette.tag.green },
|
|
{ label: 'TODAY API CALLS', value: todayUsage, color: d3roPalette.accent.amber },
|
|
{ label: 'EXPIRING (7D)', value: expiringRes.count ?? 0, color: d3roPalette.tag.red },
|
|
]
|
|
}
|
|
|
|
export default async function AdminPage(): Promise<React.ReactElement> {
|
|
const stats = await loadStats()
|
|
|
|
return (
|
|
<Grid container spacing={2}>
|
|
{stats.map((stat) => (
|
|
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={stat.label}>
|
|
<MetalCard>
|
|
<Box sx={{ textAlign: 'center', py: 2 }}>
|
|
<PhosphorText variant="label" sx={{ mb: 1, display: 'block', color: d3roPalette.text.label }}>
|
|
{stat.label}
|
|
</PhosphorText>
|
|
<PhosphorText variant="hero" sx={{ color: stat.color ?? d3roPalette.text.primary }}>
|
|
{stat.value}
|
|
</PhosphorText>
|
|
</Box>
|
|
</MetalCard>
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
)
|
|
}
|