diff --git a/app/admin/contacts/page.tsx b/app/admin/contacts/page.tsx index a89b933..067f7f3 100644 --- a/app/admin/contacts/page.tsx +++ b/app/admin/contacts/page.tsx @@ -2,6 +2,13 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import { REQUEST_STATUS, RequestStatus } from '@/lib/request-status'; + +interface QuoteSummary { + id: string; + title: string; + status: string; +} interface Contact { id: string; @@ -9,16 +16,35 @@ interface Contact { name: string | null; service: string; message: string; - status: 'pending' | 'in_progress' | 'completed'; + status: string; created_at: string; + public_token?: string; + project_type?: string; + budget?: string; + timeline?: string; + quotes?: QuoteSummary[]; } -const STATUS_LABELS: Record = { - pending: { label: '미처리', color: 'bg-yellow-900/40 text-yellow-400' }, - in_progress: { label: '처리중', color: 'bg-blue-900/40 text-blue-400' }, - completed: { label: '완료', color: 'bg-green-900/40 text-green-400' }, +/** 상태별 색상 매핑 — admin 다크 톤 bg-*-900/40 text-*-400 */ +const STATUS_COLORS: Record = { + pending: 'bg-yellow-900/40 text-yellow-400', + reviewing: 'bg-sky-900/40 text-sky-400', + quoted: 'bg-blue-900/40 text-blue-400', + accepted: 'bg-green-900/40 text-green-400', + in_progress: 'bg-blue-900/40 text-blue-400', + completed: 'bg-green-900/40 text-green-400', + on_hold: 'bg-slate-700/60 text-slate-400', + cancelled: 'bg-red-900/40 text-red-400', }; +function getStatusColor(status: string): string { + return STATUS_COLORS[status] ?? 'bg-slate-700/60 text-slate-400'; +} + +function getStatusLabel(status: string): string { + return (REQUEST_STATUS as Record)[status]?.label ?? status; +} + const SERVICE_LABELS: Record = { lotto: '로또 추천', stock: '주식 자동매매', @@ -29,6 +55,31 @@ const SERVICE_LABELS: Record = { general: '일반 문의', }; +/** 필터 탭 정의 */ +const FILTER_TABS: { val: string; label: string }[] = [ + { val: 'all', label: '전체' }, + { val: 'pending', label: '접수' }, + { val: 'reviewing', label: '검토중' }, + { val: 'quoted', label: '견적 발송' }, + { val: 'accepted', label: '수주 확정' }, + { val: 'in_progress', label: '진행중' }, + { val: 'completed', label: '완료' }, + { val: '__other', label: '기타' }, +]; + +const OTHER_STATUSES = new Set(['on_hold', 'cancelled']); + +function matchFilter(status: string, filterVal: string): boolean { + if (filterVal === 'all') return true; + if (filterVal === '__other') return OTHER_STATUSES.has(status); + return status === filterVal; +} + +function filterCount(contacts: Contact[], filterVal: string): number { + if (filterVal === 'all') return contacts.length; + return contacts.filter((c) => matchFilter(c.status, filterVal)).length; +} + export default function AdminContactsPage() { const router = useRouter(); const [contacts, setContacts] = useState([]); @@ -37,6 +88,7 @@ export default function AdminContactsPage() { const [updating, setUpdating] = useState(null); const [filterStatus, setFilterStatus] = useState('all'); const [creatingQuote, setCreatingQuote] = useState(false); + const [copied, setCopied] = useState(false); async function createQuote(contact: Contact) { setCreatingQuote(true); @@ -84,10 +136,10 @@ export default function AdminContactsPage() { }); if (res.ok) { setContacts((prev) => - prev.map((c) => (c.id === id ? { ...c, status: status as Contact['status'] } : c)) + prev.map((c) => (c.id === id ? { ...c, status } : c)) ); if (selected?.id === id) { - setSelected((prev) => prev ? { ...prev, status: status as Contact['status'] } : null); + setSelected((prev) => prev ? { ...prev, status } : null); } } } catch (e) { @@ -97,7 +149,14 @@ export default function AdminContactsPage() { } } - const filtered = contacts.filter((c) => filterStatus === 'all' || c.status === filterStatus); + function copyTrackingLink(token: string) { + navigator.clipboard.writeText(location.origin + '/track/' + token).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + const filtered = contacts.filter((c) => matchFilter(c.status, filterStatus)); const pendingCount = contacts.filter((c) => c.status === 'pending').length; return ( @@ -115,8 +174,8 @@ export default function AdminContactsPage() { {/* 필터 탭 */} -
- {[['all', '전체'], ['pending', '미처리'], ['in_progress', '처리중'], ['completed', '완료']].map(([val, label]) => ( +
+ {FILTER_TABS.map(({ val, label }) => ( @@ -170,8 +229,8 @@ export default function AdminContactsPage() {

{contact.message}

- - {STATUS_LABELS[contact.status]?.label} + + {getStatusLabel(contact.status)} {new Date(contact.created_at).toLocaleDateString('ko-KR')} @@ -220,27 +279,85 @@ export default function AdminContactsPage() {
- {/* 상태 변경 */} -
-

상태 변경

-
- {(['pending', 'in_progress', 'completed'] as const).map((s) => ( - - ))} + {/* 프로젝트 정보 */} + {(selected.project_type || selected.budget || selected.timeline) && ( +
+

프로젝트 정보

+ {selected.project_type && ( +
+ 유형 + {selected.project_type} +
+ )} + {selected.budget && ( +
+ 예산 + {selected.budget} +
+ )} + {selected.timeline && ( +
+ 일정 + {selected.timeline} +
+ )}
+ )} + + {/* 상태 변경 — 8종 select */} +
+

상태 변경

+
+ {/* 추적 링크 복사 */} + {selected.public_token && ( + + )} + + {/* 연결된 견적 */} + {selected.quotes && selected.quotes.length > 0 && ( +
+

연결된 견적

+
+ {selected.quotes.map((q) => ( + + {q.title} + + {q.status} + + + ))} +
+
+ )} + {/* 이메일 바로 보내기 링크 */} - {/* 견적서 작성 */} + {/* 견적서 작성 (연결 견적이 있으면 라벨 변경) */}
)} diff --git a/app/api/admin/contacts/route.ts b/app/api/admin/contacts/route.ts index 01a12b0..8c1caa2 100644 --- a/app/api/admin/contacts/route.ts +++ b/app/api/admin/contacts/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; import { createAdminClient } from '@/lib/supabase/admin'; import { verifyAdminTokenNode } from '@/lib/admin-auth'; import { cookies } from 'next/headers'; +import { isRequestStatus } from '@/lib/request-status'; export const runtime = 'nodejs'; @@ -18,7 +19,7 @@ export async function GET() { const supabase = createAdminClient(); - const { data, error } = await supabase + const { data: contacts, error } = await supabase .from('contact_requests') .select('*') .order('created_at', { ascending: false }) @@ -28,7 +29,35 @@ export async function GET() { return NextResponse.json({ error: error.message }, { status: 500 }); } - return NextResponse.json({ contacts: data ?? [] }); + if (!contacts || contacts.length === 0) { + return NextResponse.json({ contacts: [] }); + } + + // 2-쿼리 머지: 연결 견적 부착 (컬럼 부재 등 오류는 빈 배열 폴백) + const ids = contacts.map((c) => c.id).filter(Boolean) as string[]; + let quotesMap: Record = {}; + try { + const { data: quotesData } = await supabase + .from('quotes') + .select('id, title, status, contact_request_id') + .in('contact_request_id', ids); + if (quotesData) { + for (const q of quotesData) { + if (!q.contact_request_id) continue; + if (!quotesMap[q.contact_request_id]) quotesMap[q.contact_request_id] = []; + quotesMap[q.contact_request_id].push({ id: q.id, title: q.title, status: q.status }); + } + } + } catch { + // 컬럼 부재 등 — 빈 배열 폴백 + } + + const enriched = contacts.map((c) => ({ + ...c, + quotes: quotesMap[c.id] ?? [], + })); + + return NextResponse.json({ contacts: enriched }); } export async function PATCH(request: Request) { @@ -37,11 +66,16 @@ export async function PATCH(request: Request) { } const { id, status } = await request.json(); + + if (typeof id !== 'string' || !isRequestStatus(status)) { + return NextResponse.json({ error: 'invalid request' }, { status: 400 }); + } + const supabase = createAdminClient(); const { error } = await supabase .from('contact_requests') - .update({ status }) + .update({ status, updated_at: new Date().toISOString() }) .eq('id', id); if (error) {