feat(admin): 주문 관리 페이지 — 입금 확인 원클릭 + 다운로드 활성화 메일
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,16 @@ const NAV_ITEMS = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/orders',
|
||||
label: '주문 관리',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/contacts',
|
||||
label: '문의 내역',
|
||||
|
||||
218
app/admin/orders/page.tsx
Normal file
218
app/admin/orders/page.tsx
Normal file
@@ -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<string, unknown> | null;
|
||||
created_at: string;
|
||||
product_name: string | null;
|
||||
customer_email: string | null;
|
||||
}
|
||||
|
||||
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' },
|
||||
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<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [updating, setUpdating] = useState<string | null>(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 (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-white text-2xl font-bold">주문 관리</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">계좌이체 입금 확인 및 다운로드 활성화</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<span className="bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 px-3 py-1 rounded-full text-sm font-medium">
|
||||
입금 대기 {pendingCount}건
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 탭 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{FILTER_TABS.map(({ val, label }) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setFilterStatus(val)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
filterStatus === val
|
||||
? 'bg-red-600/30 text-red-300 border border-red-500/30'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{val !== 'all' && (
|
||||
<span className="ml-1.5 text-xs opacity-70">
|
||||
{orders.filter((o) => o.status === val).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-2xl p-10 text-center">
|
||||
<p className="text-red-400 font-medium">{error}</p>
|
||||
<button
|
||||
onClick={() => { setLoading(true); setError(null); loadOrders(); }}
|
||||
className="mt-3 text-sm text-slate-400 hover:text-white transition"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="bg-slate-900 rounded-2xl p-10 text-center text-slate-500 border border-slate-700/50">
|
||||
주문 내역이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((order) => {
|
||||
const depositorName =
|
||||
typeof order.metadata?.depositor_name === 'string'
|
||||
? order.metadata.depositor_name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={order.id}
|
||||
className={`bg-slate-900 rounded-xl p-4 border transition-all ${
|
||||
order.status === 'cancelled'
|
||||
? 'border-slate-800/50 opacity-50'
|
||||
: 'border-slate-700/50 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 상품명 + 이메일 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-white font-medium text-sm truncate">
|
||||
{order.product_name ?? '(상품 없음)'}
|
||||
</span>
|
||||
<span className="text-blue-400 font-semibold text-sm flex-shrink-0">
|
||||
₩{order.amount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
<span className="truncate">
|
||||
{order.customer_email ?? order.user_id ?? '이메일 없음'}
|
||||
</span>
|
||||
{depositorName && (
|
||||
<span className="flex-shrink-0 bg-slate-700 text-slate-300 px-2 py-0.5 rounded-full">
|
||||
입금자: {depositorName}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-shrink-0">
|
||||
{new Date(order.created_at).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 뱃지 + 액션 버튼 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{order.status === 'paid' ? (
|
||||
<span className="text-green-400 text-xs font-medium">다운로드 활성</span>
|
||||
) : null}
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium ${STATUS_LABELS[order.status]?.color}`}
|
||||
>
|
||||
{STATUS_LABELS[order.status]?.label}
|
||||
</span>
|
||||
{order.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'paid')}
|
||||
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>
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'cancelled')}
|
||||
disabled={updating === order.id}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-700 text-slate-400 hover:bg-slate-600 hover:text-white transition disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
app/api/admin/orders/route.ts
Normal file
97
app/api/admin/orders/route.ts
Normal file
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user