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:
윤찬 2026-04-12 21:32:47 +09:00
parent f7c50eb2ed
commit dca1b90faa
34 changed files with 3142 additions and 45 deletions

View 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>
)
}

View 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>
)
}

View 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>
)
}