feat(phase3b): admin 영상화 납품 — 연결 트랙 표시 + 영상 URL 입력

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
This commit is contained in:
2026-07-09 23:15:55 +09:00
parent 6b6f1dbe0e
commit 65a8680adc
2 changed files with 238 additions and 29 deletions

View File

@@ -2,6 +2,14 @@
import { useEffect, useState } from 'react';
interface MusicTrack {
id: string;
title: string | null;
audio_url: string | null;
video_status: string;
video_url: string | null;
}
interface Order {
id: string;
user_id: string | null;
@@ -12,8 +20,16 @@ interface Order {
created_at: string;
product_name: string | null;
customer_email: string | null;
musicTrack?: MusicTrack | null;
}
const VIDEO_STATUS_LABELS: Record<string, { label: string; color: string }> = {
none: { label: '미신청', color: 'bg-slate-700/60 text-slate-500' },
requested: { label: '신청됨', color: 'bg-yellow-900/40 text-yellow-400' },
in_progress: { label: '제작 중', color: 'bg-blue-900/40 text-blue-400' },
delivered: { label: '납품 완료', color: 'bg-green-900/40 text-green-400' },
};
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '입금 대기', color: 'bg-yellow-900/40 text-yellow-400' },
paid: { label: '완료', color: 'bg-green-900/40 text-green-400' },
@@ -33,6 +49,7 @@ export default function AdminOrdersPage() {
const [error, setError] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('all');
const [updating, setUpdating] = useState<string | null>(null);
const [videoUrls, setVideoUrls] = useState<Record<string, string>>({});
async function loadOrders() {
try {
@@ -82,6 +99,73 @@ export default function AdminOrdersPage() {
}
}
async function deliverVideo(order: Order) {
const videoUrl = (videoUrls[order.id] ?? '').trim();
if (!videoUrl) {
alert('영상 URL을 입력해주세요.');
return;
}
const ok = confirm('영상 납품을 완료 처리하시겠습니까? 고객에게 다운로드 활성화 메일이 발송됩니다.');
if (!ok) return;
setUpdating(order.id);
try {
const res = await fetch('/api/admin/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: order.id, status: 'paid', videoUrl }),
});
if (res.ok) {
setOrders((prev) =>
prev.map((o) =>
o.id === order.id
? {
...o,
status: 'paid',
musicTrack: o.musicTrack
? { ...o.musicTrack, video_status: 'delivered', video_url: videoUrl }
: o.musicTrack,
}
: o
)
);
} else {
alert('납품 처리에 실패했습니다.');
}
} catch (e) {
console.error(e);
alert('네트워크 오류가 발생했습니다.');
} finally {
setUpdating(null);
}
}
async function markVideoInProgress(orderId: string) {
setUpdating(orderId);
try {
const res = await fetch('/api/admin/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: orderId, videoStatus: 'in_progress' }),
});
if (res.ok) {
setOrders((prev) =>
prev.map((o) =>
o.id === orderId
? { ...o, musicTrack: o.musicTrack ? { ...o.musicTrack, video_status: 'in_progress' } : o.musicTrack }
: o
)
);
} else {
alert('상태 변경에 실패했습니다.');
}
} catch (e) {
console.error(e);
alert('네트워크 오류가 발생했습니다.');
} finally {
setUpdating(null);
}
}
const filtered = orders.filter((o) => filterStatus === 'all' || o.status === filterStatus);
const pendingCount = orders.filter((o) => o.status === 'pending').length;
@@ -212,6 +296,55 @@ export default function AdminOrdersPage() {
)}
</div>
</div>
{order.product_id === 'music_video' && (
<div className="mt-3 pt-3 border-t border-slate-800 flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-slate-500 flex-shrink-0"> :</span>
<span className="text-sm text-white truncate">
{order.musicTrack?.title ?? '(트랙 정보 없음)'}
</span>
{order.musicTrack?.audio_url && (
<audio controls src={order.musicTrack.audio_url} className="h-8" />
)}
{order.musicTrack && (
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium flex-shrink-0 ${
VIDEO_STATUS_LABELS[order.musicTrack.video_status]?.color ?? 'bg-slate-700/60 text-slate-500'
}`}
>
{VIDEO_STATUS_LABELS[order.musicTrack.video_status]?.label ?? order.musicTrack.video_status}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
<input
type="text"
placeholder="영상 URL"
value={videoUrls[order.id] ?? order.musicTrack?.video_url ?? ''}
onChange={(e) =>
setVideoUrls((prev) => ({ ...prev, [order.id]: e.target.value }))
}
className="bg-slate-800 border border-slate-700 text-white text-xs rounded-lg px-2.5 py-1.5 w-56 placeholder:text-slate-500 focus:outline-none focus:border-slate-500"
/>
<button
onClick={() => markVideoInProgress(order.id)}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/30 transition disabled:opacity-50"
>
</button>
<button
onClick={() => deliverVideo(order)}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-green-600/20 text-green-400 border border-green-500/30 hover:bg-green-600/30 transition disabled:opacity-50"
>
{updating === order.id ? '처리중...' : '납품 완료'}
</button>
</div>
</div>
)}
</div>
);
})}