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:
2026-05-16 05:38:59 +09:00
parent fa9b05c7e8
commit 7f196f1c19

221
app/admin/survey/page.tsx Normal file
View 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>
);
}