diff --git a/AGENTS.md b/AGENTS.md index 080ddf8..7ac56aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ bun scripts/og-images.ts <임시 폴더> # 유형 공유 페이지 og:image - 결과 화면은 `src/results/<회차>.json` 만 읽고 원본 응답을 모른다(집계 규칙은 `docs/data-model.md`). TanStack Charts(`src/components/ResultChart.tsx`)로 그리고, 보기 기준은 `?by=` 에 남는다. 차트 색은 dataviz 검증 스크립트를 통과한 값이다. - 선택지에 `code: other`(기타)가 있는 문항은 "기타"를 고르면 직접 적을 수 있다. 다음 회차 선택지를 고르는 데 쓴다(`docs/research/2026-11-trend-tracking.md`). - 제출은 Turnstile 로 막는다. 위젯은 마지막 문항 화면에만 띄운다 — 토큰이 5분이면 만료된다. 서버(`submitResponse`)는 저장 전에 `verifyTurnstile`(`src/lib/turnstile.ts`)로 `success`·action `survey_submit`·호스트 이름을 확인하고, 하나라도 어긋나면 거절한다(fail closed). 허용 호스트는 `wrangler.jsonc` 의 `TURNSTILE_HOSTNAMES`(운영에는 localhost 를 넣지 않는다)이고, 점으로 시작하는 항목(`.survey.dalestudy.com`)은 미리보기 주소를 위해 하위 도메인을 모두 받는다. -- 운영진은 `/admin` 에서 회차별 응답 수와 원본 응답을 읽기만 한다. 고치거나 지우는 기능과 SQL 입력창은 두지 않는다. 인증은 직접 만들지 않고 Cloudflare Access 가 `/admin` 을 잠근다. Access 는 잠근 경로로 가는 요청에만 토큰을 붙이므로 데이터는 서버 함수가 아니라 `/admin/api/*`(회차 요약 `summary`, 원본 한 페이지 `responses`)가 주고, 요청마다 `verifyAccessToken`(`src/lib/access.ts`)으로 확인해 하나라도 어긋나면 거절한다(fail closed). 로컬에서는 토큰이 없어 늘 거절되므로 미리보기에서 확인한다. 접근 대상은 처리방침과 맞물린다(`docs/operations.md`). +- 운영진은 `/admin` 에서 회차별 응답 수와 원본 응답을 읽기만 한다. 고치거나 지우는 기능과 SQL 입력창은 두지 않는다. 인증은 직접 만들지 않고 Cloudflare Access 가 `/admin` 을 잠근다. Access 는 잠근 경로로 가는 요청에만 토큰을 붙이므로 데이터는 서버 함수가 아니라 `/admin/api/*`(회차 요약 `summary`, 원본 전체 `responses`)가 주고, 요청마다 `verifyAccessToken`(`src/lib/access.ts`)으로 확인해 하나라도 어긋나면 거절한다(fail closed). 원본 응답은 회차 단위로 한 번에 받아 브라우저에서 거르고 정렬한다. 로컬에서는 토큰이 없어 늘 거절되므로 미리보기에서 확인한다. 접근 대상은 처리방침과 맞물린다(`docs/operations.md`). ## 작업 방식 diff --git a/bun.lock b/bun.lock index 6738804..512e6ec 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@tanstack/charts": "1.2.0", "@tanstack/react-router": "^1.170.38", "@tanstack/react-start": "^1.168.56", + "@tanstack/react-table": "9.3.1", "daleui": "^1.5.0", "drizzle-orm": "^0.45.2", "react": "^19.3.0", @@ -314,6 +315,8 @@ "@tanstack/react-store": ["@tanstack/react-store@0.11.2", "", { "dependencies": { "@tanstack/store": "0.11.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-oj5O5DmiMWE+ih+SioX2ayC1kbK7d3fpLx0B75fPJr9nY3zU+hoW010yr64qXsY/9fyiLVE2bn5mGNV3owm8RQ=="], + "@tanstack/react-table": ["@tanstack/react-table@9.3.1", "", { "dependencies": { "@tanstack/react-store": "^0.11.2", "@tanstack/table-core": "9.3.1" }, "peerDependencies": { "react": ">=18" } }, "sha512-E6Ya5TnY6EaGyZYIUqig/29Lr/gJSAubQkLJFqiWkUl5nRCr0Xr9hvJot+NiRTtG+TzGxHTiqUW3bnLtQxlHug=="], + "@tanstack/router-core": ["@tanstack/router-core@1.171.39", "", { "dependencies": { "@tanstack/history": "1.162.5", "cookie-es": "^3.0.0", "seroval": "^1.6.7", "seroval-plugins": "^1.6.7" } }, "sha512-qUhoJ0meuSUqGYQbHbcJYdASr06YtK+bulbj6vtrhKYFkLgkSsZ6tXmMyym7sFxiSJSPYgasgozrL01LyyR/6A=="], "@tanstack/router-generator": ["@tanstack/router-generator@1.167.46", "", { "dependencies": { "@babel/types": "^7.29.8", "@tanstack/router-core": "1.171.39", "@tanstack/router-utils": "1.162.4", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.9.6", "zod": "^4.5.4" } }, "sha512-JSailAThcqPvqBzE3+MhNh6zQyZM2IdmIsHOxZgAc061Lh++SZqamNm/Whe7QNXWK5sLP4E0J1F8AI0hP8wTSw=="], @@ -334,6 +337,8 @@ "@tanstack/store": ["@tanstack/store@0.11.2", "", {}, "sha512-sJ4mjol8uQsHV0gOJzzjwXfh2Fwm+Sz0+8deqiTm4jGbMdjzNSW+xZCFm0kUa870uhd8yi+DpKnZb5Kc4apa0Q=="], + "@tanstack/table-core": ["@tanstack/table-core@9.3.1", "", { "dependencies": { "@tanstack/store": "^0.11.2" } }, "sha512-zQvlxSNi2qGM3L4jh85Z6hqMfdIR5qOfD9KHOU32nuKFJGN0RlJ7UvV1lsPb1fPVhpFYI0VP3jJGxLdLDpTKjQ=="], + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.162.0", "", {}, "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA=="], "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], diff --git a/docs/stack.md b/docs/stack.md index 79acc4f..dc6b9fd 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -11,6 +11,7 @@ Tally 같은 설문 도구 대신 직접 만든다. 응답 화면을 달레 UI | DB | D1 + Drizzle | Workers 바인딩으로 바로 쓰고, 마이그레이션은 drizzle-kit 이 만든다 | `src/db/`, `drizzle/` | | UI | 달레 UI (`daleui`) | 커뮤니티 디자인 시스템 | 컴포넌트 전반 | | 차트 | TanStack Charts | 결과 화면, 관리자 화면의 날짜별 응답 | `src/components/ResultChart.tsx`, `src/components/Admin.tsx` | +| 표 | TanStack Table | 관리자 화면에서 원본 응답을 거르고 정렬한다. 열이 많아 가로로 펼치지 않고 보이는 열을 고른다 | `src/routes/admin.raw.tsx` | | 봇 방지 | Turnstile | 로그인 없이 자동 제출을 막는다 | `src/lib/turnstile.ts`, `src/components/Turnstile.tsx` | | 운영자 인증 | Cloudflare Access | 로그인 코드를 들여오지 않고 GitHub 팀으로 잠근다 | `src/lib/access.ts`, `src/routes/admin_.api.$part.ts` | | 문항 원본 | YAML, Bun 내장 파서 | 사람이 읽고 고치기 쉽고, 파서 의존성이 없다 | `questions/`, `scripts/questions.ts` | diff --git a/package.json b/package.json index 1b02543..cb31cdb 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@tanstack/charts": "1.2.0", "@tanstack/react-router": "^1.170.38", "@tanstack/react-start": "^1.168.56", + "@tanstack/react-table": "9.3.1", "daleui": "^1.5.0", "drizzle-orm": "^0.45.2", "react": "^19.3.0", diff --git a/src/components/Admin.tsx b/src/components/Admin.tsx index c0f8f7c..ba2f47f 100644 --- a/src/components/Admin.tsx +++ b/src/components/Admin.tsx @@ -9,10 +9,7 @@ import { BRAND } from '@/components/ResultChart' import { koDate } from '@/lib/date' import type { AdminSummary } from '@/server/admin' -// 원본 목록과 상세가 위아래로 쌓이는 너비. 화면(CSS)과 응답을 고를 때 스크롤(JS)이 같은 값을 본다. -export const STACKED = '(max-width: 900px)' - -// 인라인 스타일로는 못 하는 것(:focus-visible, 화면 너비별 규칙, 탭의 활성 표시)만 둔다. +// 인라인 스타일로는 못 하는 것(:focus-visible, :hover, 화면 너비별 규칙, 탭의 활성 표시)만 둔다. // 탭의 활성 표시를 인라인으로 덧씌우면 React 가 지울 때 축약 속성의 색까지 지워 비활성 탭에 밑줄이 남는다. export const ADMIN_CSS = ` .admin-panel { display: flex; flex-direction: column; border: 1px solid var(--colors-border-neutral); border-radius: var(--radii-lg); } @@ -22,18 +19,39 @@ export const ADMIN_CSS = ` .admin-stat-value { font-size: 2.25rem; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; } .admin-tab { display: inline-flex; align-items: center; min-height: 48px; padding: 0 var(--spacing-12); margin-bottom: -1px; border-bottom: 2px solid transparent; color: var(--colors-fg-neutral-placeholder); font-weight: 500; text-decoration: none; } .admin-tab[aria-current='page'] { color: var(--colors-fg-brand); border-bottom-color: var(--colors-bg-solid-brand); font-weight: 600; } -.admin-tab:focus-visible, .admin-row:focus-visible { outline: 2px solid var(--colors-border-brand-focus); outline-offset: -2px; } -.admin-raw { display: grid; grid-template-columns: minmax(0, 420px) minmax(0, 1fr); gap: var(--spacing-16); align-items: start; } -.admin-back { display: none; } +.admin-tab:focus-visible, .admin-sort:focus-visible, .admin-expand:focus-visible, .admin-picker summary:focus-visible { outline: 2px solid var(--colors-border-brand-focus); outline-offset: 2px; border-radius: var(--radii-sm); } + +/* 원본 응답 표. 칸 폭을 화면에 맞추고(table-layout: fixed) 긴 답은 줄바꿈해서 가로 스크롤이 생기지 않게 한다. */ +.admin-table { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: 0.875rem; line-height: 1.5; } +.admin-table th, .admin-table td { padding: var(--spacing-8) var(--spacing-12); text-align: left; vertical-align: top; overflow-wrap: anywhere; border-bottom: 1px solid var(--colors-bg-neutral-hover); } +.admin-table th { font-weight: 600; color: var(--colors-fg-neutral-placeholder); border-bottom-color: var(--colors-border-neutral); } +.admin-table tbody tr:hover { background: var(--colors-bg-brand); } +.admin-table tr.admin-open, .admin-table tr.admin-detail { background: var(--colors-bg-brand); } +.admin-table tr.admin-open td { border-bottom: 0; } +.admin-table tr.admin-detail > td { padding: var(--spacing-8) var(--spacing-16) var(--spacing-20) 5.5rem; } +.admin-sort, .admin-expand { display: inline-flex; align-items: center; gap: var(--spacing-4); min-height: 32px; padding: 0; border: 0; background: none; font: inherit; color: inherit; text-align: left; cursor: pointer; } +.admin-expand { font-weight: 600; color: var(--colors-fg-brand); font-variant-numeric: tabular-nums; } +.admin-expand svg { transition: transform 0.15s; } +.admin-expand[aria-expanded='true'] svg { transform: rotate(90deg); } +.admin-picker { position: relative; } +.admin-picker summary { display: inline-flex; align-items: center; min-height: 40px; padding: 0 var(--spacing-12); border: 1px solid var(--colors-border-neutral); border-radius: var(--radii-md); cursor: pointer; font-weight: 500; } +.admin-picker-panel { position: absolute; z-index: 1; top: calc(100% + var(--spacing-4)); left: 0; width: min(360px, calc(100vw - 48px)); max-height: 60vh; overflow-y: auto; padding: var(--spacing-12) var(--spacing-16); display: flex; flex-direction: column; gap: var(--spacing-12); background: var(--colors-app-bg); border: 1px solid var(--colors-border-neutral); border-radius: var(--radii-md); box-shadow: 0 8px 24px rgb(0 0 0 / 0.12); } +.admin-picker fieldset { margin: 0; padding: 0; border: 0; display: flex; flex-direction: column; gap: var(--spacing-8); } +.admin-picker legend { padding: 0 0 var(--spacing-4); font-size: 0.875rem; font-weight: 600; color: var(--colors-fg-brand); } + @media (max-width: 640px) { .admin-stats { gap: var(--spacing-8); } .admin-stat { padding: var(--spacing-12); gap: var(--spacing-4); } .admin-stat-label { font-size: 0.75rem; } .admin-stat-value { font-size: 1.5rem; } -} -@media ${STACKED} { - .admin-raw { grid-template-columns: minmax(0, 1fr); } - .admin-back { display: inline-flex; } + /* 휴대폰에서는 행을 카드처럼 쌓고, 머리글은 정렬 버튼 줄로 남긴다 */ + .admin-table, .admin-table tbody, .admin-table tr, .admin-table td { display: block; width: auto; } + .admin-table thead tr { display: flex; flex-wrap: wrap; gap: 0 var(--spacing-12); padding: var(--spacing-4) var(--spacing-12); border-bottom: 1px solid var(--colors-border-neutral); } + .admin-table thead th { width: auto !important; padding: 0; border: 0; } + .admin-table tbody tr { padding: var(--spacing-8) 0; border-bottom: 1px solid var(--colors-bg-neutral-hover); } + .admin-table td { padding: var(--spacing-2) var(--spacing-12); border: 0; } + .admin-table td[data-label]:not(:first-child)::before { content: attr(data-label); display: block; font-size: 0.75rem; color: var(--colors-fg-neutral-placeholder); } + .admin-table tr.admin-detail > td { padding: var(--spacing-8) var(--spacing-12) var(--spacing-12); } } ` diff --git a/src/routes/admin.raw.tsx b/src/routes/admin.raw.tsx index ef42078..3fdd4fc 100644 --- a/src/routes/admin.raw.tsx +++ b/src/routes/admin.raw.tsx @@ -1,28 +1,72 @@ import { createFileRoute } from '@tanstack/react-router' -import { Button, Heading, Text } from 'daleui' -import { useRef, useState } from 'react' -import { AdminError, STACKED } from '@/components/Admin' +import { + columnFacetingFeature, + columnFilteringFeature, + columnVisibilityFeature, + createColumnHelper, + createExpandedRowModel, + createFacetedRowModel, + createFilteredRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFn_includesString, + globalFilteringFeature, + rowExpandingFeature, + rowPaginationFeature, + rowSortingFeature, + tableFeatures, + useTable, +} from '@tanstack/react-table' +import { Button, Checkbox, Icon, Select, Text, TextInput } from 'daleui' +import { useMemo } from 'react' +import { AdminError } from '@/components/Admin' import { EDITIONS } from '@/editions' import { adminFetch } from '@/lib/adminFetch' import { answerText } from '@/lib/csv' import { OTHER, otherKey } from '@/questions/logic' -import { type Answers, type Question, questionsFor } from '@/questions/survey' -import { type AdminResponses, PAGE_SIZE } from '@/server/admin' +import { type Answers, type Question, questionsFor, SCALE_POINTS } from '@/questions/survey' +import type { AdminResponses } from '@/server/admin' -// 목록에서 응답을 알아보기 쉽게 요약으로 보여 줄 문항 -const SUMMARY = ['status', 'experience', 'role'] +// 처음 보이는 문항 열. 응답을 알아보기 쉬운 것들이다. +const DEFAULT_COLUMNS = ['status', 'experience', 'role'] +// 문항 열을 이보다 많이 펼치면 칸이 좁아져 읽기 어렵다. 가로 스크롤 대신 열을 고르게 한다. +const MAX_COLUMNS = 5 +const PAGE_SIZE = 50 -// 원본 응답을 한 건씩 읽는다. 페이지를 넘겨도 부모(/admin)의 요약은 다시 받지 않는다. +// 원본 응답을 표로 거르고 정렬하며 읽는다. 회차의 응답 전체를 한 번에 받아 브라우저에서 처리한다. export const Route = createFileRoute('/admin/raw')({ - validateSearch: (search: Record): { page?: number } => ({ - page: Number.isInteger(search.page) && Number(search.page) > 1 ? Number(search.page) : undefined, - }), - loaderDeps: ({ search }) => ({ edition: search.edition ?? EDITIONS.filter((e) => e.survey)[0].id, page: search.page ?? 1 }), - loader: async ({ deps }) => ({ ...deps, data: await adminFetch('responses', { edition: deps.edition, page: String(deps.page) }) }), + loaderDeps: ({ search }) => ({ edition: search.edition ?? EDITIONS.filter((e) => e.survey)[0].id }), + loader: async ({ deps }) => ({ ...deps, data: await adminFetch('responses', { edition: deps.edition }) }), component: Raw, errorComponent: AdminError, }) +type Entry = { + // 제출 순서. 가장 먼저 낸 응답이 1번이다. + no: number + createdAt: string + answers: Answers + // 전체 검색 대상: 모든 답의 문구와 "기타"에 적은 내용 + search: string +} + +const features = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + columnFilteringFeature, + globalFilteringFeature, + filteredRowModel: createFilteredRowModel(), + filterFns: { includesString: filterFn_includesString }, + columnFacetingFeature, + facetedRowModel: createFacetedRowModel(), + columnVisibilityFeature, + rowExpandingFeature, + expandedRowModel: createExpandedRowModel(), + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel(), +}) +const helper = createColumnHelper() + // 선택지 code 를 화면에 보이는 문구로 바꾼다. 복수 선택은 쉼표로 잇고, "기타"에 적은 내용을 붙인다. function answerLabel(q: Question, answers: Answers): string { const a = answers[q.id] @@ -37,138 +81,288 @@ function answerLabel(q: Question, answers: Answers): string { return Array.isArray(a) ? a.map(label).join(', ') : label(a) } +// 거르기와 정렬에 쓰는 선택지 목록. 척도는 1~5점이다. +const choices = (q: Question) => (q.type === 'scale' ? SCALE_POINTS.map((code) => ({ code, label: code })) : q.options) + +// 선택지는 가나다순이 아니라 문항에 정한 순서로 정렬한다(경력이면 짧은 순). 복수 선택은 첫 번째 답으로. +function rank(q: Question, answers: Answers): number | undefined { + const a = answers[q.id] + const code = Array.isArray(a) ? a[0] : a + if (code === undefined) return undefined + return choices(q).findIndex((o) => o.code === code) +} + const submittedAt = (iso: string) => new Date(iso).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', month: 'numeric', day: 'numeric', hour: 'numeric', minute: '2-digit' }) +function buildColumns(questions: Question[]) { + return helper.columns([ + helper.accessor('no', { + header: '#', + enableColumnFilter: false, + cell: ({ row }) => ( + + ), + }), + helper.accessor('createdAt', { + header: '제출', + enableColumnFilter: false, + cell: ({ row }) => submittedAt(row.original.createdAt), + }), + ...questions.map((q) => + helper.accessor((r) => rank(q, r.answers), { + id: q.id, + header: q.label, + cell: ({ row }) => answerLabel(q, row.original.answers), + enableSorting: q.type !== 'long', + enableColumnFilter: q.type !== 'long', + sortUndefined: 'last', + // 정렬 값이 숫자(선택지 순번)라 그대로 두면 내림차순부터 시작한다. 문항에 정한 순서부터 보이게 한다. + sortDescFirst: false, + filterFn: (row, _id, code: string) => { + const a = row.original.answers[q.id] + return Array.isArray(a) ? a.includes(code) : a === code + }, + }), + ), + helper.accessor('search', { header: '검색', enableSorting: false, enableColumnFilter: false }), + ]) +} + function Raw() { - const { edition, page: current, data } = Route.useLoaderData() - const navigate = Route.useNavigate() - const pages = Math.max(1, Math.ceil(data.total / PAGE_SIZE)) - const go = (page: number) => navigate({ search: (prev) => ({ ...prev, page }) }) + const { edition, data } = Route.useLoaderData() + const questions = useMemo(() => questionsFor(edition) ?? [], [edition]) + const columns = useMemo(() => buildColumns(questions), [questions]) + const entries = useMemo( + () => + data.rows.map( + (r, i): Entry => ({ + no: data.rows.length - i, + createdAt: r.createdAt, + answers: r.answers, + search: questions + .map((q) => answerLabel(q, r.answers)) + .join(' ') + .toLowerCase(), + }), + ), + [data, questions], + ) + + const table = useTable({ + features, + columns, + data: entries, + getRowCanExpand: () => true, + globalFilterFn: 'includesString', + getColumnCanGlobalFilter: (column) => column.id === 'search', + initialState: { + sorting: [{ id: 'no', desc: true }], + pagination: { pageIndex: 0, pageSize: PAGE_SIZE }, + columnVisibility: Object.fromEntries([['search', false], ...questions.map((q) => [q.id, DEFAULT_COLUMNS.includes(q.id)])]), + }, + }) + + if (entries.length === 0) return 아직 응답이 없어요. + + const questionColumns = questions.map((q) => ({ q, column: table.getColumn(q.id)! })) + const shown = questionColumns.filter(({ column }) => column.getIsVisible()) + const filtered = table.getFilteredRowModel().rows.length + const { pageIndex } = table.state.pagination + const visibleCount = table.getVisibleLeafColumns().length + const filtering = table.state.columnFilters.length > 0 || Boolean(table.state.globalFilter) - if (data.rows.length === 0) return 아직 응답이 없어요. return (
+
+
+ table.setGlobalFilter(e.target.value.toLowerCase())} + /> +
+
+ + 열 고르기 ({shown.length}/{MAX_COLUMNS}) + +
+ {[...new Set(questions.map((q) => q.section))].map((section) => ( +
+ {section} + {questionColumns + .filter(({ q }) => q.section === section) + .map(({ q, column }) => ( + = MAX_COLUMNS} + onChange={(on) => { + // 가린 열에 걸린 필터는 보이지 않으니 함께 푼다 + if (!on) column.setFilterValue(undefined) + column.toggleVisibility(on) + }} + /> + ))} +
+ ))} +
+
+ {filtering && ( + + )} +
+ + {shown.some(({ q }) => q.type !== 'long') && ( +
+ {shown + .filter(({ q }) => q.type !== 'long') + .map(({ q, column }) => { + // 다른 필터가 걸린 상태에서 이 선택지를 고르면 몇 건이 남는지 + const facet = column.getFacetedRowModel().rows + const count = (code: string) => + facet.filter((r) => { + const a = r.original.answers[q.id] + return Array.isArray(a) ? a.includes(code) : a === code + }).length + return ( + + ) + })} +
+ )} + - 최근 응답부터 · 응답을 고르면 모든 답이 보여요 + {filtering ? `${entries.length.toLocaleString()}건 중 ${filtered.toLocaleString()}건` : `${entries.length.toLocaleString()}건`} · 번호를 누르면 모든 답이 보여요 - {/* 페이지나 회차가 바뀌면 고른 응답을 첫 번째로 되돌린다 */} - - - - {((current - 1) * PAGE_SIZE + 1).toLocaleString()}–{Math.min(current * PAGE_SIZE, data.total).toLocaleString()} / {data.total.toLocaleString()} - - - - } - /> + +
+ + + {table.getHeaderGroups().map((group) => ( + + {group.headers.map((header) => { + const sorted = header.column.getIsSorted() + return ( + + ) + })} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+ {header.column.getCanSort() ? ( + + ) : ( + + )} +
+ +
+ {filtered === 0 && ( +
+ 조건에 맞는 응답이 없어요. +
+ )} + +
) } -// 열이 많아 표로 펼치면 가로로 넘치므로, 목록에서 응답을 고르고 상세에서 문항별로 읽는다. -// 좁은 화면에서는 둘이 위아래로 쌓이므로, 고르면 상세로 내려가고 "목록으로"로 돌아온다. -function Responses({ rows, firstNo, questions, pager }: { rows: AdminResponses['rows']; firstNo: number; questions: Question[]; pager: React.ReactNode }) { - const [picked, setPicked] = useState(0) - const detail = useRef(null) - const items = useRef<(HTMLButtonElement | null)[]>([]) - const selected = rows[picked] - const summary = SUMMARY.map((id) => questions.find((q) => q.id === id)).filter((q) => q !== undefined) +// 행 하나와, 펼쳤을 때 그 아래 이어지는 모든 답 +function RowGroup({ expanded, colSpan, questions, answers, children }: { expanded: boolean; colSpan: number; questions: Question[]; answers: Answers; children: React.ReactNode }) { const sections = [...new Set(questions.map((q) => q.section))] - .map((title) => ({ title, items: questions.filter((q) => q.section === title && selected.answers[q.id] !== undefined) })) + .map((title) => ({ title, items: questions.filter((q) => q.section === title && answers[q.id] !== undefined) })) .filter((s) => s.items.length > 0) - - const pick = (i: number) => { - setPicked(i) - if (window.matchMedia(STACKED).matches) detail.current?.scrollIntoView({ block: 'start' }) - } - const backToList = () => { - items.current[picked]?.scrollIntoView({ block: 'center' }) - items.current[picked]?.focus() - } - return ( -
-
-
    - {rows.map((row, i) => ( -
  • - -
  • - ))} -
- {pager} -
- -
-
- - 응답 #{(firstNo - picked).toLocaleString()} - - - {submittedAt(selected.createdAt)} 제출 - -
- {sections.map((s) => ( -
- - {s.title} - -
- {s.items.map((q) => ( -
-
{q.label}
-
- {answerLabel(q, selected.answers)} - {q.type !== 'long' && ( - - {q.id}: {answerText(selected.answers, q.id)} - - )} -
-
+ <> + {children} + {expanded && ( + + +
+ {sections.map((s) => ( +
+ + {s.title} + +
+ {s.items.map((q) => ( +
+
{q.label}
+
+ {answerLabel(q, answers)} + {q.type !== 'long' && ( + + {q.id}: {answerText(answers, q.id)} + + )} +
+
+ ))} +
+
))} -
-
- ))} -
- -
-
-
+ + + + )} + ) } @@ -180,28 +374,10 @@ const pager: React.CSSProperties = { padding: 'var(--spacing-12) var(--spacing-16)', } -// 브라우저 기본 포커스 표시 대신 .admin-row:focus-visible 을 쓰고, 버튼 모양만 지운다 -const rowButton: React.CSSProperties = { - border: 0, - margin: 0, - font: 'inherit', - color: 'inherit', - textAlign: 'left', - boxSizing: 'border-box', - width: '100%', - minHeight: 44, - cursor: 'pointer', - display: 'flex', - flexDirection: 'column', - gap: 'var(--spacing-4)', - padding: 'var(--spacing-12) var(--spacing-16)', - background: 'var(--colors-app-bg)', -} - +// 좁으면 문항 아래로 답이 내려간다 const answerRow: React.CSSProperties = { - display: 'grid', - gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', - gap: 'var(--spacing-4) var(--spacing-16)', - paddingBottom: 'var(--spacing-12)', - borderBottom: '1px solid var(--colors-bg-neutral-hover)', + display: 'flex', + flexWrap: 'wrap', + gap: 'var(--spacing-2) var(--spacing-16)', + lineHeight: 1.5, } diff --git a/src/routes/admin_.api.$part.ts b/src/routes/admin_.api.$part.ts index 58bf89c..d209e3e 100644 --- a/src/routes/admin_.api.$part.ts +++ b/src/routes/admin_.api.$part.ts @@ -9,7 +9,7 @@ import { loadResponses, loadSummary } from '@/server/admin' const PRIVATE = { 'Cache-Control': 'no-store', 'X-Robots-Tag': 'noindex' } // GET /admin/api/summary?edition=2026 → 응답 수, 날짜별 응답, 문항별 분포 -// GET /admin/api/responses?edition=2026&page=1 → 원본 응답 한 페이지 +// GET /admin/api/responses?edition=2026 → 원본 응답 전체 // 서버 함수(/_serverFn)가 아니라 /admin 아래에 두는 건, Access 가 잠근 경로로 가는 요청에만 토큰을 붙여 주기 때문이다. // 파일 이름의 admin_ 는 화면 레이아웃(admin.tsx) 아래로 들어가지 않게 한다. export const Route = createFileRoute('/admin_/api/$part')({ @@ -25,11 +25,7 @@ export const Route = createFileRoute('/admin_/api/$part')({ const db = getDb(env.DB) if (params.part === 'summary') return Response.json(await loadSummary(db, edition.id), { headers: PRIVATE }) - if (params.part === 'responses') { - const page = Number(search.get('page') ?? '1') - if (!Number.isInteger(page) || page < 1) return Response.json({ error: '페이지가 올바르지 않아요.' }, { status: 400, headers: PRIVATE }) - return Response.json(await loadResponses(db, edition.id, page), { headers: PRIVATE }) - } + if (params.part === 'responses') return Response.json(await loadResponses(db, edition.id), { headers: PRIVATE }) return Response.json({ error: '없는 주소예요.' }, { status: 404, headers: PRIVATE }) }, }, diff --git a/src/server/admin.ts b/src/server/admin.ts index e3154fc..a4ddf10 100644 --- a/src/server/admin.ts +++ b/src/server/admin.ts @@ -4,8 +4,6 @@ import { responses } from '@/db/schema' import { type Answers, questionsFor } from '@/questions/survey' import { aggregate, type QuestionStat } from '@/results/aggregate' -export const PAGE_SIZE = 50 - // 회차 전체 그림. 원본 페이지를 넘겨도 다시 받지 않도록 원본 응답과 따로 준다. export interface AdminSummary { total: number @@ -15,9 +13,9 @@ export interface AdminSummary { stats: QuestionStat[] } +// 원본 응답 전체. 거르기·정렬을 브라우저에서 하므로 한 번에 준다(운영진만 받고, 캐시하지 않는다). export interface AdminResponses { - total: number - // 최근 응답부터 PAGE_SIZE 건 + // 최근 응답부터 rows: { createdAt: string; answers: Answers }[] } @@ -35,17 +33,11 @@ export async function loadSummary(db: Database, edition: string): Promise { - const ofEdition = eq(responses.edition, edition) - const [[{ total }], rows] = await Promise.all([ - db.select({ total: count() }).from(responses).where(ofEdition), - db - .select({ createdAt: responses.createdAt, answers: responses.answers }) - .from(responses) - .where(ofEdition) - .orderBy(desc(responses.createdAt)) - .limit(PAGE_SIZE) - .offset((page - 1) * PAGE_SIZE), - ]) - return { total, rows } +export async function loadResponses(db: Database, edition: string): Promise { + const rows = await db + .select({ createdAt: responses.createdAt, answers: responses.answers }) + .from(responses) + .where(eq(responses.edition, edition)) + .orderBy(desc(responses.createdAt)) + return { rows } }