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:
AutonetSellCar Deploy
2025-12-30 13:24:39 +09:00
commit 1f0dcb1ddb
224 changed files with 55119 additions and 0 deletions

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