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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue