feat(admin): Phase V2-6 Admin CRM 고도화 — CRUD + 차트 + 결제 + 감사
- DB: audit_log 테이블(diff 포함) + subscriptions.admin_note + super_admin role - Edge Functions 4개: admin-users, admin-subscriptions, admin-payments, admin-audit-log - 공유 유틸: admin-auth.ts(권한 검증), audit.ts(감사로그 기록) - Swagger UI: 독립 정적 페이지 + OpenAPI 3.0 spec - CRUD 페이지: 구독 생성/수정/삭제, role 변경, 감사로그 목록/상세 - recharts: feature별 StackedBar + DAU Line + Top Users HorizontalBar - 결제 이력: DB + Payple API 병행 조회 - 권한: super_admin만 위험 작업, admin은 조회 전용 - RLS: admin/super_admin IN 정책 + super_admin 쓰기 정책 - SQL RPC: admin_usage_by_feature, admin_top_users, admin_dau
This commit is contained in:
parent
f7c50eb2ed
commit
dca1b90faa
34 changed files with 3142 additions and 45 deletions
|
|
@ -9,6 +9,8 @@ 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'
|
||||
|
|
@ -19,6 +21,11 @@ const NAV_ITEMS = [
|
|||
{ 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 /> },
|
||||
]
|
||||
|
||||
const EXTERNAL_LINKS = [
|
||||
{ key: 'swagger', href: '/admin-swagger/', label: 'API Docs', icon: <ApiIcon /> },
|
||||
]
|
||||
|
||||
export function AdminSidebar(): React.ReactElement {
|
||||
|
|
@ -78,6 +85,41 @@ export function AdminSidebar(): React.ReactElement {
|
|||
</ListItem>
|
||||
)
|
||||
})}
|
||||
<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}` }}>
|
||||
|
|
|
|||
128
apps/admin/src/components/audit-diff-viewer.tsx
Normal file
128
apps/admin/src/components/audit-diff-viewer.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/audit-diff-viewer.tsx
|
||||
// before/after JSON diff 뷰어
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
|
||||
interface AuditDiffViewerProps {
|
||||
beforeData: Record<string, unknown> | null
|
||||
afterData: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface DiffEntry {
|
||||
key: string
|
||||
before: unknown
|
||||
after: unknown
|
||||
type: 'added' | 'removed' | 'changed' | 'unchanged'
|
||||
}
|
||||
|
||||
function computeDiff(
|
||||
before: Record<string, unknown> | null,
|
||||
after: Record<string, unknown> | null
|
||||
): DiffEntry[] {
|
||||
const allKeys = new Set<string>([
|
||||
...Object.keys(before ?? {}),
|
||||
...Object.keys(after ?? {}),
|
||||
])
|
||||
|
||||
const entries: DiffEntry[] = []
|
||||
for (const key of allKeys) {
|
||||
const bVal = before?.[key]
|
||||
const aVal = after?.[key]
|
||||
const bStr = JSON.stringify(bVal)
|
||||
const aStr = JSON.stringify(aVal)
|
||||
|
||||
if (bVal === undefined) {
|
||||
entries.push({ key, before: undefined, after: aVal, type: 'added' })
|
||||
} else if (aVal === undefined) {
|
||||
entries.push({ key, before: bVal, after: undefined, type: 'removed' })
|
||||
} else if (bStr !== aStr) {
|
||||
entries.push({ key, before: bVal, after: aVal, type: 'changed' })
|
||||
} else {
|
||||
entries.push({ key, before: bVal, after: aVal, type: 'unchanged' })
|
||||
}
|
||||
}
|
||||
|
||||
// changed/added/removed first
|
||||
return entries.sort((a, b) => {
|
||||
const order = { changed: 0, added: 1, removed: 2, unchanged: 3 }
|
||||
return order[a.type] - order[b.type]
|
||||
})
|
||||
}
|
||||
|
||||
const typeColors: Record<DiffEntry['type'], string> = {
|
||||
added: d3roPalette.tag.green,
|
||||
removed: d3roPalette.tag.red,
|
||||
changed: d3roPalette.accent.amber,
|
||||
unchanged: d3roPalette.text.muted,
|
||||
}
|
||||
|
||||
const typeLabels: Record<DiffEntry['type'], string> = {
|
||||
added: '+',
|
||||
removed: '-',
|
||||
changed: '~',
|
||||
unchanged: ' ',
|
||||
}
|
||||
|
||||
function formatValue(val: unknown): string {
|
||||
if (val === undefined) return '(none)'
|
||||
if (val === null) return 'null'
|
||||
if (typeof val === 'string') return `"${val}"`
|
||||
return JSON.stringify(val)
|
||||
}
|
||||
|
||||
export function AuditDiffViewer({ beforeData, afterData }: AuditDiffViewerProps): React.ReactElement {
|
||||
if (!beforeData && !afterData) {
|
||||
return <PhosphorText variant="dim">No diff data</PhosphorText>
|
||||
}
|
||||
|
||||
const diff = computeDiff(beforeData, afterData)
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: 1,
|
||||
p: 1.5,
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
{diff.map((entry) => (
|
||||
<Box
|
||||
key={entry.key}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
gap: 1,
|
||||
py: 0.25,
|
||||
opacity: entry.type === 'unchanged' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ color: typeColors[entry.type], width: 12, flexShrink: 0, textAlign: 'center' }}>
|
||||
{typeLabels[entry.type]}
|
||||
</Box>
|
||||
<Box sx={{ color: d3roPalette.text.label, minWidth: 140, flexShrink: 0 }}>
|
||||
{entry.key}
|
||||
</Box>
|
||||
{entry.type === 'changed' ? (
|
||||
<Box>
|
||||
<Box component="span" sx={{ color: d3roPalette.tag.red, textDecoration: 'line-through' }}>
|
||||
{formatValue(entry.before)}
|
||||
</Box>
|
||||
<Box component="span" sx={{ mx: 0.5, color: d3roPalette.text.muted }}>{'->'}</Box>
|
||||
<Box component="span" sx={{ color: d3roPalette.tag.green }}>
|
||||
{formatValue(entry.after)}
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ color: typeColors[entry.type] }}>
|
||||
{entry.type === 'removed' ? formatValue(entry.before) : formatValue(entry.after)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
60
apps/admin/src/components/charts/dau-chart.tsx
Normal file
60
apps/admin/src/components/charts/dau-chart.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/charts/dau-chart.tsx
|
||||
// DAU/활성 유저 추이 — Line chart
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
|
||||
interface DauRow {
|
||||
date: string
|
||||
active_users: number
|
||||
}
|
||||
|
||||
interface DauChartProps {
|
||||
data: DauRow[]
|
||||
}
|
||||
|
||||
export function DauChart({ data }: DauChartProps): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: 250 }}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
|
||||
tickFormatter={(val: string) => val.slice(5)}
|
||||
/>
|
||||
<YAxis tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 11,
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="active_users"
|
||||
stroke={d3roPalette.accent.amber}
|
||||
strokeWidth={2}
|
||||
dot={{ fill: d3roPalette.accent.amber, r: 3 }}
|
||||
name="Active Users"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
88
apps/admin/src/components/charts/feature-usage-chart.tsx
Normal file
88
apps/admin/src/components/charts/feature-usage-chart.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/charts/feature-usage-chart.tsx
|
||||
// feature별 일간 API 호출 — StackedBar
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
|
||||
interface FeatureUsageRow {
|
||||
date: string
|
||||
feature: string
|
||||
total_count: number
|
||||
unique_users: number
|
||||
}
|
||||
|
||||
interface FeatureUsageChartProps {
|
||||
data: FeatureUsageRow[]
|
||||
}
|
||||
|
||||
const FEATURE_COLORS: Record<string, string> = {
|
||||
llm_haiku: d3roPalette.accent.amber,
|
||||
llm_sonnet: d3roPalette.tag.green,
|
||||
llm_opus: d3roPalette.tag.purple,
|
||||
stt_transcribe: d3roPalette.tag.blue,
|
||||
translate: d3roPalette.tag.blue,
|
||||
}
|
||||
|
||||
const DEFAULT_COLOR = d3roPalette.text.secondary
|
||||
|
||||
export function FeatureUsageChart({ data }: FeatureUsageChartProps): React.ReactElement {
|
||||
// Pivot: group by date, features as columns
|
||||
const features = [...new Set(data.map(d => d.feature))]
|
||||
const dateMap = new Map<string, Record<string, string | number>>()
|
||||
|
||||
for (const row of data) {
|
||||
const existing = dateMap.get(row.date) ?? { date: row.date }
|
||||
existing[row.feature] = row.total_count
|
||||
dateMap.set(row.date, existing)
|
||||
}
|
||||
|
||||
const chartData = [...dateMap.values()].sort((a, b) =>
|
||||
String(a.date).localeCompare(String(b.date))
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: 300 }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
|
||||
tickFormatter={(val: string) => val.slice(5)}
|
||||
/>
|
||||
<YAxis tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 11,
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontFamily: d3roFontMono, fontSize: 11 }} />
|
||||
{features.map((feature) => (
|
||||
<Bar
|
||||
key={feature}
|
||||
dataKey={feature}
|
||||
stackId="a"
|
||||
fill={FEATURE_COLORS[feature] ?? DEFAULT_COLOR}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
65
apps/admin/src/components/charts/top-users-chart.tsx
Normal file
65
apps/admin/src/components/charts/top-users-chart.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/charts/top-users-chart.tsx
|
||||
// 유저별 사용량 Top 20 — Horizontal bar
|
||||
|
||||
import {
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
|
||||
interface TopUserRow {
|
||||
user_id: string
|
||||
name: string | null
|
||||
total_count: number
|
||||
feature_count: number
|
||||
}
|
||||
|
||||
interface TopUsersChartProps {
|
||||
data: TopUserRow[]
|
||||
}
|
||||
|
||||
export function TopUsersChart({ data }: TopUsersChartProps): React.ReactElement {
|
||||
const chartData = data.map(row => ({
|
||||
name: row.name ?? row.user_id.substring(0, 8),
|
||||
total: row.total_count,
|
||||
features: row.feature_count,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Box sx={{ width: '100%', height: Math.max(250, data.length * 28) }}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={chartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={d3roPalette.border.subtle} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={100}
|
||||
tick={{ fill: d3roPalette.text.muted, fontFamily: d3roFontMono, fontSize: 10 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
background: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 11,
|
||||
color: d3roPalette.text.primary,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="total" fill={d3roPalette.accent.amber} name="Total Calls" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
93
apps/admin/src/components/memo-dialog.tsx
Normal file
93
apps/admin/src/components/memo-dialog.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/memo-dialog.tsx
|
||||
// 메모 입력 다이얼로그 — 감사로그 기록 시 필수
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
} from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
|
||||
interface MemoDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
description?: string
|
||||
onConfirm: (memo: string) => void
|
||||
onCancel: () => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export function MemoDialog({ open, title, description, onConfirm, onCancel, loading }: MemoDialogProps): React.ReactElement {
|
||||
const [memo, setMemo] = useState('')
|
||||
|
||||
const handleConfirm = (): void => {
|
||||
if (memo.trim()) {
|
||||
onConfirm(memo.trim())
|
||||
setMemo('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = (): void => {
|
||||
setMemo('')
|
||||
onCancel()
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { bgcolor: d3roPalette.bg.card, color: d3roPalette.text.primary } }}
|
||||
>
|
||||
<DialogTitle sx={{ fontFamily: d3roFontMono }}>
|
||||
<PhosphorText variant="heading">{title}</PhosphorText>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{description && (
|
||||
<PhosphorText variant="dim" sx={{ display: 'block', mb: 2 }}>
|
||||
{description}
|
||||
</PhosphorText>
|
||||
)}
|
||||
<TextField
|
||||
autoFocus
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="Reason for this action (required)..."
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
sx={{
|
||||
mt: 1,
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.primary,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={handleCancel} sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.secondary }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!memo.trim() || loading}
|
||||
variant="contained"
|
||||
sx={{ fontFamily: d3roFontMono }}
|
||||
>
|
||||
{loading ? 'Processing...' : 'Confirm'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
169
apps/admin/src/components/payment-history.tsx
Normal file
169
apps/admin/src/components/payment-history.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/payment-history.tsx
|
||||
// 결제 이력 패널 — DB + Payple 조회
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Box, Button, CircularProgress } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { callAdminApi } from '@/lib/admin-api'
|
||||
|
||||
interface PaymentHistoryProps {
|
||||
userId: string
|
||||
}
|
||||
|
||||
interface AuditLogEntry {
|
||||
id: number
|
||||
action: string
|
||||
memo: string
|
||||
created_at: string
|
||||
before_data: Record<string, unknown> | null
|
||||
after_data: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
interface PaymentData {
|
||||
subscription: Record<string, unknown> | null
|
||||
auditLogs: AuditLogEntry[]
|
||||
paypleHistory?: Record<string, unknown>
|
||||
paypleError?: string
|
||||
}
|
||||
|
||||
export function PaymentHistory({ userId }: PaymentHistoryProps): React.ReactElement {
|
||||
const [data, setData] = useState<PaymentData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [paypleLoading, setPaypleLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async (): Promise<void> => {
|
||||
try {
|
||||
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}`)
|
||||
setData(result)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
void load()
|
||||
}, [userId])
|
||||
|
||||
const loadPayple = async (): Promise<void> => {
|
||||
setPaypleLoading(true)
|
||||
try {
|
||||
const result = await callAdminApi<PaymentData>(`admin-payments?userId=${userId}&source=payple`)
|
||||
setData(result)
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setPaypleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
|
||||
<CircularProgress size={24} sx={{ color: d3roPalette.accent.amber }} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return <PhosphorText variant="dim">Failed to load payment data</PhosphorText>
|
||||
}
|
||||
|
||||
const sub = data.subscription
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{/* Subscription summary */}
|
||||
{sub && (
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT INFO</PhosphorText>
|
||||
<Box sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.small.size, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||
<Row label="PROVIDER" value={((sub.payment_provider as string) ?? 'none').toUpperCase()} />
|
||||
<Row label="PAYPLE PAYER ID" value={(sub.payple_payer_id as string) ?? '-'} />
|
||||
<Row label="PAYPLE OID" value={(sub.payple_pay_oid as string) ?? '-'} />
|
||||
<Row label="RENEWAL FAILURES" value={String(sub.renewal_failures ?? 0)} />
|
||||
</Box>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* Payple direct query */}
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
<PhosphorText variant="label">PAYPLE HISTORY</PhosphorText>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => void loadPayple()}
|
||||
disabled={paypleLoading}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
|
||||
>
|
||||
{paypleLoading ? 'Loading...' : 'Fetch from Payple'}
|
||||
</Button>
|
||||
</Box>
|
||||
{data.paypleHistory ? (
|
||||
<Box sx={{
|
||||
fontFamily: d3roFontMono, fontSize: d3roTypo.small.size,
|
||||
bgcolor: d3roPalette.bg.inset, borderRadius: 1, p: 1, maxHeight: 300, overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap', wordBreak: 'break-all', color: d3roPalette.text.primary,
|
||||
}}>
|
||||
{JSON.stringify(data.paypleHistory, null, 2)}
|
||||
</Box>
|
||||
) : data.paypleError ? (
|
||||
<PhosphorText variant="dim" sx={{ color: d3roPalette.tag.red }}>{data.paypleError}</PhosphorText>
|
||||
) : (
|
||||
<PhosphorText variant="dim">Click "Fetch from Payple" to query payment history</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Audit log timeline */}
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 1 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>SUBSCRIPTION TIMELINE</PhosphorText>
|
||||
{data.auditLogs.length === 0 ? (
|
||||
<PhosphorText variant="dim">No subscription changes recorded</PhosphorText>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{data.auditLogs.map((log) => (
|
||||
<Box
|
||||
key={log.id}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.small.size,
|
||||
borderLeft: `2px solid ${d3roPalette.accent.amber}`,
|
||||
pl: 1.5,
|
||||
py: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1, alignItems: 'baseline' }}>
|
||||
<Box component="span" sx={{ color: d3roPalette.text.muted, fontSize: 10 }}>
|
||||
{new Date(log.created_at).toLocaleString()}
|
||||
</Box>
|
||||
<Box component="span" sx={{ color: d3roPalette.accent.amber }}>
|
||||
{log.action}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ color: d3roPalette.text.secondary, mt: 0.25 }}>{log.memo}</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
130
apps/admin/src/components/role-change-dialog.tsx
Normal file
130
apps/admin/src/components/role-change-dialog.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/role-change-dialog.tsx
|
||||
// role 변경 확인 다이얼로그 — super_admin 전용
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
} from '@mui/material'
|
||||
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'
|
||||
|
||||
interface RoleChangeDialogProps {
|
||||
open: boolean
|
||||
userId: string
|
||||
userName: string | null
|
||||
currentRole: Role
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function RoleChangeDialog({
|
||||
open, userId, userName, currentRole, onClose, onSuccess,
|
||||
}: RoleChangeDialogProps): React.ReactElement {
|
||||
const [newRole, setNewRole] = useState<Role>(currentRole)
|
||||
const [memo, setMemo] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleConfirm = async (): Promise<void> => {
|
||||
if (!memo.trim() || newRole === currentRole) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await callAdminApi('admin-users', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ userId, newRole, memo: memo.trim() }),
|
||||
})
|
||||
setMemo('')
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to change role')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{ sx: { bgcolor: d3roPalette.bg.card, color: d3roPalette.text.primary } }}
|
||||
>
|
||||
<DialogTitle sx={{ fontFamily: d3roFontMono }}>
|
||||
<PhosphorText variant="heading">CHANGE USER ROLE</PhosphorText>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<PhosphorText variant="dim" sx={{ display: 'block', mb: 2 }}>
|
||||
{userName ?? userId.substring(0, 8)} : {currentRole}
|
||||
</PhosphorText>
|
||||
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.label }}>New Role</InputLabel>
|
||||
<Select
|
||||
value={newRole}
|
||||
onChange={(e) => setNewRole(e.target.value as Role)}
|
||||
label="New Role"
|
||||
sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.primary }}
|
||||
>
|
||||
<MenuItem value="user">user</MenuItem>
|
||||
<MenuItem value="admin">admin</MenuItem>
|
||||
<MenuItem value="super_admin">super_admin</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
placeholder="Reason for role change (required)..."
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.primary,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<PhosphorText variant="dim" sx={{ display: 'block', mt: 1, color: d3roPalette.tag.red }}>
|
||||
{error}
|
||||
</PhosphorText>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} sx={{ fontFamily: d3roFontMono, color: d3roPalette.text.secondary }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={!memo.trim() || newRole === currentRole || loading}
|
||||
variant="contained"
|
||||
color={newRole === 'super_admin' ? 'error' : 'primary'}
|
||||
sx={{ fontFamily: d3roFontMono }}
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Role'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
187
apps/admin/src/components/subscription-form.tsx
Normal file
187
apps/admin/src/components/subscription-form.tsx
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/components/subscription-form.tsx
|
||||
// 구독 생성/수정 공용 폼
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
} from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { callAdminApi } from '@/lib/admin-api'
|
||||
|
||||
type Tier = 'free' | 'pro' | 'pro_plus'
|
||||
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
|
||||
|
||||
interface SubscriptionFormProps {
|
||||
mode: 'create' | 'edit'
|
||||
userId: string
|
||||
initial?: {
|
||||
tier: Tier
|
||||
status: SubStatus
|
||||
currentPeriodEnd: string | null
|
||||
overageCredits: number
|
||||
adminNote: string | null
|
||||
}
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function SubscriptionForm({ mode, userId, initial, onSuccess }: SubscriptionFormProps): React.ReactElement {
|
||||
const [tier, setTier] = useState<Tier>(initial?.tier ?? 'free')
|
||||
const [status, setStatus] = useState<SubStatus>(initial?.status ?? 'active')
|
||||
const [periodEnd, setPeriodEnd] = useState(initial?.currentPeriodEnd?.split('T')[0] ?? '')
|
||||
const [overageCredits, setOverageCredits] = useState(initial?.overageCredits ?? 0)
|
||||
const [adminNote, setAdminNote] = useState(initial?.adminNote ?? '')
|
||||
const [memo, setMemo] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const handleSubmit = async (): Promise<void> => {
|
||||
if (!memo.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
await callAdminApi('admin-subscriptions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
tier,
|
||||
status,
|
||||
currentPeriodEnd: periodEnd ? new Date(periodEnd).toISOString() : undefined,
|
||||
adminNote: adminNote || undefined,
|
||||
memo: memo.trim(),
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
status,
|
||||
currentPeriodEnd: periodEnd ? new Date(periodEnd).toISOString() : undefined,
|
||||
overageCredits,
|
||||
adminNote: adminNote || undefined,
|
||||
memo: memo.trim(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
setSuccess(true)
|
||||
setMemo('')
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Operation failed')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputSx = {
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.primary,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<MetalCard>
|
||||
<Box sx={{ p: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<PhosphorText variant="label">
|
||||
{mode === 'create' ? 'CREATE SUBSCRIPTION' : 'EDIT SUBSCRIPTION'}
|
||||
</PhosphorText>
|
||||
|
||||
<FormControl fullWidth sx={inputSx}>
|
||||
<InputLabel sx={{ fontFamily: d3roFontMono }}>Tier</InputLabel>
|
||||
<Select value={tier} onChange={(e) => setTier(e.target.value as Tier)} label="Tier">
|
||||
<MenuItem value="free">FREE</MenuItem>
|
||||
<MenuItem value="pro">PRO</MenuItem>
|
||||
<MenuItem value="pro_plus">PRO+</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth sx={inputSx}>
|
||||
<InputLabel sx={{ fontFamily: d3roFontMono }}>Status</InputLabel>
|
||||
<Select value={status} onChange={(e) => setStatus(e.target.value as SubStatus)} label="Status">
|
||||
<MenuItem value="active">ACTIVE</MenuItem>
|
||||
<MenuItem value="canceled">CANCELED</MenuItem>
|
||||
<MenuItem value="past_due">PAST DUE</MenuItem>
|
||||
<MenuItem value="expired">EXPIRED</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Period End"
|
||||
type="date"
|
||||
value={periodEnd}
|
||||
onChange={(e) => setPeriodEnd(e.target.value)}
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
sx={inputSx}
|
||||
/>
|
||||
|
||||
{mode === 'edit' && (
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Overage Credits"
|
||||
type="number"
|
||||
value={overageCredits}
|
||||
onChange={(e) => setOverageCredits(parseInt(e.target.value, 10) || 0)}
|
||||
sx={inputSx}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Admin Note"
|
||||
multiline
|
||||
rows={2}
|
||||
value={adminNote}
|
||||
onChange={(e) => setAdminNote(e.target.value)}
|
||||
sx={inputSx}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Memo (required for audit log)"
|
||||
multiline
|
||||
rows={2}
|
||||
value={memo}
|
||||
onChange={(e) => setMemo(e.target.value)}
|
||||
placeholder="Reason for this action..."
|
||||
sx={{
|
||||
...inputSx,
|
||||
'& .MuiInputBase-root': {
|
||||
...inputSx['& .MuiInputBase-root'],
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && <Alert severity="error" sx={{ fontFamily: d3roFontMono }}>{error}</Alert>}
|
||||
{success && <Alert severity="success" sx={{ fontFamily: d3roFontMono }}>Operation successful</Alert>}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!memo.trim() || loading}
|
||||
sx={{ fontFamily: d3roFontMono, alignSelf: 'flex-end' }}
|
||||
>
|
||||
{loading ? 'Processing...' : mode === 'create' ? 'Create' : 'Update'}
|
||||
</Button>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue