diff --git a/app/admin/components/AdminSidebar.tsx b/app/admin/components/AdminSidebar.tsx index bdaa70b..62e3e03 100644 --- a/app/admin/components/AdminSidebar.tsx +++ b/app/admin/components/AdminSidebar.tsx @@ -36,6 +36,16 @@ const NAV_ITEMS = [ ), }, + { + href: '/admin/orders', + label: '주문 관리', + icon: ( + + + + ), + }, { href: '/admin/contacts', label: '문의 내역', diff --git a/app/admin/orders/page.tsx b/app/admin/orders/page.tsx new file mode 100644 index 0000000..1aac32b --- /dev/null +++ b/app/admin/orders/page.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +interface Order { + id: string; + user_id: string | null; + product_id: string | null; + amount: number; + status: 'pending' | 'paid' | 'cancelled'; + metadata: Record | null; + created_at: string; + product_name: string | null; + customer_email: string | null; +} + +const STATUS_LABELS: Record = { + pending: { label: '입금 대기', color: 'bg-yellow-900/40 text-yellow-400' }, + paid: { label: '완료', color: 'bg-green-900/40 text-green-400' }, + cancelled: { label: '취소', color: 'bg-slate-700/60 text-slate-500' }, +}; + +const FILTER_TABS = [ + { val: 'all', label: '전체' }, + { val: 'pending', label: '입금 대기' }, + { val: 'paid', label: '완료' }, + { val: 'cancelled', label: '취소' }, +] as const; + +export default function AdminOrdersPage() { + const [orders, setOrders] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filterStatus, setFilterStatus] = useState('all'); + const [updating, setUpdating] = useState(null); + + async function loadOrders() { + try { + const res = await fetch('/api/admin/orders'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const d = await res.json(); + setOrders(d.orders ?? []); + } catch (e) { + setError(e instanceof Error ? e.message : '불러오기 실패'); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadOrders(); + }, []); + + async function updateStatus(id: string, status: 'paid' | 'cancelled' | 'pending') { + if (status === 'paid') { + const ok = confirm('입금을 확인하셨습니까? 고객에게 다운로드 활성화 메일이 발송됩니다.'); + if (!ok) return; + } + setUpdating(id); + try { + const res = await fetch('/api/admin/orders', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, status }), + }); + if (res.ok) { + setOrders((prev) => + prev.map((o) => (o.id === id ? { ...o, status } : 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; + + return ( +
+
+
+

주문 관리

+

계좌이체 입금 확인 및 다운로드 활성화

+
+ {pendingCount > 0 && ( + + 입금 대기 {pendingCount}건 + + )} +
+ + {/* 필터 탭 */} +
+ {FILTER_TABS.map(({ val, label }) => ( + + ))} +
+ + {loading ? ( +
+
+
+ ) : error ? ( +
+

{error}

+ +
+ ) : filtered.length === 0 ? ( +
+ 주문 내역이 없습니다 +
+ ) : ( +
+ {filtered.map((order) => { + const depositorName = + typeof order.metadata?.depositor_name === 'string' + ? order.metadata.depositor_name + : null; + + return ( +
+
+ {/* 상품명 + 이메일 */} +
+
+ + {order.product_name ?? '(상품 없음)'} + + + ₩{order.amount.toLocaleString()} + +
+
+ + {order.customer_email ?? order.user_id ?? '이메일 없음'} + + {depositorName && ( + + 입금자: {depositorName} + + )} + + {new Date(order.created_at).toLocaleDateString('ko-KR')} + +
+
+ + {/* 상태 뱃지 + 액션 버튼 */} +
+ {order.status === 'paid' ? ( + 다운로드 활성 + ) : null} + + {STATUS_LABELS[order.status]?.label} + + {order.status === 'pending' && ( + <> + + + + )} +
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/app/api/admin/orders/route.ts b/app/api/admin/orders/route.ts new file mode 100644 index 0000000..17294e3 --- /dev/null +++ b/app/api/admin/orders/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { verifyAdminTokenNode } from '@/lib/admin-auth'; +import { getProductById } from '@/lib/supabase/product-files'; +import { sendOrderPaidEmail } from '@/lib/order-emails'; + +export const runtime = 'nodejs'; + +async function checkAuth() { + const cookieStore = await cookies(); + const token = cookieStore.get('admin_token')?.value; + return token && verifyAdminTokenNode(token); +} + +// GET: 주문 목록 (최근 200건) — 상품명 + 주문자 이메일 포함 +export async function GET() { + if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const supabase = createAdminClient(); + + // 2-쿼리 방식: FK 관계 중첩 select 대신 명시적 조인으로 안전하게 + const { data: orders, error } = await supabase + .from('orders') + .select('id, user_id, product_id, amount, status, metadata, created_at') + .order('created_at', { ascending: false }) + .limit(200); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + + if (!orders || orders.length === 0) { + return NextResponse.json({ orders: [] }); + } + + // 상품명 조회 + 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([ + 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 }), + ]); + + 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 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, + })); + + return NextResponse.json({ orders: enriched }); +} + +// PATCH: 상태 변경 ('paid' 전환 시 고객에게 다운로드 활성화 메일) +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)) { + 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 }); + + // 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); + } + } + + return NextResponse.json({ success: true }); +}