| 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<EventType, string> = { |
| plogging: 'νλ‘κΉ
', |
| afforestation: 'λ무 μ¬κΈ°', |
| energy_saving: 'μλμ§ μ μ½', |
| commute_green: 'μΉνκ²½ ν΅κ·Ό', |
| livestock_feed: 'μ λ©ν μ¬λ£', |
| }; |
|
|
| const programTypeLabel: Record<Program['programType'], string> = { |
| corporate_event: 'κΈ°μ
ESG', |
| public_caas: '곡곡 CaaS', |
| sponsor: 'μ€ν°μ', |
| livestock: 'μΆμ°', |
| }; |
|
|
| const verifyTypeLabel: Record<EventVerification['type'], string> = { |
| 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<VerificationFraudFlag, string> = { |
| 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<Program | null>(null); |
| const [loadingProgram, setLoadingProgram] = useState(!mockProgram); |
| const program = mockProgram ?? dbProgram; |
|
|
| const [filter, setFilter] = useState<VerifyFilter>('all'); |
| const [selected, setSelected] = useState<Set<string>>(new Set()); |
| const [statusOverrides, setStatusOverrides] = useState< |
| Record<string, EventVerification['status']> |
| >({}); |
|
|
| 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<string, { approved: number; pending: number; rejected: number }>(); |
| 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 ( |
| <div className="p-12 text-center text-ink-light font-mono uppercase animate-pulse"> |
| νλ‘κ·Έλ¨ μμΈ λ°μ΄ν° λκΈ°ν μ€... [SYNCHRONIZING...] |
| </div> |
| ); |
| } |
|
|
| if (!program) { |
| return ( |
| <div className="p-12 text-center"> |
| <p className="text-ink-light">νλ‘κ·Έλ¨μ μ°Ύμ μ μμ΅λλ€.</p> |
| <Link to="/admin/programs" className="text-sm text-ink-dark underline mt-2 inline-block"> |
| λͺ©λ‘μΌλ‘ λμκ°κΈ° |
| </Link> |
| </div> |
| ); |
| } |
|
|
| 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 ( |
| <div> |
| {/* Header */} |
| <div className="mb-8"> |
| <Link |
| to="/admin/programs" |
| className="text-xs text-ink-light hover:text-ink-dark transition-colors" |
| > |
| β νλ‘κ·Έλ¨ λͺ©λ‘ |
| </Link> |
| <div className="flex items-center gap-3 mt-2 flex-wrap"> |
| <span className={`text-xs font-bold px-2 py-0.5 ${program.programType === 'corporate_event' ? 'bg-accent-teal text-white' : 'bg-status-info text-paper-white'}`}> |
| {programTypeLabel[program.programType]} |
| </span> |
| {program.activityType && ( |
| <span className="text-xs font-bold px-2 py-0.5 bg-paper-gray text-ink-dark"> |
| {eventTypeLabel[program.activityType]} |
| </span> |
| )} |
| <span className={`text-xs font-bold px-2 py-0.5 ${st.cls}`}>{st.label}</span> |
| <h2 className="text-2xl font-bold text-ink-black">{program.title}</h2> |
| </div> |
| <p className="text-sm text-ink-medium mt-1"> |
| {program.organizationName} Β· {program.startDate} ~ {program.endDate} |
| </p> |
| {program.methodology && ( |
| <p className="text-xs text-ink-medium mt-1"> |
| λ°©λ²λ‘ : <span className="font-semibold">{program.methodology}</span> |
| {program.methodologyAutoAssigned && ( |
| <span className="ml-2 text-[10px] px-1.5 py-0.5 border border-border-default text-ink-light">μλν λΉ</span> |
| )} |
| </p> |
| )} |
| </div> |
| |
| {/* Key metrics */} |
| <div className="grid grid-cols-5 gap-px bg-border-default border border-border-default mb-6"> |
| <div className="bg-paper-white px-5 py-4"> |
| <p className="text-xs text-ink-light">μ°Έμ¬μ¨</p> |
| <p className="text-xl font-bold text-ink-black mt-1">{participationRate !== 'β' ? <><Dn>{participationRate}</Dn>%</> : 'β'}</p> |
| {program.targetParticipants > 0 && ( |
| <p className="text-xs text-ink-medium mt-0.5"> |
| {program.currentParticipants.toLocaleString()}/{program.targetParticipants.toLocaleString()}λͺ
|
| </p> |
| )} |
| </div> |
| <div className="bg-paper-white px-5 py-4"> |
| <p className="text-xs text-ink-light">μΈμ¦ μΉμΈμ¨</p> |
| <p className="text-xl font-bold text-ink-black mt-1"><Dn>{verificationRate}</Dn>%</p> |
| <p className="text-xs text-ink-medium mt-0.5"> |
| {program.approvedVerifications.toLocaleString()}/{program.totalVerifications.toLocaleString()}건 |
| </p> |
| </div> |
| <div className="bg-paper-white px-5 py-4"> |
| <p className="text-xs text-ink-light">COβ κ°μΆλ</p> |
| <p className="text-xl font-bold text-ink-black mt-1"> |
| {program.co2ReductionKg > 0 ? program.co2ReductionKg.toLocaleString() : 'β'} |
| </p> |
| <p className="text-xs text-ink-medium mt-0.5"> |
| {program.co2ReductionKg > 0 ? 'kg COβe' : 'μ°μ μ€'} |
| </p> |
| </div> |
| <div className="bg-paper-white px-5 py-4"> |
| <p className="text-xs text-ink-light">ν¬μΈνΈ</p> |
| <p className="text-xl font-bold text-ink-black mt-1"> |
| {program.pointsDistributed > 0 ? program.pointsDistributed.toLocaleString() : 'β'} |
| </p> |
| <p className="text-xs text-ink-medium mt-0.5"> |
| {program.pointsDistributed > 0 ? 'P μ§κΈ' : ''} |
| </p> |
| </div> |
| <div className="bg-paper-white px-5 py-4"> |
| <p className="text-xs text-ink-light"> |
| {program.programType === 'corporate_event' ? 'SaaS μμλ£' : 'μμ°'} |
| </p> |
| <p className="text-xl font-bold text-ink-black mt-1"> |
| {program.programType === 'corporate_event' |
| ? formatKRW(program.saasFeePaid ?? 0) |
| : formatKRW(program.budgetKRW)} |
| </p> |
| </div> |
| </div> |
|
|
| {} |
| {program.programType === 'public_caas' && ( |
| <div className="grid grid-cols-3 gap-4 mb-6"> |
| <div className="border border-border-default bg-paper-white rounded-lg shadow-sm p-4"> |
| <p className="text-xs text-ink-light">μ΄ API νΈμΆ</p> |
| <p className="text-lg font-bold text-ink-black mt-1">{formatNum(program.apiCallsTotal ?? 0)}</p> |
| </div> |
| <div className="border border-border-default bg-paper-white rounded-lg shadow-sm p-4"> |
| <p className="text-xs text-ink-light">κΈμΌ API</p> |
| <p className="text-lg font-bold text-ink-black mt-1">{formatNum(program.apiCallsToday ?? 0)}</p> |
| </div> |
| <div className="border border-border-default bg-paper-white rounded-lg shadow-sm p-4"> |
| <p className="text-xs text-ink-light">λΆμ μκΈ κ°μ§</p> |
| <p className={`text-lg font-bold mt-1 ${(program.fraudDetected ?? 0) > 0 ? 'text-status-error' : 'text-ink-light'}`}> |
| {program.fraudDetected ?? 0}건 |
| </p> |
| </div> |
| </div> |
| )} |
|
|
| {} |
| {counts.flagged > 0 && ( |
| <div className="border border-status-warning bg-paper-cream px-4 py-3 mb-6 flex items-center justify-between"> |
| <div className="text-xs text-ink-dark"> |
| <span className="font-semibold text-status-warning">μ΄μ μ§ν {counts.flagged}건 κ°μ§</span> |
| <span className="text-ink-medium ml-2"> |
| AI μ λ’°λΒ·GPS·무κ²Β·μ μΆ ν¨ν΄ κΈ°λ° μλ νμ§. κ²ν νμ νλͺ©μ μ°μ νμΈνμΈμ. |
| </span> |
| </div> |
| <button |
| type="button" |
| onClick={() => setFilter('flagged')} |
| className="px-3 py-1 text-xs font-semibold border border-status-warning text-status-warning hover:bg-paper-white transition-colors" |
| > |
| μ΄μ μ§ν νν° |
| </button> |
| </div> |
| )} |
|
|
| {} |
| {(dailyTrend.length > 0 || leaderboard.length > 0) && ( |
| <div className="grid grid-cols-5 gap-6 mb-6"> |
| <div className="col-span-3 border border-border-default bg-paper-white rounded-lg shadow-sm"> |
| <div className="px-6 py-4 border-b border-border-default flex items-center justify-between"> |
| <div> |
| <h3 className="text-sm font-semibold text-ink-black">μΌμλ³ μΈμ¦ μΆμ΄</h3> |
| <p className="text-xs text-ink-light mt-0.5">μΉμΈ/λκΈ°/λ°λ € λμ λΆν¬</p> |
| </div> |
| <div className="flex items-center gap-3 text-[10px] text-ink-medium"> |
| <span className="flex items-center gap-1"><span className="w-2 h-2 bg-status-success inline-block" />μΉμΈ</span> |
| <span className="flex items-center gap-1"><span className="w-2 h-2 bg-status-warning inline-block" />λκΈ°</span> |
| <span className="flex items-center gap-1"><span className="w-2 h-2 bg-status-error inline-block" />λ°λ €</span> |
| </div> |
| </div> |
| <div className="p-6"> |
| {dailyTrend.length === 0 ? ( |
| <p className="text-sm text-ink-light text-center py-8">λ°μ΄ν° μμ</p> |
| ) : ( |
| <div className="flex items-end gap-3 h-40"> |
| {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 ( |
| <div key={d.date} className="flex-1 flex flex-col items-center"> |
| <div className="w-full h-32 flex flex-col justify-end border-b border-border-default relative"> |
| <div style={{ height: `${rPct}%` }} className="bg-status-error w-full" /> |
| <div style={{ height: `${pPct}%` }} className="bg-status-warning w-full" /> |
| <div style={{ height: `${aPct}%` }} className="bg-status-success w-full" /> |
| </div> |
| <p className="text-[10px] text-ink-light mt-1.5 font-mono"> |
| {d.date.slice(5)} |
| </p> |
| <p className="text-xs font-semibold text-ink-black">{d.total}</p> |
| </div> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
| </div> |
| |
| <div className="col-span-2 border border-border-default bg-paper-white rounded-lg shadow-sm"> |
| <div className="px-6 py-4 border-b border-border-default"> |
| <h3 className="text-sm font-semibold text-ink-black">μ°Έμ¬μ 리λ보λ</h3> |
| <p className="text-xs text-ink-light mt-0.5">COβ κΈ°μ¬λ Top 8</p> |
| </div> |
| <div className="p-4"> |
| {leaderboard.length === 0 ? ( |
| <p className="text-sm text-ink-light text-center py-8">λ°μ΄ν° μμ</p> |
| ) : ( |
| <div className="space-y-2"> |
| {leaderboard.map((l, idx) => { |
| const widthPct = (l.co2 / maxCo2) * 100; |
| return ( |
| <div key={l.name} className="flex items-center gap-3"> |
| <span className="text-xs font-mono text-ink-light w-5 text-right"> |
| {String(idx + 1).padStart(2, '0')} |
| </span> |
| <div className="flex-1 min-w-0"> |
| <div className="flex items-center justify-between text-xs"> |
| <span className="font-medium text-ink-dark truncate"> |
| {l.name} |
| {l.team && <span className="text-ink-light ml-1.5">Β· {l.team}</span>} |
| </span> |
| <span className="font-mono text-ink-black ml-2 whitespace-nowrap"> |
| <Dn>{l.co2.toFixed(1)}</Dn> kg |
| </span> |
| </div> |
| <div className="h-1 bg-paper-gray mt-1 relative"> |
| <div |
| className="h-full bg-ink-dark" |
| style={{ width: `${widthPct}%` }} |
| /> |
| </div> |
| <p className="text-[10px] text-ink-light mt-0.5"> |
| {l.approved}/{l.count} μΉμΈ Β· μκ±° <Dn>{l.waste.toFixed(1)}</Dn> kg |
| </p> |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| )} |
| </div> |
| </div> |
| </div> |
| )} |
|
|
| {} |
| {program.programType === 'corporate_event' && |
| ((program.wasteCollectedKg != null && program.wasteCollectedKg > 0) || |
| (program.treesPlanted != null && program.treesPlanted! > 0)) && ( |
| <div className="border border-border-default bg-paper-white rounded-lg shadow-sm p-6 mb-6"> |
| <h3 className="text-sm font-semibold text-ink-black mb-4">νλ νΉν μ§ν</h3> |
| <div className="grid grid-cols-3 gap-6"> |
| {program.wasteCollectedKg != null && program.wasteCollectedKg > 0 && ( |
| <div> |
| <p className="text-xs text-ink-light">μ΄ μκ±°λ</p> |
| <p className="text-2xl font-bold text-ink-black mt-1"> |
| {program.wasteCollectedKg.toLocaleString()} <span className="text-sm font-normal text-ink-medium">kg</span> |
| </p> |
| <p className="text-xs text-ink-light mt-1"> |
| μΈλΉ νκ· <Dn>{(program.wasteCollectedKg / Math.max(1, program.currentParticipants)).toFixed(1)}</Dn> kg |
| </p> |
| </div> |
| )} |
| {program.treesPlanted != null && program.treesPlanted > 0 && ( |
| <> |
| <div> |
| <p className="text-xs text-ink-light">μμ¬ λ무</p> |
| <p className="text-2xl font-bold text-ink-black mt-1"> |
| {program.treesPlanted} <span className="text-sm font-normal text-ink-medium">그루</span> |
| </p> |
| </div> |
| <div> |
| <p className="text-xs text-ink-light">μμ μ°κ° COβ ν‘μλ</p> |
| <p className="text-2xl font-bold text-ink-black mt-1"> |
| {(program.treesPlanted * 22).toLocaleString()} <span className="text-sm font-normal text-ink-medium">kg/λ
</span> |
| </p> |
| <p className="text-xs text-ink-light mt-1">μμ’
νκ· 22kg COβ/λ
κΈ°μ€</p> |
| </div> |
| </> |
| )} |
| </div> |
| </div> |
| )} |
|
|
| {} |
| {decoratedVerifications.length > 0 && ( |
| <div className="border border-border-default bg-paper-white rounded-lg shadow-sm mb-6"> |
| <div className="px-6 py-4 border-b border-border-default flex items-center justify-between"> |
| <div> |
| <h3 className="text-sm font-semibold text-ink-black">μΈμ¦ κ΄λ¦¬</h3> |
| <p className="text-xs text-ink-light mt-0.5"> |
| AI μλ νμ + <TermTip termKey="fraudFlag">μ΄μ μ§ν</TermTip> νμ§ + μλ κ²ν |
| </p> |
| </div> |
| <div className="flex items-center gap-1 text-xs"> |
| {( |
| [ |
| { 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) => ( |
| <button |
| key={tab.key} |
| type="button" |
| onClick={() => setFilter(tab.key)} |
| className={`px-2.5 py-1 border transition-colors ${ |
| filter === tab.key |
| ? 'border-accent-teal bg-accent-teal text-white' |
| : 'border-border-default text-ink-medium hover:text-ink-dark' |
| }`} |
| > |
| {tab.label} <span className="font-mono ml-1">{tab.count}</span> |
| </button> |
| ))} |
| </div> |
| </div> |
| |
| {selected.size > 0 && ( |
| <div className="px-6 py-3 bg-paper-cream border-b border-border-default flex items-center justify-between"> |
| <p className="text-xs text-ink-dark"> |
| <span className="font-semibold">{selected.size}건</span> μ νλ¨ |
| </p> |
| <div className="flex items-center gap-2"> |
| <button |
| type="button" |
| onClick={() => bulkAction('approved')} |
| className="px-3 py-1 text-xs font-semibold border border-status-success text-status-success hover:bg-paper-white transition-colors" |
| > |
| μΌκ΄ μΉμΈ |
| </button> |
| <button |
| type="button" |
| onClick={() => bulkAction('rejected')} |
| className="px-3 py-1 text-xs font-semibold border border-status-error text-status-error hover:bg-paper-white transition-colors" |
| > |
| μΌκ΄ λ°λ € |
| </button> |
| <button |
| type="button" |
| onClick={() => setSelected(new Set())} |
| className="px-3 py-1 text-xs text-ink-medium hover:text-ink-dark transition-colors" |
| > |
| μ ν ν΄μ |
| </button> |
| </div> |
| </div> |
| )} |
| |
| {filteredVerifications.length === 0 ? ( |
| <div className="p-8 text-center"> |
| <p className="text-sm text-ink-light"> |
| νμ¬ νν° μ‘°κ±΄μ ν΄λΉνλ νλͺ©μ΄ μμ΅λλ€. |
| </p> |
| </div> |
| ) : ( |
| <table className="w-full text-sm"> |
| <thead> |
| <tr className="border-b border-border-default text-xs text-ink-light uppercase tracking-wider"> |
| <th className="px-4 py-3 w-8 text-center"> |
| <input |
| type="checkbox" |
| className="accent-ink-black" |
| checked={ |
| filteredVerifications.length > 0 && |
| selected.size === filteredVerifications.length |
| } |
| onChange={toggleSelectAll} |
| /> |
| </th> |
| <th className="text-left px-4 py-3 font-medium">μ°Έμ¬μ</th> |
| <th className="text-left px-4 py-3 font-medium">μΈμ¦ μΌμ</th> |
| <th className="text-left px-4 py-3 font-medium">λ°©μ</th> |
| <th className="text-left px-4 py-3 font-medium">λ΄μ©</th> |
| <th className="text-center px-4 py-3 font-medium"> |
| <TermTip termKey="confidenceScore">μ λ’°λ</TermTip> |
| </th> |
| <th className="text-left px-4 py-3 font-medium">νλκ·Έ</th> |
| <th className="text-center px-4 py-3 font-medium">μν</th> |
| </tr> |
| </thead> |
| <tbody> |
| {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 ( |
| <tr |
| key={v.id} |
| className={`border-b border-border-default last:border-b-0 ${ |
| isSelected ? 'bg-paper-cream' : '' |
| }`} |
| > |
| <td className="px-4 py-3 text-center"> |
| <input |
| type="checkbox" |
| className="accent-ink-black" |
| checked={isSelected} |
| onChange={() => toggleSelect(v.id)} |
| /> |
| </td> |
| <td className="px-4 py-3"> |
| <div className="font-medium text-ink-dark">{v.participantName}</div> |
| {v.participantTeam && ( |
| <div className="text-[10px] text-ink-light">{v.participantTeam}</div> |
| )} |
| </td> |
| <td className="px-4 py-3 font-mono text-xs text-ink-light whitespace-nowrap"> |
| {v.verifiedAt} |
| </td> |
| <td className="px-4 py-3"> |
| <span className="text-xs font-bold px-2 py-0.5 bg-paper-gray text-ink-dark"> |
| {verifyTypeLabel[v.type]} |
| </span> |
| </td> |
| <td className="px-4 py-3 text-ink-medium max-w-xs"> |
| <div className="truncate">{v.value}</div> |
| {v.locationName && ( |
| <div className="text-[10px] text-ink-light mt-0.5"> |
| π {v.locationName} |
| </div> |
| )} |
| {v.rejectionReason && ( |
| <div className="text-[10px] text-status-error mt-0.5"> |
| μ¬μ : {v.rejectionReason} |
| </div> |
| )} |
| </td> |
| <td className="px-4 py-3 text-center"> |
| {v.confidenceScore != null ? ( |
| <span className={`font-mono text-xs font-semibold ${confClass}`}> |
| {v.confidenceScore} |
| </span> |
| ) : ( |
| <span className="text-xs text-ink-light">β</span> |
| )} |
| </td> |
| <td className="px-4 py-3"> |
| <div className="flex flex-wrap gap-1"> |
| {flags.length === 0 ? ( |
| <span className="text-[10px] text-ink-light">β</span> |
| ) : ( |
| flags.map((f) => ( |
| <span |
| key={f} |
| className="text-[10px] font-semibold px-1.5 py-0.5 border border-status-warning text-status-warning bg-paper-cream" |
| > |
| {fraudFlagLabel[f]} |
| </span> |
| )) |
| )} |
| </div> |
| </td> |
| <td |
| className={`px-4 py-3 text-center font-semibold text-xs ${vst.className}`} |
| > |
| {vst.label} |
| </td> |
| </tr> |
| ); |
| })} |
| </tbody> |
| </table> |
| )} |
| </div> |
| )} |
|
|
| {} |
| <div className="border-2 border-accent-teal bg-paper-white rounded-lg shadow-sm"> |
| <div className="px-6 py-4 border-b border-border-strong flex items-center justify-between"> |
| <div> |
| <h3 className="text-sm font-bold text-ink-black"> |
| ESG κ²°κ³Ό λ³΄κ³ μ |
| </h3> |
| <p className="text-xs text-ink-medium mt-0.5"> |
| μλ μμ± β {program.programType === 'corporate_event' ? 'κΈ°μ
ν보μλ£ λ° ESG 곡μ' : 'NGMS νμ λ³΄κ³ λ° κ°μ¬'} νμ© κ°λ₯ |
| </p> |
| </div> |
| <button |
| type="button" |
| className="px-3 py-1.5 text-xs font-semibold border border-accent-teal text-ink-black hover:bg-paper-gray transition-colors" |
| > |
| PDF λ€μ΄λ‘λ |
| </button> |
| </div> |
| <div className="p-6"> |
| <div className="text-center mb-6 pb-6 border-b border-border-default"> |
| <p className="text-xs text-ink-light uppercase tracking-widest mb-2">ESG IMPACT REPORT</p> |
| <h4 className="text-lg font-bold text-ink-black">{program.title}</h4> |
| <p className="text-sm text-ink-medium">{program.organizationName} Β· {program.startDate} ~ {program.endDate}</p> |
| </div> |
| <div className="grid grid-cols-3 gap-6 mb-6"> |
| <div className="text-center p-4 border border-border-default"> |
| <p className="text-3xl font-bold text-ink-black">{program.currentParticipants.toLocaleString()}</p> |
| <p className="text-xs text-ink-light mt-1">μ°Έμ¬ μΈμ</p> |
| </div> |
| <div className="text-center p-4 border border-border-default"> |
| <p className="text-3xl font-bold text-ink-black"> |
| {program.co2ReductionKg > 0 |
| ? program.co2ReductionKg.toLocaleString() |
| : program.treesPlanted ?? 0} |
| </p> |
| <p className="text-xs text-ink-light mt-1"> |
| {program.co2ReductionKg > 0 ? 'kg COβe κ°μΆ' : '그루 μμ¬'} |
| </p> |
| </div> |
| <div className="text-center p-4 border border-border-default"> |
| <p className="text-3xl font-bold text-ink-black">{program.approvedVerifications.toLocaleString()}</p> |
| <p className="text-xs text-ink-light mt-1">μΈμ¦ μλ£ κ±΄μ</p> |
| </div> |
| </div> |
| <p className="text-xs text-ink-light text-center"> |
| λ³Έ λ³΄κ³ μλ Carboany νλ€μ€ μμ€ν
μ μν΄ μλ μμ±λμμΌλ©°, |
| κ΅μ νμν¬λ λ§ λ°©λ²λ‘ μ κΈ°λ°νμ¬ λ°μ΄ν°κ° μ λνλμμ΅λλ€. |
| </p> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|