Initial commit: AutonetSellCar platform with deployment system
- Frontend: Next.js 14 with TypeScript - Backend: FastAPI with SQLAlchemy - Agent: Carmodoo sync agent - Deployment: Docker Compose based staging/production setup - Scripts: Automated deployment with rollback support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
432
frontend/src/app/admin/inquiries/page.tsx
Normal file
432
frontend/src/app/admin/inquiries/page.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { inquiryApi, Inquiry, InquiryWithMessages, InquiryStats } from '@/lib/api';
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
pending: '대기중',
|
||||
in_progress: '처리중',
|
||||
resolved: '해결됨',
|
||||
closed: '종료',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800',
|
||||
in_progress: 'bg-blue-100 text-blue-800',
|
||||
resolved: 'bg-green-100 text-green-800',
|
||||
closed: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
general: '일반',
|
||||
vehicle: '차량',
|
||||
payment: '결제',
|
||||
shipping: '배송',
|
||||
dealer: '딜러',
|
||||
account: '계정',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
export default function AdminInquiriesPage() {
|
||||
const [inquiries, setInquiries] = useState<Inquiry[]>([]);
|
||||
const [stats, setStats] = useState<InquiryStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('');
|
||||
|
||||
// Modal state
|
||||
const [selectedInquiry, setSelectedInquiry] = useState<InquiryWithMessages | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [responseMessage, setResponseMessage] = useState('');
|
||||
const [responseStatus, setResponseStatus] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
useEffect(() => {
|
||||
fetchInquiries();
|
||||
fetchStats();
|
||||
}, [page, statusFilter, categoryFilter]);
|
||||
|
||||
const fetchInquiries = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await inquiryApi.adminGetInquiries(page, pageSize, statusFilter || undefined, categoryFilter || undefined);
|
||||
setInquiries(response.inquiries);
|
||||
setTotal(response.total);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiries:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await inquiryApi.adminGetStats();
|
||||
setStats(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const openInquiryDetail = async (inquiry: Inquiry) => {
|
||||
try {
|
||||
const detail = await inquiryApi.adminGetInquiryDetail(inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setResponseStatus(detail.inquiry.status);
|
||||
setShowModal(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch inquiry detail:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRespond = async () => {
|
||||
if (!selectedInquiry || !responseMessage.trim()) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
await inquiryApi.adminRespond(selectedInquiry.inquiry.id, {
|
||||
message: responseMessage.trim(),
|
||||
status: responseStatus || undefined
|
||||
});
|
||||
|
||||
// Refresh
|
||||
const detail = await inquiryApi.adminGetInquiryDetail(selectedInquiry.inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setResponseMessage('');
|
||||
fetchInquiries();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to respond:', error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (status: string) => {
|
||||
if (!selectedInquiry) return;
|
||||
|
||||
try {
|
||||
await inquiryApi.adminUpdateStatus(selectedInquiry.inquiry.id, status);
|
||||
const detail = await inquiryApi.adminGetInquiryDetail(selectedInquiry.inquiry.id);
|
||||
setSelectedInquiry(detail);
|
||||
setResponseStatus(status);
|
||||
fetchInquiries();
|
||||
fetchStats();
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-800">문의 관리</h1>
|
||||
<p className="text-gray-600 mt-1">고객 문의를 관리하고 응답합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow-sm">
|
||||
<p className="text-gray-500 text-sm">전체</p>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-yellow-600 text-sm">대기중</p>
|
||||
<p className="text-2xl font-bold text-yellow-700">{stats.pending}</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-blue-600 text-sm">처리중</p>
|
||||
<p className="text-2xl font-bold text-blue-700">{stats.in_progress}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-green-600 text-sm">해결됨</p>
|
||||
<p className="text-2xl font-bold text-green-700">{stats.resolved}</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 p-4 rounded-lg shadow-sm">
|
||||
<p className="text-gray-600 text-sm">종료</p>
|
||||
<p className="text-2xl font-bold text-gray-700">{stats.closed}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white p-4 rounded-lg shadow-md">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">상태</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="pending">대기중</option>
|
||||
<option value="in_progress">처리중</option>
|
||||
<option value="resolved">해결됨</option>
|
||||
<option value="closed">종료</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">카테고리</label>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => { setCategoryFilter(e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">전체</option>
|
||||
<option value="general">일반</option>
|
||||
<option value="vehicle">차량</option>
|
||||
<option value="payment">결제</option>
|
||||
<option value="shipping">배송</option>
|
||||
<option value="dealer">딜러</option>
|
||||
<option value="account">계정</option>
|
||||
<option value="other">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inquiry List */}
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-600 border-t-transparent rounded-full mx-auto"></div>
|
||||
</div>
|
||||
) : inquiries.length === 0 ? (
|
||||
<div className="p-12 text-center text-gray-500">
|
||||
문의가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">카테고리</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">제목</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">상태</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">등록일</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200">
|
||||
{inquiries.map((inquiry) => (
|
||||
<tr key={inquiry.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 text-sm text-gray-500">#{inquiry.id}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
{CATEGORY_LABELS[inquiry.category] || inquiry.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<p className="text-sm font-medium text-gray-900 truncate max-w-xs">
|
||||
{inquiry.subject || '제목 없음'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate max-w-xs">
|
||||
{inquiry.message.substring(0, 50)}...
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${STATUS_COLORS[inquiry.status]}`}>
|
||||
{STATUS_LABELS[inquiry.status] || inquiry.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{formatDate(inquiry.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => openInquiryDetail(inquiry)}
|
||||
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
|
||||
>
|
||||
상세보기
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<span className="px-4 py-2 text-gray-600">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="px-4 py-2 bg-white rounded-lg shadow disabled:opacity-50"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{showModal && selectedInquiry && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-3xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 border-b sticky top-0 bg-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold">문의 상세 #{selectedInquiry.inquiry.id}</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Inquiry Info */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">카테고리:</span>
|
||||
<span className="ml-2 font-medium">{CATEGORY_LABELS[selectedInquiry.inquiry.category] || selectedInquiry.inquiry.category}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">상태:</span>
|
||||
<span className={`ml-2 px-2 py-0.5 text-xs rounded-full ${STATUS_COLORS[selectedInquiry.inquiry.status]}`}>
|
||||
{STATUS_LABELS[selectedInquiry.inquiry.status] || selectedInquiry.inquiry.status}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">이메일:</span>
|
||||
<span className="ml-2 font-medium">{selectedInquiry.inquiry.contact_email || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">전화번호:</span>
|
||||
<span className="ml-2 font-medium">{selectedInquiry.inquiry.contact_phone || '-'}</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-gray-500">등록일:</span>
|
||||
<span className="ml-2 font-medium">{formatDate(selectedInquiry.inquiry.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Original Message */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">문의 내용</h3>
|
||||
<div className="bg-gray-50 p-4 rounded-lg">
|
||||
<p className="font-medium mb-2">{selectedInquiry.inquiry.subject || '제목 없음'}</p>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{selectedInquiry.inquiry.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Thread */}
|
||||
{selectedInquiry.messages.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">대화 내역</h3>
|
||||
<div className="space-y-3">
|
||||
{selectedInquiry.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`p-3 rounded-lg ${
|
||||
msg.is_admin
|
||||
? 'bg-primary-50 border-l-4 border-primary-500'
|
||||
: 'bg-gray-50 border-l-4 border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`text-xs font-medium ${msg.is_admin ? 'text-primary-600' : 'text-gray-600'}`}>
|
||||
{msg.is_admin ? '관리자' : '고객'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400">{formatDate(msg.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700 whitespace-pre-wrap">{msg.message}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Update */}
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">상태 변경</h3>
|
||||
<div className="flex gap-2">
|
||||
{['pending', 'in_progress', 'resolved', 'closed'].map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => handleStatusChange(status)}
|
||||
className={`px-3 py-1 rounded-lg text-sm transition ${
|
||||
selectedInquiry.inquiry.status === status
|
||||
? 'bg-primary-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{STATUS_LABELS[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Form */}
|
||||
{selectedInquiry.inquiry.status !== 'closed' && (
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-700 mb-2">답변 작성</h3>
|
||||
<textarea
|
||||
value={responseMessage}
|
||||
onChange={(e) => setResponseMessage(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="답변 내용을 입력하세요..."
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<select
|
||||
value={responseStatus}
|
||||
onChange={(e) => setResponseStatus(e.target.value)}
|
||||
className="border border-gray-300 rounded-lg px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">상태 유지</option>
|
||||
<option value="in_progress">처리중으로 변경</option>
|
||||
<option value="resolved">해결됨으로 변경</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleRespond}
|
||||
disabled={sending || !responseMessage.trim()}
|
||||
className="bg-primary-600 text-white px-6 py-2 rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{sending ? '전송중...' : '답변 전송'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user