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:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user