d3ro-voice/apps/admin/src/app/(admin)/users/[id]/page.tsx
윤찬 46673ee941 refactor(admin): CRM을 apps/admin 독립 프로젝트로 분리
- 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 등록
2026-04-12 20:36:24 +09:00

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>
)
}