- apps/web에서 admin 라우트/가드/sidebar 링크 제거 - apps/admin: 독립 Next.js 앱 (포트 3001) - 자체 login/unauthorized/auth callback - admin-sidebar: Overview/Users/Subscriptions/Usage - requireAdmin() 가드: profile.role='admin' 체크 - monorepo workspace에 apps/admin 등록
109 lines
5 KiB
TypeScript
109 lines
5 KiB
TypeScript
// apps/admin/src/app/(admin)/users/[id]/page.tsx
|
|
// 유저 상세 — 프로필 + 구독 + 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>
|
|
<PhosphorText variant="title" sx={{ mb: 3 }}>USER DETAIL</PhosphorText>
|
|
|
|
<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="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 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() : '-'} />
|
|
</Box>
|
|
) : (
|
|
<PhosphorText variant="dim">No subscription</PhosphorText>
|
|
)}
|
|
</Box>
|
|
</MetalCard>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<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>
|
|
)
|
|
}
|