feat(admin): 주문 관리 페이지 — 입금 확인 원클릭 + 다운로드 활성화 메일

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 09:13:34 +09:00
parent 8dafb98f47
commit 7b02e28f6c
3 changed files with 325 additions and 0 deletions

View File

@@ -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
View 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>
);
}

View 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 });
}