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 등록
This commit is contained in:
parent
daed9f90d5
commit
46673ee941
23 changed files with 536 additions and 343 deletions
|
|
@ -1,120 +0,0 @@
|
|||
// 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue