CarboAny / src /pages /admin /ProgramsPage.tsx
Esketch's picture
deploy: [P4] Strangler Pattern API Adapter Release (Orphan Clean Build v2)
daaf9d7
Raw
History Blame Contribute Delete
6.75 kB
import { useState, useMemo, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { getAdminPrograms, type AdminProgramSummary } from '../../lib/admin';
import { CAMPAIGN_STATUS } from '../../lib/campaignStatusConfig';
const formatKRW = (n: number) =>
n === 0 ? 'โ€”' : new Intl.NumberFormat('ko-KR').format(n) + '์›';
const formatNum = (n: number) =>
n === 0 ? 'โ€”' : n.toLocaleString('ko-KR');
function ProgressBar({ current, target }: { current: number; target: number }) {
const pct = target === 0 ? 0 : Math.min(100, (current / target) * 100);
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-paper-gray">
<div className="h-full bg-ink-black transition-all" style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-ink-medium w-8 text-right">{pct.toFixed(0)}%</span>
</div>
);
}
export default function ProgramsPage() {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [programs, setPrograms] = useState<AdminProgramSummary[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getAdminPrograms()
.then(setPrograms)
.finally(() => setLoading(false));
}, []);
const filteredPrograms = useMemo(() => {
return programs.filter((p) => {
const matchSearch = p.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
p.sponsorName.toLowerCase().includes(searchTerm.toLowerCase());
const matchStatus = statusFilter === 'all' || p.status === statusFilter;
return matchSearch && matchStatus;
});
}, [programs, searchTerm, statusFilter]);
if (loading) {
return <div className="p-12 text-center text-ink-light font-mono uppercase animate-pulse">ํ”„๋กœ๊ทธ๋žจ ๋ฐ์ดํ„ฐ ๋™๊ธฐํ™” ์ค‘... [SYNCHRONIZING...]</div>;
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between border-b border-border-strong pb-4">
<div>
<h2 className="text-2xl font-bold text-ink-black italic tracking-tight uppercase">์บ ํŽ˜์ธ ์šด์˜ ๊ด€๋ฆฌ [CAMPAIGN OPERATIONS]</h2>
<p className="text-sm text-ink-medium mt-1">ํ”Œ๋žซํผ ์ „์ฒด ํƒ„์†Œ ๊ฐ์ถ• ์บ ํŽ˜์ธ ๋ผ์ดํ”„์‚ฌ์ดํด ๊ด€๋ฆฌ</p>
</div>
<Link
to="/admin/programs/new"
className="px-4 py-2 bg-ink-black text-paper-white text-xs font-bold hover:bg-accent-teal transition-colors uppercase tracking-widest rounded-sm border-2 border-ink-black"
>
+ ์บ ํŽ˜์ธ ์ƒ์„ฑ [CREATE]
</Link>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-4 items-center bg-paper-cream p-4 border-2 border-border-default rounded-sm">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="์บ ํŽ˜์ธ๋ช… ๋˜๋Š” ์Šคํฐ์„œ ๊ฒ€์ƒ‰..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 text-xs border border-border-default bg-paper-white focus:outline-none focus:border-ink-black"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 text-xs border border-border-default bg-paper-white focus:outline-none"
>
<option value="all">์ „์ฒด ์ƒํƒœ [ALL]</option>
<option value="active">์ง„ํ–‰์ค‘ [ACTIVE]</option>
<option value="draft">์ค€๋น„์ค‘ [DRAFT]</option>
<option value="completed">์ข…๋ฃŒ [COMPLETED]</option>
</select>
</div>
{/* Program Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPrograms.map((p) => {
const st = CAMPAIGN_STATUS[p.status as keyof typeof CAMPAIGN_STATUS] || { label: p.status, cls: 'border-border-default' };
return (
<div key={p.id} className="border-2 border-ink-black bg-paper-white p-6 shadow-sm hover:shadow-md transition-shadow relative group rounded-sm">
<div className="flex items-start justify-between mb-4">
<div>
<span className={`text-[9px] font-bold px-1.5 py-0.5 border ${st.cls || 'border-border-default'} uppercase tracking-tighter mb-2 inline-block`}>
{st.label}
</span>
<span className="ml-1 text-[9px] font-bold px-1.5 py-0.5 border border-border-default text-ink-light uppercase tracking-tighter mb-2 inline-block">
{p.source === 'db' ? 'DB' : 'DEMO'}
</span>
<h3 className="text-base font-bold text-ink-black group-hover:text-accent-teal transition-colors">{p.title}</h3>
<p className="text-xs text-ink-medium mt-0.5">{p.sponsorName}</p>
</div>
</div>
<div className="space-y-4">
<div>
<div className="flex justify-between text-[10px] text-ink-light uppercase font-bold mb-1 font-mono">
<span>์ง„ํ–‰ ํ˜„ํ™ฉ [PROGRESS]</span>
<span>{formatNum(p.currentCo2Kg)} / {formatNum(p.targetCo2Kg)} kg</span>
</div>
<ProgressBar current={p.currentCo2Kg} target={p.targetCo2Kg} />
</div>
<div className="grid grid-cols-2 gap-4 border-t border-border-default pt-4">
<div>
<p className="text-[9px] text-ink-light uppercase font-bold">์ฐธ์—ฌ์ž [PARTICIPANTS]</p>
<p className="text-sm font-bold text-ink-black">{formatNum(p.participantCount)}</p>
</div>
<div>
<p className="text-[9px] text-ink-light uppercase font-bold">์˜ˆ์‚ฐ [BUDGET]</p>
<p className="text-sm font-bold text-ink-black font-mono">{formatKRW(p.budgetKrw)}</p>
</div>
</div>
</div>
<div className="mt-6 pt-4 border-t border-dotted border-border-default flex justify-between items-center">
<p className="text-[10px] text-ink-light font-mono italic">
{p.startDate} ~ {p.endDate || 'Ongoing'}
</p>
<Link
to={`/admin/programs/${p.id}`}
className="text-[10px] font-bold text-ink-black hover:text-accent-teal underline underline-offset-2 uppercase"
>
๊ด€๋ฆฌํ•˜๊ธฐ [MANAGE] &rarr;
</Link>
</div>
</div>
);
})}
</div>
</div>
);
}