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,210 +0,0 @@
|
|||
// apps/web/src/app/(app)/admin/users/page.tsx
|
||||
// Admin 유저 목록 — profiles JOIN subscriptions, 검색/필터/페이지네이션
|
||||
|
||||
import { Box } 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 Link from 'next/link'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
interface UserRow {
|
||||
id: string
|
||||
name: string | null
|
||||
email: string | null
|
||||
tier: string
|
||||
role: string
|
||||
created_at: string
|
||||
subscription_status: string | null
|
||||
payment_provider: string | null
|
||||
}
|
||||
|
||||
async function loadUsers(page: number, search: string, tierFilter: string): Promise<{ users: UserRow[]; total: number }> {
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
// profiles 조회 (DB 타입에 role/조인이 미정의이므로 raw cast)
|
||||
const from = page * PAGE_SIZE
|
||||
const to = from + PAGE_SIZE - 1
|
||||
|
||||
// 단순 profiles 조회 + 별도 subscriptions 조회
|
||||
const profileQuery = supabase
|
||||
.from('profiles')
|
||||
.select('id, name, tier, created_at', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
|
||||
if (search) {
|
||||
profileQuery.ilike('name', `%${search}%`)
|
||||
}
|
||||
|
||||
if (tierFilter && tierFilter !== 'all') {
|
||||
profileQuery.eq('tier', tierFilter as 'free' | 'pro' | 'pro_plus')
|
||||
}
|
||||
|
||||
const { data: rawProfiles, count } = await profileQuery
|
||||
const profiles = (rawProfiles ?? []) as Array<Record<string, unknown>>
|
||||
|
||||
// 해당 유저들의 구독 정보 조회
|
||||
const userIds = profiles.map((p) => p.id as string)
|
||||
const { data: rawSubs } = userIds.length > 0
|
||||
? await supabase.from('subscriptions').select('user_id, status, payment_provider').in('user_id', userIds)
|
||||
: { data: [] }
|
||||
const subs = (rawSubs ?? []) as Array<Record<string, unknown>>
|
||||
const subMap = new Map(subs.map((s) => [s.user_id as string, s]))
|
||||
|
||||
// role 조회 (별도 raw query — DB 타입에 role 미정의)
|
||||
const { data: rawRoles } = userIds.length > 0
|
||||
? await supabase.from('profiles').select('id, role').in('id', userIds)
|
||||
: { data: [] }
|
||||
const roles = (rawRoles ?? []) as Array<Record<string, unknown>>
|
||||
const roleMap = new Map(roles.map((r) => [r.id as string, (r.role as string) ?? 'user']))
|
||||
|
||||
const users: UserRow[] = profiles.map((row) => {
|
||||
const sub = subMap.get(row.id as string)
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string | null,
|
||||
email: null,
|
||||
tier: (row.tier as string) ?? 'free',
|
||||
role: roleMap.get(row.id as string) ?? 'user',
|
||||
created_at: row.created_at as string,
|
||||
subscription_status: (sub?.status as string) ?? null,
|
||||
payment_provider: (sub?.payment_provider as string) ?? null,
|
||||
}
|
||||
})
|
||||
|
||||
return { users, total: count ?? 0 }
|
||||
}
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ page?: string; search?: string; tier?: string }>
|
||||
}
|
||||
|
||||
export default async function AdminUsersPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
const params = await searchParams
|
||||
const page = parseInt(params.page ?? '0', 10)
|
||||
const search = params.search ?? ''
|
||||
const tierFilter = params.tier ?? 'all'
|
||||
const { users, total } = await loadUsers(page, search, tierFilter)
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* Filters */}
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 2, alignItems: 'center' }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.text.label }}>
|
||||
{total} USERS
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1 }} />
|
||||
{['all', 'free', 'pro', 'pro_plus'].map((t) => (
|
||||
<Link
|
||||
key={t}
|
||||
href={`/admin/users?tier=${t}&search=${search}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<PhosphorText
|
||||
variant="label"
|
||||
sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1, cursor: 'pointer',
|
||||
bgcolor: tierFilter === t ? d3roPalette.bg.inset : 'transparent',
|
||||
color: tierFilter === t ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{t === 'all' ? 'ALL' : t === 'pro_plus' ? 'PRO+' : t.toUpperCase()}
|
||||
</PhosphorText>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Table */}
|
||||
<MetalCard sx={{ overflow: 'auto' }}>
|
||||
<Box
|
||||
component="table"
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
'& th, & td': { py: 1, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label, fontWeight: d3roTypo.label.weight, textTransform: 'uppercase', letterSpacing: d3roTypo.label.spacing },
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>NAME</th>
|
||||
<th>TIER</th>
|
||||
<th>ROLE</th>
|
||||
<th>STATUS</th>
|
||||
<th>PROVIDER</th>
|
||||
<th>JOINED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<Link href={`/admin/users/${u.id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
{u.name ?? u.id.substring(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<TierBadge tier={u.tier} />
|
||||
</td>
|
||||
<td style={{ color: u.role === 'admin' ? d3roPalette.tag.purple : d3roPalette.text.secondary }}>
|
||||
{u.role.toUpperCase()}
|
||||
</td>
|
||||
<td style={{ color: u.subscription_status === 'active' ? d3roPalette.tag.green : d3roPalette.text.muted }}>
|
||||
{u.subscription_status?.toUpperCase() ?? '-'}
|
||||
</td>
|
||||
<td style={{ color: d3roPalette.text.secondary }}>
|
||||
{u.payment_provider ?? '-'}
|
||||
</td>
|
||||
<td style={{ color: d3roPalette.text.muted }}>
|
||||
{new Date(u.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: '16px 0' }}>
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2 }}>
|
||||
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => (
|
||||
<Link
|
||||
key={i}
|
||||
href={`/admin/users?page=${i}&tier=${tierFilter}&search=${search}`}
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<PhosphorText
|
||||
variant="label"
|
||||
sx={{
|
||||
px: 1, py: 0.5, borderRadius: 1,
|
||||
bgcolor: page === i ? d3roPalette.accent.amber : d3roPalette.bg.inset,
|
||||
color: page === i ? d3roPalette.bg.app : d3roPalette.text.secondary,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</PhosphorText>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function TierBadge({ tier }: { tier: string }): React.ReactElement {
|
||||
const color = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary
|
||||
const label = tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()
|
||||
return <span style={{ color }}>{label}</span>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue