d3ro-voice/apps/admin/src/app/(admin)/users/page.tsx
윤찬 8af75a0a1e feat: V2-7 Admin 콘솔 리디자인 + 3단계 권한 + SaaS 전환 + 코드 정리
- Admin CRM: D3RO Console 스타일 전체 적용 (panelSx/tableSx/filterBtnSx)
- 3단계 권한: manager/admin/super_admin (DB + Edge Functions + Frontend)
- 랜딩 페이지: 1회 결제 → 월간/연간 구독 SaaS 모델 (10개 언어)
- SSE 스트리밍: VoiceConversation Premium LLM 라우팅 + fallback
- Supabase 클라이언트: packages/api-client 공통 추출 (browser+server)
- RPC 함수 타입: 9개 정의 (admin_usage_by_feature 등)
- callAdminApi 401 버그 수정 (getUser() 선행 토큰 갱신)
2026-04-13 01:28:10 +09:00

201 lines
7.8 KiB
TypeScript

// apps/admin/src/app/(admin)/users/page.tsx
// D3RO Console — Users list
import { Box } from '@mui/material'
import { C, FONT, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import Link from 'next/link'
const PAGE_SIZE = 20
interface UserRow {
id: string
name: 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()
const from = page * PAGE_SIZE
const to = from + PAGE_SIZE - 1
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 subMap = new Map(
((rawSubs ?? []) as Array<Record<string, unknown>>).map((s) => [s.user_id as string, s])
)
const { data: rawRoles } = userIds.length > 0
? await supabase.from('profiles').select('id, role').in('id', userIds)
: { data: [] }
const roleMap = new Map(
((rawRoles ?? []) as Array<Record<string, unknown>>).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,
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)
const roleColor = (r: string): string =>
r === 'super_admin' ? C.purple400 : r === 'admin' ? C.green400 : r === 'manager' ? C.orange400 : C.dim
return (
<>
{/* Header */}
<Box sx={{
...panelSx,
height: 72, flexShrink: 0,
display: 'flex', alignItems: 'center', px: 4, justifyContent: 'space-between',
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ width: 4, height: 24, bgcolor: C.accent, borderRadius: 4 }} />
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '18px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase', color: C.bright, m: 0,
}}>
Users
</Box>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '10px', letterSpacing: '0.1em',
color: C.dim, bgcolor: C.border, px: 1, py: 0.5, borderRadius: '4px',
}}>
{total} TOTAL
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{['all', 'free', 'pro', 'pro_plus'].map((t) => (
<Link key={t} href={`/users?tier=${t}&search=${search}`} style={{ textDecoration: 'none' }}>
<Box sx={filterBtnSx(tierFilter === t)}>
{t === 'all' ? 'ALL' : t === 'pro_plus' ? 'PRO+' : t.toUpperCase()}
</Box>
</Link>
))}
</Box>
</Box>
{/* Table */}
<Box sx={{ ...panelSx, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
<Box sx={{ flex: 1, overflow: 'auto', p: 3, position: 'relative', zIndex: 1 }}>
<Box component="table" sx={tableSx}>
<thead>
<tr>
<th>Name</th>
<th style={{ width: 80 }}>Tier</th>
<th style={{ width: 100 }}>Role</th>
<th style={{ width: 100 }}>Status</th>
<th style={{ width: 100 }}>Provider</th>
<th style={{ width: 110 }}>Joined</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>
<Link href={`/users/${u.id}`} style={{ color: C.accent, textDecoration: 'none' }}>
{u.name ?? u.id.substring(0, 8)}
</Link>
</td>
<td>
<Box component="span" sx={statusBadgeSx(
u.tier === 'pro_plus' ? 'purple' : u.tier === 'pro' ? 'green' : 'blue'
)}>
{u.tier === 'pro_plus' ? 'PRO+' : u.tier.toUpperCase()}
</Box>
</td>
<td style={{ color: roleColor(u.role) }}>{u.role.toUpperCase()}</td>
<td>
{u.subscription_status ? (
<Box component="span" sx={statusBadgeSx(
u.subscription_status === 'active' ? 'green' : u.subscription_status === 'expired' ? 'red' : 'orange'
)}>
{u.subscription_status.toUpperCase()}
</Box>
) : (
<span style={{ color: C.dim }}>-</span>
)}
</td>
<td style={{ color: C.dim }}>{u.payment_provider ?? '-'}</td>
<td style={{ color: C.dim, whiteSpace: 'nowrap' }}>{new Date(u.created_at).toLocaleDateString()}</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={6} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>No users found</td></tr>
)}
</tbody>
</Box>
</Box>
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{
display: 'flex', justifyContent: 'center', gap: 0.5,
py: 2, borderTop: `1px solid ${C.border}`,
position: 'relative', zIndex: 1,
}}>
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => (
<Link key={i} href={`/users?page=${i}&tier=${tierFilter}&search=${search}`} style={{ textDecoration: 'none' }}>
<Box sx={{
fontFamily: FONT, fontSize: '11px', fontWeight: 500,
width: 28, height: 28,
display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '4px',
bgcolor: page === i ? C.accent : 'transparent',
color: page === i ? C.bright : C.dim,
border: `1px solid ${page === i ? C.accent : C.border}`,
cursor: 'pointer',
'&:hover': { borderColor: C.dim },
transition: 'all 0.15s',
}}>
{i + 1}
</Box>
</Link>
))}
</Box>
)}
</Box>
</>
)
}