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() 선행 토큰 갱신)
This commit is contained in:
parent
d0e854c255
commit
8af75a0a1e
50 changed files with 2185 additions and 950 deletions
|
|
@ -1,9 +1,8 @@
|
|||
// apps/admin/src/app/(admin)/users/page.tsx
|
||||
// 유저 목록 — profiles + subscriptions, 검색/필터/페이지네이션
|
||||
// D3RO Console — Users list
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { C, FONT, panelSx, tableSx, filterBtnSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import Link from 'next/link'
|
||||
|
||||
|
|
@ -21,7 +20,6 @@ interface UserRow {
|
|||
|
||||
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
|
||||
|
||||
|
|
@ -31,12 +29,8 @@ async function loadUsers(page: number, search: string, tierFilter: string): Prom
|
|||
.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')
|
||||
}
|
||||
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>>
|
||||
|
|
@ -84,72 +78,124 @@ export default async function AdminUsersPage({ searchParams }: PageProps): Promi
|
|||
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 (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>USERS</PhosphorText>
|
||||
|
||||
<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={`/users?tier=${t}&search=${search}`} style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1,
|
||||
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>
|
||||
|
||||
<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' },
|
||||
}}>
|
||||
<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={`/users/${u.id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
{u.name ?? u.id.substring(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ color: u.tier === 'pro_plus' ? d3roPalette.tag.purple : u.tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary }}>
|
||||
{u.tier === 'pro_plus' ? 'PRO+' : u.tier.toUpperCase()}
|
||||
</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>
|
||||
<>
|
||||
{/* 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>
|
||||
</MetalCard>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2 }}>
|
||||
{Array.from({ length: Math.min(totalPages, 10) }, (_, i) => (
|
||||
<Link key={i} href={`/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>
|
||||
<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>
|
||||
</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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue