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
96
apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
Normal file
96
apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// apps/admin/src/app/(admin)/audit-log/[id]/page.tsx
|
||||
// 감사로그 상세 — before/after diff 뷰
|
||||
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { AuditDiffViewer } from '@/components/audit-diff-viewer'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function AuditLogDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
await requireAdmin()
|
||||
const { id } = await params
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
const { data: log } = await supabase
|
||||
.from('audit_log')
|
||||
.select('*')
|
||||
.eq('id', parseInt(id, 10))
|
||||
.maybeSingle()
|
||||
|
||||
if (!log) notFound()
|
||||
const typedLog = log as Record<string, unknown>
|
||||
|
||||
// Admin profile
|
||||
const { data: adminProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.eq('id', typedLog.admin_id as string)
|
||||
.maybeSingle()
|
||||
|
||||
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>
|
||||
</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()} />
|
||||
</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>
|
||||
|
||||
<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}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
137
apps/admin/src/app/(admin)/audit-log/page.tsx
Normal file
137
apps/admin/src/app/(admin)/audit-log/page.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// apps/admin/src/app/(admin)/audit-log/page.tsx
|
||||
// 감사로그 목록
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ target_type?: string; page?: string }>
|
||||
}
|
||||
|
||||
export default async function AuditLogPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
await requireAdmin()
|
||||
const params = await searchParams
|
||||
const targetTypeFilter = params.target_type ?? 'all'
|
||||
const page = parseInt(params.page ?? '1', 10)
|
||||
const limit = 20
|
||||
const from = (page - 1) * limit
|
||||
const to = from + limit - 1
|
||||
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
let query = supabase
|
||||
.from('audit_log')
|
||||
.select('*', { count: 'exact' })
|
||||
|
||||
if (targetTypeFilter !== 'all') {
|
||||
query = query.eq('target_type', targetTypeFilter)
|
||||
}
|
||||
|
||||
const { data: rawLogs, count } = await query
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to)
|
||||
|
||||
const logs = (rawLogs ?? []) as Array<Record<string, unknown>>
|
||||
const totalPages = Math.ceil((count ?? 0) / limit)
|
||||
|
||||
// Admin names
|
||||
const adminIds = [...new Set(logs.map(l => l.admin_id as string))]
|
||||
let adminMap: Record<string, string> = {}
|
||||
if (adminIds.length > 0) {
|
||||
const { data: admins } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.in('id', adminIds)
|
||||
if (admins) {
|
||||
adminMap = Object.fromEntries(
|
||||
(admins as Array<{ id: string; name: string | null }>).map(a => [a.id, a.name ?? 'Unknown'])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</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>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
95
apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
Normal file
95
apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/app/(admin)/subscriptions/[id]/client.tsx
|
||||
// 구독 수정/삭제 클라이언트 컴포넌트
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Box, Button } from '@mui/material'
|
||||
import { d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { SubscriptionForm } from '@/components/subscription-form'
|
||||
import { MemoDialog } from '@/components/memo-dialog'
|
||||
import { callAdminApi } from '@/lib/admin-api'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type Tier = 'free' | 'pro' | 'pro_plus'
|
||||
type SubStatus = 'active' | 'canceled' | 'past_due' | 'expired'
|
||||
|
||||
interface SubscriptionDetailClientProps {
|
||||
userId: string
|
||||
hasSub: boolean
|
||||
isSuperAdmin: boolean
|
||||
initialSub?: {
|
||||
tier: Tier
|
||||
status: SubStatus
|
||||
currentPeriodEnd: string | null
|
||||
overageCredits: number
|
||||
adminNote: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export function SubscriptionDetailClient({
|
||||
userId, hasSub, isSuperAdmin, initialSub,
|
||||
}: SubscriptionDetailClientProps): React.ReactElement {
|
||||
const router = useRouter()
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
|
||||
const handleDelete = async (memo: string): Promise<void> => {
|
||||
setDeleteLoading(true)
|
||||
try {
|
||||
await callAdminApi(`admin-subscriptions?userId=${userId}`, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ memo }),
|
||||
})
|
||||
setDeleteOpen(false)
|
||||
router.refresh()
|
||||
} catch {
|
||||
// error handled in dialog
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
return <Box />
|
||||
}
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SubscriptionForm
|
||||
mode="create"
|
||||
userId={userId}
|
||||
onSuccess={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
140
apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
Normal file
140
apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// apps/admin/src/app/(admin)/subscriptions/[id]/page.tsx
|
||||
// 구독 상세 + 수정/삭제 (super_admin) — [id]는 user_id
|
||||
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { SubscriptionDetailClient } from './client'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
export default async function SubscriptionDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
const { id: userId } = await params
|
||||
const admin = await requireAdmin()
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
const [subRes, profileRes, auditRes] = await Promise.all([
|
||||
supabase.from('subscriptions').select('*').eq('user_id', userId).maybeSingle(),
|
||||
supabase.from('profiles').select('id, name, tier, role').eq('id', userId).maybeSingle(),
|
||||
supabase.from('audit_log').select('*')
|
||||
.eq('target_id', userId)
|
||||
.eq('target_type', 'subscription')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20),
|
||||
])
|
||||
|
||||
const sub = subRes.data as Record<string, unknown> | null
|
||||
const profile = profileRes.data as Record<string, unknown> | null
|
||||
|
||||
if (!profile) notFound()
|
||||
|
||||
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>
|
||||
</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) ?? '-'} />
|
||||
</Box>
|
||||
) : (
|
||||
<PhosphorText variant="dim">No subscription record</PhosphorText>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Grid>
|
||||
</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}
|
||||
/>
|
||||
|
||||
{/* 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 },
|
||||
}}>
|
||||
<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>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
48
apps/admin/src/app/(admin)/subscriptions/new/client.tsx
Normal file
48
apps/admin/src/app/(admin)/subscriptions/new/client.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/app/(admin)/subscriptions/new/client.tsx
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Box, TextField } from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono } from '@d3ro/ui/theme'
|
||||
import { SubscriptionForm } from '@/components/subscription-form'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface NewSubscriptionClientProps {
|
||||
initialUserId: string
|
||||
}
|
||||
|
||||
export function NewSubscriptionClient({ initialUserId }: NewSubscriptionClientProps): React.ReactElement {
|
||||
const [userId, setUserId] = useState(initialUserId)
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ display: 'block', mb: 1 }}>TARGET USER ID</PhosphorText>
|
||||
<TextField
|
||||
fullWidth
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="UUID of the user..."
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.primary,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{userId && (
|
||||
<SubscriptionForm
|
||||
mode="create"
|
||||
userId={userId}
|
||||
onSuccess={() => router.push(`/subscriptions/${userId}`)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
30
apps/admin/src/app/(admin)/subscriptions/new/page.tsx
Normal file
30
apps/admin/src/app/(admin)/subscriptions/new/page.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// apps/admin/src/app/(admin)/subscriptions/new/page.tsx
|
||||
// 새 구독 생성 (VIP 부여) — super_admin 전용
|
||||
|
||||
import { Box } from '@mui/material'
|
||||
import { PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { requireSuperAdmin } from '@/lib/admin-guard'
|
||||
import Link from 'next/link'
|
||||
import { NewSubscriptionClient } from './client'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ userId?: string }>
|
||||
}
|
||||
|
||||
export default async function NewSubscriptionPage({ searchParams }: PageProps): Promise<React.ReactElement> {
|
||||
await requireSuperAdmin()
|
||||
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>
|
||||
</Box>
|
||||
<NewSubscriptionClient initialUserId={userId} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -63,7 +63,18 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
|
||||
return (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>SUBSCRIPTIONS</PhosphorText>
|
||||
<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,
|
||||
}}>
|
||||
+ NEW
|
||||
</PhosphorText>
|
||||
</Link>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
{['all', 'active', 'canceled', 'past_due', 'expired'].map((s) => (
|
||||
|
|
@ -86,7 +97,7 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
'& 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></tr>
|
||||
<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) => (
|
||||
|
|
@ -106,6 +117,11 @@ export default async function AdminSubscriptionsPage({ searchParams }: PageProps
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
// apps/admin/src/app/(admin)/usage/page.tsx
|
||||
// 사용량 집계 — feature별, 날짜 범위
|
||||
// 사용량 — feature별 차트 + DAU + Top users + 테이블
|
||||
|
||||
import { Box, Grid } from '@mui/material'
|
||||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import Link from 'next/link'
|
||||
import { FeatureUsageChart } from '@/components/charts/feature-usage-chart'
|
||||
import { DauChart } from '@/components/charts/dau-chart'
|
||||
import { TopUsersChart } from '@/components/charts/top-users-chart'
|
||||
|
||||
interface PageProps {
|
||||
searchParams: Promise<{ days?: string }>
|
||||
|
|
@ -14,16 +18,32 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
const params = await searchParams
|
||||
const days = parseInt(params.days ?? '7', 10)
|
||||
const since = new Date(Date.now() - days * 86400000).toISOString().split('T')[0]
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const supabase = await getSupabaseServerClient()
|
||||
const { data: rawData } = await supabase
|
||||
.from('daily_usage')
|
||||
.select('date, feature, count, user_id')
|
||||
.gte('date', since)
|
||||
.order('date', { ascending: false })
|
||||
|
||||
const rows = (rawData ?? []) as Array<{ date: string; feature: string; count: number; user_id: string }>
|
||||
// Fetch all data in parallel
|
||||
// RPC functions are defined in migration but not in Database types — cast via unknown
|
||||
const rpcClient = supabase as unknown as {
|
||||
rpc: (fn: string, params: Record<string, unknown>) => Promise<{ data: unknown[]; error: unknown }>
|
||||
}
|
||||
|
||||
const [featureRes, dauRes, topUsersRes, rawDataRes] = await Promise.all([
|
||||
rpcClient.rpc('admin_usage_by_feature', { p_from: since, p_to: today }),
|
||||
rpcClient.rpc('admin_dau', { p_from: since, p_to: today }),
|
||||
rpcClient.rpc('admin_top_users', { p_from: since, p_to: today, p_limit: 20 }),
|
||||
supabase.from('daily_usage')
|
||||
.select('date, feature, count, user_id')
|
||||
.gte('date', since)
|
||||
.order('date', { ascending: false }),
|
||||
])
|
||||
|
||||
const featureData = (featureRes.data ?? []) as Array<{ date: string; feature: string; total_count: number; unique_users: number }>
|
||||
const dauData = (dauRes.data ?? []) as Array<{ date: string; active_users: number }>
|
||||
const topUsersData = (topUsersRes.data ?? []) as Array<{ user_id: string; name: string | null; total_count: number; feature_count: number }>
|
||||
|
||||
// Summary cards from raw data
|
||||
const rows = (rawDataRes.data ?? []) as Array<{ date: string; feature: string; count: number; user_id: string }>
|
||||
const featureMap = new Map<string, { total: number; users: Set<string> }>()
|
||||
for (const r of rows) {
|
||||
const entry = featureMap.get(r.feature) ?? { total: 0, users: new Set<string>() }
|
||||
|
|
@ -31,41 +51,27 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
entry.users.add(r.user_id)
|
||||
featureMap.set(r.feature, entry)
|
||||
}
|
||||
|
||||
const summaries = Array.from(featureMap.entries())
|
||||
.map(([feature, { total, users }]) => ({ feature, total, uniqueUsers: users.size }))
|
||||
.sort((a, b) => b.total - a.total)
|
||||
|
||||
const dailyMap = new Map<string, Map<string, number>>()
|
||||
for (const r of rows) {
|
||||
const dayEntry = dailyMap.get(r.date) ?? new Map<string, number>()
|
||||
dayEntry.set(r.feature, (dayEntry.get(r.feature) ?? 0) + r.count)
|
||||
dailyMap.set(r.date, dayEntry)
|
||||
}
|
||||
|
||||
const dailyRows: Array<{ date: string; feature: string; total: number }> = []
|
||||
for (const [date, features] of dailyMap) {
|
||||
for (const [feature, total] of features) {
|
||||
dailyRows.push({ date, feature, total })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>USAGE</PhosphorText>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
|
||||
{[7, 14, 30].map((d) => (
|
||||
<a key={d} href={`/usage?days=${d}`} style={{ textDecoration: 'none' }}>
|
||||
<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>
|
||||
</a>
|
||||
</Link>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Summary cards */}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{summaries.map((s) => (
|
||||
<Grid size={{ xs: 6, md: 3 }} key={s.feature}>
|
||||
|
|
@ -80,6 +86,49 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
))}
|
||||
</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>
|
||||
|
|
@ -88,17 +137,19 @@ export default async function AdminUsagePage({ searchParams }: PageProps): Promi
|
|||
'& th, & td': { py: 0.5, px: 1.5, textAlign: 'left', borderBottom: `1px solid ${d3roPalette.border.subtle}` },
|
||||
'& th': { color: d3roPalette.text.label, textTransform: 'uppercase' },
|
||||
}}>
|
||||
<thead><tr><th>DATE</th><th>FEATURE</th><th>CALLS</th></tr></thead>
|
||||
<thead><tr><th>DATE</th><th>FEATURE</th><th>CALLS</th><th>UNIQUE USERS</th></tr></thead>
|
||||
<tbody>
|
||||
{dailyRows.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.toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
{dailyRows.length === 0 && (
|
||||
<tr><td colSpan={3} style={{ textAlign: 'center', color: d3roPalette.text.muted }}>No usage data</td></tr>
|
||||
{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>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { Box, Grid } from '@mui/material'
|
|||
import { MetalCard, PhosphorText } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { getSupabaseServerClient } from '@/lib/supabase-server'
|
||||
import { requireAdmin } from '@/lib/admin-guard'
|
||||
import { notFound } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { RoleChangeButton } from './role-change-button'
|
||||
import { PaymentHistory } from '@/components/payment-history'
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>
|
||||
|
|
@ -13,6 +17,7 @@ interface PageProps {
|
|||
|
||||
export default async function AdminUserDetailPage({ params }: PageProps): Promise<React.ReactElement> {
|
||||
const { id } = await params
|
||||
const admin = await requireAdmin()
|
||||
const supabase = await getSupabaseServerClient()
|
||||
|
||||
const [profileRes, subRes, usageRes] = await Promise.all([
|
||||
|
|
@ -32,9 +37,19 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
const tier = (profile.tier as string) ?? 'free'
|
||||
const tierColor = tier === 'pro_plus' ? d3roPalette.tag.purple : tier === 'pro' ? d3roPalette.tag.green : d3roPalette.accent.amber
|
||||
|
||||
const userRole = ((profile.role as string) ?? 'user') as 'user' | 'admin' | 'super_admin'
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PhosphorText variant="title" sx={{ mb: 3 }}>USER DETAIL</PhosphorText>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<PhosphorText variant="title">USER DETAIL</PhosphorText>
|
||||
<RoleChangeButton
|
||||
userId={id}
|
||||
userName={(profile.name as string) ?? null}
|
||||
currentRole={userRole}
|
||||
isSuperAdmin={admin.role === 'super_admin'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
|
|
@ -45,6 +60,7 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
<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>
|
||||
|
|
@ -61,6 +77,11 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
<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>
|
||||
</Box>
|
||||
) : (
|
||||
<PhosphorText variant="dim">No subscription</PhosphorText>
|
||||
|
|
@ -95,6 +116,12 @@ export default async function AdminUserDetailPage({ params }: PageProps): Promis
|
|||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
|
||||
{/* Payment History */}
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1, display: 'block' }}>PAYMENT HISTORY</PhosphorText>
|
||||
<PaymentHistory userId={id} />
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
50
apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
Normal file
50
apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
'use client'
|
||||
|
||||
// apps/admin/src/app/(admin)/users/[id]/role-change-button.tsx
|
||||
// super_admin만 볼 수 있는 role 변경 버튼
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@mui/material'
|
||||
import { d3roFontMono } from '@d3ro/ui/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
|
||||
}
|
||||
|
||||
export function RoleChangeButton({
|
||||
userId, userName, currentRole, isSuperAdmin,
|
||||
}: RoleChangeButtonProps): React.ReactElement | null {
|
||||
const [open, setOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
if (!isSuperAdmin) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => setOpen(true)}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: 11 }}
|
||||
>
|
||||
Change Role
|
||||
</Button>
|
||||
<RoleChangeDialog
|
||||
open={open}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
currentRole={currentRole}
|
||||
onClose={() => setOpen(false)}
|
||||
onSuccess={() => {
|
||||
setOpen(false)
|
||||
router.refresh()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue