feat(web): 관리자 CRM 웹페이지 — admin role + 4개 페이지

- 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 네비게이션 추가
This commit is contained in:
윤찬 2026-04-12 20:24:26 +09:00
parent d3c2a4348d
commit 667c09242b
10 changed files with 816 additions and 0 deletions

View file

@ -0,0 +1,120 @@
// apps/web/src/app/(app)/admin/users/[id]/page.tsx
// Admin 유저 상세 — 프로필 + 구독 + 30일 사용량
import { Box, Grid } 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 { notFound } from 'next/navigation'
interface PageProps {
params: Promise<{ id: string }>
}
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
const { id } = await params
const supabase = await getSupabaseServerClient()
const [profileRes, subRes, usageRes] = await Promise.all([
supabase.from('profiles').select('*').eq('id', id).maybeSingle(),
supabase.from('subscriptions').select('*').eq('user_id', id).maybeSingle(),
supabase.from('daily_usage').select('*')
.eq('user_id', id)
.gte('date', new Date(Date.now() - 30 * 86400000).toISOString().split('T')[0])
.order('date', { ascending: false }),
])
const profile = profileRes.data as Record<string, unknown> | null
if (!profile) notFound()
const sub = subRes.data as Record<string, unknown> | null
const usage = (usageRes.data ?? []) as Array<Record<string, unknown>>
const tier = (profile.tier as string) ?? 'free'
const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber
return (
<Box>
{/* Profile Card */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PROFILE</PhosphorText>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size }}>
<Row label="ID" value={id} />
<Row label="NAME" value={(profile.name as string) ?? '-'} />
<Row label="TIER" value={tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()} valueColor={tierColor} />
<Row label="ROLE" value={((profile.role as string) ?? 'user').toUpperCase()} />
<Row label="LOCALE" value={(profile.locale as string) ?? '-'} />
<Row label="JOINED" value={new Date(profile.created_at as string).toLocaleDateString()} />
</Box>
</Box>
</MetalCard>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>SUBSCRIPTION</PhosphorText>
{sub ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size }}>
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()}
valueColor={(sub.status as string) === 'active' ? d3roPalette.tag.green : d3roPalette.tag.red} />
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? '-').toUpperCase()} />
<Row label="PERIOD START" value={sub.current_period_start ? new Date(sub.current_period_start as string).toLocaleDateString() : '-'} />
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
<Row label="CANCEL AT" value={sub.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : '-'} />
<Row label="FAILURES" value={String(sub.renewal_failures ?? 0)} />
</Box>
) : (
<PhosphorText variant="dim">No subscription</PhosphorText>
)}
</Box>
</MetalCard>
</Grid>
</Grid>
{/* Usage (30 days) */}
<MetalCard>
<Box sx={{ p: 1 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>USAGE (30 DAYS)</PhosphorText>
{usage.length === 0 ? (
<PhosphorText variant="dim">No usage data</PhosphorText>
) : (
<Box
component="table"
sx={{
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
'& th, & td': { py: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
}}
>
<thead>
<tr><th>DATE</th><th>FEATURE</th><th>COUNT</th></tr>
</thead>
<tbody>
{usage.map((row, i) => (
<tr key={i}>
<td>{row.date as string}</td>
<td>{row.feature as string}</td>
<td style={{ color: d3roPalette.accent.amber }}>{row.count as number}</td>
</tr>
))}
</tbody>
</Box>
)}
</Box>
</MetalCard>
</Box>
)
}
function Row({ label, value, valueColor }: { label: string; value: string; valueColor?: string }): React.ReactElement {
return (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: d3roPalette.text.label }}>{label}</span>
<span style={{ color: valueColor ?? d3roPalette.text.primary }}>{value}</span>
</Box>
)
}