import { useEffect, useMemo, useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import type { Program, EventType, EventVerification, VerificationFraudFlag } from '../../types'; import { findMockProgramById } from '../../data/mockPrograms'; import { getAdminProgram } from '../../lib/admin'; import { CAMPAIGN_STATUS } from '../../lib/campaignStatusConfig'; import TermTip from '../../components/common/TermTip'; import Dn from '../../components/Dn'; const eventTypeLabel: Record = { plogging: '플로깅', afforestation: '나무 심기', energy_saving: '에너지 절약', commute_green: '친환경 통근', livestock_feed: '저메탄 사료', }; const programTypeLabel: Record = { corporate_event: '기업 ESG', public_caas: '공공 CaaS', sponsor: '스폰서', livestock: '축산', }; const verifyTypeLabel: Record = { photo: '사진', gps: 'GPS', weight: '무게 측정', combo: '복합 인증', }; const verifyStatusConfig: Record< EventVerification['status'], { label: string; className: string } > = { pending: { label: '대기', className: 'text-status-warning' }, approved: { label: '승인', className: 'text-status-success' }, rejected: { label: '반려', className: 'text-status-error' }, }; const fraudFlagLabel: Record = { duplicate_photo: '중복 사진', gps_mismatch: 'GPS 이탈', weight_outlier: '무게 이상치', rapid_submission: '연속 제출', low_confidence: '저신뢰도', metadata_stripped: '메타데이터 누락', }; type VerifyFilter = 'all' | 'pending' | 'approved' | 'rejected' | 'flagged'; const formatKRW = (n: number) => n === 0 ? '—' : new Intl.NumberFormat('ko-KR').format(n) + '원'; const formatNum = (n: number) => n === 0 ? '—' : n.toLocaleString('ko-KR'); export default function ProgramDetailPage() { const { id } = useParams<{ id: string }>(); const mockProgram = findMockProgramById(id); const [dbProgram, setDbProgram] = useState(null); const [loadingProgram, setLoadingProgram] = useState(!mockProgram); const program = mockProgram ?? dbProgram; const [filter, setFilter] = useState('all'); const [selected, setSelected] = useState>(new Set()); const [statusOverrides, setStatusOverrides] = useState< Record >({}); useEffect(() => { if (!id || mockProgram) { setDbProgram(null); setLoadingProgram(false); return; } let cancelled = false; setLoadingProgram(true); getAdminProgram(id) .then((nextProgram) => { if (!cancelled) setDbProgram(nextProgram); }) .finally(() => { if (!cancelled) setLoadingProgram(false); }); return () => { cancelled = true; }; }, [id, mockProgram]); const decoratedVerifications = useMemo(() => { const verifications = program?.verifications ?? []; return verifications.map((v) => ({ ...v, status: statusOverrides[v.id] ?? v.status, })); }, [program?.verifications, statusOverrides]); const filteredVerifications = useMemo(() => { if (filter === 'all') return decoratedVerifications; if (filter === 'flagged') return decoratedVerifications.filter((v) => (v.fraudFlags?.length ?? 0) > 0); return decoratedVerifications.filter((v) => v.status === filter); }, [decoratedVerifications, filter]); const dailyTrend = useMemo(() => { const m = new Map(); for (const v of decoratedVerifications) { const date = v.verifiedAt.slice(0, 10); const cur = m.get(date) ?? { approved: 0, pending: 0, rejected: 0 }; cur[v.status] += 1; m.set(date, cur); } return Array.from(m.entries()) .sort(([a], [b]) => a.localeCompare(b)) .map(([date, counts]) => ({ date, ...counts, total: counts.approved + counts.pending + counts.rejected })); }, [decoratedVerifications]); const leaderboard = useMemo(() => { const m = new Map< string, { name: string; team?: string; count: number; approved: number; co2: number; waste: number } >(); for (const v of decoratedVerifications) { const key = v.participantName; const cur = m.get(key) ?? { name: v.participantName, team: v.participantTeam, count: 0, approved: 0, co2: 0, waste: 0, }; cur.count += 1; if (v.status === 'approved') cur.approved += 1; cur.co2 += v.co2ContributionKg ?? 0; cur.waste += v.wasteKg ?? 0; m.set(key, cur); } return Array.from(m.values()) .sort((a, b) => b.co2 - a.co2 || b.approved - a.approved) .slice(0, 8); }, [decoratedVerifications]); const counts = useMemo(() => { const c = { all: 0, pending: 0, approved: 0, rejected: 0, flagged: 0 }; for (const v of decoratedVerifications) { c.all += 1; c[v.status] += 1; if ((v.fraudFlags?.length ?? 0) > 0) c.flagged += 1; } return c; }, [decoratedVerifications]); if (loadingProgram) { return (
프로그램 상세 데이터 동기화 중... [SYNCHRONIZING...]
); } if (!program) { return (

프로그램을 찾을 수 없습니다.

목록으로 돌아가기
); } const st = CAMPAIGN_STATUS[program.status]; const participationRate = program.targetParticipants > 0 ? ((program.currentParticipants / program.targetParticipants) * 100).toFixed(1) : '—'; const verificationRate = program.totalVerifications > 0 ? ((program.approvedVerifications / program.totalVerifications) * 100).toFixed(1) : '0'; const toggleSelect = (vid: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(vid)) next.delete(vid); else next.add(vid); return next; }); }; const toggleSelectAll = () => { if (selected.size === filteredVerifications.length) { setSelected(new Set()); } else { setSelected(new Set(filteredVerifications.map((v) => v.id))); } }; const bulkAction = (status: EventVerification['status']) => { if (selected.size === 0) return; setStatusOverrides((prev) => { const next = { ...prev }; for (const sid of selected) next[sid] = status; return next; }); setSelected(new Set()); }; const maxDaily = Math.max(1, ...dailyTrend.map((d) => d.total)); const maxCo2 = Math.max(0.01, ...leaderboard.map((l) => l.co2)); return (
{/* Header */}
← 프로그램 목록
{programTypeLabel[program.programType]} {program.activityType && ( {eventTypeLabel[program.activityType]} )} {st.label}

{program.title}

{program.organizationName} · {program.startDate} ~ {program.endDate}

{program.methodology && (

방법론: {program.methodology} {program.methodologyAutoAssigned && ( 자동할당 )}

)}
{/* Key metrics */}

참여율

{participationRate !== '—' ? <>{participationRate}% : '—'}

{program.targetParticipants > 0 && (

{program.currentParticipants.toLocaleString()}/{program.targetParticipants.toLocaleString()}명

)}

인증 승인율

{verificationRate}%

{program.approvedVerifications.toLocaleString()}/{program.totalVerifications.toLocaleString()}건

CO₂ 감축량

{program.co2ReductionKg > 0 ? program.co2ReductionKg.toLocaleString() : '—'}

{program.co2ReductionKg > 0 ? 'kg CO₂e' : '산정 중'}

포인트

{program.pointsDistributed > 0 ? program.pointsDistributed.toLocaleString() : '—'}

{program.pointsDistributed > 0 ? 'P 지급' : ''}

{program.programType === 'corporate_event' ? 'SaaS 수수료' : '예산'}

{program.programType === 'corporate_event' ? formatKRW(program.saasFeePaid ?? 0) : formatKRW(program.budgetKRW)}

{/* Public CaaS extras: API + Fraud */} {program.programType === 'public_caas' && (

총 API 호출

{formatNum(program.apiCallsTotal ?? 0)}

금일 API

{formatNum(program.apiCallsToday ?? 0)}

부정수급 감지

0 ? 'text-status-error' : 'text-ink-light'}`}> {program.fraudDetected ?? 0}건

)} {/* Fraud / Integrity Summary */} {counts.flagged > 0 && (
이상 징후 {counts.flagged}건 감지 AI 신뢰도·GPS·무게·제출 패턴 기반 자동 탐지. 검토 필요 항목을 우선 확인하세요.
)} {/* Daily Trend + Leaderboard */} {(dailyTrend.length > 0 || leaderboard.length > 0) && (

일자별 인증 추이

승인/대기/반려 누적 분포

승인 대기 반려
{dailyTrend.length === 0 ? (

데이터 없음

) : (
{dailyTrend.map((d) => { const heightPct = (d.total / maxDaily) * 100; const aPct = d.total > 0 ? (d.approved / d.total) * heightPct : 0; const pPct = d.total > 0 ? (d.pending / d.total) * heightPct : 0; const rPct = d.total > 0 ? (d.rejected / d.total) * heightPct : 0; return (

{d.date.slice(5)}

{d.total}

); })}
)}

참여자 리더보드

CO₂ 기여도 Top 8

{leaderboard.length === 0 ? (

데이터 없음

) : (
{leaderboard.map((l, idx) => { const widthPct = (l.co2 / maxCo2) * 100; return (
{String(idx + 1).padStart(2, '0')}
{l.name} {l.team && · {l.team}} {l.co2.toFixed(1)} kg

{l.approved}/{l.count} 승인 · 수거 {l.waste.toFixed(1)} kg

); })}
)}
)} {/* Event-specific metrics (waste, trees) */} {program.programType === 'corporate_event' && ((program.wasteCollectedKg != null && program.wasteCollectedKg > 0) || (program.treesPlanted != null && program.treesPlanted! > 0)) && (

활동 특화 지표

{program.wasteCollectedKg != null && program.wasteCollectedKg > 0 && (

총 수거량

{program.wasteCollectedKg.toLocaleString()} kg

인당 평균 {(program.wasteCollectedKg / Math.max(1, program.currentParticipants)).toFixed(1)} kg

)} {program.treesPlanted != null && program.treesPlanted > 0 && ( <>

식재 나무

{program.treesPlanted} 그루

예상 연간 CO₂ 흡수량

{(program.treesPlanted * 22).toLocaleString()} kg/년

수종 평균 22kg CO₂/년 기준

)}
)} {/* Verification management */} {decoratedVerifications.length > 0 && (

인증 관리

AI 자동 판정 + 이상 징후 탐지 + 수동 검토

{( [ { key: 'all', label: '전체', count: counts.all }, { key: 'pending', label: '대기', count: counts.pending }, { key: 'approved', label: '승인', count: counts.approved }, { key: 'rejected', label: '반려', count: counts.rejected }, { key: 'flagged', label: '이상 징후', count: counts.flagged }, ] as { key: VerifyFilter; label: string; count: number }[] ).map((tab) => ( ))}
{selected.size > 0 && (

{selected.size}건 선택됨

)} {filteredVerifications.length === 0 ? (

현재 필터 조건에 해당하는 항목이 없습니다.

) : ( {filteredVerifications.map((v) => { const vst = verifyStatusConfig[v.status]; const flags = v.fraudFlags ?? []; const isSelected = selected.has(v.id); const confidence = v.confidenceScore ?? 0; const confClass = confidence >= 90 ? 'text-status-success' : confidence >= 60 ? 'text-status-warning' : 'text-status-error'; return ( ); })}
0 && selected.size === filteredVerifications.length } onChange={toggleSelectAll} /> 참여자 인증 일시 방식 내용 신뢰도 플래그 상태
toggleSelect(v.id)} />
{v.participantName}
{v.participantTeam && (
{v.participantTeam}
)}
{v.verifiedAt} {verifyTypeLabel[v.type]}
{v.value}
{v.locationName && (
📍 {v.locationName}
)} {v.rejectionReason && (
사유: {v.rejectionReason}
)}
{v.confidenceScore != null ? ( {v.confidenceScore} ) : ( )}
{flags.length === 0 ? ( ) : ( flags.map((f) => ( {fraudFlagLabel[f]} )) )}
{vst.label}
)}
)} {/* ESG Report Preview */}

ESG 결과 보고서

자동 생성 — {program.programType === 'corporate_event' ? '기업 홍보자료 및 ESG 공시' : 'NGMS 행정보고 및 감사'} 활용 가능

ESG IMPACT REPORT

{program.title}

{program.organizationName} · {program.startDate} ~ {program.endDate}

{program.currentParticipants.toLocaleString()}

참여 인원

{program.co2ReductionKg > 0 ? program.co2ReductionKg.toLocaleString() : program.treesPlanted ?? 0}

{program.co2ReductionKg > 0 ? 'kg CO₂e 감축' : '그루 식재'}

{program.approvedVerifications.toLocaleString()}

인증 완료 건수

본 보고서는 Carboany 하네스 시스템에 의해 자동 생성되었으며, 국제 탄소크레딧 방법론에 기반하여 데이터가 정량화되었습니다.

); }