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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -36,60 +36,136 @@ export async function GET() {
|
||||
const productIds = [...new Set(orders.map((o) => o.product_id).filter(Boolean))] as string[];
|
||||
const userIds = [...new Set(orders.map((o) => o.user_id).filter(Boolean))] as string[];
|
||||
|
||||
const [productsRes, profilesRes] = await Promise.all([
|
||||
// music_video 주문의 연결 트랙 id 모으기
|
||||
const trackIds = [
|
||||
...new Set(
|
||||
orders
|
||||
.filter((o) => o.product_id === 'music_video')
|
||||
.map((o) =>
|
||||
typeof (o.metadata as Record<string, unknown> | null)?.music_track_id === 'string'
|
||||
? ((o.metadata as Record<string, unknown>).music_track_id as string)
|
||||
: null,
|
||||
)
|
||||
.filter((v): v is string => v !== null),
|
||||
),
|
||||
];
|
||||
|
||||
const [productsRes, profilesRes, tracksRes] = await Promise.all([
|
||||
productIds.length > 0
|
||||
? supabase.from('products').select('id, name').in('id', productIds)
|
||||
: Promise.resolve({ data: [] as { id: string; name: string }[] | null, error: null }),
|
||||
userIds.length > 0
|
||||
? supabase.from('profiles').select('id, email').in('id', userIds)
|
||||
: Promise.resolve({ data: [] as { id: string; email: string }[] | null, error: null }),
|
||||
trackIds.length > 0
|
||||
? supabase.from('music_tracks').select('id, title, audio_url, video_status, video_url').in('id', trackIds)
|
||||
: Promise.resolve({
|
||||
data: [] as { id: string; title: string | null; audio_url: string | null; video_status: string; video_url: string | null }[] | null,
|
||||
error: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
const productMap = Object.fromEntries((productsRes.data ?? []).map((p) => [p.id, p.name]));
|
||||
const profileMap = Object.fromEntries((profilesRes.data ?? []).map((p) => [p.id, p.email]));
|
||||
const trackMap = Object.fromEntries((tracksRes.data ?? []).map((t) => [t.id, t]));
|
||||
|
||||
const enriched = orders.map((o) => ({
|
||||
...o,
|
||||
product_name: o.product_id ? (productMap[o.product_id] ?? null) : null,
|
||||
customer_email: o.user_id ? (profileMap[o.user_id] ?? null) : null,
|
||||
}));
|
||||
const enriched = orders.map((o) => {
|
||||
const trackId =
|
||||
o.product_id === 'music_video' && typeof (o.metadata as Record<string, unknown> | null)?.music_track_id === 'string'
|
||||
? ((o.metadata as Record<string, unknown>).music_track_id as string)
|
||||
: null;
|
||||
return {
|
||||
...o,
|
||||
product_name: o.product_id ? (productMap[o.product_id] ?? null) : null,
|
||||
customer_email: o.user_id ? (profileMap[o.user_id] ?? null) : null,
|
||||
musicTrack: trackId ? (trackMap[trackId] ?? null) : null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ orders: enriched });
|
||||
}
|
||||
|
||||
// PATCH: 상태 변경 ('paid' 전환 시 고객에게 다운로드 활성화 메일)
|
||||
// PATCH: 상태 변경('paid' 전환 시 고객에게 다운로드 활성화 메일) + music_video 영상화 납품(videoUrl/videoStatus)
|
||||
export async function PATCH(request: Request) {
|
||||
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id, status } = await request.json();
|
||||
if (typeof id !== 'string' || !['pending', 'paid', 'cancelled'].includes(status)) {
|
||||
const { id, status, videoUrl, videoStatus } = await request.json();
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
|
||||
}
|
||||
if (status !== undefined && !['pending', 'paid', 'cancelled'].includes(status)) {
|
||||
return NextResponse.json({ error: 'invalid status' }, { status: 400 });
|
||||
}
|
||||
if (videoStatus !== undefined && !['none', 'requested', 'in_progress', 'delivered'].includes(videoStatus)) {
|
||||
return NextResponse.json({ error: 'invalid videoStatus' }, { status: 400 });
|
||||
}
|
||||
if (videoUrl !== undefined && typeof videoUrl !== 'string') {
|
||||
return NextResponse.json({ error: 'invalid videoUrl' }, { status: 400 });
|
||||
}
|
||||
if (status === undefined && videoUrl === undefined && videoStatus === undefined) {
|
||||
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { data: order, error } = await supabase
|
||||
.from('orders')
|
||||
.update({ status, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.select('id, product_id, user_id')
|
||||
.single();
|
||||
|
||||
if (error || !order) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
|
||||
let order: { id: string; product_id: string | null; user_id: string | null; metadata: Record<string, unknown> | null };
|
||||
|
||||
// paid 전환 시에만 메일 발송 — 실패해도 상태 변경은 이미 완료
|
||||
if (status === 'paid' && order.product_id && order.user_id) {
|
||||
try {
|
||||
const product = await getProductById(supabase, order.product_id);
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('email')
|
||||
.eq('id', order.user_id)
|
||||
.maybeSingle();
|
||||
if (product && profile?.email) {
|
||||
await sendOrderPaidEmail({ product, customerEmail: profile.email });
|
||||
if (status !== undefined) {
|
||||
const { data, error } = await supabase
|
||||
.from('orders')
|
||||
.update({ status, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.select('id, product_id, user_id, metadata')
|
||||
.single();
|
||||
|
||||
if (error || !data) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
|
||||
order = data;
|
||||
|
||||
// paid 전환 시에만 메일 발송 — 실패해도 상태 변경은 이미 완료
|
||||
if (status === 'paid' && order.product_id && order.user_id) {
|
||||
try {
|
||||
const product = await getProductById(supabase, order.product_id);
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('email')
|
||||
.eq('id', order.user_id)
|
||||
.maybeSingle();
|
||||
if (product && profile?.email) {
|
||||
await sendOrderPaidEmail({ product, customerEmail: profile.email });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('paid email failed', e);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// status 변경 없이 영상화 트랙만 갱신하는 경우 — 주문 조회로 product_id/metadata 확보
|
||||
const { data, error } = await supabase
|
||||
.from('orders')
|
||||
.select('id, product_id, user_id, metadata')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (error || !data) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
|
||||
order = data;
|
||||
}
|
||||
|
||||
// music_video 주문의 연결 트랙 영상화 상태 갱신
|
||||
if (order.product_id === 'music_video') {
|
||||
const trackId = typeof order.metadata?.music_track_id === 'string' ? (order.metadata.music_track_id as string) : null;
|
||||
if (trackId) {
|
||||
if (status === 'paid' || videoUrl !== undefined) {
|
||||
const trackUpdate: Record<string, unknown> = { video_status: 'delivered' };
|
||||
if (videoUrl !== undefined) trackUpdate.video_url = videoUrl;
|
||||
const { error: trackError } = await supabase.from('music_tracks').update(trackUpdate).eq('id', trackId);
|
||||
if (trackError) console.error('music_tracks video delivery update failed', trackError);
|
||||
} else if (videoStatus === 'in_progress') {
|
||||
const { error: trackError } = await supabase
|
||||
.from('music_tracks')
|
||||
.update({ video_status: 'in_progress' })
|
||||
.eq('id', trackId);
|
||||
if (trackError) console.error('music_tracks video in_progress update failed', trackError);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('paid email failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user