From 65a8680adc035d6fa1ab7f881ce540138ef11c08 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 9 Jul 2026 23:15:55 +0900 Subject: [PATCH] =?UTF-8?q?feat(phase3b):=20admin=20=EC=98=81=EC=83=81?= =?UTF-8?q?=ED=99=94=20=EB=82=A9=ED=92=88=20=E2=80=94=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=20=ED=8A=B8=EB=9E=99=20=ED=91=9C=EC=8B=9C=20+=20=EC=98=81?= =?UTF-8?q?=EC=83=81=20URL=20=EC=9E=85=EB=A0=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ --- app/admin/orders/page.tsx | 133 +++++++++++++++++++++++++++++++++ app/api/admin/orders/route.ts | 134 ++++++++++++++++++++++++++-------- 2 files changed, 238 insertions(+), 29 deletions(-) diff --git a/app/admin/orders/page.tsx b/app/admin/orders/page.tsx index f89aadc..27293ce 100644 --- a/app/admin/orders/page.tsx +++ b/app/admin/orders/page.tsx @@ -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 = { + 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 = { 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(null); const [filterStatus, setFilterStatus] = useState('all'); const [updating, setUpdating] = useState(null); + const [videoUrls, setVideoUrls] = useState>({}); 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() { )} + + {order.product_id === 'music_video' && ( +
+
+ 연결 트랙: + + {order.musicTrack?.title ?? '(트랙 정보 없음)'} + + {order.musicTrack?.audio_url && ( +
+ +
+ + 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" + /> + + +
+
+ )} ); })} diff --git a/app/api/admin/orders/route.ts b/app/api/admin/orders/route.ts index 17294e3..205b8c2 100644 --- a/app/api/admin/orders/route.ts +++ b/app/api/admin/orders/route.ts @@ -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 | null)?.music_track_id === 'string' + ? ((o.metadata as Record).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 | null)?.music_track_id === 'string' + ? ((o.metadata as Record).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 | 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 = { 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); } }