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,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue