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,11 +1,10 @@
|
|||
// apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
|
||||
// 감사로그 상세 — before/after diff 뷰
|
||||
// D3RO Console — Audit log detail
|
||||
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { C, FONT, panelSx } from '@/lib/console-theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { AuditDiffViewer } from '@/components/audit-diff-viewer'
|
||||
|
|
@ -15,7 +14,7 @@ interface PageProps {
|
|||
}
|
||||
|
||||
export default async function AuditLogDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
await requireAdmin()
|
||||
await requireManager()
|
||||
const { id } = await params
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
|
|
@ -38,59 +37,93 @@ export default async function AuditLogDetailPage({ params }: PageProps): Promise
|
|||
const adminName = (adminProfile as { name: string | null } | null)?.name ?? 'Unknown'
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">AUDIT LOG #{id}</PhosphorText>
|
||||
<Link href="/audit-log" style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
|
||||
</Link>
|
||||
<>
|
||||
{/* 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,
|
||||
}}>
|
||||
Audit Log #{id}
|
||||
</Box>
|
||||
<Link href="/audit-log" style={{ textDecoration: 'none' }}>
|
||||
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim, '&:hover': { color: C.text } }}>
|
||||
{'<'} Back
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DETAILS</PhosphorText>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Row label="ACTION" value={typedLog.action as string} />
|
||||
<Row label="ADMIN" value={adminName} />
|
||||
<Row label="TARGET TYPE" value={typedLog.target_type as string} />
|
||||
<Row label="TARGET ID" value={typedLog.target_id as string} />
|
||||
<Row label="DATE" value={new Date(typedLog.created_at as string).toLocaleString()} />
|
||||
{/* Content */}
|
||||
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
|
||||
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
|
||||
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{/* Details card */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Details
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
|
||||
<Row label="ACTION" value={typedLog.action as string} valueColor={C.accent} />
|
||||
<Row label="ADMIN" value={adminName} />
|
||||
<Row label="TARGET TYPE" value={typedLog.target_type as string} />
|
||||
<Row label="TARGET ID" value={typedLog.target_id as string} />
|
||||
<Row label="DATE" value={new Date(typedLog.created_at as string).toLocaleString()} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>MEMO</PhosphorText>
|
||||
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{typedLog.memo as string}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>CHANGES (DIFF)</PhosphorText>
|
||||
<AuditDiffViewer
|
||||
beforeData={typedLog.before_data as Record<string, unknown> | null}
|
||||
afterData={typedLog.after_data as Record<string, unknown> | null}
|
||||
/>
|
||||
{/* Memo card */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Memo
|
||||
</Box>
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.text, whiteSpace: 'pre-wrap' }}>
|
||||
{typedLog.memo as string}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Diff viewer */}
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Changes (Diff)
|
||||
</Box>
|
||||
<AuditDiffViewer
|
||||
beforeData={typedLog.before_data as Record<string, unknown> | null}
|
||||
afterData={typedLog.after_data as Record<string, unknown> | null}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
function Row({ label, value, valueColor }: { label: string; value: string; valueColor?: string }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: d3roPalette.text.label }}>{label}</span>
|
||||
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box component="span" sx={{ color: C.dim }}>{label}</Box>
|
||||
<Box component="span" sx={{ color: valueColor ?? C.text }}>{value}</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
// apps/admin/src/app/(admin)/audit-log/page.tsx
|
||||
// 감사로그 목록
|
||||
// D3RO Console — Audit log 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 } from '@/lib/console-theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface PageProps {
|
||||
|
|
@ -13,7 +12,7 @@ interface PageProps {
|
|||
}
|
||||
|
||||
export default async function AuditLogPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
await requireAdmin()
|
||||
await requireManager()
|
||||
const params = await searchParams
|
||||
const targetTypeFilter = params.target_type ?? 'all'
|
||||
const page = parseInt(params.page ?? '1', 10)
|
||||
|
|
@ -54,84 +53,120 @@ export default async function AuditLogPage({ searchParams }: PageProps): Promise
|
|||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>AUDIT LOG</PhosphorText>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
{['all', 'subscription', 'profile'].map((t) => (
|
||||
<Link key={t} href={`/audit-log?target_type=${t}`} style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1,
|
||||
bgcolor: targetTypeFilter === t ? d3roPalette.bg.inset : 'transparent',
|
||||
color: targetTypeFilter === t ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}>
|
||||
{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: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
|
||||
}}>
|
||||
<thead><tr><th>DATE</th><th>ADMIN</th><th>ACTION</th><th>TARGET</th><th>MEMO</th><th>DETAIL</th></tr></thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: 16 }}>No audit logs</td></tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id as number}>
|
||||
<td style={{ color: d3roPalette.text.muted, whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.created_at as string).toLocaleString()}
|
||||
</td>
|
||||
<td>{adminMap[log.admin_id as string] ?? (log.admin_id as string).substring(0, 8)}</td>
|
||||
<td style={{ color: d3roPalette.accent.amber }}>{log.action as string}</td>
|
||||
<td>
|
||||
<Link
|
||||
href={
|
||||
(log.target_type as string) === 'subscription'
|
||||
? `/subscriptions/${log.target_id as string}`
|
||||
: `/users/${log.target_id as string}`
|
||||
}
|
||||
style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}
|
||||
>
|
||||
{(log.target_id as string).substring(0, 8)}...
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{log.memo as string}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/audit-log/${log.id as number}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
View
|
||||
</Link>
|
||||
</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,
|
||||
}}>
|
||||
Audit Log
|
||||
</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',
|
||||
}}>
|
||||
{count ?? 0} TOTAL
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Box sx={{ mt: 2, display: 'flex', gap: 1, justifyContent: 'center' }}>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).slice(0, 10).map((p) => (
|
||||
<Link key={p} href={`/audit-log?target_type=${targetTypeFilter}&page=${p}`} style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1, py: 0.25, borderRadius: 0.5,
|
||||
bgcolor: p === page ? d3roPalette.bg.inset : 'transparent',
|
||||
color: p === page ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}>
|
||||
{p}
|
||||
</PhosphorText>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{['all', 'subscription', 'profile'].map((t) => (
|
||||
<Link key={t} href={`/audit-log?target_type=${t}`} style={{ textDecoration: 'none' }}>
|
||||
<Box sx={filterBtnSx(targetTypeFilter === t)}>
|
||||
{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>Date</th>
|
||||
<th>Admin</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
<th>Memo</th>
|
||||
<th style={{ width: 60 }}>Detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>No audit logs</td></tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id as number}>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.created_at as string).toLocaleString()}
|
||||
</td>
|
||||
<td>{adminMap[log.admin_id as string] ?? (log.admin_id as string).substring(0, 8)}</td>
|
||||
<td style={{ color: C.accent }}>{log.action as string}</td>
|
||||
<td>
|
||||
<Link
|
||||
href={
|
||||
(log.target_type as string) === 'subscription'
|
||||
? `/subscriptions/${log.target_id as string}`
|
||||
: `/users/${log.target_id as string}`
|
||||
}
|
||||
style={{ color: C.accent, textDecoration: 'none' }}
|
||||
>
|
||||
{(log.target_id as string).substring(0, 8)}...
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{log.memo as string}
|
||||
</td>
|
||||
<td>
|
||||
<Link href={`/audit-log/${log.id as number}`} style={{ color: C.accent, textDecoration: 'none' }}>
|
||||
View
|
||||
</Link>
|
||||
</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) => i + 1).map((p) => (
|
||||
<Link key={p} href={`/audit-log?target_type=${targetTypeFilter}&page=${p}`} style={{ textDecoration: 'none' }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '11px', fontWeight: 500,
|
||||
width: 28, height: 28,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '4px',
|
||||
bgcolor: p === page ? C.accent : 'transparent',
|
||||
color: p === page ? C.bright : C.dim,
|
||||
border: `1px solid ${p === page ? C.accent : C.border}`,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { borderColor: C.dim },
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{p}
|
||||
</Box>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,37 @@
|
|||
// apps/admin/src/app/(admin)/layout.tsx
|
||||
// Admin 레이아웃 — requireAdmin() 가드 + Sidebar
|
||||
// Admin 레이아웃 — D3RO Console 스타일
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
import { AdminSidebar } from '@/components/admin-sidebar'
|
||||
import { C } from '@/lib/console-theme'
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): Promise<React.ReactElement> {
|
||||
await requireAdmin()
|
||||
await requireManager()
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
|
||||
<Box sx={{
|
||||
display: 'flex',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
overflow: 'hidden',
|
||||
p: 2,
|
||||
gap: 2,
|
||||
bgcolor: C.base,
|
||||
}}>
|
||||
<AdminSidebar />
|
||||
<Box component="main" sx={{ flex: 1, overflow: 'auto', p: 4 }}>
|
||||
<Box component="main" sx={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
|
|||
interface SubscriptionDetailClientProps {
|
||||
userId: string
|
||||
hasSub: boolean
|
||||
isSuperAdmin: boolean
|
||||
/** manager 이상: 구독 수정 가능 */
|
||||
canEdit: boolean
|
||||
/** admin 이상: 구독 생성/삭제 가능 */
|
||||
canCreateDelete: boolean
|
||||
initialSub?: {
|
||||
tier: Tier
|
||||
status: SubStatus
|
||||
|
|
@ -28,7 +31,7 @@ interface SubscriptionDetailClientProps {
|
|||
}
|
||||
|
||||
export function SubscriptionDetailClient({
|
||||
userId, hasSub, isSuperAdmin, initialSub,
|
||||
userId, hasSub, canEdit, canCreateDelete, initialSub,
|
||||
}: SubscriptionDetailClientProps): React.ReactElement {
|
||||
const router = useRouter()
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
|
|
@ -50,7 +53,8 @@ export function SubscriptionDetailClient({
|
|||
}
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
// 수정도 생성/삭제도 안 되면 빈 박스
|
||||
if (!canEdit && !canCreateDelete) {
|
||||
return <Box />
|
||||
}
|
||||
|
||||
|
|
@ -58,38 +62,46 @@ export function SubscriptionDetailClient({
|
|||
<Box>
|
||||
{hasSub && initialSub ? (
|
||||
<>
|
||||
<SubscriptionForm
|
||||
mode="edit"
|
||||
userId={userId}
|
||||
initial={initialSub}
|
||||
onSuccess={() => router.refresh()}
|
||||
/>
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
sx={{ fontFamily: d3roFontMono }}
|
||||
>
|
||||
Delete Subscription
|
||||
</Button>
|
||||
</Box>
|
||||
<MemoDialog
|
||||
open={deleteOpen}
|
||||
title="DELETE SUBSCRIPTION"
|
||||
description={`This will soft-delete the subscription for user ${userId}. The subscription will be set to expired/free.`}
|
||||
onConfirm={(memo) => void handleDelete(memo)}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
loading={deleteLoading}
|
||||
/>
|
||||
{/* manager 이상: 수정 가능 */}
|
||||
{canEdit && (
|
||||
<SubscriptionForm
|
||||
mode="edit"
|
||||
userId={userId}
|
||||
initial={initialSub}
|
||||
onSuccess={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
{/* admin 이상: 삭제 가능 */}
|
||||
{canCreateDelete && (
|
||||
<>
|
||||
<Box sx={{ mt: 2, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
sx={{ fontFamily: d3roFontMono }}
|
||||
>
|
||||
Delete Subscription
|
||||
</Button>
|
||||
</Box>
|
||||
<MemoDialog
|
||||
open={deleteOpen}
|
||||
title="DELETE SUBSCRIPTION"
|
||||
description={`This will soft-delete the subscription for user ${userId}. The subscription will be set to expired/free.`}
|
||||
onConfirm={(memo) => void handleDelete(memo)}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
loading={deleteLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
) : canCreateDelete ? (
|
||||
<SubscriptionForm
|
||||
mode="create"
|
||||
userId={userId}
|
||||
onSuccess={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
// apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
|
||||
// 구독 상세 + 수정/삭제 (super_admin) — [id]는 user_id
|
||||
// D3RO Console — Subscription detail
|
||||
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { C, FONT, panelSx, tableSx } from '@/lib/console-theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { requireManager, hasMinRole } from '@/lib/admin-guard'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { SubscriptionDetailClient } from './client'
|
||||
|
|
@ -16,7 +15,7 @@ interface PageProps {
|
|||
|
||||
export default async function SubscriptionDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
const { id: userId } = await params
|
||||
const admin = await requireAdmin()
|
||||
const admin = await requireManager()
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
const [subRes, profileRes, auditRes] = await Promise.all([
|
||||
|
|
@ -37,104 +36,134 @@ export default async function SubscriptionDetailPage({ params }: PageProps): Pro
|
|||
const auditLogs = (auditRes.data ?? []) as Array<Record<string, unknown>>
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">SUBSCRIPTION DETAIL</PhosphorText>
|
||||
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
|
||||
</Link>
|
||||
<>
|
||||
{/* 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,
|
||||
}}>
|
||||
Subscription Detail
|
||||
</Box>
|
||||
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
|
||||
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim, '&:hover': { color: C.text } }}>
|
||||
{'<'} Back
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>USER</PhosphorText>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Row label="NAME" value={(profile.name as string) ?? '-'} />
|
||||
<Row label="ID" value={userId} />
|
||||
<Row label="ROLE" value={((profile.role as string) ?? 'user').toUpperCase()} />
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>CURRENT SUBSCRIPTION</PhosphorText>
|
||||
{sub ? (
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Row label="TIER" value={((sub.tier as string) ?? 'free').toUpperCase()} />
|
||||
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} />
|
||||
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
|
||||
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
|
||||
<Row label="OVERAGE" value={String(sub.overage_credits ?? 0)} />
|
||||
<Row label="NOTE" value={(sub.admin_note as string) ?? '-'} />
|
||||
{/* Content */}
|
||||
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
|
||||
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
|
||||
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{/* User card */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
User
|
||||
</Box>
|
||||
) : (
|
||||
<PhosphorText variant="dim">No subscription record</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
|
||||
<Row label="NAME" value={(profile.name as string) ?? '-'} />
|
||||
<Row label="ID" value={userId} />
|
||||
<Row label="ROLE" value={((profile.role as string) ?? 'user').toUpperCase()} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Client component for CRUD actions */}
|
||||
<SubscriptionDetailClient
|
||||
userId={userId}
|
||||
hasSub={!!sub}
|
||||
isSuperAdmin={admin.role === 'super_admin'}
|
||||
initialSub={sub ? ({
|
||||
tier: (sub.tier as 'free' | 'pro' | 'pro_plus') ?? 'free',
|
||||
status: (sub.status as 'active' | 'canceled' | 'past_due' | 'expired') ?? 'active',
|
||||
currentPeriodEnd: (sub.current_period_end as string | null) ?? null,
|
||||
overageCredits: (sub.overage_credits as number) ?? 0,
|
||||
adminNote: (sub.admin_note as string | null) ?? null,
|
||||
}) : undefined}
|
||||
/>
|
||||
{/* Current subscription card */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Current Subscription
|
||||
</Box>
|
||||
{sub ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
|
||||
<Row label="TIER" value={((sub.tier as string) ?? 'free').toUpperCase()} />
|
||||
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} />
|
||||
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
|
||||
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
|
||||
<Row label="OVERAGE" value={String(sub.overage_credits ?? 0)} />
|
||||
<Row label="NOTE" value={(sub.admin_note as string) ?? '-'} />
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No subscription record</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Audit trail */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>AUDIT TRAIL</PhosphorText>
|
||||
{auditLogs.length === 0 ? (
|
||||
<PhosphorText variant="dim">No audit records</PhosphorText>
|
||||
) : (
|
||||
<MetalCard sx={{ overflow: 'auto' }}>
|
||||
<Box component="table" sx={{
|
||||
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
|
||||
'& th, & td': { py: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label },
|
||||
{/* Client component for CRUD actions */}
|
||||
<SubscriptionDetailClient
|
||||
userId={userId}
|
||||
hasSub={!!sub}
|
||||
canEdit={true}
|
||||
canCreateDelete={hasMinRole(admin, 'admin')}
|
||||
initialSub={sub ? ({
|
||||
tier: (sub.tier as 'free' | 'pro' | 'pro_plus') ?? 'free',
|
||||
status: (sub.status as 'active' | 'canceled' | 'past_due' | 'expired') ?? 'active',
|
||||
currentPeriodEnd: (sub.current_period_end as string | null) ?? null,
|
||||
overageCredits: (sub.overage_credits as number) ?? 0,
|
||||
adminNote: (sub.admin_note as string | null) ?? null,
|
||||
}) : undefined}
|
||||
/>
|
||||
|
||||
{/* Audit trail */}
|
||||
<Box sx={{ ...panelSx, p: 2.5, mt: 3 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
<thead><tr><th>DATE</th><th>ACTION</th><th>MEMO</th><th>DETAIL</th></tr></thead>
|
||||
<tbody>
|
||||
{auditLogs.map((log) => (
|
||||
<tr key={log.id as number}>
|
||||
<td style={{ color: d3roPalette.text.muted, whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.created_at as string).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ color: d3roPalette.accent.amber }}>{log.action as string}</td>
|
||||
<td>{(log.memo as string).substring(0, 50)}</td>
|
||||
<td>
|
||||
<Link href={`/audit-log/${log.id as number}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
Audit Trail
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)}
|
||||
{auditLogs.length === 0 ? (
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No audit records</Box>
|
||||
) : (
|
||||
<Box component="table" sx={tableSx}>
|
||||
<thead><tr><th>Date</th><th>Action</th><th>Memo</th><th>Detail</th></tr></thead>
|
||||
<tbody>
|
||||
{auditLogs.map((log) => (
|
||||
<tr key={log.id as number}>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>
|
||||
{new Date(log.created_at as string).toLocaleString()}
|
||||
</td>
|
||||
<td style={{ color: C.accent }}>{log.action as string}</td>
|
||||
<td>{(log.memo as string).substring(0, 50)}</td>
|
||||
<td>
|
||||
<Link href={`/audit-log/${log.id as number}`} style={{ color: C.accent, textDecoration: 'none' }}>
|
||||
View
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: d3roPalette.text.label }}>{label}</span>
|
||||
<span style={{ color: d3roPalette.text.primary }}>{value}</span>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box component="span" sx={{ color: C.dim }}>{label}</Box>
|
||||
<Box component="span" sx={{ color: C.text }}>{value}</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// apps/admin/src/app/(admin)/subscriptions/new/page.tsx
|
||||
// 새 구독 생성 (VIP 부여) — super_admin 전용
|
||||
// D3RO Console — New subscription (VIP grant)
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { requireSuperAdmin } from '@/lib/admin-guard'
|
||||
import { C, FONT, panelSx } from '@/lib/console-theme'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
import { NewSubscriptionClient } from './client'
|
||||
|
||||
|
|
@ -12,19 +12,41 @@ interface PageProps {
|
|||
}
|
||||
|
||||
export default async function NewSubscriptionPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
await requireSuperAdmin()
|
||||
await requireAdmin()
|
||||
const params = await searchParams
|
||||
const userId = params.userId ?? ''
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">NEW SUBSCRIPTION</PhosphorText>
|
||||
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>{'<'} Back</PhosphorText>
|
||||
</Link>
|
||||
<>
|
||||
{/* 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,
|
||||
}}>
|
||||
New Subscription
|
||||
</Box>
|
||||
<Link href="/subscriptions" style={{ textDecoration: 'none' }}>
|
||||
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim, '&:hover': { color: C.text } }}>
|
||||
{'<'} Back
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
<NewSubscriptionClient initialUserId={userId} />
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
|
||||
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
|
||||
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
|
||||
<NewSubscriptionClient initialUserId={userId} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
// apps/admin/src/app/(admin)/subscriptions/page.tsx
|
||||
// 구독 목록 — active/canceled/past_due/expired 필터
|
||||
// D3RO Console — Subscriptions 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'
|
||||
|
||||
|
|
@ -34,9 +33,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
.order('current_period_end', { ascending: true })
|
||||
.limit(100)
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
subQuery.eq('status', statusFilter)
|
||||
}
|
||||
if (statusFilter !== 'all') subQuery.eq('status', statusFilter)
|
||||
|
||||
const { data: rawSubs } = await subQuery
|
||||
const rawSubsArr = (rawSubs ?? []) as Array<Record<string, unknown>>
|
||||
|
|
@ -61,72 +58,112 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
profile_name: profileMap.get(row.user_id as string) ?? null,
|
||||
}))
|
||||
|
||||
const statusColor = (s: string): 'green' | 'red' | 'orange' =>
|
||||
s === 'active' ? 'green' : s === 'expired' ? 'red' : 'orange'
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
|
||||
<PhosphorText variant="title">SUBSCRIPTIONS</PhosphorText>
|
||||
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1,
|
||||
bgcolor: d3roPalette.accent.amber,
|
||||
color: d3roPalette.bg.app,
|
||||
<>
|
||||
{/* 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,
|
||||
}}>
|
||||
+ NEW
|
||||
</PhosphorText>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
{['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => (
|
||||
<Link key={s} href={`/subscriptions?status=${s}`} style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1,
|
||||
bgcolor: statusFilter === s ? d3roPalette.bg.inset : 'transparent',
|
||||
color: statusFilter === s ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}>
|
||||
{s.toUpperCase().replace('_', ' ')}
|
||||
</PhosphorText>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<MetalCard sx={{ overflow: 'auto' }}>
|
||||
<Box component="table" sx={{
|
||||
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
|
||||
'& th, & td': { py: 0.75, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
|
||||
}}>
|
||||
<thead>
|
||||
<tr><th>USER</th><th>TIER</th><th>STATUS</th><th>PROVIDER</th><th>EXPIRES</th><th>CANCEL</th><th>FAILS</th><th>EDIT</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subs.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<Link href={`/users/${s.user_id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
{s.profile_name ?? s.user_id.substring(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td style={{ color: s.tier === 'pro_plus' ? d3roPalette.tag.purple : s.tier === 'pro' ? d3roPalette.tag.green : d3roPalette.text.secondary }}>
|
||||
{s.tier === 'pro_plus' ? 'PRO+' : s.tier.toUpperCase()}
|
||||
</td>
|
||||
<td style={{ color: s.status === 'active' ? d3roPalette.tag.green : s.status === 'expired' ? d3roPalette.tag.red : d3roPalette.accent.amber }}>
|
||||
{s.status.toUpperCase()}
|
||||
</td>
|
||||
<td>{s.payment_provider}</td>
|
||||
<td style={{ color: d3roPalette.text.muted }}>{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : '-'}</td>
|
||||
<td style={{ color: s.cancel_at ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.cancel_at ? new Date(s.cancel_at).toLocaleDateString() : '-'}</td>
|
||||
<td style={{ color: s.renewal_failures > 0 ? d3roPalette.tag.red : d3roPalette.text.muted }}>{s.renewal_failures}</td>
|
||||
<td>
|
||||
<Link href={`/subscriptions/${s.user_id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none' }}>
|
||||
Edit
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
Subscriptions
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||
{['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => (
|
||||
<Link key={s} href={`/subscriptions?status=${s}`} style={{ textDecoration: 'none' }}>
|
||||
<Box sx={filterBtnSx(statusFilter === s)}>
|
||||
{s.toUpperCase().replace('_', ' ')}
|
||||
</Box>
|
||||
</Link>
|
||||
))}
|
||||
<Box sx={{ height: 20, width: '1px', bgcolor: C.borderHl, mx: 0.5 }} />
|
||||
<Link href="/subscriptions/new" style={{ textDecoration: 'none' }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase',
|
||||
px: 1.5, py: 0.5, borderRadius: '4px',
|
||||
bgcolor: C.accent, color: C.bright,
|
||||
cursor: 'pointer',
|
||||
'&:hover': { opacity: 0.9 },
|
||||
transition: 'opacity 0.15s',
|
||||
}}>
|
||||
+ NEW
|
||||
</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>User</th>
|
||||
<th style={{ width: 80 }}>Tier</th>
|
||||
<th style={{ width: 90 }}>Status</th>
|
||||
<th style={{ width: 90 }}>Provider</th>
|
||||
<th style={{ width: 100 }}>Expires</th>
|
||||
<th style={{ width: 100 }}>Cancel</th>
|
||||
<th style={{ width: 50 }}>Fails</th>
|
||||
<th style={{ width: 50, textAlign: 'right' }}>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subs.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>
|
||||
<Link href={`/users/${s.user_id}`} style={{ color: C.accent, textDecoration: 'none' }}>
|
||||
{s.profile_name ?? s.user_id.substring(0, 8)}
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Box component="span" sx={statusBadgeSx(
|
||||
s.tier === 'pro_plus' ? 'purple' : s.tier === 'pro' ? 'green' : 'blue'
|
||||
)}>
|
||||
{s.tier === 'pro_plus' ? 'PRO+' : s.tier.toUpperCase()}
|
||||
</Box>
|
||||
</td>
|
||||
<td>
|
||||
<Box component="span" sx={statusBadgeSx(statusColor(s.status))}>
|
||||
{s.status.toUpperCase()}
|
||||
</Box>
|
||||
</td>
|
||||
<td style={{ color: C.dim }}>{s.payment_provider}</td>
|
||||
<td style={{ color: C.dim, whiteSpace: 'nowrap' }}>
|
||||
{s.current_period_end ? new Date(s.current_period_end).toLocaleDateString() : '-'}
|
||||
</td>
|
||||
<td style={{ color: s.cancel_at ? C.red400 : C.dim, whiteSpace: 'nowrap' }}>
|
||||
{s.cancel_at ? new Date(s.cancel_at).toLocaleDateString() : '-'}
|
||||
</td>
|
||||
<td style={{ color: s.renewal_failures > 0 ? C.red400 : C.dim }}>
|
||||
{s.renewal_failures}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<Link href={`/subscriptions/${s.user_id}`} style={{ color: C.accent, textDecoration: 'none', fontSize: '11px' }}>
|
||||
EDIT
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{subs.length === 0 && (
|
||||
<tr><td colSpan={8} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>No subscriptions found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
// apps/admin/src/app/(admin)/usage/page.tsx
|
||||
// 사용량 — feature별 차트 + DAU + Top users + 테이블
|
||||
// D3RO Console — Usage analytics
|
||||
|
||||
import { Box, Grid } 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 } from '@/lib/console-theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import Link from 'next/link'
|
||||
import { FeatureUsageChart } from '@/components/charts/feature-usage-chart'
|
||||
|
|
@ -56,105 +55,139 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
.sort((a, b) => b.total - a.total)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>USAGE</PhosphorText>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
{[7, 14, 30].map((d) => (
|
||||
<Link key={d} href={`/usage?days=${d}`} style={{ textDecoration: 'none' }}>
|
||||
<PhosphorText variant="label" sx={{
|
||||
px: 1.5, py: 0.5, borderRadius: 1,
|
||||
bgcolor: days === d ? d3roPalette.bg.inset : 'transparent',
|
||||
color: days === d ? d3roPalette.accent.amber : d3roPalette.text.secondary,
|
||||
}}>{d}D</PhosphorText>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Summary cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{summaries.map((s) => (
|
||||
<Grid size={{ xs: 6, md: 3 }} key={s.feature}>
|
||||
<MetalCard>
|
||||
<Box sx={{ textAlign: 'center', py: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 0.5, display: 'block', color: d3roPalette.text.label }}>{s.feature.toUpperCase()}</PhosphorText>
|
||||
<PhosphorText variant="value" sx={{ color: d3roPalette.accent.amber }}>{s.total.toLocaleString()}</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ display: 'block', mt: 0.5 }}>{s.uniqueUsers} users</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Feature usage stacked bar chart */}
|
||||
<MetalCard sx={{ mb: 3 }}>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>FEATURE USAGE (DAILY)</PhosphorText>
|
||||
{featureData.length > 0 ? (
|
||||
<FeatureUsageChart data={featureData} />
|
||||
) : (
|
||||
<PhosphorText variant="dim">No data</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{/* DAU chart */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DAILY ACTIVE USERS</PhosphorText>
|
||||
{dauData.length > 0 ? (
|
||||
<DauChart data={dauData} />
|
||||
) : (
|
||||
<PhosphorText variant="dim">No data</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
|
||||
{/* Top users chart */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>TOP USERS</PhosphorText>
|
||||
{topUsersData.length > 0 ? (
|
||||
<TopUsersChart data={topUsersData} />
|
||||
) : (
|
||||
<PhosphorText variant="dim">No data</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Daily breakdown table */}
|
||||
<MetalCard sx={{ overflow: 'auto' }}>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>DAILY BREAKDOWN</PhosphorText>
|
||||
<Box component="table" sx={{
|
||||
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
|
||||
'& th, & td': { py: 0.5, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
|
||||
<>
|
||||
{/* 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,
|
||||
}}>
|
||||
<thead><tr><th>DATE</th><th>FEATURE</th><th>CALLS</th><th>UNIQUE USERS</th></tr></thead>
|
||||
<tbody>
|
||||
{featureData.length > 0 ? (
|
||||
[...featureData].reverse().map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{ color: d3roPalette.text.muted }}>{r.date}</td>
|
||||
<td>{r.feature}</td>
|
||||
<td style={{ color: d3roPalette.accent.amber }}>{r.total_count.toLocaleString()}</td>
|
||||
<td style={{ color: d3roPalette.text.secondary }}>{r.unique_users}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: d3roPalette.text.muted, padding: 16 }}>No usage data</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
Usage
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{[7, 14, 30].map((d) => (
|
||||
<Link key={d} href={`/usage?days=${d}`} style={{ textDecoration: 'none' }}>
|
||||
<Box sx={filterBtnSx(days === d)}>
|
||||
{d}D
|
||||
</Box>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
|
||||
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
|
||||
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
|
||||
|
||||
{/* Summary cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{summaries.map((s) => (
|
||||
<Grid size={{ xs: 6, md: 3 }} key={s.feature}>
|
||||
<Box sx={{ ...panelSx, p: 2.5, textAlign: 'center' }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 1,
|
||||
}}>
|
||||
{s.feature.toUpperCase()}
|
||||
</Box>
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '24px', fontWeight: 300, color: C.accent }}>
|
||||
{s.total.toLocaleString()}
|
||||
</Box>
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '10px', color: C.dim, mt: 0.5 }}>
|
||||
{s.uniqueUsers} users
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{/* Feature usage stacked bar chart */}
|
||||
<Box sx={{ ...panelSx, p: 2.5, mb: 3 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Feature Usage (Daily)
|
||||
</Box>
|
||||
{featureData.length > 0 ? (
|
||||
<FeatureUsageChart data={featureData} />
|
||||
) : (
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No data</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{/* DAU chart */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Daily Active Users
|
||||
</Box>
|
||||
{dauData.length > 0 ? (
|
||||
<DauChart data={dauData} />
|
||||
) : (
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No data</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
{/* Top users chart */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Top Users
|
||||
</Box>
|
||||
{topUsersData.length > 0 ? (
|
||||
<TopUsersChart data={topUsersData} />
|
||||
) : (
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No data</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Daily breakdown table */}
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Daily Breakdown
|
||||
</Box>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<thead><tr><th>Date</th><th>Feature</th><th>Calls</th><th>Unique Users</th></tr></thead>
|
||||
<tbody>
|
||||
{featureData.length > 0 ? (
|
||||
[...featureData].reverse().map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td>{r.date}</td>
|
||||
<td>{r.feature}</td>
|
||||
<td style={{ color: C.accent }}>{r.total_count.toLocaleString()}</td>
|
||||
<td>{r.unique_users}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: C.dim, padding: '32px 0' }}>No usage data</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
// apps/admin/src/app/(admin)/users/[id]/page.tsx
|
||||
// 유저 상세 — 프로필 + 구독 + 30일 사용량
|
||||
// D3RO Console — User detail
|
||||
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { C, FONT, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { requireManager, isAdmin } from '@/lib/admin-guard'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { RoleChangeButton } from './role-change-button'
|
||||
|
|
@ -17,7 +16,7 @@ interface PageProps {
|
|||
|
||||
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
const { id } = await params
|
||||
const admin = await requireAdmin()
|
||||
const admin = await requireManager()
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
const [profileRes, subRes, usageRes] = await Promise.all([
|
||||
|
|
@ -35,102 +34,151 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
const sub = subRes.data as Record<string, unknown> | null
|
||||
const usage = (usageRes.data ?? []) as Array<Record<string, unknown>>
|
||||
const tier = (profile.tier as string) ?? 'free'
|
||||
const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber
|
||||
const tierBadge: 'purple' | 'green' | 'orange' = tier === 'pro_plus' ? 'purple' : tier === 'pro' ? 'green' : 'orange'
|
||||
|
||||
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'admin' | 'super_admin'
|
||||
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'manager' | 'admin' | 'super_admin'
|
||||
const roleColor = userRole === 'super_admin' ? C.purple400 : userRole === 'admin' ? C.green400 : userRole === 'manager' ? C.orange400 : C.dim
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">USER DETAIL</PhosphorText>
|
||||
<>
|
||||
{/* 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,
|
||||
}}>
|
||||
User Detail
|
||||
</Box>
|
||||
<Link href="/users" style={{ textDecoration: 'none' }}>
|
||||
<Box component="span" sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim, '&:hover': { color: C.text } }}>
|
||||
{'<'} Back
|
||||
</Box>
|
||||
</Link>
|
||||
</Box>
|
||||
<RoleChangeButton
|
||||
userId={id}
|
||||
userName={(profile.name as string) ?? null}
|
||||
currentRole={userRole}
|
||||
isSuperAdmin={admin.role === 'super_admin'}
|
||||
callerRole={admin.role}
|
||||
isAdminOrAbove={isAdmin(admin)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PROFILE</PhosphorText>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size }}>
|
||||
<Row label="ID" value={id} />
|
||||
<Row label="NAME" value={(profile.name as string) ?? '-'} />
|
||||
<Row label="TIER" value={tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()} valueColor={tierColor} />
|
||||
<Row label="ROLE" value={userRole.toUpperCase()} valueColor={userRole === 'super_admin' ? d3roPalette.tag.purple : userRole === 'admin' ? d3roPalette.tag.green : d3roPalette.text.secondary} />
|
||||
<Row label="LOCALE" value={(profile.locale as string) ?? '-'} />
|
||||
<Row label="JOINED" value={new Date(profile.created_at as string).toLocaleDateString()} />
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>SUBSCRIPTION</PhosphorText>
|
||||
{sub ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, fontFamily: d3roFontMono, fontSize: d3roTypo.small.size }}>
|
||||
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} valueColor={(sub.status as string) === 'active' ? d3roPalette.tag.green : d3roPalette.tag.red} />
|
||||
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? '-').toUpperCase()} />
|
||||
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
|
||||
<Row label="CANCEL AT" value={sub.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : '-'} />
|
||||
<Box sx={{ mt: 0.5 }}>
|
||||
<Link href={`/subscriptions/${id}`} style={{ color: d3roPalette.accent.amber, textDecoration: 'none', fontSize: 11 }}>
|
||||
Edit Subscription {'->'}
|
||||
</Link>
|
||||
</Box>
|
||||
{/* Content */}
|
||||
<Box sx={{ ...panelSx, flex: 1, overflow: 'auto' }}>
|
||||
<Box className="bg-grid" sx={{ position: 'absolute', inset: 0, opacity: 0.02, pointerEvents: 'none' }} />
|
||||
<Box sx={{ p: 3, position: 'relative', zIndex: 1 }}>
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{/* Profile card */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Profile
|
||||
</Box>
|
||||
) : (
|
||||
<PhosphorText variant="dim">No subscription</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
|
||||
<Row label="ID" value={id} />
|
||||
<Row label="NAME" value={(profile.name as string) ?? '-'} />
|
||||
<Row label="TIER" value={tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()}>
|
||||
<Box component="span" sx={statusBadgeSx(tierBadge)}>
|
||||
{tier === 'pro_plus' ? 'PRO+' : tier.toUpperCase()}
|
||||
</Box>
|
||||
</Row>
|
||||
<Row label="ROLE" value={userRole.toUpperCase()} valueColor={roleColor} />
|
||||
<Row label="LOCALE" value={(profile.locale as string) ?? '-'} />
|
||||
<Row label="JOINED" value={new Date(profile.created_at as string).toLocaleDateString()} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>USAGE (30 DAYS)</PhosphorText>
|
||||
{usage.length === 0 ? (
|
||||
<PhosphorText variant="dim">No usage data</PhosphorText>
|
||||
) : (
|
||||
<Box component="table" sx={{
|
||||
width: '100%', borderCollapse: 'collapse', fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
|
||||
'& th, & td': { py: 0.5, px: 1, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
|
||||
{/* Subscription card */}
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Subscription
|
||||
</Box>
|
||||
{sub ? (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, fontFamily: FONT, fontSize: '12px' }}>
|
||||
<Row label="STATUS" value={((sub.status as string) ?? '-').toUpperCase()} valueColor={(sub.status as string) === 'active' ? C.green400 : C.red400} />
|
||||
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? '-').toUpperCase()} />
|
||||
<Row label="PERIOD END" value={sub.current_period_end ? new Date(sub.current_period_end as string).toLocaleDateString() : '-'} />
|
||||
<Row label="CANCEL AT" value={sub.cancel_at ? new Date(sub.cancel_at as string).toLocaleDateString() : '-'} />
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<Link href={`/subscriptions/${id}`} style={{ color: C.accent, textDecoration: 'none', fontFamily: FONT, fontSize: '11px' }}>
|
||||
Edit Subscription {'->'}
|
||||
</Link>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No subscription</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Usage table */}
|
||||
<Box sx={{ ...panelSx, p: 2.5, mb: 3 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
<thead><tr><th>DATE</th><th>FEATURE</th><th>COUNT</th></tr></thead>
|
||||
<tbody>
|
||||
{usage.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.date as string}</td>
|
||||
<td>{row.feature as string}</td>
|
||||
<td style={{ color: d3roPalette.accent.amber }}>{row.count as number}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
Usage (30 Days)
|
||||
</Box>
|
||||
)}
|
||||
{usage.length === 0 ? (
|
||||
<Box sx={{ fontFamily: FONT, fontSize: '12px', color: C.dim }}>No usage data</Box>
|
||||
) : (
|
||||
<Box component="table" sx={tableSx}>
|
||||
<thead><tr><th>Date</th><th>Feature</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
{usage.map((row, i) => (
|
||||
<tr key={i}>
|
||||
<td>{row.date as string}</td>
|
||||
<td>{row.feature as string}</td>
|
||||
<td style={{ color: C.accent }}>{row.count as number}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Payment History */}
|
||||
<Box sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Box sx={{
|
||||
fontFamily: FONT, fontSize: '10px', fontWeight: 500,
|
||||
letterSpacing: '0.1em', textTransform: 'uppercase', color: C.dim, mb: 2,
|
||||
}}>
|
||||
Payment History
|
||||
</Box>
|
||||
<PaymentHistory userId={id} />
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Payment History */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT HISTORY</PhosphorText>
|
||||
<PaymentHistory userId={id} />
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ label, value, valueColor }: { label: string; value: string; valueColor?: string }): React.ReactElement {
|
||||
function Row({ label, value, valueColor, children }: {
|
||||
label: string
|
||||
value: string
|
||||
valueColor?: string
|
||||
children?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: d3roPalette.text.label }}>{label}</span>
|
||||
<span style={{ color: valueColor ?? d3roPalette.text.primary }}>{value}</span>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box component="span" sx={{ color: C.dim }}>{label}</Box>
|
||||
{children ?? <Box component="span" sx={{ color: valueColor ?? C.text }}>{value}</Box>}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,24 +5,25 @@
|
|||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@mui/material'
|
||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { FONT } from '@/lib/console-theme'
|
||||
import { RoleChangeDialog } from '@/components/role-change-dialog'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface RoleChangeButtonProps {
|
||||
userId: string
|
||||
userName: string | null
|
||||
currentRole: 'user' | 'admin' | 'super_admin'
|
||||
isSuperAdmin: boolean
|
||||
currentRole: 'user' | 'manager' | 'admin' | 'super_admin'
|
||||
callerRole: 'manager' | 'admin' | 'super_admin'
|
||||
isAdminOrAbove: boolean
|
||||
}
|
||||
|
||||
export function RoleChangeButton({
|
||||
userId, userName, currentRole, isSuperAdmin,
|
||||
userId, userName, currentRole, callerRole, isAdminOrAbove,
|
||||
}: RoleChangeButtonProps): React.ReactElement | null {
|
||||
const [open, setOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
if (!isSuperAdmin) return null
|
||||
if (!isAdminOrAbove) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -30,7 +31,7 @@ export function RoleChangeButton({
|
|||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
|
||||
sx={{ fontFamily: FONT, fontSize: 11 }}
|
||||
>
|
||||
Change Role
|
||||
</Button>
|
||||
|
|
@ -39,6 +40,7 @@ export function RoleChangeButton({
|
|||
userId={userId}
|
||||
userName={userName}
|
||||
currentRole={currentRole}
|
||||
callerRole={callerRole}
|
||||
onClose={() => setOpen(false)}
|
||||
onSuccess={() => {
|
||||
setOpen(false)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
84
apps/admin/src/app/globals.css
Normal file
84
apps/admin/src/app/globals.css
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/* D3RO Console — Global Styles */
|
||||
|
||||
:root {
|
||||
--sys-base: #000000;
|
||||
--sys-panel: #09090b;
|
||||
--sys-panel-hover: #121214;
|
||||
--sys-border: #1f1f22;
|
||||
--sys-border-hl: #27272a;
|
||||
--text-dim: #71717a;
|
||||
--text-base: #a1a1aa;
|
||||
--text-bright: #ffffff;
|
||||
--accent: #ff5c28;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--sys-base);
|
||||
color: var(--text-bright);
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Scanline */
|
||||
.console-scanline {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(255, 92, 40, 0.2), transparent);
|
||||
opacity: 0.5;
|
||||
animation: scan 8s linear infinite;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% { transform: translateY(-100%); }
|
||||
100% { transform: translateY(100vh); }
|
||||
}
|
||||
|
||||
/* Grid pattern overlay */
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(255, 255, 255, 0.02) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-white { text-shadow: 0 0 15px rgba(255, 255, 255, 0.5), 0 0 30px rgba(255, 255, 255, 0.2); }
|
||||
.glow-green { text-shadow: 0 0 15px rgba(34, 197, 94, 0.6), 0 0 30px rgba(34, 197, 94, 0.3); }
|
||||
.glow-orange { text-shadow: 0 0 15px rgba(249, 115, 22, 0.6), 0 0 30px rgba(249, 115, 22, 0.3); }
|
||||
.glow-red { text-shadow: 0 0 15px rgba(239, 68, 68, 0.6), 0 0 30px rgba(239, 68, 68, 0.3); }
|
||||
.glow-accent { text-shadow: 0 0 15px rgba(255, 92, 40, 0.6), 0 0 30px rgba(255, 92, 40, 0.3); }
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 4px; height: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--sys-border-hl); border-radius: 2px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
|
||||
|
||||
/* Pulse animation for status dot */
|
||||
@keyframes pulse-ring {
|
||||
0% { transform: scale(0.8); opacity: 1; }
|
||||
100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
.status-dot-pulse {
|
||||
position: relative;
|
||||
}
|
||||
.status-dot-pulse::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
animation: pulse-ring 1.5s ease-out infinite;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/app/layout.tsx
|
||||
// Admin CRM Root Layout — 항상 dark 모드
|
||||
// Admin CRM Root Layout — D3RO Console 스타일
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material'
|
||||
import { AppRouterCacheProvider } from '@mui/material-nextjs/v15-appRouter'
|
||||
import { getTheme } from '@d3ro/ui/theme'
|
||||
import './globals.css'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
|
|
@ -17,7 +18,13 @@ export default function RootLayout({
|
|||
|
||||
return (
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div className="console-scanline" />
|
||||
<AppRouterCacheProvider options={{ key: 'mui' }}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,19 +20,21 @@ import { PhosphorText } from '@d3ro/ui/components/ds'
|
|||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { callAdminApi } from '@/lib/admin-api'
|
||||
|
||||
type Role = 'user' | 'admin' | 'super_admin'
|
||||
type Role = 'user' | 'manager' | 'admin' | 'super_admin'
|
||||
|
||||
interface RoleChangeDialogProps {
|
||||
open: boolean
|
||||
userId: string
|
||||
userName: string | null
|
||||
currentRole: Role
|
||||
/** 현재 로그인한 admin의 role */
|
||||
callerRole?: Role
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function RoleChangeDialog({
|
||||
open, userId, userName, currentRole, onClose, onSuccess,
|
||||
open, userId, userName, currentRole, callerRole = 'admin', onClose, onSuccess,
|
||||
}: RoleChangeDialogProps): React.ReactElement {
|
||||
const [newRole, setNewRole] = useState<Role>(currentRole)
|
||||
const [memo, setMemo] = useState('')
|
||||
|
|
@ -83,8 +85,9 @@ export function RoleChangeDialog({
|
|||
sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.primary }}
|
||||
>
|
||||
<MenuItem value="user">user</MenuItem>
|
||||
<MenuItem value="admin">admin</MenuItem>
|
||||
<MenuItem value="super_admin">super_admin</MenuItem>
|
||||
<MenuItem value="manager">manager</MenuItem>
|
||||
{callerRole === 'super_admin' && <MenuItem value="admin">admin</MenuItem>}
|
||||
{callerRole === 'super_admin' && <MenuItem value="super_admin">super_admin</MenuItem>}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,10 +14,16 @@ export async function callAdminApi<T = Record<string, unknown>>(
|
|||
options: AdminApiOptions = {}
|
||||
): Promise<T> {
|
||||
const supabase = getSupabaseBrowserClient()
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
|
||||
// getUser()로 토큰 갱신을 트리거한 뒤 세션에서 access_token 획득
|
||||
const { data: { user }, error: userError } = await supabase.auth.getUser()
|
||||
if (userError || !user) {
|
||||
throw new Error('Not authenticated — please re-login')
|
||||
}
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
if (!session?.access_token) {
|
||||
throw new Error('Not authenticated')
|
||||
throw new Error('Not authenticated — session expired')
|
||||
}
|
||||
|
||||
const response = await fetch(`${SUPABASE_URL}/functions/v1/${path}`, {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
// apps/admin/src/lib/admin-guard.ts
|
||||
// RSC용 admin 가드 — app_metadata.role = 'admin' | 'super_admin'
|
||||
// RSC용 3단계 권한 가드 — manager < admin < super_admin
|
||||
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getSupabaseServerClient } from './supabase-server'
|
||||
|
||||
export type AdminRole = 'admin' | 'super_admin'
|
||||
export type AdminRole = 'manager' | 'admin' | 'super_admin'
|
||||
|
||||
const ROLE_LEVEL: Record<string, number> = {
|
||||
user: 0,
|
||||
manager: 1,
|
||||
admin: 2,
|
||||
super_admin: 3,
|
||||
}
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
|
|
@ -13,8 +20,8 @@ export interface AdminUser {
|
|||
role: AdminRole
|
||||
}
|
||||
|
||||
/** admin 이상 (admin, super_admin) */
|
||||
export async function requireAdmin(): Promise<AdminUser> {
|
||||
/** manager 이상 (manager, admin, super_admin) — CRM 접근 최소 권한 */
|
||||
export async function requireManager(): Promise<AdminUser> {
|
||||
const supabase = await getSupabaseServerClient()
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
|
|
@ -23,7 +30,7 @@ export async function requireAdmin(): Promise<AdminUser> {
|
|||
}
|
||||
|
||||
const role = (user.app_metadata as Record<string, unknown>)?.role as string | undefined
|
||||
if (role !== 'admin' && role !== 'super_admin') {
|
||||
if ((ROLE_LEVEL[role ?? ''] ?? 0) < ROLE_LEVEL.manager) {
|
||||
redirect('/unauthorized')
|
||||
}
|
||||
|
||||
|
|
@ -41,16 +48,35 @@ export async function requireAdmin(): Promise<AdminUser> {
|
|||
}
|
||||
}
|
||||
|
||||
/** super_admin 전용 */
|
||||
export async function requireSuperAdmin(): Promise<AdminUser> {
|
||||
const adminUser = await requireAdmin()
|
||||
if (adminUser.role !== 'super_admin') {
|
||||
/** admin 이상 (admin, super_admin) */
|
||||
export async function requireAdmin(): Promise<AdminUser> {
|
||||
const adminUser = await requireManager()
|
||||
if ((ROLE_LEVEL[adminUser.role] ?? 0) < ROLE_LEVEL.admin) {
|
||||
redirect('/unauthorized')
|
||||
}
|
||||
return adminUser
|
||||
}
|
||||
|
||||
/** super_admin 전용 */
|
||||
export async function requireSuperAdmin(): Promise<AdminUser> {
|
||||
const adminUser = await requireManager()
|
||||
if ((ROLE_LEVEL[adminUser.role] ?? 0) < ROLE_LEVEL.super_admin) {
|
||||
redirect('/unauthorized')
|
||||
}
|
||||
return adminUser
|
||||
}
|
||||
|
||||
/** 최소 role 레벨 체크 */
|
||||
export function hasMinRole(user: AdminUser, minRole: AdminRole): boolean {
|
||||
return (ROLE_LEVEL[user.role] ?? 0) >= (ROLE_LEVEL[minRole] ?? 0)
|
||||
}
|
||||
|
||||
/** role이 super_admin인지 체크 */
|
||||
export function isSuperAdmin(user: AdminUser): boolean {
|
||||
return user.role === 'super_admin'
|
||||
}
|
||||
|
||||
/** role이 admin 이상인지 체크 */
|
||||
export function isAdmin(user: AdminUser): boolean {
|
||||
return (ROLE_LEVEL[user.role] ?? 0) >= ROLE_LEVEL.admin
|
||||
}
|
||||
|
|
|
|||
114
apps/admin/src/lib/console-theme.ts
Normal file
114
apps/admin/src/lib/console-theme.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// apps/admin/src/lib/console-theme.ts
|
||||
// D3RO Console 디자인 토큰 — 터미널/콘솔 스타일
|
||||
|
||||
export const C = {
|
||||
// backgrounds
|
||||
base: '#000000',
|
||||
panel: '#09090b',
|
||||
panelHover: '#121214',
|
||||
// borders
|
||||
border: '#1f1f22',
|
||||
borderHl: '#27272a',
|
||||
// text
|
||||
dim: '#71717a',
|
||||
text: '#a1a1aa',
|
||||
bright: '#ffffff',
|
||||
// accent
|
||||
accent: '#ff5c28',
|
||||
// semantic
|
||||
green: '#22c55e',
|
||||
green400: '#4ade80',
|
||||
orange: '#f97316',
|
||||
orange400: '#fb923c',
|
||||
red: '#ef4444',
|
||||
red400: '#f87171',
|
||||
blue: '#3b82f6',
|
||||
blue400: '#60a5fa',
|
||||
purple: '#a855f7',
|
||||
purple400: '#c084fc',
|
||||
} as const
|
||||
|
||||
export const FONT = '"JetBrains Mono", ui-monospace, monospace'
|
||||
|
||||
/** 공통 패널 스타일 */
|
||||
export const panelSx = {
|
||||
bgcolor: C.panel,
|
||||
border: `1px solid ${C.border}`,
|
||||
borderRadius: '16px',
|
||||
position: 'relative' as const,
|
||||
overflow: 'hidden',
|
||||
'&:hover': { borderColor: C.borderHl },
|
||||
transition: 'border-color 0.2s',
|
||||
}
|
||||
|
||||
/** 테이블 공통 스타일 */
|
||||
export const tableSx = {
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse' as const,
|
||||
fontFamily: FONT,
|
||||
fontSize: '12px',
|
||||
'& th': {
|
||||
pb: 1.5,
|
||||
fontWeight: 400,
|
||||
textTransform: 'uppercase' as const,
|
||||
letterSpacing: '0.1em',
|
||||
color: C.dim,
|
||||
textAlign: 'left' as const,
|
||||
borderBottom: `1px solid ${C.borderHl}`,
|
||||
},
|
||||
'& td': {
|
||||
py: 1.5,
|
||||
textAlign: 'left' as const,
|
||||
color: C.text,
|
||||
borderBottom: `1px solid ${C.border}50`,
|
||||
},
|
||||
'& tr:hover td': {
|
||||
bgcolor: `${C.borderHl}33`,
|
||||
},
|
||||
}
|
||||
|
||||
/** 필터 버튼 스타일 */
|
||||
export const filterBtnSx = (active: boolean) => ({
|
||||
px: 1.5,
|
||||
py: 0.5,
|
||||
borderRadius: '4px',
|
||||
fontFamily: FONT,
|
||||
fontSize: '10px',
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase' as const,
|
||||
cursor: 'pointer',
|
||||
border: `1px solid ${active ? C.borderHl : 'transparent'}`,
|
||||
bgcolor: active ? C.borderHl : 'transparent',
|
||||
color: active ? C.bright : C.text,
|
||||
'&:hover': {
|
||||
bgcolor: C.panelHover,
|
||||
borderColor: C.border,
|
||||
},
|
||||
transition: 'all 0.15s',
|
||||
})
|
||||
|
||||
/** 상태 뱃지 스타일 */
|
||||
export function statusBadgeSx(variant: 'green' | 'red' | 'orange' | 'blue' | 'purple') {
|
||||
const colorMap = {
|
||||
green: { bg: 'rgba(34, 197, 94, 0.1)', fg: C.green400, border: 'rgba(34, 197, 94, 0.2)' },
|
||||
red: { bg: 'rgba(239, 68, 68, 0.1)', fg: C.red400, border: 'rgba(239, 68, 68, 0.2)' },
|
||||
orange: { bg: 'rgba(249, 115, 22, 0.1)', fg: C.orange400, border: 'rgba(249, 115, 22, 0.2)' },
|
||||
blue: { bg: 'rgba(59, 130, 246, 0.1)', fg: C.blue400, border: 'rgba(59, 130, 246, 0.2)' },
|
||||
purple: { bg: 'rgba(168, 85, 247, 0.1)', fg: C.purple400, border: 'rgba(168, 85, 247, 0.2)' },
|
||||
}
|
||||
const c = colorMap[variant]
|
||||
return {
|
||||
display: 'inline-block',
|
||||
px: 1,
|
||||
py: 0.25,
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
fontFamily: FONT,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '0.05em',
|
||||
bgcolor: c.bg,
|
||||
color: c.fg,
|
||||
border: `1px solid ${c.border}`,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,4 @@
|
|||
// apps/admin/src/lib/supabase-browser.ts
|
||||
// 클라이언트 컴포넌트용 Supabase 클라이언트
|
||||
// re-export from shared package
|
||||
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import type { Database } from '@d3ro/api-client'
|
||||
|
||||
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
|
||||
|
||||
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
|
||||
if (cachedClient) return cachedClient
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||
|
||||
cachedClient = createBrowserClient<Database>(url, key)
|
||||
return cachedClient
|
||||
}
|
||||
export { getSupabaseBrowserClient, isSupabaseConfigured } from '@d3ro/api-client/supabase-browser'
|
||||
|
|
|
|||
|
|
@ -1,30 +1,10 @@
|
|||
// apps/admin/src/lib/supabase-server.ts
|
||||
// RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션)
|
||||
// Next.js cookies() 주입 래퍼 — 실제 로직은 @d3ro/api-client
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
||||
import type { Database } from '@d3ro/api-client'
|
||||
import { createSupabaseServerClient } from '@d3ro/api-client/supabase-server'
|
||||
|
||||
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient<Database>>> {
|
||||
export async function getSupabaseServerClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? ''
|
||||
|
||||
return createServerClient<Database>(url, key, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
cookieStore.set(name, value, options)
|
||||
})
|
||||
} catch {
|
||||
// RSC에서 set은 실패 가능
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return createSupabaseServerClient(cookieStore)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,18 +269,16 @@ class VoiceConversationService extends EventEmitter {
|
|||
this._setState('thinking')
|
||||
|
||||
try {
|
||||
// Ollama /api/chat 호출 (스트리밍)
|
||||
const llmService = getLocalLLMService()
|
||||
const chatMessages = this._buildChatMessages()
|
||||
// LLM 백엔드 선택: premium 설정 + 사용 가능 → PremiumLLM, 아니면 LocalLLM
|
||||
const { generator: chatGenerator, backend } = await this._createChatStream()
|
||||
logger.info(`Conversation LLM backend: ${backend}`)
|
||||
|
||||
const assistantMsgId = crypto.randomUUID()
|
||||
let accumulated = ''
|
||||
const ttsSentences: string[] = []
|
||||
let sentenceBuffer = ''
|
||||
|
||||
const generator = llmService.chatStream(chatMessages)
|
||||
|
||||
for await (const token of generator) {
|
||||
for await (const token of chatGenerator) {
|
||||
accumulated += token
|
||||
|
||||
// 렌더러에 델타 전송
|
||||
|
|
@ -359,6 +357,34 @@ class VoiceConversationService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 백엔드 선택 + chatStream 생성.
|
||||
* premium 설정 + 사용 가능 → PremiumLLM, 실패 시 LocalLLM fallback.
|
||||
*/
|
||||
private async _createChatStream(): Promise<{
|
||||
generator: AsyncGenerator<string, string>
|
||||
backend: 'local' | 'premium'
|
||||
}> {
|
||||
const chatMessages = this._buildChatMessages()
|
||||
const backend = configGet('llmBackend')
|
||||
|
||||
if (backend === 'premium') {
|
||||
try {
|
||||
const { getPremiumLLMService } = await import('./PremiumLLMService')
|
||||
const premium = getPremiumLLMService()
|
||||
if (premium.isAvailable()) {
|
||||
return { generator: premium.chatStream(chatMessages), backend: 'premium' }
|
||||
}
|
||||
logger.warn('Premium LLM not available for conversation, falling back to local')
|
||||
} catch (err) {
|
||||
logger.warn('Premium LLM init failed for conversation:', err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const local = getLocalLLMService()
|
||||
return { generator: local.chatStream(chatMessages), backend: 'local' }
|
||||
}
|
||||
|
||||
private _buildChatMessages(): Array<{ role: string; content: string }> {
|
||||
const chatMsgs: Array<{ role: string; content: string }> = [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
|
|
|
|||
|
|
@ -1,26 +1,6 @@
|
|||
// apps/web/src/lib/supabase-browser.ts
|
||||
// 브라우저 컴포넌트용 Supabase 클라이언트.
|
||||
|
||||
'use client'
|
||||
|
||||
// apps/web/src/lib/supabase-browser.ts
|
||||
// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합)
|
||||
// re-export from shared package
|
||||
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
import type { Database } from '@d3ro/api-client'
|
||||
|
||||
let cachedClient: ReturnType<typeof createBrowserClient<Database>> | null = null
|
||||
|
||||
export function getSupabaseBrowserClient(): ReturnType<typeof createBrowserClient<Database>> {
|
||||
if (cachedClient) return cachedClient
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
|
||||
|
||||
cachedClient = createBrowserClient<Database>(url, key)
|
||||
return cachedClient
|
||||
}
|
||||
|
||||
export function isSupabaseConfigured(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
|
||||
}
|
||||
export { getSupabaseBrowserClient, isSupabaseConfigured } from '@d3ro/api-client/supabase-browser'
|
||||
|
|
|
|||
|
|
@ -1,35 +1,12 @@
|
|||
// apps/web/src/lib/supabase-server.ts
|
||||
// RSC/route handler용 Supabase 클라이언트 (쿠키 기반 세션).
|
||||
// Database 제네릭 주입 (@supabase/ssr 0.10 + supabase-js 2.103 정합).
|
||||
// Next.js cookies() 주입 래퍼 — 실제 로직은 @d3ro/api-client
|
||||
|
||||
import { cookies } from 'next/headers'
|
||||
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
||||
import type { Database } from '@d3ro/api-client'
|
||||
import { createSupabaseServerClient, isSupabaseConfiguredServer } from '@d3ro/api-client/supabase-server'
|
||||
|
||||
export async function getSupabaseServerClient(): Promise<ReturnType<typeof createServerClient<Database>>> {
|
||||
export { isSupabaseConfiguredServer }
|
||||
|
||||
export async function getSupabaseServerClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
const url = process.env.NEXT_PUBLIC_SUPABASE_URL ?? 'https://placeholder.supabase.co'
|
||||
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? 'placeholder-anon-key'
|
||||
|
||||
return createServerClient<Database>(url, key, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet: Array<{ name: string; value: string; options: CookieOptions }>) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
cookieStore.set(name, value, options)
|
||||
})
|
||||
} catch {
|
||||
// RSC에서 set은 실패하지만 route handler에서는 성공
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function isSupabaseConfiguredServer(): boolean {
|
||||
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY)
|
||||
return createSupabaseServerClient(cookieStore)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue