feat: 질문지 제출 기능 + 관리자 응답 관리 + iframe 미리보기 수정
- 질문지 HTML에 제출/임시저장 JavaScript 추가 (localStorage 임시저장, API 제출) - questionnaire_responses 테이블 마이그레이션 (005) - /api/questionnaire/submit POST 엔드포인트 - 관리자 질문지 응답 목록/상세/상태변경 페이지 및 API - 관리자 문서 미리보기를 fetch+srcdoc 방식으로 변경 (X-Frame-Options 우회) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -127,6 +127,94 @@
|
|||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-info .field-input {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid #cbd5e1;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1e293b;
|
||||||
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-info .field-input:focus {
|
||||||
|
border-bottom-color: #1a56db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-info .field-input::placeholder {
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Submit Section ── */
|
||||||
|
.submit-section {
|
||||||
|
margin-top: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 14px 48px;
|
||||||
|
background: #1a56db;
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
background: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:disabled {
|
||||||
|
background: #94a3b8;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-msg {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-msg.success {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-msg.error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-draft-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 12px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-draft-btn:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Section ── */
|
/* ── Section ── */
|
||||||
.section {
|
.section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
@@ -412,20 +500,20 @@
|
|||||||
<!-- Client Info -->
|
<!-- Client Info -->
|
||||||
<div class="client-info">
|
<div class="client-info">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">고객명</span>
|
<span class="field-label">고객명 <span style="color:#ef4444">*</span></span>
|
||||||
<span class="field-value"></span>
|
<input type="text" id="clientName" class="field-input" placeholder="홍길동" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">연락처</span>
|
<span class="field-label">연락처</span>
|
||||||
<span class="field-value"></span>
|
<input type="tel" id="clientPhone" class="field-input" placeholder="010-0000-0000">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">이메일</span>
|
<span class="field-label">이메일 <span style="color:#ef4444">*</span></span>
|
||||||
<span class="field-value"></span>
|
<input type="email" id="clientEmail" class="field-input" placeholder="example@email.com" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<span class="field-label">작성일</span>
|
<span class="field-label">작성일</span>
|
||||||
<span class="field-value"></span>
|
<span class="field-value" id="fillDate"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -690,6 +778,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Section -->
|
||||||
|
<div class="submit-section">
|
||||||
|
<button type="button" class="save-draft-btn" onclick="saveDraft()">
|
||||||
|
임시 저장
|
||||||
|
</button>
|
||||||
|
<button type="button" class="submit-btn" id="submitBtn" onclick="submitQuestionnaire()">
|
||||||
|
질문지 제출하기
|
||||||
|
</button>
|
||||||
|
<div id="submitMsg" class="submit-msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer Notice -->
|
<!-- Footer Notice -->
|
||||||
<div class="footer-notice">
|
<div class="footer-notice">
|
||||||
<strong>안내사항</strong><br>
|
<strong>안내사항</strong><br>
|
||||||
@@ -715,5 +814,160 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 작성일 자동 채우기
|
||||||
|
document.getElementById('fillDate').textContent = new Date().toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit'
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 응답 수집
|
||||||
|
function collectResponses() {
|
||||||
|
const responses = {};
|
||||||
|
|
||||||
|
// 텍스트 질문 (textarea)
|
||||||
|
document.querySelectorAll('.question').forEach((q, idx) => {
|
||||||
|
const num = idx + 1;
|
||||||
|
const textarea = q.querySelector('textarea');
|
||||||
|
const radios = q.querySelectorAll('input[type="radio"]');
|
||||||
|
const checkboxes = q.querySelectorAll('input[type="checkbox"]');
|
||||||
|
|
||||||
|
if (radios.length > 0) {
|
||||||
|
const checked = q.querySelector('input[type="radio"]:checked');
|
||||||
|
if (checked) {
|
||||||
|
responses['q' + num] = checked.closest('.q-option').textContent.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkboxes.length > 0) {
|
||||||
|
const selected = [];
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
if (cb.checked) selected.push(cb.closest('.q-option').textContent.trim());
|
||||||
|
});
|
||||||
|
if (selected.length > 0) {
|
||||||
|
responses['q' + num + '_selected'] = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textarea) {
|
||||||
|
const val = textarea.value.trim();
|
||||||
|
if (val) {
|
||||||
|
responses['q' + num + (radios.length > 0 || checkboxes.length > 0 ? '_detail' : '')] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 추가 요청사항 (마지막 textarea)
|
||||||
|
const lastSection = document.querySelectorAll('.section');
|
||||||
|
const additionalTextarea = lastSection[lastSection.length - 1]?.querySelector('textarea');
|
||||||
|
if (additionalTextarea && additionalTextarea.value.trim()) {
|
||||||
|
responses['additional'] = additionalTextarea.value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유효성 검사
|
||||||
|
function validate() {
|
||||||
|
const name = document.getElementById('clientName').value.trim();
|
||||||
|
const email = document.getElementById('clientEmail').value.trim();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
alert('고객명을 입력해주세요.');
|
||||||
|
document.getElementById('clientName').focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!email) {
|
||||||
|
alert('이메일을 입력해주세요.');
|
||||||
|
document.getElementById('clientEmail').focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
alert('올바른 이메일 형식을 입력해주세요.');
|
||||||
|
document.getElementById('clientEmail').focus();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 임시 저장 (로컬)
|
||||||
|
function saveDraft() {
|
||||||
|
const data = {
|
||||||
|
clientName: document.getElementById('clientName').value,
|
||||||
|
clientEmail: document.getElementById('clientEmail').value,
|
||||||
|
clientPhone: document.getElementById('clientPhone').value,
|
||||||
|
responses: collectResponses(),
|
||||||
|
savedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
localStorage.setItem('questionnaire_draft_ebay', JSON.stringify(data));
|
||||||
|
|
||||||
|
const msg = document.getElementById('submitMsg');
|
||||||
|
msg.className = 'submit-msg success';
|
||||||
|
msg.textContent = '임시 저장 완료! (브라우저에 저장됨)';
|
||||||
|
setTimeout(() => { msg.textContent = ''; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 임시 저장 복원
|
||||||
|
function loadDraft() {
|
||||||
|
const saved = localStorage.getItem('questionnaire_draft_ebay');
|
||||||
|
if (!saved) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(saved);
|
||||||
|
if (data.clientName) document.getElementById('clientName').value = data.clientName;
|
||||||
|
if (data.clientEmail) document.getElementById('clientEmail').value = data.clientEmail;
|
||||||
|
if (data.clientPhone) document.getElementById('clientPhone').value = data.clientPhone;
|
||||||
|
} catch (e) {
|
||||||
|
// 복원 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 제출
|
||||||
|
async function submitQuestionnaire() {
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
const msg = document.getElementById('submitMsg');
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '제출 중...';
|
||||||
|
msg.textContent = '';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
clientName: document.getElementById('clientName').value.trim(),
|
||||||
|
clientEmail: document.getElementById('clientEmail').value.trim(),
|
||||||
|
clientPhone: document.getElementById('clientPhone').value.trim() || null,
|
||||||
|
responses: collectResponses(),
|
||||||
|
type: 'ebay-tool'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/questionnaire/submit', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await res.json();
|
||||||
|
|
||||||
|
if (res.ok && result.success) {
|
||||||
|
msg.className = 'submit-msg success';
|
||||||
|
msg.innerHTML = '질문지가 성공적으로 제출되었습니다!<br>담당자가 확인 후 연락드리겠습니다. 감사합니다.';
|
||||||
|
btn.textContent = '제출 완료';
|
||||||
|
localStorage.removeItem('questionnaire_draft_ebay');
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || '제출에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
msg.className = 'submit-msg error';
|
||||||
|
msg.textContent = err.message || '서버 오류가 발생했습니다. 이메일(bgg8988@gmail.com)로 직접 보내주세요.';
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '질문지 제출하기';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 페이지 로드 시 임시 저장 복원
|
||||||
|
loadDraft();
|
||||||
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -68,6 +68,16 @@ const NAV_ITEMS = [
|
|||||||
</svg>
|
</svg>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/admin/questionnaire',
|
||||||
|
label: '질문지 응답',
|
||||||
|
icon: (
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/admin/marketing',
|
href: '/admin/marketing',
|
||||||
label: '마케팅 에셋',
|
label: '마케팅 에셋',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -47,6 +47,19 @@ const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
|||||||
|
|
||||||
export default function AdminDocumentsPage() {
|
export default function AdminDocumentsPage() {
|
||||||
const [previewDoc, setPreviewDoc] = useState<Document | null>(null);
|
const [previewDoc, setPreviewDoc] = useState<Document | null>(null);
|
||||||
|
const [previewHtml, setPreviewHtml] = useState<string>('');
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
|
||||||
|
// iframe src 대신 fetch + srcdoc 방식으로 X-Frame-Options 우회
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewDoc) { setPreviewHtml(''); return; }
|
||||||
|
setPreviewLoading(true);
|
||||||
|
fetch(`/api/admin/documents/${previewDoc.fileName}`)
|
||||||
|
.then(res => res.ok ? res.text() : Promise.reject('문서를 불러올 수 없습니다'))
|
||||||
|
.then(html => setPreviewHtml(html))
|
||||||
|
.catch(() => setPreviewHtml('<p style="padding:2rem;color:red;">문서를 불러올 수 없습니다.</p>'))
|
||||||
|
.finally(() => setPreviewLoading(false));
|
||||||
|
}, [previewDoc]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto">
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
@@ -126,13 +139,20 @@ export default function AdminDocumentsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* iframe */}
|
{/* 문서 미리보기 (fetch + srcdoc 방식) */}
|
||||||
<iframe
|
{previewLoading ? (
|
||||||
src={`/api/admin/documents/${previewDoc.fileName}`}
|
<div className="flex items-center justify-center bg-white" style={{ height: '80vh' }}>
|
||||||
className="w-full bg-white"
|
<div className="text-slate-400 text-sm">문서를 불러오는 중...</div>
|
||||||
style={{ height: '80vh' }}
|
</div>
|
||||||
title={previewDoc.title}
|
) : (
|
||||||
/>
|
<iframe
|
||||||
|
srcDoc={previewHtml}
|
||||||
|
className="w-full bg-white"
|
||||||
|
style={{ height: '80vh' }}
|
||||||
|
title={previewDoc.title}
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
256
app/admin/questionnaire/page.tsx
Normal file
256
app/admin/questionnaire/page.tsx
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface QuestionnaireResponse {
|
||||||
|
id: string;
|
||||||
|
questionnaire_type: string;
|
||||||
|
client_name: string;
|
||||||
|
client_email: string;
|
||||||
|
client_phone: string | null;
|
||||||
|
responses: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
admin_notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
reviewed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
submitted: { label: '접수', color: 'bg-blue-900/40 text-blue-400 border-blue-500/30' },
|
||||||
|
reviewed: { label: '검토완료', color: 'bg-green-900/40 text-green-400 border-green-500/30' },
|
||||||
|
archived: { label: '보관', color: 'bg-slate-700/60 text-slate-400 border-slate-500/30' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUESTION_LABELS: Record<string, string> = {
|
||||||
|
q1: '주 사용 부품 사이트 URL',
|
||||||
|
q2: '주요 취급 부품 카테고리',
|
||||||
|
q3: '샘플 품번 목록',
|
||||||
|
q4: '현재 eBay 리스팅 URL',
|
||||||
|
q5: 'eBay 셀러 계정 등급',
|
||||||
|
q6: '주 판매 카테고리',
|
||||||
|
q7: '예상 월간 리스팅 건수',
|
||||||
|
q8: 'Fitment 정확도 기대치',
|
||||||
|
q8_detail: 'Fitment 추가 의견',
|
||||||
|
q9_selected: '타겟 마켓',
|
||||||
|
q9_detail: '타겟 마켓 기타',
|
||||||
|
q10: '리스팅 1건 소요 시간',
|
||||||
|
q11: '기존 리스팅 관리 방식',
|
||||||
|
q11_detail: '서드파티 툴 이름',
|
||||||
|
q12: '관세/통관 계산 방식',
|
||||||
|
q13: 'eBay Developer API 키 보유',
|
||||||
|
q14: '선호 AI 모델',
|
||||||
|
q15: '현재 자동화 도구',
|
||||||
|
q16: 'AI API 키 보유 여부',
|
||||||
|
q17: '포트폴리오 활용 동의',
|
||||||
|
additional: '추가 요청사항',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AdminQuestionnairePage() {
|
||||||
|
const [responses, setResponses] = useState<QuestionnaireResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selected, setSelected] = useState<QuestionnaireResponse | null>(null);
|
||||||
|
const [adminNotes, setAdminNotes] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchResponses();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchResponses() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/questionnaire');
|
||||||
|
if (!res.ok) throw new Error();
|
||||||
|
const json = await res.json();
|
||||||
|
setResponses(json.data || []);
|
||||||
|
} catch {
|
||||||
|
setResponses([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(item: QuestionnaireResponse) {
|
||||||
|
setSelected(item);
|
||||||
|
setAdminNotes(item.admin_notes || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(id: string, status: string) {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await fetch(`/api/admin/questionnaire/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status, admin_notes: adminNotes }),
|
||||||
|
});
|
||||||
|
await fetchResponses();
|
||||||
|
if (selected?.id === id) {
|
||||||
|
setSelected(prev => prev ? { ...prev, status, admin_notes: adminNotes } : null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-white text-2xl font-bold">질문지 응답</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-0.5">
|
||||||
|
고객이 제출한 요구사항 질문지 응답을 관리합니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-slate-400 text-sm py-12 text-center">불러오는 중...</div>
|
||||||
|
) : responses.length === 0 ? (
|
||||||
|
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 p-12 text-center">
|
||||||
|
<svg className="w-12 h-12 text-slate-600 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||||
|
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-slate-400 text-sm">아직 제출된 질문지가 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 목록 */}
|
||||||
|
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-700/50">
|
||||||
|
<th className="text-left text-slate-400 font-medium px-5 py-3">고객명</th>
|
||||||
|
<th className="text-left text-slate-400 font-medium px-5 py-3">이메일</th>
|
||||||
|
<th className="text-left text-slate-400 font-medium px-5 py-3">유형</th>
|
||||||
|
<th className="text-left text-slate-400 font-medium px-5 py-3">상태</th>
|
||||||
|
<th className="text-left text-slate-400 font-medium px-5 py-3">접수일</th>
|
||||||
|
<th className="text-right text-slate-400 font-medium px-5 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{responses.map((item) => {
|
||||||
|
const st = STATUS_CONFIG[item.status] || STATUS_CONFIG.submitted;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={item.id}
|
||||||
|
className={`border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer transition ${
|
||||||
|
selected?.id === item.id ? 'bg-slate-800/50' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => openDetail(item)}
|
||||||
|
>
|
||||||
|
<td className="px-5 py-3 text-white font-medium">{item.client_name}</td>
|
||||||
|
<td className="px-5 py-3 text-slate-300">{item.client_email}</td>
|
||||||
|
<td className="px-5 py-3 text-slate-400">{item.questionnaire_type}</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${st.color}`}>
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-slate-400">{formatDate(item.created_at)}</td>
|
||||||
|
<td className="px-5 py-3 text-right">
|
||||||
|
<svg className="w-4 h-4 text-slate-500 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 패널 */}
|
||||||
|
{selected && (
|
||||||
|
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/50">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-white font-semibold">
|
||||||
|
{selected.client_name} — 응답 상세
|
||||||
|
</h3>
|
||||||
|
<p className="text-slate-400 text-xs mt-0.5">
|
||||||
|
{selected.client_email}
|
||||||
|
{selected.client_phone && ` · ${selected.client_phone}`}
|
||||||
|
{' · '}접수: {formatDate(selected.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800 transition"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" 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 className="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||||
|
{Object.entries(selected.responses).map(([key, value]) => (
|
||||||
|
<div key={key} className="bg-slate-800/50 rounded-lg p-4">
|
||||||
|
<div className="text-xs text-slate-400 font-medium mb-1.5">
|
||||||
|
{QUESTION_LABELS[key] || key}
|
||||||
|
</div>
|
||||||
|
<div className="text-white text-sm whitespace-pre-wrap">
|
||||||
|
{Array.isArray(value) ? (value as string[]).join(', ') : String(value)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{Object.keys(selected.responses).length === 0 && (
|
||||||
|
<p className="text-slate-500 text-sm text-center py-4">응답 내용이 없습니다</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 관리자 메모 + 상태 변경 */}
|
||||||
|
<div className="px-5 py-4 border-t border-slate-700/50 space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-slate-400 text-xs font-medium block mb-1.5">관리자 메모</label>
|
||||||
|
<textarea
|
||||||
|
value={adminNotes}
|
||||||
|
onChange={(e) => setAdminNotes(e.target.value)}
|
||||||
|
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white text-sm resize-none focus:outline-none focus:border-red-500/50"
|
||||||
|
rows={2}
|
||||||
|
placeholder="내부 참고용 메모..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{selected.status !== 'reviewed' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(selected.id, 'reviewed')}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-lg text-xs font-medium bg-green-600/20 text-green-400 hover:bg-green-600/30 transition border border-green-500/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? '저장 중...' : '검토 완료'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selected.status !== 'archived' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(selected.id, 'archived')}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-lg text-xs font-medium bg-slate-700 text-slate-300 hover:bg-slate-600 transition disabled:opacity-50"
|
||||||
|
>
|
||||||
|
보관 처리
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{selected.status !== 'submitted' && (
|
||||||
|
<button
|
||||||
|
onClick={() => updateStatus(selected.id, 'submitted')}
|
||||||
|
disabled={saving}
|
||||||
|
className="px-4 py-2 rounded-lg text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition border border-blue-500/20 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
접수로 되돌리기
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
app/api/admin/questionnaire/[id]/route.ts
Normal file
69
app/api/admin/questionnaire/[id]/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||||
|
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 GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
if (!(await checkAuth())) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data, error } = await admin
|
||||||
|
.from('questionnaire_responses')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[Admin Questionnaire] DB error:', error);
|
||||||
|
return NextResponse.json({ error: '조회 실패' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태/메모 업데이트
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
if (!(await checkAuth())) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const { status, admin_notes } = body;
|
||||||
|
|
||||||
|
const updates: Record<string, unknown> = {};
|
||||||
|
if (status) updates.status = status;
|
||||||
|
if (admin_notes !== undefined) updates.admin_notes = admin_notes;
|
||||||
|
if (status === 'reviewed') updates.reviewed_at = new Date().toISOString();
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { error } = await admin
|
||||||
|
.from('questionnaire_responses')
|
||||||
|
.update(updates)
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[Admin Questionnaire] Update error:', error);
|
||||||
|
return NextResponse.json({ error: '업데이트 실패' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
||||||
32
app/api/admin/questionnaire/route.ts
Normal file
32
app/api/admin/questionnaire/route.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||||
|
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 GET() {
|
||||||
|
if (!(await checkAuth())) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data, error } = await admin
|
||||||
|
.from('questionnaire_responses')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[Admin Questionnaire] DB error:', error);
|
||||||
|
return NextResponse.json({ error: '데이터 조회 실패' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}
|
||||||
41
app/api/questionnaire/submit/route.ts
Normal file
41
app/api/questionnaire/submit/route.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { clientName, clientEmail, clientPhone, responses, type } = body;
|
||||||
|
|
||||||
|
if (!responses || typeof responses !== 'object') {
|
||||||
|
return NextResponse.json({ error: '응답 데이터가 없습니다.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!clientName || !clientEmail) {
|
||||||
|
return NextResponse.json({ error: '이름과 이메일은 필수입니다.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data, error } = await admin
|
||||||
|
.from('questionnaire_responses')
|
||||||
|
.insert({
|
||||||
|
questionnaire_type: type || 'ebay-tool',
|
||||||
|
client_name: clientName,
|
||||||
|
client_email: clientEmail,
|
||||||
|
client_phone: clientPhone || null,
|
||||||
|
responses,
|
||||||
|
status: 'submitted',
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('[Questionnaire] DB insert error:', error);
|
||||||
|
return NextResponse.json({ error: '저장에 실패했습니다.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, id: data.id });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Questionnaire] Submit error:', err);
|
||||||
|
return NextResponse.json({ error: '서버 오류가 발생했습니다.' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,14 +26,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// 관리자 문서 API: iframe 미리보기 허용 (동일 출처만)
|
|
||||||
{
|
|
||||||
source: "/api/admin/documents/:path*",
|
|
||||||
headers: [
|
|
||||||
{ key: "Cache-Control", value: "no-store, max-age=0" },
|
|
||||||
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// API 엔드포인트: 캐시 금지 + CORS 차단
|
// API 엔드포인트: 캐시 금지 + CORS 차단
|
||||||
{
|
{
|
||||||
source: "/api/:path*",
|
source: "/api/:path*",
|
||||||
|
|||||||
24
supabase/migrations/005_questionnaire_responses.sql
Normal file
24
supabase/migrations/005_questionnaire_responses.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 질문지 응답 저장 테이블
|
||||||
|
-- 목적: 고객이 작성한 요구사항 질문지 응답을 저장
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS questionnaire_responses (
|
||||||
|
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||||
|
questionnaire_type VARCHAR(100) NOT NULL DEFAULT 'ebay-tool',
|
||||||
|
client_name VARCHAR(200),
|
||||||
|
client_email VARCHAR(300),
|
||||||
|
client_phone VARCHAR(50),
|
||||||
|
responses JSONB NOT NULL,
|
||||||
|
status VARCHAR(20) DEFAULT 'submitted',
|
||||||
|
admin_notes TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
reviewed_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questionnaire_type ON questionnaire_responses(questionnaire_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questionnaire_status ON questionnaire_responses(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questionnaire_created ON questionnaire_responses(created_at DESC);
|
||||||
|
|
||||||
|
-- RLS 비활성화 (서버 사이드에서만 접근, service_role 사용)
|
||||||
|
ALTER TABLE questionnaire_responses DISABLE ROW LEVEL SECURITY;
|
||||||
Reference in New Issue
Block a user