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:
윤찬 2026-04-13 01:28:10 +09:00
parent d0e854c255
commit 8af75a0a1e
50 changed files with 2185 additions and 950 deletions

View file

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