feat(phase1): admin 광고 관리 — 채널·캠페인 CRUD 탭 + 에셋 탭 재편

admin/marketing을 2탭(광고 채널/마케팅 에셋)으로 재구성하고 ad-channels
API(GET/POST/PATCH/DELETE)를 소비하는 CRUD UI를 신규 추가. 기존 에셋
그리드·체크리스트·PNG 변환 기능은 손실 없이 assets 탭으로 이동. 사이드바
라벨을 '마케팅 에셋' → '광고 관리'로 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 15:27:34 +09:00
parent f693c4c5b4
commit a85758566a
2 changed files with 318 additions and 2 deletions

View File

@@ -88,7 +88,7 @@ const NAV_ITEMS = [
},
{
href: '/admin/marketing',
label: '마케팅 에셋',
label: '광고 관리',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}

View File

@@ -2,6 +2,18 @@
import { useState, useEffect, useCallback } from 'react';
type AdminTab = 'channels' | 'assets';
interface AdChannel {
id: string;
name: string;
url: string | null;
status: 'active' | 'paused';
memo: string | null;
created_at: string;
updated_at: string;
}
const ASSETS = [
{
file: '/marketing/thumb-homepage-A.svg',
@@ -133,6 +145,18 @@ const CHECKLIST_ITEMS = {
type CheckKey = string;
export default function MarketingPage() {
const [section, setSection] = useState<AdminTab>('channels');
// 광고 채널 상태
const [channels, setChannels] = useState<AdChannel[]>([]);
const [channelsLoading, setChannelsLoading] = useState(true);
const [channelsError, setChannelsError] = useState<string | null>(null);
const [newChannel, setNewChannel] = useState({ name: '', url: '', memo: '' });
const [creatingChannel, setCreatingChannel] = useState(false);
const [channelMutating, setChannelMutating] = useState<string | null>(null);
const [editingMemoId, setEditingMemoId] = useState<string | null>(null);
const [memoDraft, setMemoDraft] = useState('');
const [preview, setPreview] = useState<typeof ASSETS[0] | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [checks, setChecks] = useState<Record<CheckKey, boolean>>({});
@@ -140,6 +164,105 @@ export default function MarketingPage() {
const [activeTab, setActiveTab] = useState<'design' | 'pm' | 'quality' | 'marketing'>('design');
const [convertingPng, setConvertingPng] = useState<string | null>(null);
async function loadChannels() {
setChannelsLoading(true);
setChannelsError(null);
try {
const res = await fetch('/api/admin/ad-channels');
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '채널 로드 실패');
setChannels(data.channels ?? []);
} catch (e) {
setChannelsError(e instanceof Error ? e.message : '채널 로드 실패');
} finally {
setChannelsLoading(false);
}
}
useEffect(() => {
if (section === 'channels') loadChannels();
}, [section]);
async function createChannel(e: React.FormEvent) {
e.preventDefault();
if (!newChannel.name.trim()) {
setChannelsError('채널명을 입력해주세요.');
return;
}
setCreatingChannel(true);
setChannelsError(null);
try {
const res = await fetch('/api/admin/ad-channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newChannel.name.trim(),
url: newChannel.url.trim() || undefined,
memo: newChannel.memo.trim() || undefined,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '채널 등록 실패');
setNewChannel({ name: '', url: '', memo: '' });
await loadChannels();
} catch (e) {
setChannelsError(e instanceof Error ? e.message : '채널 등록 실패');
} finally {
setCreatingChannel(false);
}
}
async function patchChannel(id: string, patch: Partial<Pick<AdChannel, 'name' | 'url' | 'status' | 'memo'>>) {
setChannelMutating(id);
setChannelsError(null);
try {
const res = await fetch(`/api/admin/ad-channels/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '채널 수정 실패');
await loadChannels();
} catch (e) {
setChannelsError(e instanceof Error ? e.message : '채널 수정 실패');
} finally {
setChannelMutating(null);
}
}
async function toggleChannelStatus(channel: AdChannel) {
await patchChannel(channel.id, { status: channel.status === 'active' ? 'paused' : 'active' });
}
function startEditMemo(channel: AdChannel) {
setEditingMemoId(channel.id);
setMemoDraft(channel.memo ?? '');
}
async function saveMemo(id: string) {
await patchChannel(id, { memo: memoDraft.trim() || null });
setEditingMemoId(null);
setMemoDraft('');
}
async function deleteChannel(id: string, name: string) {
const ok = confirm(`"${name}" 채널을 삭제하시겠습니까?`);
if (!ok) return;
setChannelMutating(id);
setChannelsError(null);
try {
const res = await fetch(`/api/admin/ad-channels/${id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '채널 삭제 실패');
await loadChannels();
} catch (e) {
setChannelsError(e instanceof Error ? e.message : '채널 삭제 실패');
} finally {
setChannelMutating(null);
}
}
useEffect(() => {
const saved = localStorage.getItem('marketing_checks');
if (saved) setChecks(JSON.parse(saved));
@@ -235,11 +358,202 @@ export default function MarketingPage() {
return (
<div className="p-8 max-w-[1400px]">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white mb-1"> </h1>
<p className="text-slate-400 text-sm"> · .</p>
</div>
{/* 탭 스위처 */}
<div className="flex gap-2 mb-8 border-b border-slate-800">
{([
{ key: 'channels', label: '광고 채널' },
{ key: 'assets', label: '마케팅 에셋' },
] as const).map(({ key, label }) => (
<button
key={key}
onClick={() => setSection(key)}
className={`px-4 py-2.5 text-sm font-semibold border-b-2 transition-all ${
section === key
? 'text-white border-red-500'
: 'text-slate-500 border-transparent hover:text-slate-300'
}`}
>
{label}
</button>
))}
</div>
{section === 'channels' && (
<div>
{channelsError && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-900/20 border border-red-500/30 text-red-400 text-sm">
{channelsError}
</div>
)}
{/* 신규 채널 추가 폼 */}
<form
onSubmit={createChannel}
className="bg-slate-900 rounded-xl border border-slate-700 p-5 mb-6 grid grid-cols-1 md:grid-cols-[1.2fr_1.5fr_2fr_auto] gap-3 items-end"
>
<div>
<label className="text-slate-400 text-xs block mb-1"> *</label>
<input
type="text"
value={newChannel.name}
onChange={(e) => setNewChannel({ ...newChannel, name: e.target.value })}
disabled={creatingChannel}
placeholder="예: 크몽 홈페이지 제작"
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1">URL</label>
<input
type="text"
value={newChannel.url}
onChange={(e) => setNewChannel({ ...newChannel, url: e.target.value })}
disabled={creatingChannel}
placeholder="https://kmong.com/gig/..."
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1"></label>
<input
type="text"
value={newChannel.memo}
onChange={(e) => setNewChannel({ ...newChannel, memo: e.target.value })}
disabled={creatingChannel}
placeholder="비고"
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
disabled={creatingChannel}
className="bg-red-600 hover:bg-red-500 disabled:opacity-60 text-white font-bold px-4 py-2 rounded text-sm whitespace-nowrap"
>
{creatingChannel ? '추가 중...' : '+ 채널 추가'}
</button>
</form>
{/* 채널 테이블 */}
{channelsLoading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
</div>
) : channels.length === 0 ? (
<div className="bg-slate-900 rounded-2xl p-10 text-center text-slate-500 border border-slate-700/50">
</div>
) : (
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800 text-slate-400">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3">URL</th>
<th className="text-center px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
</tr>
</thead>
<tbody>
{channels.map((channel) => (
<tr key={channel.id} className="border-t border-slate-800">
<td className="px-4 py-3 text-white font-medium">{channel.name}</td>
<td className="px-4 py-3">
{channel.url ? (
<a
href={channel.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 underline truncate max-w-[220px] inline-block align-bottom"
>
{channel.url}
</a>
) : (
<span className="text-slate-600">-</span>
)}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => toggleChannelStatus(channel)}
disabled={channelMutating === channel.id}
className={`px-2 py-1 rounded text-xs font-medium disabled:opacity-50 ${
channel.status === 'active'
? 'bg-emerald-600/30 text-emerald-300 border border-emerald-500/40'
: 'bg-slate-700 text-slate-400'
}`}
>
{channel.status === 'active' ? '운영중' : '중지'}
</button>
</td>
<td className="px-4 py-3 text-slate-300 max-w-[240px]">
{editingMemoId === channel.id ? (
<div className="flex items-center gap-1.5">
<input
type="text"
value={memoDraft}
onChange={(e) => setMemoDraft(e.target.value)}
autoFocus
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-2 py-1 text-xs"
/>
<button
onClick={() => saveMemo(channel.id)}
disabled={channelMutating === channel.id}
className="text-emerald-400 hover:text-emerald-300 text-xs px-1.5 disabled:opacity-50"
>
</button>
<button
onClick={() => { setEditingMemoId(null); setMemoDraft(''); }}
className="text-slate-500 hover:text-slate-300 text-xs px-1.5"
>
</button>
</div>
) : (
<button
onClick={() => startEditMemo(channel)}
className="text-left w-full truncate hover:text-white transition-all"
title="클릭하여 편집"
>
{channel.memo || <span className="text-slate-600">- ( )</span>}
</button>
)}
</td>
<td className="px-4 py-3 text-slate-500 text-xs whitespace-nowrap">
{new Date(channel.created_at).toLocaleDateString('ko-KR')}
</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<button
onClick={() => deleteChannel(channel.id, channel.name)}
disabled={channelMutating === channel.id}
className="text-red-400 hover:text-red-300 px-2 text-xs font-medium disabled:opacity-50"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{section === 'assets' && (
<>
{/* 헤더 */}
<div className="mb-8">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-white mb-1"> </h1>
<h2 className="text-lg font-bold text-white mb-1"> </h2>
<p className="text-slate-400 text-sm">· 4 </p>
</div>
<button
@@ -594,6 +908,8 @@ export default function MarketingPage() {
</div>
</div>
)}
</>
)}
</div>
);
}