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:
윤찬 2026-04-13 01:28:10 +09:00
parent d0e854c255
commit 8af75a0a1e
50 changed files with 2185 additions and 950 deletions

View file

@ -1,18 +1,22 @@
// apps/admin/src/app/(admin)/page.tsx
// CRM 대시보드 — 요약 카드 4개
// D3RO Console — Dashboard Overview
import { Box, Grid } from '@mui/material'
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
import { d3roPalette } from '@d3ro/ui/theme'
import { Box } from '@mui/material'
import { getSupabaseServerClient } from '@/lib/supabase-server'
import { C, FONT, panelSx, tableSx } from '@/lib/console-theme'
import Link from 'next/link'
interface StatCard {
interface StatData {
label: string
value: string | number
color?: string
value: number
color: string
glowClass: string
borderColor: string
badge: string
badgeColor?: string
}
async function loadStats(): Promise<StatCard[]> {
async function loadStats(): Promise<StatData[]> {
const supabase = await getSupabaseServerClient()
const [profilesRes, paidRes, usageRes, expiringRes] = await Promise.all([
@ -30,35 +34,230 @@ async function loadStats(): Promise<StatCard[]> {
?.reduce((sum, r) => sum + (r.count ?? 0), 0) ?? 0
return [
{ label: 'TOTAL USERS', value: profilesRes.count ?? 0 },
{ label: 'PAID SUBSCRIBERS', value: paidRes.count ?? 0, color: d3roPalette.tag.green },
{ label: 'TODAY API CALLS', value: todayUsage, color: d3roPalette.accent.amber },
{ label: 'EXPIRING (7D)', value: expiringRes.count ?? 0, color: d3roPalette.tag.red },
{ label: 'Total Users', value: profilesRes.count ?? 0, color: C.bright, glowClass: 'glow-white', borderColor: C.bright, badge: 'ALL TIME' },
{ label: 'Paid Subscribers', value: paidRes.count ?? 0, color: C.green400, glowClass: 'glow-green', borderColor: C.green, badge: 'ACTIVE' },
{ label: 'Today API Calls', value: todayUsage, color: C.orange, glowClass: 'glow-orange', borderColor: C.orange, badge: '24H VOL', badgeColor: C.orange400 },
{ label: 'Expiring (7D)', value: expiringRes.count ?? 0, color: C.red, glowClass: 'glow-red', borderColor: C.red, badge: 'WARNING' },
]
}
async function loadRecentAuditLogs(): Promise<Array<Record<string, unknown>>> {
const supabase = await getSupabaseServerClient()
const { data } = await supabase
.from('audit_log')
.select('*')
.order('created_at', { ascending: false })
.limit(10)
return (data ?? []) as Array<Record<string, unknown>>
}
function MiniBarChart({ value, maxVal, color }: { value: number; maxVal: number; color: string }): React.ReactElement {
const heights = [25, 50, 33, 75, maxVal > 0 ? Math.max(10, (value / Math.max(maxVal, 1)) * 100) : 5]
return (
<Box sx={{ display: 'flex', gap: '3px', alignItems: 'flex-end', height: 32 }}>
{heights.map((h, i) => (
<Box key={i} sx={{
width: 6,
height: `${h}%`,
bgcolor: i === 4 ? color : C.borderHl,
...(i === 4 ? { boxShadow: `0 0 8px ${color}80` } : {}),
}} />
))}
</Box>
)
}
export default async function AdminOverviewPage(): Promise<React.ReactElement> {
const stats = await loadStats()
const logs = await loadRecentAuditLogs()
const maxStatVal = Math.max(...stats.map((s) => s.value), 1)
return (
<Box>
<PhosphorText variant="title" sx={{ mb: 3 }}>OVERVIEW</PhosphorText>
<Grid container spacing={2}>
{stats.map((stat) => (
<Grid size={{ xs: 12, sm: 6, md: 3 }} key={stat.label}>
<MetalCard>
<Box sx={{ textAlign: 'center', py: 2 }}>
<PhosphorText variant="label" sx={{ mb: 1, display: 'block', color: d3roPalette.text.label }}>
{stat.label}
</PhosphorText>
<PhosphorText variant="hero" sx={{ color: stat.color ?? d3roPalette.text.primary }}>
{stat.value}
</PhosphorText>
<>
{/* Header bar */}
<Box sx={{
...panelSx,
height: 88, flexShrink: 0,
display: 'flex', alignItems: 'center',
px: 4, justifyContent: 'space-between',
}}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.03, pointerEvents: 'none' }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, position: 'relative', zIndex: 1 }}>
<Box sx={{ width: 4, height: 32, bgcolor: C.accent, borderRadius: 4 }} />
<Box>
<Box component="h2" sx={{
fontFamily: FONT, fontSize: '20px', fontWeight: 300,
letterSpacing: '0.15em', textTransform: 'uppercase',
color: C.bright, m: 0,
}}>
Dashboard Overview
</Box>
<Box component="p" sx={{
fontFamily: FONT, fontSize: '12px',
letterSpacing: '0.15em', color: C.dim, mt: 0.5, m: 0,
}}>
REAL-TIME TELEMETRY
</Box>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, position: 'relative', zIndex: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '9px', letterSpacing: '0.2em', textTransform: 'uppercase', color: C.dim, mb: 0.5 }}>
Server Status
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', fontWeight: 500, letterSpacing: '0.1em', color: C.green400 }}>
NOMINAL
</Box>
</MetalCard>
</Grid>
))}
</Grid>
</Box>
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke={C.green400}><path strokeLinecap="square" strokeWidth="2" d="M5 13l4 4L19 7" /></svg>
</Box>
</Box>
<Box sx={{ height: 32, width: '1px', bgcolor: C.borderHl }} />
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '9px', letterSpacing: '0.2em', textTransform: 'uppercase', color: C.dim, mb: 0.5 }}>
Region
</Box>
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', fontWeight: 500, letterSpacing: '0.1em', color: C.bright }}>
AP-SEOUL
</Box>
</Box>
</Box>
</Box>
{/* Main content */}
<Box sx={{ flex: 1, display: 'flex', gap: 2, overflow: 'hidden' }}>
{/* Left: Stats cards */}
<Box sx={{ width: 400, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 2, overflowY: 'auto', pr: 0.5 }}>
{stats.map((stat) => (
<Box key={stat.label} sx={panelSx}>
<Box sx={{ p: 3, position: 'relative', zIndex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', minHeight: 140 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box component="h3" sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 400,
letterSpacing: '0.25em', textTransform: 'uppercase',
color: C.text, m: 0,
borderLeft: `2px solid ${stat.borderColor}`,
pl: 1,
}}>
{stat.label}
</Box>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '10px',
color: stat.badgeColor ?? C.dim,
bgcolor: C.border,
px: 1, py: 0.5, borderRadius: '4px',
}}>
{stat.badge}
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', mt: 2 }}>
<MiniBarChart value={stat.value} maxVal={maxStatVal} color={stat.color} />
<Box
className={stat.glowClass}
component="span"
sx={{
fontFamily: FONT,
fontSize: '3.75rem',
fontWeight: 300,
lineHeight: 1,
color: stat.color,
letterSpacing: '-0.05em',
fontVariantNumeric: 'tabular-nums',
}}
>
{stat.value}
</Box>
</Box>
</Box>
</Box>
))}
</Box>
{/* Right: Activity Log */}
<Box sx={{ ...panelSx, flex: 1, display: 'flex', flexDirection: 'column', minHeight: 400 }}>
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
{/* Log header */}
<Box sx={{
px: 3, py: 2,
borderBottom: `1px solid ${C.border}`,
bgcolor: `${C.base}4D`,
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
position: 'relative', zIndex: 1,
}}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke={C.dim}><path strokeLinecap="square" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" /></svg>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '12px', fontWeight: 500,
letterSpacing: '0.2em', textTransform: 'uppercase', color: C.bright,
}}>
System Activity Log
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Link href="/audit-log" style={{ textDecoration: 'none' }}>
<Box component="span" sx={{
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
letterSpacing: '0.1em', textTransform: 'uppercase',
px: 1.5, py: 0.5, borderRadius: '4px',
bgcolor: C.borderHl, color: C.bright,
cursor: 'pointer',
'&:hover': { bgcolor: C.dim },
transition: 'background 0.15s',
}}>
View All
</Box>
</Link>
</Box>
</Box>
{/* Log table */}
<Box sx={{ flex: 1, overflow: 'auto', p: 3, position: 'relative', zIndex: 1 }}>
<Box component="table" sx={tableSx}>
<thead>
<tr>
<th style={{ width: 140 }}>Timestamp</th>
<th style={{ width: 120 }}>Action</th>
<th style={{ width: 100 }}>Target</th>
<th>Memo</th>
</tr>
</thead>
<tbody>
{logs.length === 0 ? (
<tr>
<td colSpan={4} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>
No activity records yet
</td>
</tr>
) : (
logs.map((log) => {
const action = log.action as string
const actionColor = action.includes('delete') ? C.red400
: action.includes('create') ? C.green400
: action.includes('role') ? C.purple400
: C.orange400
return (
<tr key={log.id as number}>
<td style={{ color: C.dim, whiteSpace: 'nowrap' }}>
{new Date(log.created_at as string).toLocaleTimeString()}
</td>
<td style={{ color: actionColor }}>
{action.toUpperCase().replace('.', '_')}
</td>
<td>{(log.target_type as string).toUpperCase()}</td>
<td style={{ color: C.dim }}>
{((log.memo as string) ?? '').substring(0, 60)}
</td>
</tr>
)
})
)}
</tbody>
</Box>
</Box>
</Box>
</Box>
</>
)
}