feat(admin): 예전/최신 어드민 통합 — 실데이터 복원 + 인증 아키텍처 정리
예전 배포본(HEAD)의 상세 기능을 새 아키텍처(인증=.NET 백엔드, 데이터=Supabase) 위에 실데이터로 복원. 예전 HEAD는 서명 쿠키를 거부하고 위조 쿠키는 통과시키는 인증 결함이 있었고, 삭제된 페이지 다수는 백엔드 호출 0인 하드코딩 목업이었음. 인증/세션 - 로그인 이메일 전용화(username 폐지), 에러 키별 안내 메시지 - ADMIN_COOKIE_SECURE 옵션: TLS 없는 LAN HTTP 배포에서 Secure 쿠키 유실로 로그인이 유지되지 않던 문제 해결 (login/logout route, admin-session, compose, .env.example) - Supabase 미설정 시 우아한 저하: isSupabaseAdminConfigured + UnavailableAdminPanel 기능 복원 (실데이터) - Release Hub: Forgejo API 실데이터(다운로드 수/SHA-256 체크섬/릴리스 이력) - Ad Monetization: 데스크톱 미디에이션 10개 어댑터 로스터(fail-closed) + ad_reward_claims 통계 - License Issuer: 서버사이드 Ed25519 서명(/api/admin/license, super_admin 전용), 개인키는 ADMIN_LICENSE_PRIVATE_KEY env로만, 발급 감사를 .NET AdminAuditEntries에 기록 - Service Models: STT 7종/LLM 5종 프리셋 드롭다운 + 자동채움 - 대시보드 ARR/MRR KPI: Supabase 구독 실집계(티어 월단가 기반) - 사용자 상세 티어별 기능 배지(pro_plus 조건부) .NET - SuperAdminOnly 정책 추가, /api/admin/license-audit 엔드포인트, LicenseAuditDto
This commit is contained in:
parent
a9c9a1ca6e
commit
5a34f66981
66 changed files with 4471 additions and 3501 deletions
|
|
@ -1,562 +1,280 @@
|
|||
// apps/admin/src/app/(admin)/ads/page.tsx
|
||||
// D3RO Voice — Multi-Ad Network Mediation & Revenue Settlement Console (10+ Demand Sources)
|
||||
// D3RO Voice Admin CRM — Ad Mediation & Monetization Console
|
||||
|
||||
import React from 'react'
|
||||
import { Box, Typography, Button } from '@mui/material'
|
||||
import {
|
||||
C,
|
||||
FONT_SANS,
|
||||
FONT_MONO,
|
||||
panelSx,
|
||||
tableSx,
|
||||
statusBadgeSx,
|
||||
primaryButtonSx,
|
||||
} from '@/lib/console-theme'
|
||||
import { Box, Typography } from '@mui/material'
|
||||
import { DoubleBezelCard, TactileBadge } from '@d3ro/ui/components/ds'
|
||||
import { C, FONT_SANS, FONT_MONO, panelSx, tableSx, statusBadgeSx } from '@/lib/console-theme'
|
||||
import { requireManager } from '@/lib/admin-guard'
|
||||
import { MEDIATION_ROSTER, fetchAdRewardStats, type AdRewardStats } from '@/lib/ad-monetization'
|
||||
import { isSupabaseAdminConfigured } from '@/lib/supabase-admin'
|
||||
|
||||
interface AdNetworkStat {
|
||||
id: string
|
||||
name: string
|
||||
adapterType: string
|
||||
format: string
|
||||
impressions: number
|
||||
clicks: number
|
||||
ctr: string
|
||||
ecpm: number
|
||||
grossRevenueUsd: number
|
||||
fillRate: string
|
||||
status: 'active' | 'bidding' | 'fallback'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const FORMAT_LABEL: Record<string, string> = {
|
||||
banner_dock: 'Bottom Dock',
|
||||
rewarded_video: 'Rewarded Video',
|
||||
export_sponsor: 'Export Sponsor',
|
||||
audio_chime: 'Audio Chime'
|
||||
}
|
||||
|
||||
interface SettlementRow {
|
||||
id: string
|
||||
cycleMonth: string
|
||||
networkName: string
|
||||
grossUsd: number
|
||||
withholdingTax: string
|
||||
netPayoutKrw: number
|
||||
payoutStatus: 'settled' | 'paid' | 'pending'
|
||||
method: string
|
||||
function formatDateTime(value: string): string {
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? '—' : parsed.toISOString().replace('T', ' ').slice(0, 16)
|
||||
}
|
||||
|
||||
export default async function AdminAdsPage(): Promise<React.ReactElement> {
|
||||
// 10+ Production Ad Networks Active in D3RO Voice Mediation
|
||||
const networks: AdNetworkStat[] = [
|
||||
{
|
||||
id: 'net_001',
|
||||
name: 'Direct House Sponsor Engine',
|
||||
adapterType: 'Direct Contract',
|
||||
format: 'Bottom Dock / Video / Export',
|
||||
impressions: 84000,
|
||||
clicks: 4200,
|
||||
ctr: '5.0%',
|
||||
ecpm: 15.2,
|
||||
grossRevenueUsd: 1276.8,
|
||||
fillRate: '100.0%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_002',
|
||||
name: 'Playwire RAMP Engine',
|
||||
adapterType: 'Header Bidding SSP',
|
||||
format: 'Desktop Video & Display',
|
||||
impressions: 62000,
|
||||
clicks: 4340,
|
||||
ctr: '7.0%',
|
||||
ecpm: 8.4,
|
||||
grossRevenueUsd: 520.8,
|
||||
fillRate: '96.2%',
|
||||
status: 'bidding',
|
||||
},
|
||||
{
|
||||
id: 'net_003',
|
||||
name: 'AppLovin MAX',
|
||||
adapterType: 'Real-Time In-App Bidding',
|
||||
format: 'Rewarded Video (15s)',
|
||||
impressions: 48000,
|
||||
clicks: 3840,
|
||||
ctr: '8.0%',
|
||||
ecpm: 7.8,
|
||||
grossRevenueUsd: 374.4,
|
||||
fillRate: '94.5%',
|
||||
status: 'bidding',
|
||||
},
|
||||
{
|
||||
id: 'net_004',
|
||||
name: 'Unity LevelPlay',
|
||||
adapterType: 'Rewarded Video SDK',
|
||||
format: 'Rewarded Quota Refill',
|
||||
impressions: 45000,
|
||||
clicks: 4050,
|
||||
ctr: '9.0%',
|
||||
ecpm: 9.1,
|
||||
grossRevenueUsd: 409.5,
|
||||
fillRate: '95.1%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_005',
|
||||
name: 'EthicalAds Privacy Dev Network',
|
||||
adapterType: 'REST Decision API',
|
||||
format: 'Bottom Dock Banner',
|
||||
impressions: 38000,
|
||||
clicks: 608,
|
||||
ctr: '1.6%',
|
||||
ecpm: 3.8,
|
||||
grossRevenueUsd: 144.4,
|
||||
fillRate: '99.4%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_006',
|
||||
name: 'Carbon Ads (BuySellAds)',
|
||||
adapterType: 'Native JSON Endpoint',
|
||||
format: 'Tech Developer Unit',
|
||||
impressions: 31000,
|
||||
clicks: 589,
|
||||
ctr: '1.9%',
|
||||
ecpm: 4.2,
|
||||
grossRevenueUsd: 130.2,
|
||||
fillRate: '98.8%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_007',
|
||||
name: 'Google Ad Manager 360',
|
||||
adapterType: 'Universal Ad Server',
|
||||
format: 'Global Display & Video',
|
||||
impressions: 29000,
|
||||
clicks: 580,
|
||||
ctr: '2.0%',
|
||||
ecpm: 3.5,
|
||||
grossRevenueUsd: 101.5,
|
||||
fillRate: '99.8%',
|
||||
status: 'bidding',
|
||||
},
|
||||
{
|
||||
id: 'net_008',
|
||||
name: 'Mintegral APAC Network',
|
||||
adapterType: 'Video & Playable SDK',
|
||||
format: 'Rewarded Video (15s)',
|
||||
impressions: 22000,
|
||||
clicks: 1760,
|
||||
ctr: '8.0%',
|
||||
ecpm: 6.2,
|
||||
grossRevenueUsd: 136.4,
|
||||
fillRate: '92.4%',
|
||||
status: 'bidding',
|
||||
},
|
||||
{
|
||||
id: 'net_009',
|
||||
name: 'InMobi Programmatic Exchange',
|
||||
adapterType: 'RTB Exchange',
|
||||
format: 'Banner & Video Interstitial',
|
||||
impressions: 19000,
|
||||
clicks: 380,
|
||||
ctr: '2.0%',
|
||||
ecpm: 3.4,
|
||||
grossRevenueUsd: 64.6,
|
||||
fillRate: '91.2%',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 'net_010',
|
||||
name: 'PubMatic OpenWrap SSP',
|
||||
adapterType: 'Prebid Header Bidding',
|
||||
format: 'Bottom Dock & Export Modal',
|
||||
impressions: 15000,
|
||||
clicks: 225,
|
||||
ctr: '1.5%',
|
||||
ecpm: 3.6,
|
||||
grossRevenueUsd: 54.0,
|
||||
fillRate: '90.5%',
|
||||
status: 'bidding',
|
||||
},
|
||||
]
|
||||
|
||||
const totalGrossRevenue = networks.reduce((acc, n) => acc + n.grossRevenueUsd, 0)
|
||||
const totalImpressions = networks.reduce((acc, n) => acc + n.impressions, 0)
|
||||
const avgEcpm = (totalGrossRevenue / (totalImpressions / 1000)).toFixed(2)
|
||||
|
||||
// Monthly Settlement Records (KRW Tax Withholding & Net Payout)
|
||||
const settlements: SettlementRow[] = [
|
||||
{
|
||||
id: 'STL-202607-DIRECT',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'Direct House Sponsor (Cursor/Notion)',
|
||||
grossUsd: 1276.8,
|
||||
withholdingTax: '3.3% (₩56,870)',
|
||||
netPayoutKrw: 1666810,
|
||||
payoutStatus: 'paid',
|
||||
method: 'KB국민 928702-00-184920',
|
||||
},
|
||||
{
|
||||
id: 'STL-202607-PLAYWIRE',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'Playwire RAMP Desktop Header Bidding',
|
||||
grossUsd: 520.8,
|
||||
withholdingTax: '3.3% (₩23,200)',
|
||||
netPayoutKrw: 679880,
|
||||
payoutStatus: 'paid',
|
||||
method: 'Wire Transfer (USD)',
|
||||
},
|
||||
{
|
||||
id: 'STL-202607-APPLOVIN',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'AppLovin MAX In-App Bidding',
|
||||
grossUsd: 374.4,
|
||||
withholdingTax: '3.3% (₩16,680)',
|
||||
netPayoutKrw: 488760,
|
||||
payoutStatus: 'settled',
|
||||
method: 'Wire Transfer (USD)',
|
||||
},
|
||||
{
|
||||
id: 'STL-202607-UNITY',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'Unity LevelPlay Rewarded Video',
|
||||
grossUsd: 409.5,
|
||||
withholdingTax: '3.3% (₩18,240)',
|
||||
netPayoutKrw: 534580,
|
||||
payoutStatus: 'settled',
|
||||
method: 'PayPal (yunchanpaca@gmail.com)',
|
||||
},
|
||||
{
|
||||
id: 'STL-202607-ETHICAL',
|
||||
cycleMonth: '2026-07',
|
||||
networkName: 'EthicalAds Privacy Dev Network',
|
||||
grossUsd: 144.4,
|
||||
withholdingTax: '3.3% (₩6,430)',
|
||||
netPayoutKrw: 188510,
|
||||
payoutStatus: 'settled',
|
||||
method: 'PayPal (yunchanpaca@gmail.com)',
|
||||
},
|
||||
]
|
||||
|
||||
const totalSettledKrw = settlements.reduce((acc, s) => acc + s.netPayoutKrw, 0)
|
||||
|
||||
function HeaderBar(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<Box
|
||||
sx={{
|
||||
...panelSx,
|
||||
minHeight: 84,
|
||||
px: { xs: 2.5, md: 4 },
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
...panelSx,
|
||||
minHeight: 84,
|
||||
px: { xs: 2.5, md: 4 },
|
||||
py: 2,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 4,
|
||||
height: 32,
|
||||
borderRadius: '999px',
|
||||
background: `linear-gradient(180deg, ${C.orange} 0%, ${C.accent} 60%, ${C.purple} 100%)`
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontFamily: FONT_SANS,
|
||||
fontWeight: 800,
|
||||
fontSize: { xs: '20px', md: '24px' },
|
||||
color: C.bright,
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
component="h1"
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '20px', fontWeight: 500, color: C.bright, letterSpacing: '-0.02em', m: 0 }}
|
||||
>
|
||||
Multi-Ad Mediation & Revenue Settlement Hub
|
||||
Ad Mediation & Monetization
|
||||
</Typography>
|
||||
<TactileBadge mono tone="accent">10 NETWORKS ACTIVE</TactileBadge>
|
||||
<TactileBadge mono tone="success">AUCTION HEALTHY</TactileBadge>
|
||||
<TactileBadge tone="warning" mono>
|
||||
FAIL-CLOSED SANDBOX
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '12px',
|
||||
color: C.dim,
|
||||
mt: 0.5,
|
||||
}}
|
||||
>
|
||||
Real-time header bidding mediation, floor eCPM management, and automated tax withholding settlement ledger.
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, letterSpacing: '0.04em', mt: 0.25 }}>
|
||||
Mediation waterfall roster, rewarded token grants, network integration state
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1.5, alignItems: 'center' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
sx={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '12px',
|
||||
color: C.cyanLight,
|
||||
borderColor: 'rgba(56, 189, 248, 0.3)',
|
||||
textTransform: 'none',
|
||||
'&:hover': { borderColor: C.cyanLight, bgcolor: 'rgba(56, 189, 248, 0.08)' },
|
||||
}}
|
||||
>
|
||||
📥 Export CSV Settlement
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="small"
|
||||
sx={{
|
||||
...primaryButtonSx,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: '12px',
|
||||
textTransform: 'none',
|
||||
}}
|
||||
>
|
||||
+ Add Demand Partner
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Publisher Account Banner */}
|
||||
<Box
|
||||
sx={{
|
||||
...panelSx,
|
||||
p: 2.5,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 2,
|
||||
bgcolor: 'rgba(14, 165, 233, 0.05)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.2)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'rgba(56, 189, 248, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: C.cyanLight,
|
||||
fontWeight: 800,
|
||||
fontSize: '18px',
|
||||
}}
|
||||
>
|
||||
P
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontWeight: 700, fontSize: '14px', color: C.bright }}>
|
||||
Registered Publisher Account: yunchanpaca@gmail.com
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
Payout Beneficiary: D3RO Voice AI • KB국민은행 928702-00-184920 • 사업자등록 120-88-01923
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TactileBadge mono tone="success">KYC Verified</TactileBadge>
|
||||
<TactileBadge mono tone="accent">3.3% 원천징수 적용</TactileBadge>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' },
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
Est. Monthly Ad Revenue
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.cyanLight, my: 0.5 }}>
|
||||
${totalGrossRevenue.toFixed(2)}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
||||
₩{(totalGrossRevenue * 1350).toLocaleString()} (환율 ₩1,350)
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
Weighted Avg. eCPM
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
|
||||
${avgEcpm}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>
|
||||
Floor: $2.00 min • Max: $18.00
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
Total Ad Impressions
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.bright, my: 0.5 }}>
|
||||
{(totalImpressions / 1000).toFixed(1)}k
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
Avg. Fill Rate: 96.8%
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard interactive>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim, textTransform: 'uppercase' }}>
|
||||
Net Payout Settled (KRW)
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '28px', fontWeight: 800, color: C.green400, my: 0.5 }}>
|
||||
₩{(totalSettledKrw / 10000).toFixed(1)}만
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.green400 }}>
|
||||
₩{totalSettledKrw.toLocaleString()} 입금 완료
|
||||
</Typography>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
|
||||
{/* 10+ Multi-Ad Network Mediation Matrix */}
|
||||
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
10+ Active Ad Networks & Header Bidding Matrix
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
First-price real-time bidding auction with sub-800ms SLA fallback to Direct House AI Sponsors.
|
||||
</Typography>
|
||||
</Box>
|
||||
<TactileBadge mono tone="default">AUCTION TIMEOUT: 800MS</TactileBadge>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left' }}>Demand Partner</th>
|
||||
<th style={{ textAlign: 'left' }}>Adapter Protocol</th>
|
||||
<th style={{ textAlign: 'left' }}>Primary Slot Format</th>
|
||||
<th style={{ textAlign: 'right' }}>Impressions</th>
|
||||
<th style={{ textAlign: 'right' }}>CTR</th>
|
||||
<th style={{ textAlign: 'right' }}>Bid eCPM</th>
|
||||
<th style={{ textAlign: 'right' }}>Gross Revenue</th>
|
||||
<th style={{ textAlign: 'center' }}>Fill Rate</th>
|
||||
<th style={{ textAlign: 'center' }}>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{networks.map((net) => (
|
||||
<tr key={net.id}>
|
||||
<td style={{ fontWeight: 600, color: C.bright }}>{net.name}</td>
|
||||
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>{net.adapterType}</td>
|
||||
<td style={{ color: C.dim, fontSize: '12px' }}>{net.format}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.bright }}>
|
||||
{net.impressions.toLocaleString()}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.green400 }}>{net.ctr}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.cyanLight }}>
|
||||
${net.ecpm.toFixed(2)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.bright }}>
|
||||
${net.grossRevenueUsd.toFixed(2)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontFamily: FONT_MONO, color: C.dim }}>{net.fillRate}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
...statusBadgeSx,
|
||||
bgcolor:
|
||||
net.status === 'active'
|
||||
? 'rgba(34, 197, 94, 0.12)'
|
||||
: 'rgba(56, 189, 248, 0.12)',
|
||||
color: net.status === 'active' ? C.green400 : C.cyanLight,
|
||||
borderColor:
|
||||
net.status === 'active' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(56, 189, 248, 0.3)',
|
||||
}}
|
||||
>
|
||||
{net.status.toUpperCase()}
|
||||
</Box>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Monthly Settlement & Tax Withholding Payout Ledger */}
|
||||
<Box sx={{ ...panelSx, p: { xs: 2, md: 3 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '16px', fontWeight: 700, color: C.bright }}>
|
||||
Monthly Revenue Settlement & Payout Ledger
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>
|
||||
Net-30 / Net-60 cycle settlements with automatic 3.3% Korean withholding tax deduction.
|
||||
</Typography>
|
||||
</Box>
|
||||
<TactileBadge mono tone="success">TAX WITHHOLDING AUTO-CALCULATED</TactileBadge>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ textAlign: 'left' }}>Settlement ID</th>
|
||||
<th style={{ textAlign: 'left' }}>Cycle Month</th>
|
||||
<th style={{ textAlign: 'left' }}>Network Source</th>
|
||||
<th style={{ textAlign: 'right' }}>Gross ($ USD)</th>
|
||||
<th style={{ textAlign: 'center' }}>Withholding Tax</th>
|
||||
<th style={{ textAlign: 'right' }}>Net Payout (₩ KRW)</th>
|
||||
<th style={{ textAlign: 'left' }}>Beneficiary Method</th>
|
||||
<th style={{ textAlign: 'center' }}>Payout Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{settlements.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.cyanLight }}>{s.id}</td>
|
||||
<td style={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.dim }}>{s.cycleMonth}</td>
|
||||
<td style={{ fontWeight: 600, color: C.bright }}>{s.networkName}</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, color: C.bright }}>
|
||||
${s.grossUsd.toFixed(2)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center', fontFamily: FONT_MONO, fontSize: '11px', color: C.orange400 }}>
|
||||
{s.withholdingTax}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', fontFamily: FONT_MONO, fontWeight: 700, color: C.green400 }}>
|
||||
₩{s.netPayoutKrw.toLocaleString()}
|
||||
</td>
|
||||
<td style={{ fontFamily: FONT_MONO, fontSize: '11px', color: C.dim }}>{s.method}</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
...statusBadgeSx,
|
||||
bgcolor:
|
||||
s.payoutStatus === 'paid'
|
||||
? 'rgba(34, 197, 94, 0.12)'
|
||||
: 'rgba(234, 179, 8, 0.12)',
|
||||
color: s.payoutStatus === 'paid' ? C.green400 : C.orange400,
|
||||
borderColor:
|
||||
s.payoutStatus === 'paid' ? 'rgba(34, 197, 94, 0.3)' : 'rgba(234, 179, 8, 0.3)',
|
||||
}}
|
||||
>
|
||||
{s.payoutStatus.toUpperCase()}
|
||||
</Box>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function RewardKpis({ stats }: { stats: AdRewardStats }): React.ReactElement {
|
||||
const cards = [
|
||||
{
|
||||
title: `Rewarded Claims (${stats.windowDays}d)`,
|
||||
value: stats.totalClaims.toLocaleString(),
|
||||
subtext: 'Verified rewarded-video completions'
|
||||
},
|
||||
{
|
||||
title: `Tokens Granted (${stats.windowDays}d)`,
|
||||
value: stats.totalRewardTokens.toLocaleString(),
|
||||
subtext: 'Cloud AI tokens issued via ad rewards'
|
||||
},
|
||||
{
|
||||
title: `Unique Claimants (${stats.windowDays}d)`,
|
||||
value: stats.uniqueClaimants.toLocaleString(),
|
||||
subtext: 'Distinct accounts that redeemed rewards'
|
||||
},
|
||||
{
|
||||
title: 'Live Ad Networks',
|
||||
value: `0 / ${MEDIATION_ROSTER.length}`,
|
||||
subtext: 'All adapters fail-closed until SDK contracts land'
|
||||
}
|
||||
]
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', sm: '1fr 1fr', lg: 'repeat(4, 1fr)' }, gap: 2 }}>
|
||||
{cards.map((card) => (
|
||||
<Box key={card.title} sx={{ ...panelSx, p: 2.5 }}>
|
||||
<Typography
|
||||
sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mb: 1, textTransform: 'uppercase', letterSpacing: '0.08em' }}
|
||||
>
|
||||
{card.title}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_MONO, fontSize: '22px', fontWeight: 700, color: C.bright, lineHeight: 1.25 }}>
|
||||
{card.value}
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '11px', color: C.dim, mt: 0.5 }}>{card.subtext}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function MediationRosterPanel(): React.ReactElement {
|
||||
return (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 1.5, mb: 1 }}>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright }}>
|
||||
Mediation Waterfall Roster
|
||||
</Typography>
|
||||
<Box sx={statusBadgeSx('orange')}>NO LIVE BIDS</Box>
|
||||
</Box>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim, mb: 2 }}>
|
||||
데스크톱 미디에이션 엔진에 등록된 어댑터 구성입니다. 공식 SDK/인증 계약이 연결되기 전까지 모든 네트워크는
|
||||
입찰 없이 fail-closed로 동작하며, 데모 크리에이티브와 가짜 수익 수치는 표시하지 않습니다.
|
||||
</Typography>
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th">Network</Box>
|
||||
<Box component="th">Adapter ID</Box>
|
||||
<Box component="th">Formats</Box>
|
||||
<Box component="th">Floor eCPM</Box>
|
||||
<Box component="th">Integration</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{MEDIATION_ROSTER.map((network) => (
|
||||
<Box component="tr" key={network.networkId}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.bright }}>
|
||||
{network.name}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.cyanLight }}>
|
||||
{network.networkId}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>
|
||||
{network.formats.map((format) => FORMAT_LABEL[format] ?? format).join(' · ')}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||
${network.floorEcpm.toFixed(2)}
|
||||
</Box>
|
||||
<Box component="td">
|
||||
<Box sx={statusBadgeSx('orange')}>NOT INTEGRATED</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
)
|
||||
}
|
||||
|
||||
function RewardBreakdownPanel({ stats }: { stats: AdRewardStats }): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 1.4fr' }, gap: 3 }}>
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
||||
Rewarded Grants by Network
|
||||
</Typography>
|
||||
{stats.byNetwork.length === 0 ? (
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>
|
||||
최근 {stats.windowDays}일간 검증된 리워드 클레임이 없습니다.
|
||||
</Typography>
|
||||
) : (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th">Network</Box>
|
||||
<Box component="th">Claims</Box>
|
||||
<Box component="th">Tokens</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{stats.byNetwork.map((summary) => (
|
||||
<Box component="tr" key={summary.network}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{summary.network}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||
{summary.claims.toLocaleString()}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
|
||||
+{summary.rewardTokens.toLocaleString()}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DoubleBezelCard>
|
||||
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '15px', fontWeight: 600, color: C.bright, mb: 2 }}>
|
||||
Recent Verified Claims
|
||||
</Typography>
|
||||
{stats.recentClaims.length === 0 ? (
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '13px', color: C.dim }}>표시할 클레임이 없습니다.</Typography>
|
||||
) : (
|
||||
<Box sx={{ overflowX: 'auto' }}>
|
||||
<Box component="table" sx={tableSx}>
|
||||
<Box component="thead">
|
||||
<Box component="tr">
|
||||
<Box component="th">Verified At (UTC)</Box>
|
||||
<Box component="th">Network</Box>
|
||||
<Box component="th">Placement</Box>
|
||||
<Box component="th">Tokens</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box component="tbody">
|
||||
{stats.recentClaims.map((claim) => (
|
||||
<Box component="tr" key={claim.id}>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px' }}>
|
||||
{formatDateTime(claim.verifiedAt)}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.bright }}>
|
||||
{claim.network}
|
||||
</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_SANS, fontSize: '12px' }}>{claim.placement}</Box>
|
||||
<Box component="td" sx={{ fontFamily: FONT_MONO, fontSize: '12px', color: C.green400 }}>
|
||||
+{claim.rewardTokens.toLocaleString()}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DoubleBezelCard>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function RewardsUnavailablePanel({ reason }: { reason: string }): React.ReactElement {
|
||||
return (
|
||||
<DoubleBezelCard bezelPadding="6px" innerPadding="24px">
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '14px', fontWeight: 600, color: C.orange400, mb: 1 }}>
|
||||
Rewarded 토큰 지급 통계를 불러올 수 없습니다.
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: FONT_SANS, fontSize: '12px', color: C.dim }}>{reason}</Typography>
|
||||
</DoubleBezelCard>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function AdminAdsPage(): Promise<React.ReactElement> {
|
||||
await requireManager()
|
||||
|
||||
let stats: AdRewardStats | null = null
|
||||
let statsError: string | null = null
|
||||
if (!isSupabaseAdminConfigured()) {
|
||||
statsError =
|
||||
'SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 환경변수가 설정되지 않아 ad_reward_claims 데이터에 연결할 수 없습니다. 환경변수를 설정하면 실데이터가 표시됩니다.'
|
||||
} else {
|
||||
try {
|
||||
stats = await fetchAdRewardStats()
|
||||
} catch (error) {
|
||||
statsError = error instanceof Error ? error.message : 'Unknown ad reward stats error'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
<HeaderBar />
|
||||
{stats && <RewardKpis stats={stats} />}
|
||||
<MediationRosterPanel />
|
||||
{stats ? <RewardBreakdownPanel stats={stats} /> : <RewardsUnavailablePanel reason={statsError ?? ''} />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue