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,31 +1,41 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/admin-sidebar.tsx
|
||||
// Admin CRM 사이드바
|
||||
// D3RO Console 사이드바
|
||||
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { Box, List, ListItem, ListItemButton, ListItemIcon, ListItemText, Button } from '@mui/material'
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard'
|
||||
import PeopleIcon from '@mui/icons-material/People'
|
||||
import SubscriptionsIcon from '@mui/icons-material/Subscriptions'
|
||||
import BarChartIcon from '@mui/icons-material/BarChart'
|
||||
import HistoryIcon from '@mui/icons-material/History'
|
||||
import ApiIcon from '@mui/icons-material/Api'
|
||||
import LogoutIcon from '@mui/icons-material/Logout'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { Box } from '@mui/material'
|
||||
import { C, FONT } from '@/lib/console-theme'
|
||||
import { getSupabaseBrowserClient } from '@/lib/supabase-browser'
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ key: 'overview', path: '/', label: 'Overview', icon: <DashboardIcon /> },
|
||||
{ key: 'users', path: '/users', label: 'Users', icon: <PeopleIcon /> },
|
||||
{ key: 'subscriptions', path: '/subscriptions', label: 'Subscriptions', icon: <SubscriptionsIcon /> },
|
||||
{ key: 'usage', path: '/usage', label: 'Usage', icon: <BarChartIcon /> },
|
||||
{ key: 'audit-log', path: '/audit-log', label: 'Audit Log', icon: <HistoryIcon /> },
|
||||
]
|
||||
interface NavItem {
|
||||
key: string
|
||||
path: string
|
||||
label: string
|
||||
icon: React.ReactElement
|
||||
}
|
||||
|
||||
const EXTERNAL_LINKS = [
|
||||
{ key: 'swagger', href: '/admin-swagger/', label: 'API Docs', icon: <ApiIcon /> },
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
key: 'overview', path: '/', label: 'Overview',
|
||||
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z" /></svg>,
|
||||
},
|
||||
{
|
||||
key: 'users', path: '/users', label: 'Users',
|
||||
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" /></svg>,
|
||||
},
|
||||
{
|
||||
key: 'subscriptions', path: '/subscriptions', label: 'Subscriptions',
|
||||
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>,
|
||||
},
|
||||
{
|
||||
key: 'usage', path: '/usage', label: 'Usage Data',
|
||||
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>,
|
||||
},
|
||||
{
|
||||
key: 'audit-log', path: '/audit-log', label: 'Audit Log',
|
||||
icon: <svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>,
|
||||
},
|
||||
]
|
||||
|
||||
export function AdminSidebar(): React.ReactElement {
|
||||
|
|
@ -38,100 +48,212 @@ export function AdminSidebar(): React.ReactElement {
|
|||
router.replace('/login')
|
||||
}
|
||||
|
||||
const isActive = (path: string): boolean =>
|
||||
path === '/' ? pathname === '/' : pathname.startsWith(path)
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
width: 220,
|
||||
minHeight: '100vh',
|
||||
bgcolor: d3roPalette.bg.sidebar,
|
||||
borderRight: `1px solid ${d3roPalette.border.default}`,
|
||||
width: 280,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
bgcolor: C.panel,
|
||||
border: `1px solid ${C.border}`,
|
||||
borderRadius: '16px',
|
||||
flexShrink: 0,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.6)',
|
||||
}}>
|
||||
<Box sx={{ p: 2.5, borderBottom: `1px solid ${d3roPalette.border.default}` }}>
|
||||
<PhosphorText variant="heading">D3RO ADMIN</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ display: 'block', mt: 0.5, fontSize: 10 }}>CRM Console</PhosphorText>
|
||||
{/* Grid pattern overlay */}
|
||||
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.03, pointerEvents: 'none' }} />
|
||||
|
||||
{/* Header */}
|
||||
<Box sx={{
|
||||
px: 3, py: 4,
|
||||
borderBottom: `1px solid ${C.border}`,
|
||||
position: 'relative',
|
||||
}}>
|
||||
{/* Top accent line */}
|
||||
<Box sx={{
|
||||
position: 'absolute', top: 0, left: 0, width: '100%', height: '1px',
|
||||
background: `linear-gradient(to right, transparent, ${C.accent}, transparent)`,
|
||||
opacity: 0.5,
|
||||
}} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
||||
<Box component="span" sx={{
|
||||
fontFamily: FONT, fontSize: '14px', fontWeight: 700,
|
||||
letterSpacing: '0.2em', textTransform: 'uppercase',
|
||||
color: C.bright,
|
||||
}}>
|
||||
D3RO // ADMIN
|
||||
</Box>
|
||||
{/* Status dot */}
|
||||
<Box sx={{ position: 'relative', width: 8, height: 8 }}>
|
||||
<Box sx={{
|
||||
position: 'absolute', inset: 0, borderRadius: '50%',
|
||||
bgcolor: C.green, opacity: 0.75,
|
||||
animation: 'pulse-ring 1.5s ease-out infinite',
|
||||
'@keyframes pulse-ring': {
|
||||
'0%': { transform: 'scale(0.8)', opacity: 1 },
|
||||
'100%': { transform: 'scale(2.5)', opacity: 0 },
|
||||
},
|
||||
}} />
|
||||
<Box sx={{ position: 'relative', width: 8, height: 8, borderRadius: '50%', bgcolor: C.green }} />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="span" sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.15em', textTransform: 'uppercase',
|
||||
color: C.dim,
|
||||
}}>
|
||||
SYS.CONSOLE.v2
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<List sx={{ flex: 1, py: 1 }}>
|
||||
{/* Nav */}
|
||||
<Box component="nav" sx={{
|
||||
flex: 1, overflow: 'auto', py: 2, px: 1.5,
|
||||
display: 'flex', flexDirection: 'column', gap: 0.5,
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const active = item.path === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.path)
|
||||
const active = isActive(item.path)
|
||||
return (
|
||||
<ListItem key={item.key} disablePadding>
|
||||
<ListItemButton
|
||||
selected={active}
|
||||
onClick={() => router.push(item.path)}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
'&.Mui-selected': {
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderLeft: `3px solid ${d3roPalette.accent.amber}`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36, color: active ? d3roPalette.accent.amber : d3roPalette.text.inactive }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: active ? d3roPalette.text.primary : d3roPalette.text.secondary,
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
<Box
|
||||
key={item.key}
|
||||
onClick={() => router.push(item.path)}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
px: 2, py: 1.5,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
border: `1px solid ${active ? C.borderHl : 'transparent'}`,
|
||||
bgcolor: active ? `${C.borderHl}80` : 'transparent',
|
||||
color: active ? C.bright : C.text,
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': {
|
||||
bgcolor: active ? `${C.borderHl}80` : C.panelHover,
|
||||
borderColor: active ? C.borderHl : C.border,
|
||||
color: C.bright,
|
||||
'& svg': { color: active ? C.accent : C.bright },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Active indicator bar */}
|
||||
{active && (
|
||||
<Box sx={{
|
||||
position: 'absolute', left: 0, top: '50%', transform: 'translateY(-50%)',
|
||||
width: 3, height: '50%', bgcolor: C.accent, borderRadius: '0 4px 4px 0',
|
||||
}} />
|
||||
)}
|
||||
<Box sx={{
|
||||
mr: 2, display: 'flex', alignItems: 'center',
|
||||
color: active ? C.accent : C.dim,
|
||||
transition: 'color 0.15s',
|
||||
}}>
|
||||
{item.icon}
|
||||
</Box>
|
||||
<Box component="span" sx={{
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase',
|
||||
}}>
|
||||
{item.label}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
<ListItem disablePadding sx={{ mt: 1 }}>
|
||||
<ListItemText
|
||||
primary="EXTERNAL"
|
||||
primaryTypographyProps={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 10,
|
||||
color: d3roPalette.text.label,
|
||||
px: 2,
|
||||
pt: 1,
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
{EXTERNAL_LINKS.map((item) => (
|
||||
<ListItem key={item.key} disablePadding>
|
||||
<ListItemButton
|
||||
component="a"
|
||||
href={item.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{ fontFamily: d3roFontMono }}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: 36, color: d3roPalette.text.inactive }}>
|
||||
{item.icon}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={item.label}
|
||||
primaryTypographyProps={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.secondary,
|
||||
}}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ p: 2, borderTop: `1px solid ${d3roPalette.border.default}` }}>
|
||||
<Button
|
||||
fullWidth
|
||||
size="small"
|
||||
startIcon={<LogoutIcon />}
|
||||
onClick={() => void handleLogout()}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: 12, color: d3roPalette.text.secondary }}
|
||||
{/* External Links Section */}
|
||||
<Box sx={{ mt: 4, mb: 1, px: 2 }}>
|
||||
<Box sx={{ height: '1px', width: '100%', bgcolor: C.border, mb: 2 }} />
|
||||
<Box component="span" sx={{
|
||||
fontFamily: FONT, fontSize: '9px', fontWeight: 500,
|
||||
letterSpacing: '0.2em', textTransform: 'uppercase',
|
||||
color: C.dim,
|
||||
}}>
|
||||
External Links
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
component="a"
|
||||
href="/admin-swagger/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center',
|
||||
px: 2, py: 1.5, borderRadius: '8px',
|
||||
cursor: 'pointer', textDecoration: 'none',
|
||||
border: '1px solid transparent',
|
||||
color: C.text,
|
||||
transition: 'all 0.15s',
|
||||
'&:hover': {
|
||||
bgcolor: C.panelHover,
|
||||
borderColor: C.border,
|
||||
color: C.bright,
|
||||
'& svg': { color: C.bright },
|
||||
},
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Button>
|
||||
<Box sx={{ mr: 2, display: 'flex', alignItems: 'center', color: C.dim, transition: 'color 0.15s' }}>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>
|
||||
</Box>
|
||||
<Box component="span" sx={{
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase',
|
||||
}}>
|
||||
API Docs
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* User card footer */}
|
||||
<Box sx={{
|
||||
p: 2,
|
||||
bgcolor: `${C.base}80`,
|
||||
borderTop: `1px solid ${C.border}`,
|
||||
backdropFilter: 'blur(8px)',
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<Box
|
||||
onClick={() => void handleLogout()}
|
||||
sx={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
p: 1.5, borderRadius: '8px',
|
||||
border: `1px solid ${C.borderHl}`,
|
||||
bgcolor: C.panel,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: C.dim,
|
||||
'& .logout-icon': { color: C.accent },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Box sx={{
|
||||
width: 32, height: 32, borderRadius: '4px',
|
||||
bgcolor: C.borderHl,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
border: `1px solid ${C.dim}4D`,
|
||||
}}>
|
||||
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', fontWeight: 700, color: C.bright }}>A</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Box component="span" sx={{ fontFamily: FONT, fontSize: '11px', fontWeight: 500, letterSpacing: '0.05em', color: C.bright }}>
|
||||
Admin User
|
||||
</Box>
|
||||
<Box component="span" sx={{ fontFamily: FONT, fontSize: '9px', letterSpacing: '0.15em', textTransform: 'uppercase', color: C.dim }}>
|
||||
SUPER_ADMIN
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className="logout-icon" sx={{ color: C.dim, display: 'flex', transition: 'color 0.15s' }}>
|
||||
<svg width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="square" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue