diff --git a/app/api/quote/[token]/route.ts b/app/api/quote/[token]/route.ts index 4987d69..227b511 100644 --- a/app/api/quote/[token]/route.ts +++ b/app/api/quote/[token]/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { createAdminClient } from '@/lib/supabase/admin'; +import { sendQuoteDecisionEmail } from '@/lib/request-emails'; export const runtime = 'nodejs'; @@ -24,31 +25,79 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token: return NextResponse.json({ quote: data, expired }); } -// 고객이 견적 수락 +// 고객이 견적 수락/거절 export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) { const { token } = await params; - const body = await request.json(); // { selectedItems, selectedMaintenance } + const body = await request.json(); // { action?, selectedItems, selectedMaintenance, total } + const action: 'accept' | 'reject' = body.action === 'reject' ? 'reject' : 'accept'; const supabase = createAdminClient(); const { data: quote, error: findErr } = await supabase .from('quotes') - .select('id, title, client_name, client_email') + .select('id, title, client_name, client_email, status, contact_request_id') .eq('public_token', token) .single(); if (findErr || !quote) return NextResponse.json({ error: 'Not found' }, { status: 404 }); - // 상태를 accepted로 변경 - await supabase - .from('quotes') - .update({ - status: 'accepted', - accepted_items: body.selectedItems, - accepted_maintenance: body.selectedMaintenance, - accepted_total: body.total, - updated_at: new Date().toISOString(), - }) - .eq('id', quote.id); + // 이미 처리된 견적 중복 처리 방지 + if (quote.status === 'accepted' || quote.status === 'rejected') { + return NextResponse.json({ error: '이미 처리된 견적입니다' }, { status: 409 }); + } + + const now = new Date().toISOString(); + + if (action === 'accept') { + // 상태를 accepted로 변경 (기존 로직 유지) + await supabase + .from('quotes') + .update({ + status: 'accepted', + accepted_items: body.selectedItems, + accepted_maintenance: body.selectedMaintenance, + accepted_total: body.total, + updated_at: now, + }) + .eq('id', quote.id); + } else { + // 상태를 rejected로 변경 (accepted_* 미기록) + await supabase + .from('quotes') + .update({ + status: 'rejected', + updated_at: now, + }) + .eq('id', quote.id); + } + + // 연결된 의뢰 상태 동기화 (실패 시 무시) + if (quote.contact_request_id) { + try { + const crStatus = action === 'accept' ? 'accepted' : 'on_hold'; + await supabase + .from('contact_requests') + .update({ status: crStatus, updated_at: now }) + .eq('id', quote.contact_request_id); + } catch (e) { + console.error('[quote POST] contact_request sync failed:', e); + } + } + + // 관리자 알림 메일 (실패 시 무시) + try { + const decision = action === 'accept' ? 'accepted' : 'rejected'; + const totalValue = action === 'accept' && typeof body.total === 'number' && Number.isFinite(body.total) + ? body.total + : undefined; + await sendQuoteDecisionEmail({ + decision, + quoteTitle: quote.title, + clientName: quote.client_name || '고객', + total: totalValue, + }); + } catch (e) { + console.error('[quote POST] sendQuoteDecisionEmail failed:', e); + } return NextResponse.json({ success: true }); } diff --git a/app/quote/[token]/page.tsx b/app/quote/[token]/page.tsx index 1bdeef2..ec581de 100644 --- a/app/quote/[token]/page.tsx +++ b/app/quote/[token]/page.tsx @@ -37,6 +37,8 @@ export default function QuotePage() { const [activeTab, setActiveTab] = useState<'overview' | 'wbs' | 'quote' | 'maintenance'>('overview'); const [submitting, setSubmitting] = useState(false); const [submitted, setSubmitted] = useState(false); + const [rejected, setRejected] = useState(false); + const [alreadyProcessed, setAlreadyProcessed] = useState(false); const [isPrinting, setIsPrinting] = useState(false); useEffect(() => { @@ -89,15 +91,31 @@ export default function QuotePage() { if (!quote) return; setSubmitting(true); const selectedItems = quote.items.filter((i) => !i.optional || checkedOptional[i.id]).map((i) => i.id); - await fetch(`/api/quote/${token}`, { + const res = await fetch(`/api/quote/${token}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ selectedItems, selectedMaintenance, total: grandTotal }), }); setSubmitting(false); + if (res.status === 409) { setAlreadyProcessed(true); return; } setSubmitted(true); } + async function handleReject() { + if (!quote) return; + const confirmed = window.confirm('견적을 거절하시겠습니까? 조건 조정이 필요하시면 회신으로 말씀해 주세요.'); + if (!confirmed) return; + setSubmitting(true); + const res = await fetch(`/api/quote/${token}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'reject' }), + }); + setSubmitting(false); + if (res.status === 409) { setAlreadyProcessed(true); return; } + setRejected(true); + } + if (loading) { return (
+ 조건 조정이 필요하시면 언제든 회신 주세요.
+ 더 나은 견적으로 다시 찾아뵙겠습니다.
+
이 견적은 이미 수락 또는 거절 처리되었습니다.
+✕ 거절된 견적서입니다 — 조정이 필요하시면 회신 주세요
+