feat(admin): /admin/survey 대시보드 — 목록 + 통계 + CSV + 상세 modal
- 필터: 전체/오늘/이번 주 - 통계: Q2/Q4/Q5 분포 + 만족도 평균 + 이메일률 + 완료 시간 중간값 - 응답 테이블 (시각/나이상황/Q4/Q5/Q6 미리보기/이메일/상세) - 상세 modal: 7 질문 + 메타 14 필드 모두 표시 - CSV 다운로드 (BOM UTF-8, Excel 호환) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
221
app/admin/survey/page.tsx
Normal file
221
app/admin/survey/page.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface SurveyRow {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
age_range: string | null;
|
||||||
|
status: string | null;
|
||||||
|
awareness_freq: string | null;
|
||||||
|
tools_used: string[] | null;
|
||||||
|
tools_other: string | null;
|
||||||
|
cost_range: string | null;
|
||||||
|
best_tool: string | null;
|
||||||
|
best_satisfy: number | null;
|
||||||
|
free_opinion: string | null;
|
||||||
|
email: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
referrer: string | null;
|
||||||
|
utm_source: string | null;
|
||||||
|
completion_seconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
age_range: Record<string, number>;
|
||||||
|
status: Record<string, number>;
|
||||||
|
awareness_freq: Record<string, number>;
|
||||||
|
cost_range: Record<string, number>;
|
||||||
|
best_tool: Record<string, number>;
|
||||||
|
satisfy_avg: string;
|
||||||
|
email_rate: string;
|
||||||
|
completion_seconds_median: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Range = 'all' | 'today' | 'week';
|
||||||
|
|
||||||
|
export default function AdminSurveyPage() {
|
||||||
|
const [range, setRange] = useState<Range>('all');
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [rows, setRows] = useState<SurveyRow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selected, setSelected] = useState<SurveyRow | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load(r: Range) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/survey?range=${r}`);
|
||||||
|
const data = await res.json();
|
||||||
|
setTotal(data.total ?? 0);
|
||||||
|
setStats(data.stats ?? null);
|
||||||
|
setRows(data.responses ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load(range);
|
||||||
|
}, [range]);
|
||||||
|
|
||||||
|
function downloadCsv() {
|
||||||
|
window.location.href = `/api/admin/survey?range=${range}&format=csv`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtCount(counts: Record<string, number> | undefined): string {
|
||||||
|
if (!counts) return '';
|
||||||
|
return Object.entries(counts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.map(([k, v]) => `${k} ${v}`)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-7xl mx-auto">
|
||||||
|
<div className="mb-6 flex items-end justify-between gap-3 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-white text-2xl font-bold">설문 응답</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-0.5">
|
||||||
|
CONTOUR PMF 설문 — 총 {total}건
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{(['all', 'today', 'week'] as Range[]).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => setRange(r)}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm font-bold transition ${
|
||||||
|
range === r
|
||||||
|
? 'bg-violet-600 text-white'
|
||||||
|
: 'bg-slate-800 text-slate-300 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r === 'all' ? '전체' : r === 'today' ? '오늘' : '이번 주'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={downloadCsv}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-sm font-bold bg-emerald-600 hover:bg-emerald-500 text-white transition"
|
||||||
|
>
|
||||||
|
📥 CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
{stats && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-slate-400 mb-2">Q2 자각 빈도</p>
|
||||||
|
<p className="text-sm text-white">{fmtCount(stats.awareness_freq) || '데이터 없음'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-slate-400 mb-2">Q4 비용</p>
|
||||||
|
<p className="text-sm text-white">{fmtCount(stats.cost_range) || '데이터 없음'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-slate-400 mb-2">Q5 만족도 평균</p>
|
||||||
|
<p className="text-xl text-violet-400 font-bold">{stats.satisfy_avg} / 5</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-xl p-4">
|
||||||
|
<p className="text-xs text-slate-400 mb-2">Q7 이메일률 / 완료 시간 (중간값)</p>
|
||||||
|
<p className="text-sm text-white">
|
||||||
|
{stats.email_rate}% · {stats.completion_seconds_median}s
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 응답 리스트 */}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-slate-400">불러오는 중...</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className="text-slate-500">응답이 없습니다.</p>
|
||||||
|
) : (
|
||||||
|
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-slate-800 text-slate-400 text-xs uppercase tracking-widest">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-3">시각</th>
|
||||||
|
<th className="text-left px-4 py-3">나이/상황</th>
|
||||||
|
<th className="text-left px-4 py-3">Q4 비용</th>
|
||||||
|
<th className="text-left px-4 py-3">Q5 만족</th>
|
||||||
|
<th className="text-left px-4 py-3">Q6 자유의견 (미리보기)</th>
|
||||||
|
<th className="text-left px-4 py-3">이메일</th>
|
||||||
|
<th className="text-left px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-t border-slate-800 hover:bg-slate-800/50 transition">
|
||||||
|
<td className="px-4 py-2 text-slate-300">{new Date(r.created_at).toLocaleString('ko-KR')}</td>
|
||||||
|
<td className="px-4 py-2 text-slate-300">{r.age_range} · {r.status}</td>
|
||||||
|
<td className="px-4 py-2 text-slate-300">{r.cost_range ?? '-'}</td>
|
||||||
|
<td className="px-4 py-2 text-slate-300">{r.best_satisfy ?? '-'}</td>
|
||||||
|
<td className="px-4 py-2 text-slate-400 max-w-xs truncate">
|
||||||
|
{r.free_opinion ?? <span className="text-slate-600">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-slate-300">{r.email ?? '-'}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelected(r)}
|
||||||
|
className="text-violet-400 hover:text-violet-300 text-xs font-bold"
|
||||||
|
>
|
||||||
|
상세
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 상세 modal */}
|
||||||
|
{selected && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="bg-slate-900 border border-slate-700 rounded-2xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-white font-bold">응답 상세</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{new Date(selected.created_at).toLocaleString('ko-KR')}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelected(null)} className="text-slate-400 hover:text-white text-2xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<dl className="space-y-3 text-sm">
|
||||||
|
{[
|
||||||
|
['Q1 나이대', selected.age_range],
|
||||||
|
['Q1 상황', selected.status],
|
||||||
|
['Q2 자각 빈도', selected.awareness_freq],
|
||||||
|
['Q3 도구', selected.tools_used?.join(', ')],
|
||||||
|
['Q3 기타', selected.tools_other],
|
||||||
|
['Q4 비용', selected.cost_range],
|
||||||
|
['Q5 최고 도구', selected.best_tool],
|
||||||
|
['Q5 만족도', selected.best_satisfy != null ? `${selected.best_satisfy} / 5` : null],
|
||||||
|
['Q6 자유 의견', selected.free_opinion],
|
||||||
|
['Q7 이메일', selected.email],
|
||||||
|
['user_agent', selected.user_agent],
|
||||||
|
['referrer', selected.referrer],
|
||||||
|
['utm_source', selected.utm_source],
|
||||||
|
['완료 시간', selected.completion_seconds != null ? `${selected.completion_seconds}초` : null],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div key={k as string} className="flex gap-3 border-b border-slate-800 pb-2">
|
||||||
|
<dt className="w-32 text-slate-400 flex-shrink-0">{k}</dt>
|
||||||
|
<dd className="text-white whitespace-pre-wrap break-words flex-1">{(v as string) || <span className="text-slate-600">—</span>}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user