From 7f196f1c19ed9457ebec7b0b89fb3f4714c70fa2 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 16 May 2026 05:38:59 +0900 Subject: [PATCH] =?UTF-8?q?feat(admin):=20/admin/survey=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=E2=80=94=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?+=20=ED=86=B5=EA=B3=84=20+=20CSV=20+=20=EC=83=81=EC=84=B8=20mod?= =?UTF-8?q?al?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 필터: 전체/오늘/이번 주 - 통계: Q2/Q4/Q5 분포 + 만족도 평균 + 이메일률 + 완료 시간 중간값 - 응답 테이블 (시각/나이상황/Q4/Q5/Q6 미리보기/이메일/상세) - 상세 modal: 7 질문 + 메타 14 필드 모두 표시 - CSV 다운로드 (BOM UTF-8, Excel 호환) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/admin/survey/page.tsx | 221 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 app/admin/survey/page.tsx diff --git a/app/admin/survey/page.tsx b/app/admin/survey/page.tsx new file mode 100644 index 0000000..49fab39 --- /dev/null +++ b/app/admin/survey/page.tsx @@ -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; + status: Record; + awareness_freq: Record; + cost_range: Record; + best_tool: Record; + satisfy_avg: string; + email_rate: string; + completion_seconds_median: number; +} + +type Range = 'all' | 'today' | 'week'; + +export default function AdminSurveyPage() { + const [range, setRange] = useState('all'); + const [total, setTotal] = useState(0); + const [stats, setStats] = useState(null); + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(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 | undefined): string { + if (!counts) return ''; + return Object.entries(counts) + .sort((a, b) => b[1] - a[1]) + .map(([k, v]) => `${k} ${v}`) + .join(' · '); + } + + return ( +
+
+
+

설문 응답

+

+ CONTOUR PMF 설문 — 총 {total}건 +

+
+
+ {(['all', 'today', 'week'] as Range[]).map((r) => ( + + ))} + +
+
+ + {/* 통계 카드 */} + {stats && ( +
+
+

Q2 자각 빈도

+

{fmtCount(stats.awareness_freq) || '데이터 없음'}

+
+
+

Q4 비용

+

{fmtCount(stats.cost_range) || '데이터 없음'}

+
+
+

Q5 만족도 평균

+

{stats.satisfy_avg} / 5

+
+
+

Q7 이메일률 / 완료 시간 (중간값)

+

+ {stats.email_rate}% · {stats.completion_seconds_median}s +

+
+
+ )} + + {/* 응답 리스트 */} + {loading ? ( +

불러오는 중...

+ ) : rows.length === 0 ? ( +

응답이 없습니다.

+ ) : ( +
+ + + + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + + + ))} + +
시각나이/상황Q4 비용Q5 만족Q6 자유의견 (미리보기)이메일
{new Date(r.created_at).toLocaleString('ko-KR')}{r.age_range} · {r.status}{r.cost_range ?? '-'}{r.best_satisfy ?? '-'} + {r.free_opinion ?? } + {r.email ?? '-'} + +
+
+ )} + + {/* 상세 modal */} + {selected && ( +
setSelected(null)} + > +
e.stopPropagation()} + > +
+
+

응답 상세

+

{new Date(selected.created_at).toLocaleString('ko-KR')}

+
+ +
+
+ {[ + ['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]) => ( +
+
{k}
+
{(v as string) || }
+
+ ))} +
+
+
+ )} +
+ ); +}