feat(admin): 의뢰→견적 연결 생성 + 견적 발송(메일·상태 동기화)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
interface Contact {
|
interface Contact {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -29,11 +30,41 @@ const SERVICE_LABELS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function AdminContactsPage() {
|
export default function AdminContactsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selected, setSelected] = useState<Contact | null>(null);
|
const [selected, setSelected] = useState<Contact | null>(null);
|
||||||
const [updating, setUpdating] = useState<string | null>(null);
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||||
|
const [creatingQuote, setCreatingQuote] = useState(false);
|
||||||
|
|
||||||
|
async function createQuote(contact: Contact) {
|
||||||
|
setCreatingQuote(true);
|
||||||
|
try {
|
||||||
|
const title = `${SERVICE_LABELS[contact.service] ?? contact.service ?? '외주 문의'} — ${contact.name ?? ''}`.trim();
|
||||||
|
const res = await fetch('/api/admin/quotes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
contact_request_id: contact.id,
|
||||||
|
client_name: contact.name ?? '',
|
||||||
|
client_email: contact.email,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
if (res.ok && d.quote?.id) {
|
||||||
|
router.push('/admin/quotes/' + d.quote.id);
|
||||||
|
} else {
|
||||||
|
alert(d.error || '견적서 생성에 실패했습니다');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('견적서 생성 중 오류가 발생했습니다');
|
||||||
|
} finally {
|
||||||
|
setCreatingQuote(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin/contacts')
|
fetch('/api/admin/contacts')
|
||||||
@@ -221,6 +252,19 @@ export default function AdminContactsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
이메일 답장하기
|
이메일 답장하기
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
{/* 견적서 작성 */}
|
||||||
|
<button
|
||||||
|
onClick={() => createQuote(selected)}
|
||||||
|
disabled={creatingQuote}
|
||||||
|
className="mt-2 w-full flex items-center justify-center gap-2 py-2 bg-violet-600/20 text-violet-300 rounded-lg text-xs hover:bg-violet-600/30 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
{creatingQuote ? '생성 중...' : '견적서 작성'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default function QuoteEditorPage() {
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||||
const [mileSaving, setMileSaving] = useState<string | null>(null);
|
const [mileSaving, setMileSaving] = useState<string | null>(null);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/admin/quotes/${id}`)
|
fetch(`/api/admin/quotes/${id}`)
|
||||||
@@ -125,6 +126,32 @@ export default function QuoteEditorPage() {
|
|||||||
setMileSaving(null);
|
setMileSaving(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 고객에게 발송 ───────────────────────
|
||||||
|
async function sendToClient() {
|
||||||
|
if (!form.client_email) return;
|
||||||
|
if (!confirm("고객에게 견적 메일을 발송하고 상태를 '발송됨'으로 변경합니다.")) return;
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/quotes/${id}/send`, { method: 'POST' });
|
||||||
|
const d = await res.json();
|
||||||
|
if (res.ok && d.success) {
|
||||||
|
setField('status', 'sent');
|
||||||
|
if (d.emailSent === false) {
|
||||||
|
alert('상태는 변경됐으나 메일 발송에 실패했습니다 — 수동 발송이 필요합니다');
|
||||||
|
} else {
|
||||||
|
alert('발송 완료');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(d.error || '발송에 실패했습니다');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('발송 중 오류가 발생했습니다');
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── helpers ────────────────────────────
|
// ── helpers ────────────────────────────
|
||||||
const setField = (k: keyof QuoteForm, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
|
const setField = (k: keyof QuoteForm, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
@@ -255,6 +282,23 @@ export default function QuoteEditorPage() {
|
|||||||
PDF 저장
|
PDF 저장
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{/* 고객에게 발송 */}
|
||||||
|
<button
|
||||||
|
onClick={sendToClient}
|
||||||
|
disabled={sending || !form.client_email}
|
||||||
|
title={!form.client_email ? '고객 이메일을 먼저 입력하세요' : '고객에게 견적 메일 발송'}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-50 disabled:cursor-not-allowed">
|
||||||
|
{sending ? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
고객에게 발송
|
||||||
|
</button>
|
||||||
|
{!form.client_email && (
|
||||||
|
<span className="text-xs text-amber-400/80">이메일 입력 필요</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 저장 */}
|
{/* 저장 */}
|
||||||
<button onClick={() => save()} disabled={saving}
|
<button onClick={() => save()} disabled={saving}
|
||||||
className={`flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold transition-all ${saved ? 'bg-green-600 text-white' : 'bg-blue-600 hover:bg-blue-500 text-white'} disabled:opacity-60`}>
|
className={`flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold transition-all ${saved ? 'bg-green-600 text-white' : 'bg-blue-600 hover:bg-blue-500 text-white'} disabled:opacity-60`}>
|
||||||
|
|||||||
84
app/api/admin/quotes/[id]/send/route.ts
Normal file
84
app/api/admin/quotes/[id]/send/route.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||||
|
import { sendQuoteSentEmail } from '@/lib/request-emails';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
async function checkAuth() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('admin_token')?.value;
|
||||||
|
return token && verifyAdminTokenNode(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
if (!(await checkAuth())) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const supabase = createAdminClient();
|
||||||
|
|
||||||
|
// 1. 견적서 조회
|
||||||
|
const { data: quote, error: fetchError } = await supabase
|
||||||
|
.from('quotes')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (fetchError || !quote) {
|
||||||
|
return NextResponse.json({ error: '견적서를 찾을 수 없습니다' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 고객 이메일 필수
|
||||||
|
if (!quote.client_email) {
|
||||||
|
return NextResponse.json({ error: '고객 이메일을 먼저 입력하세요' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. public_token 보장
|
||||||
|
const quoteToken: string = quote.public_token || crypto.randomUUID();
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
|
// 4. 견적 상태 업데이트
|
||||||
|
const updatePayload: Record<string, unknown> = { status: 'sent', updated_at: nowIso };
|
||||||
|
if (!quote.public_token) updatePayload.public_token = quoteToken;
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('quotes')
|
||||||
|
.update(updatePayload)
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error('[Quote Send] update error:', updateError.message);
|
||||||
|
return NextResponse.json({ error: '견적 상태 업데이트 실패' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 연결된 의뢰 상태 동기화 (실패해도 진행)
|
||||||
|
if (quote.contact_request_id) {
|
||||||
|
const { error: syncError } = await supabase
|
||||||
|
.from('contact_requests')
|
||||||
|
.update({ status: 'quoted', updated_at: nowIso })
|
||||||
|
.eq('id', quote.contact_request_id);
|
||||||
|
if (syncError) {
|
||||||
|
console.error('[Quote Send] contact sync error:', syncError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 견적 메일 발송 (실패해도 상태 변경은 유지)
|
||||||
|
let emailSent = true;
|
||||||
|
try {
|
||||||
|
await sendQuoteSentEmail({
|
||||||
|
clientName: quote.client_name || '고객',
|
||||||
|
clientEmail: quote.client_email,
|
||||||
|
quoteTitle: quote.title,
|
||||||
|
quoteToken,
|
||||||
|
validUntil: quote.valid_until ?? null,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
emailSent = false;
|
||||||
|
console.error('[Quote Send] email error:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, emailSent });
|
||||||
|
}
|
||||||
@@ -34,19 +34,25 @@ export async function POST(request: Request) {
|
|||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const supabase = createAdminClient();
|
const supabase = createAdminClient();
|
||||||
|
|
||||||
const { data, error } = await supabase
|
// 의뢰(contact_requests) 연결용 필드 — string만 허용
|
||||||
.from('quotes')
|
const insertData: Record<string, unknown> = {
|
||||||
.insert({
|
|
||||||
title: body.title || '새 견적서',
|
title: body.title || '새 견적서',
|
||||||
client_name: body.client_name || '',
|
client_name: typeof body.client_name === 'string' ? body.client_name : '',
|
||||||
client_email: body.client_email || '',
|
client_email: typeof body.client_email === 'string' ? body.client_email : '',
|
||||||
valid_until: body.valid_until || null,
|
valid_until: body.valid_until || null,
|
||||||
wbs: body.wbs || [],
|
wbs: body.wbs || [],
|
||||||
items: body.items || [],
|
items: body.items || [],
|
||||||
maintenance: body.maintenance || [],
|
maintenance: body.maintenance || [],
|
||||||
notes: body.notes || '',
|
notes: body.notes || '',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
})
|
};
|
||||||
|
if (typeof body.contact_request_id === 'string' && body.contact_request_id) {
|
||||||
|
insertData.contact_request_id = body.contact_request_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('quotes')
|
||||||
|
.insert(insertData)
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user