feat: admin survey responses page with expandable detail and CSV export

- Backend: GET /admin/survey-responses (list with stats, invite join)
- Backend: GET /admin/survey-responses/export (CSV download)
- Frontend: SurveyResponsesPage with expandable row detail
- Two-column Q&A grid with typed answer rendering (chips, ranked lists, quote blocks)
- Stats cards (total responses, this week)
- CSV export button with blob download
- Sidebar nav + route wiring
- Also: updated Q14 from product domain ranking to diagnostic prioritization

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-05 07:55:49 -05:00
parent 1644278fb1
commit 199cf315c6
9 changed files with 1339 additions and 12 deletions

View File

@@ -1,8 +1,12 @@
"""Admin endpoints for managing survey invites."""
"""Admin endpoints for managing survey invites and responses."""
import csv
import io
import logging
from typing import Annotated
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -11,8 +15,14 @@ from app.core.config import settings
from app.core.database import get_db
from app.core.email import EmailService
from app.models.survey_invite import SurveyInvite
from app.models.survey_response import SurveyResponse
from app.models.user import User
from app.schemas.survey import SurveyInviteCreate, SurveyInviteResponse
from app.schemas.survey import (
SurveyInviteCreate,
SurveyInviteResponse,
SurveyResponseDetail,
SurveyResponseListResponse,
)
logger = logging.getLogger(__name__)
@@ -80,3 +90,148 @@ async def list_survey_invites(
)
invites = result.scalars().all()
return [_build_invite_response(i) for i in invites]
@router.get("/survey-responses", response_model=SurveyResponseListResponse)
async def list_survey_responses(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all survey responses with summary stats."""
stmt = (
select(SurveyResponse, SurveyInvite.recipient_name)
.outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id)
.order_by(SurveyResponse.created_at.desc())
)
result = await db.execute(stmt)
rows = result.all()
one_week_ago = datetime.now(timezone.utc) - timedelta(days=7)
this_week = 0
responses: list[SurveyResponseDetail] = []
for survey_resp, invite_name in rows:
source = "invite" if survey_resp.invite_id else "direct"
responses.append(
SurveyResponseDetail(
id=str(survey_resp.id),
respondent_name=survey_resp.respondent_name,
responses=survey_resp.responses,
source=source,
invite_name=invite_name,
created_at=survey_resp.created_at,
)
)
if survey_resp.created_at >= one_week_ago:
this_week += 1
return SurveyResponseListResponse(
responses=responses,
total=len(responses),
this_week=this_week,
)
# Question IDs in survey order, used for CSV export columns.
QUESTION_IDS = [
"prereqs",
"verify_fix",
"steps_at_a_time",
"first_step",
"junior_mistake",
"pivot",
"scenario_approach",
"scenario_deeper",
"doc_pct",
"go_to_commands",
"secret_weapon",
"gotcha",
"hard_rules",
"prioritization",
"detail_level",
"ai_personality",
]
QUESTION_LABELS = {
"prereqs": "Q1: Prereqs",
"verify_fix": "Q2: Verify Fix",
"steps_at_a_time": "Q3: Steps at a Time",
"first_step": "Q4: First Step",
"junior_mistake": "Q5: Junior Mistake",
"pivot": "Q6: Pivot",
"scenario_approach": "Q7: Scenario Approach",
"scenario_deeper": "Q8: Scenario Deeper",
"doc_pct": "Q9: Doc Pct",
"go_to_commands": "Q10: Go-To Commands",
"secret_weapon": "Q11: Secret Weapon",
"gotcha": "Q12: Gotcha",
"hard_rules": "Q13: Hard Rules",
"prioritization": "Q14: Prioritization",
"detail_level": "Q15: Detail Level",
"ai_personality": "Q16: AI Personality",
}
def _format_answer(value: Any) -> str:
"""Format a survey answer for CSV export."""
if value is None:
return ""
if isinstance(value, list):
# Check if this looks like a ranked list (list of strings to number)
# Rank answers: "1. X; 2. Y"
# Multi-select: "X; Y"
# Heuristic: if it's a list, treat as multi-select joined with "; "
return "; ".join(str(v) for v in value)
return str(value)
def _format_ranked_answer(value: Any) -> str:
"""Format a ranked answer: '1. X; 2. Y'."""
if isinstance(value, list):
return "; ".join(f"{i + 1}. {v}" for i, v in enumerate(value))
return _format_answer(value)
# Question IDs that use ranked answers
RANKED_QUESTIONS = {"prioritization"}
@router.get("/survey-responses/export")
async def export_survey_responses_csv(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Export all survey responses as CSV."""
stmt = (
select(SurveyResponse)
.order_by(SurveyResponse.created_at.desc())
)
result = await db.execute(stmt)
rows = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
# Header row
headers = ["Respondent", "Date"] + [QUESTION_LABELS.get(qid, qid) for qid in QUESTION_IDS]
writer.writerow(headers)
for resp in rows:
row = [
resp.respondent_name or "Anonymous",
resp.created_at.strftime("%Y-%m-%d %H:%M") if resp.created_at else "",
]
for qid in QUESTION_IDS:
answer = resp.responses.get(qid) if resp.responses else None
if qid in RANKED_QUESTIONS:
row.append(_format_ranked_answer(answer))
else:
row.append(_format_answer(answer))
writer.writerow(row)
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=survey-responses.csv"},
)

View File

@@ -44,3 +44,20 @@ class SurveyInviteStatus(BaseModel):
"""Public invite status check — minimal info."""
name: str
status: str
class SurveyResponseDetail(BaseModel):
"""Full survey response returned to admin."""
id: str
respondent_name: Optional[str]
responses: dict[str, Any]
source: str
invite_name: Optional[str]
created_at: datetime
class SurveyResponseListResponse(BaseModel):
"""List of survey responses with summary stats."""
responses: list[SurveyResponseDetail]
total: int
this_week: int

View File

@@ -113,3 +113,59 @@ async def test_create_and_list_invites(client, admin_auth_headers):
assert list_res.status_code == 200
invites = list_res.json()
assert len(invites) >= 1
@pytest.mark.asyncio
async def test_list_survey_responses_admin(client, admin_auth_headers):
"""Admin can list survey responses."""
await client.post(
"/api/v1/survey/submit",
json={"respondent_name": "Tester", "responses": {"q1": "answer"}},
)
res = await client.get("/api/v1/admin/survey-responses", headers=admin_auth_headers)
assert res.status_code == 200
data = res.json()
assert "responses" in data
assert "total" in data
assert "this_week" in data
assert data["total"] >= 1
assert data["responses"][0]["respondent_name"] == "Tester"
assert data["responses"][0]["source"] == "direct"
@pytest.mark.asyncio
async def test_list_survey_responses_requires_admin(client, auth_headers):
"""Non-admin cannot list survey responses."""
res = await client.get("/api/v1/admin/survey-responses", headers=auth_headers)
assert res.status_code == 403
@pytest.mark.asyncio
async def test_export_survey_responses_csv(client, admin_auth_headers):
"""Admin can export survey responses as CSV."""
await client.post(
"/api/v1/survey/submit",
json={
"respondent_name": "CSV Tester",
"responses": {
"prereqs": ["Who's affected", "What changed recently"],
"verify_fix": "Have the user confirm",
"steps_at_a_time": "5 steps",
"prioritization": ["Likelihood", "Speed", "Blast radius"],
},
},
)
res = await client.get("/api/v1/admin/survey-responses/export", headers=admin_auth_headers)
assert res.status_code == 200
assert "text/csv" in res.headers["content-type"]
assert "attachment" in res.headers.get("content-disposition", "")
body = res.text
assert "CSV Tester" in body
assert "Respondent" in body
@pytest.mark.asyncio
async def test_export_survey_responses_requires_admin(client, auth_headers):
"""Non-admin cannot export survey responses."""
res = await client.get("/api/v1/admin/survey-responses/export", headers=auth_headers)
assert res.status_code == 403

View File

@@ -0,0 +1,747 @@
# Admin Survey Responses Page — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build an admin page to view submitted survey responses with expandable row detail and CSV export.
**Architecture:** New backend endpoints on the existing `admin_survey.py` router + new frontend page following the `SurveyInvitesPage` pattern. Expandable rows show Q&A detail inline. CSV export endpoint returns a downloadable file.
**Tech Stack:** FastAPI, SQLAlchemy async, Pydantic v2, React, Tailwind CSS, Lucide icons
---
### Task 1: Backend — Survey response schemas
**Files:**
- Modify: `backend/app/schemas/survey.py`
**Step 1: Add response schemas to survey.py**
Add these schemas at the end of `backend/app/schemas/survey.py`:
```python
class SurveyResponseDetail(BaseModel):
"""Full survey response returned to admin."""
id: str
respondent_name: Optional[str]
responses: dict[str, Any]
source: str # "invite" or "direct"
invite_name: Optional[str]
created_at: datetime
class SurveyResponseListResponse(BaseModel):
"""List of survey responses with summary stats."""
responses: list[SurveyResponseDetail]
total: int
this_week: int
```
**Step 2: Commit**
```bash
git add backend/app/schemas/survey.py
git commit -m "feat: add survey response admin schemas"
```
---
### Task 2: Backend — List survey responses endpoint
**Files:**
- Modify: `backend/app/api/endpoints/admin_survey.py`
**Step 1: Write the failing test**
Add to `backend/tests/test_survey.py`:
```python
@pytest.mark.asyncio
async def test_list_survey_responses_admin(client, admin_auth_headers):
"""Admin can list survey responses."""
# Submit a response first
await client.post(
"/api/v1/survey/submit",
json={"respondent_name": "Tester", "responses": {"q1": "answer"}},
)
res = await client.get("/api/v1/admin/survey-responses", headers=admin_auth_headers)
assert res.status_code == 200
data = res.json()
assert "responses" in data
assert "total" in data
assert "this_week" in data
assert data["total"] >= 1
assert data["responses"][0]["respondent_name"] == "Tester"
assert data["responses"][0]["source"] == "direct"
@pytest.mark.asyncio
async def test_list_survey_responses_requires_admin(client, auth_headers):
"""Non-admin cannot list survey responses."""
res = await client.get("/api/v1/admin/survey-responses", headers=auth_headers)
assert res.status_code == 403
```
**Step 2: Run tests to verify they fail**
Run: `cd backend && python -m pytest tests/test_survey.py::test_list_survey_responses_admin tests/test_survey.py::test_list_survey_responses_requires_admin -v`
Expected: FAIL (404 — endpoint doesn't exist yet)
**Step 3: Implement the endpoint**
Add to `backend/app/api/endpoints/admin_survey.py`:
```python
from datetime import datetime, timezone, timedelta
from app.models.survey_response import SurveyResponse
from app.schemas.survey import SurveyResponseDetail, SurveyResponseListResponse
@router.get("/survey-responses", response_model=SurveyResponseListResponse)
async def list_survey_responses(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""List all survey responses with summary stats."""
result = await db.execute(
select(SurveyResponse, SurveyInvite.recipient_name.label("invite_name"))
.outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id)
.order_by(SurveyResponse.created_at.desc())
)
rows = result.all()
one_week_ago = datetime.now(timezone.utc) - timedelta(days=7)
responses = []
this_week = 0
for row in rows:
sr = row[0] # SurveyResponse
inv_name = row[1] # invite_name or None
detail = SurveyResponseDetail(
id=str(sr.id),
respondent_name=sr.respondent_name,
responses=sr.responses,
source="invite" if sr.invite_id else "direct",
invite_name=inv_name,
created_at=sr.created_at,
)
responses.append(detail)
if sr.created_at >= one_week_ago:
this_week += 1
return SurveyResponseListResponse(
responses=responses,
total=len(responses),
this_week=this_week,
)
```
Add the missing imports at the top of `admin_survey.py`:
```python
from datetime import datetime, timezone, timedelta
from app.models.survey_response import SurveyResponse
from app.schemas.survey import SurveyResponseDetail, SurveyResponseListResponse
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && python -m pytest tests/test_survey.py -v`
Expected: ALL PASS
**Step 5: Commit**
```bash
git add backend/app/api/endpoints/admin_survey.py backend/tests/test_survey.py
git commit -m "feat: add admin list survey responses endpoint"
```
---
### Task 3: Backend — CSV export endpoint
**Files:**
- Modify: `backend/app/api/endpoints/admin_survey.py`
**Step 1: Write the failing test**
Add to `backend/tests/test_survey.py`:
```python
@pytest.mark.asyncio
async def test_export_survey_responses_csv(client, admin_auth_headers):
"""Admin can export survey responses as CSV."""
# Submit a response
await client.post(
"/api/v1/survey/submit",
json={
"respondent_name": "CSV Tester",
"responses": {
"prereqs": ["Who's affected", "What changed recently"],
"verify_fix": "Have the user confirm",
"steps_at_a_time": "5 steps",
"prioritization": ["Likelihood", "Speed", "Blast radius"],
},
},
)
res = await client.get("/api/v1/admin/survey-responses/export", headers=admin_auth_headers)
assert res.status_code == 200
assert "text/csv" in res.headers["content-type"]
assert "attachment" in res.headers.get("content-disposition", "")
body = res.text
assert "CSV Tester" in body
assert "Respondent" in body # Header row
@pytest.mark.asyncio
async def test_export_survey_responses_requires_admin(client, auth_headers):
"""Non-admin cannot export survey responses."""
res = await client.get("/api/v1/admin/survey-responses/export", headers=auth_headers)
assert res.status_code == 403
```
**Step 2: Run tests to verify they fail**
Run: `cd backend && python -m pytest tests/test_survey.py::test_export_survey_responses_csv tests/test_survey.py::test_export_survey_responses_requires_admin -v`
Expected: FAIL (404)
**Step 3: Implement the CSV export endpoint**
Add to `backend/app/api/endpoints/admin_survey.py`:
```python
import csv
import io
from fastapi.responses import StreamingResponse
# Question IDs in survey order, with display labels
SURVEY_QUESTIONS = [
("prereqs", "Q1: Pre-work info needed"),
("verify_fix", "Q2: How you verify a fix"),
("steps_at_a_time", "Q3: Steps at a time preference"),
("first_step", "Q4: First move on vague ticket"),
("junior_mistake", "Q5: Common junior mistake"),
("pivot", "Q6: When to pivot theories"),
("scenario_approach", "Q7: Scenario diagnostic steps"),
("scenario_deeper", "Q8: Scenario server checks"),
("doc_pct", "Q9: Documentation percentage"),
("go_to_commands", "Q10: Go-to commands"),
("secret_weapon", "Q11: Secret weapon"),
("gotcha", "Q12: Wrong obvious diagnosis"),
("hard_rules", "Q13: Hard rules followed"),
("prioritization", "Q14: Diagnostic prioritization"),
("detail_level", "Q15: AI suggestion specificity"),
("ai_personality", "Q16: AI colleague traits"),
]
def _format_answer(value) -> str:
"""Format a survey answer for CSV output."""
if value is None:
return ""
if isinstance(value, list):
# Ranked items: numbered; multi-select: semicolon-joined
if len(value) > 0 and all(isinstance(v, str) for v in value):
return "; ".join(f"{i+1}. {v}" if len(value) > 3 else v for i, v in enumerate(value))
return "; ".join(str(v) for v in value)
return str(value)
@router.get("/survey-responses/export")
async def export_survey_responses_csv(
db: Annotated[AsyncSession, Depends(get_db)],
current_user: Annotated[User, Depends(require_admin)],
):
"""Export all survey responses as a CSV file."""
result = await db.execute(
select(SurveyResponse).order_by(SurveyResponse.created_at.desc())
)
responses = result.scalars().all()
output = io.StringIO()
writer = csv.writer(output)
# Header row
headers = ["Respondent", "Date"] + [label for _, label in SURVEY_QUESTIONS]
writer.writerow(headers)
# Data rows
for sr in responses:
row = [
sr.respondent_name or "Anonymous",
sr.created_at.strftime("%Y-%m-%d %H:%M") if sr.created_at else "",
]
for qid, _ in SURVEY_QUESTIONS:
row.append(_format_answer(sr.responses.get(qid)))
writer.writerow(row)
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=survey-responses.csv"},
)
```
**Step 4: Run tests to verify they pass**
Run: `cd backend && python -m pytest tests/test_survey.py -v`
Expected: ALL PASS
**Step 5: Commit**
```bash
git add backend/app/api/endpoints/admin_survey.py backend/tests/test_survey.py
git commit -m "feat: add admin CSV export for survey responses"
```
---
### Task 4: Frontend — Admin API client functions
**Files:**
- Modify: `frontend/src/api/admin.ts`
**Step 1: Add the response type and API functions**
Add the type after the existing `SurveyInviteResponse` interface in `frontend/src/api/admin.ts`:
```typescript
export interface SurveyResponseDetail {
id: string
respondent_name: string | null
responses: Record<string, string | string[]>
source: 'invite' | 'direct'
invite_name: string | null
created_at: string
}
export interface SurveyResponseListResponse {
responses: SurveyResponseDetail[]
total: number
this_week: number
}
```
Add these functions inside the `adminApi` object, after the `createSurveyInvite` entry:
```typescript
// Survey Responses
listSurveyResponses: () =>
api.get<SurveyResponseListResponse>('/admin/survey-responses').then(r => r.data),
exportSurveyResponsesCsv: () =>
api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data),
```
**Step 2: Commit**
```bash
git add frontend/src/api/admin.ts
git commit -m "feat: add survey responses admin API client"
```
---
### Task 5: Frontend — Survey Responses admin page
**Files:**
- Create: `frontend/src/pages/admin/SurveyResponsesPage.tsx`
**Step 1: Create the page**
Create `frontend/src/pages/admin/SurveyResponsesPage.tsx`:
```tsx
import { useState, useEffect } from 'react'
import { adminApi } from '@/api/admin'
import type { SurveyResponseDetail } from '@/api/admin'
import { PageHeader } from '@/components/admin'
import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
// Question metadata for display
const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [
{ id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' },
{ id: 'verify_fix', num: '2', text: 'After you apply a fix, how do you verify it actually worked?', type: 'mc' },
{ id: 'steps_at_a_time', num: '3', text: 'How many steps do you prefer to see at once?', type: 'range' },
{ id: 'first_step', num: '4', text: 'A vague ticket comes in: "Internet is down." What\'s your FIRST move?', type: 'mc' },
{ id: 'junior_mistake', num: '5', text: 'Most common mistake you see junior engineers make?', type: 'mc' },
{ id: 'pivot', num: '6', text: 'How do you decide when to stop pursuing one theory and pivot?', type: 'mc' },
{ id: 'scenario_approach', num: '7', text: 'Walk through your first 3 diagnostic steps for this ticket.', type: 'text' },
{ id: 'scenario_deeper', num: '8', text: 'Server pings fine, you can RDP in. What do you check next?', type: 'text' },
{ id: 'doc_pct', num: '9', text: 'What percentage of troubleshooting steps do you actually document?', type: 'range' },
{ id: 'go_to_commands', num: '10', text: 'Top 3 go-to PowerShell commands or one-liners?', type: 'text' },
{ id: 'secret_weapon', num: '11', text: 'Secret weapon command/tool/technique?', type: 'text' },
{ id: 'gotcha', num: '12', text: 'Issue where the obvious diagnosis was WRONG?', type: 'text' },
{ id: 'hard_rules', num: '13', text: 'Which "rules" do you follow?', type: 'mc-multi' },
{ id: 'prioritization', num: '14', text: 'Rank factors by diagnostic priority influence.', type: 'rank' },
{ id: 'detail_level', num: '15', text: 'How specific should AI diagnostic suggestions be?', type: 'mc' },
{ id: 'ai_personality', num: '16', text: 'What makes an AI feel like a useful colleague?', type: 'mc' },
]
export default function SurveyResponsesPage() {
const [responses, setResponses] = useState<SurveyResponseDetail[]>([])
const [total, setTotal] = useState(0)
const [thisWeek, setThisWeek] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expandedId, setExpandedId] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
useEffect(() => {
const load = async () => {
try {
const data = await adminApi.listSurveyResponses()
setResponses(data.responses)
setTotal(data.total)
setThisWeek(data.this_week)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}
load()
}, [])
const handleExport = async () => {
setExporting(true)
try {
const blob = await adminApi.exportSurveyResponsesCsv()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'survey-responses.csv'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('Export failed')
} finally {
setExporting(false)
}
}
const formatDate = (dateStr: string) =>
new Date(dateStr).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit',
})
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<PageHeader
title="Survey Responses"
description={`${total} response${total !== 1 ? 's' : ''} collected`}
/>
<button
onClick={handleExport}
disabled={exporting || total === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] active:scale-[0.97] disabled:opacity-50 disabled:cursor-not-allowed transition-all mt-1"
>
{exporting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
Export CSV
</button>
</div>
{/* Stats */}
<div className="flex gap-4">
<div className="glass-card-static px-5 py-4 flex-1">
<div className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">Total Responses</div>
<div className="text-2xl font-heading font-bold text-gradient-brand">{total}</div>
</div>
<div className="glass-card-static px-5 py-4 flex-1">
<div className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">This Week</div>
<div className="text-2xl font-heading font-bold text-foreground">{thisWeek}</div>
</div>
</div>
{error && <p className="text-sm text-rose-500">{error}</p>}
{/* Responses Table */}
<div className="glass-card-static overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="w-10 px-4 py-3" />
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">#</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Respondent</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Source</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Date</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Answered</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">Loading...</td></tr>
) : responses.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-sm text-muted-foreground">No responses yet</td></tr>
) : (
responses.map((r, idx) => {
const isExpanded = expandedId === r.id
const answeredCount = Object.keys(r.responses).filter(k => {
const v = r.responses[k]
return v !== undefined && v !== '' && (!Array.isArray(v) || v.length > 0)
}).length
return (
<ResponseRow
key={r.id}
response={r}
index={total - idx}
answeredCount={answeredCount}
isExpanded={isExpanded}
onToggle={() => setExpandedId(isExpanded ? null : r.id)}
formatDate={formatDate}
/>
)
})
)}
</tbody>
</table>
</div>
</div>
</div>
)
}
function ResponseRow({
response: r,
index,
answeredCount,
isExpanded,
onToggle,
formatDate,
}: {
response: SurveyResponseDetail
index: number
answeredCount: number
isExpanded: boolean
onToggle: () => void
formatDate: (d: string) => string
}) {
return (
<>
<tr
onClick={onToggle}
className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer group"
>
<td className="px-4 py-3">
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-200',
isExpanded && 'rotate-180'
)}
/>
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{index}</td>
<td className="px-4 py-3 text-sm text-foreground">{r.respondent_name || 'Anonymous'}</td>
<td className="px-4 py-3">
<span className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider',
r.source === 'invite'
? 'bg-primary/10 text-primary'
: 'bg-[rgba(255,255,255,0.06)] text-muted-foreground'
)}>
{r.source === 'invite' ? <Link2 className="h-3 w-3" /> : <User className="h-3 w-3" />}
{r.source === 'invite' ? r.invite_name || 'Invite' : 'Direct'}
</span>
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{formatDate(r.created_at)}</td>
<td className="px-4 py-3">
<span className="font-label text-xs text-muted-foreground">{answeredCount} / {QUESTIONS.length}</span>
</td>
</tr>
{isExpanded && (
<tr>
<td colSpan={6} className="p-0">
<ExpandedDetail responses={r.responses} />
</td>
</tr>
)}
</>
)
}
function ExpandedDetail({ responses }: { responses: Record<string, string | string[]> }) {
return (
<div
className="px-6 py-5 animate-fade-in-up"
style={{ background: 'rgba(0, 0, 0, 0.15)', borderTop: '1px solid rgba(6, 182, 212, 0.1)' }}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{QUESTIONS.map(q => {
const answer = responses[q.id]
const hasAnswer = answer !== undefined && answer !== '' && (!Array.isArray(answer) || answer.length > 0)
return (
<div
key={q.id}
className="rounded-[10px] p-4"
style={{
background: 'rgba(24, 26, 31, 0.6)',
border: '1px solid var(--glass-border)',
}}
>
<div className="font-label text-[10px] mb-1 font-medium" style={{ color: '#06b6d4' }}>Q{q.num}</div>
<div className="text-[13px] font-medium text-foreground/80 mb-2 leading-snug">{q.text}</div>
{!hasAnswer ? (
<div className="text-[12px] text-[#5a6170] italic">No answer</div>
) : q.type === 'mc-multi' && Array.isArray(answer) ? (
<div className="flex flex-wrap gap-1.5">
{answer.map((v, i) => (
<span key={i} className="inline-block rounded-full px-2.5 py-0.5 text-[11px] font-label bg-primary/10 text-primary">
{v}
</span>
))}
</div>
) : q.type === 'rank' && Array.isArray(answer) ? (
<ol className="space-y-1">
{answer.map((v, i) => (
<li key={i} className="flex items-center gap-2 text-[12px]">
<span className="font-label text-[10px] font-semibold w-4 text-center" style={{ color: '#06b6d4' }}>{i + 1}</span>
<span className="text-muted-foreground">{v}</span>
</li>
))}
</ol>
) : q.type === 'text' ? (
<div
className="text-[13px] text-muted-foreground leading-relaxed rounded-lg p-2.5"
style={{ background: 'rgba(0, 0, 0, 0.2)', borderLeft: '2px solid rgba(6, 182, 212, 0.2)' }}
>
{String(answer)}
</div>
) : (
<div className="text-[13px] text-muted-foreground">{String(answer)}</div>
)}
</div>
)
})}
</div>
</div>
)
}
```
**Step 2: Verify build**
Run: `cd frontend && npm run build`
Expected: Build succeeds (page isn't routed yet but should compile)
**Step 3: Commit**
```bash
git add frontend/src/pages/admin/SurveyResponsesPage.tsx
git commit -m "feat: add admin survey responses page component"
```
---
### Task 6: Frontend — Wire up route, sidebar, and lazy import
**Files:**
- Modify: `frontend/src/router.tsx`
- Modify: `frontend/src/components/admin/AdminSidebar.tsx`
**Step 1: Add lazy import to router.tsx**
Add after the `AdminSurveyInvitesPage` lazy import (line 54):
```typescript
const AdminSurveyResponsesPage = lazy(() => import('@/pages/admin/SurveyResponsesPage'))
```
**Step 2: Add route to router.tsx**
Add a new route object after the `survey-invites` route (after line 405):
```typescript
{
path: 'survey-responses',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSurveyResponsesPage />
</Suspense>
),
},
```
**Step 3: Add sidebar nav item**
In `frontend/src/components/admin/AdminSidebar.tsx`:
Add `MessageSquareText` to the lucide import:
```typescript
import {
LayoutDashboard,
Users,
Ticket,
FileText,
Gauge,
ToggleLeft,
Settings,
FolderTree,
ClipboardList,
MessageSquareText,
ArrowLeft,
} from 'lucide-react'
```
Add the nav item after the Survey Invites entry:
```typescript
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
```
**Step 4: Verify build**
Run: `cd frontend && npm run build`
Expected: Build succeeds with no errors
**Step 5: Commit**
```bash
git add frontend/src/router.tsx frontend/src/components/admin/AdminSidebar.tsx
git commit -m "feat: wire up admin survey responses route and sidebar nav"
```
---
### Task 7: End-to-end verification
**Step 1: Run all backend tests**
Run: `cd backend && python -m pytest tests/test_survey.py -v`
Expected: ALL PASS
**Step 2: Run frontend build**
Run: `cd frontend && npm run build`
Expected: Build succeeds
**Step 3: Manual verification**
1. Start backend: `cd backend && uvicorn app.main:app --reload`
2. Start frontend: `cd frontend && npm run dev`
3. Submit a test survey at `http://localhost:5173/survey`
4. Log in as admin, navigate to Admin > Survey Responses
5. Verify: stats show, table shows response, click to expand, verify Q&A renders
6. Click Export CSV, verify file downloads with correct data
**Step 4: Final commit**
```bash
git add -A
git commit -m "feat: admin survey responses page with expandable detail and CSV export
- Backend: GET /admin/survey-responses (list with stats)
- Backend: GET /admin/survey-responses/export (CSV download)
- Frontend: SurveyResponsesPage with expandable row detail
- Two-column Q&A grid with typed answer rendering
- Stats cards (total, this week)
- CSV export button
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>"
```

View File

@@ -32,6 +32,21 @@ export interface SurveyInviteResponse {
survey_url: string
}
export interface SurveyResponseDetail {
id: string
respondent_name: string | null
responses: Record<string, string | string[]>
source: 'invite' | 'direct'
invite_name: string | null
created_at: string
}
export interface SurveyResponseListResponse {
responses: SurveyResponseDetail[]
total: number
this_week: number
}
export const adminApi = {
// Dashboard
getDashboardMetrics: () =>
@@ -158,6 +173,12 @@ export const adminApi = {
api.get<SurveyInviteResponse[]>('/admin/survey-invites').then(r => r.data),
createSurveyInvite: (data: { recipient_name: string; recipient_email?: string; send_email?: boolean }) =>
api.post<SurveyInviteResponse>('/admin/survey-invites', data).then(r => r.data),
// Survey Responses
listSurveyResponses: () =>
api.get<SurveyResponseListResponse>('/admin/survey-responses').then(r => r.data),
exportSurveyResponsesCsv: () =>
api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data),
}
export default adminApi

View File

@@ -9,6 +9,7 @@ import {
Settings,
FolderTree,
ClipboardList,
MessageSquareText,
ArrowLeft,
} from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -23,6 +24,7 @@ const navItems = [
{ path: '/admin/settings', label: 'Settings', icon: Settings },
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
]
interface AdminSidebarProps {

View File

@@ -96,16 +96,14 @@ const SLIDES: SurveySlideData[] = [
{
id: 'ranking',
questions: [
{ id: 'domain_rank', type: 'rank', num: '14', text: 'Drag to rank: which technical domains should FlowPilot handle first?', hint: 'Most important at the top.',
{ id: 'prioritization', type: 'rank', num: '14', text: 'Rank these factors by how much they influence your diagnostic order.', hint: 'What drives which theory you investigate first? Drag to reorder — most influential at the top.',
items: [
"Windows Server / Active Directory",
"Microsoft 365 / Exchange",
"Networking (DNS, DHCP, VPN, Firewall)",
"Security / Compliance",
"Virtualization (Hyper-V, VMware)",
"Backup & Disaster Recovery",
"Cloud (Azure / AWS)",
"Endpoint Management"
"Likelihood of being the root cause",
"How fast I can test or rule it out",
"Blast radius — impact if it's the problem",
"How recently something changed",
"Tool / access availability right now",
"Past experience with similar symptoms"
]},
]
},

View File

@@ -0,0 +1,322 @@
import { useState, useEffect } from 'react'
import { adminApi, type SurveyResponseDetail, type SurveyResponseListResponse } from '@/api/admin'
import { PageHeader } from '@/components/admin'
import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [
{ id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' },
{ id: 'verify_fix', num: '2', text: 'After you apply a fix, how do you verify it actually worked?', type: 'mc' },
{ id: 'steps_at_a_time', num: '3', text: 'How many steps do you prefer to see at once?', type: 'range' },
{ id: 'first_step', num: '4', text: 'A vague ticket comes in: "Internet is down." What\'s your FIRST move?', type: 'mc' },
{ id: 'junior_mistake', num: '5', text: 'Most common mistake you see junior engineers make?', type: 'mc' },
{ id: 'pivot', num: '6', text: 'How do you decide when to stop pursuing one theory and pivot?', type: 'mc' },
{ id: 'scenario_approach', num: '7', text: 'Walk through your first 3 diagnostic steps for this ticket.', type: 'text' },
{ id: 'scenario_deeper', num: '8', text: 'Server pings fine, you can RDP in. What do you check next?', type: 'text' },
{ id: 'doc_pct', num: '9', text: 'What percentage of troubleshooting steps do you actually document?', type: 'range' },
{ id: 'go_to_commands', num: '10', text: 'Top 3 go-to PowerShell commands or one-liners?', type: 'text' },
{ id: 'secret_weapon', num: '11', text: 'Secret weapon command/tool/technique?', type: 'text' },
{ id: 'gotcha', num: '12', text: 'Issue where the obvious diagnosis was WRONG?', type: 'text' },
{ id: 'hard_rules', num: '13', text: 'Which "rules" do you follow?', type: 'mc-multi' },
{ id: 'prioritization', num: '14', text: 'Rank factors by diagnostic priority influence.', type: 'rank' },
{ id: 'detail_level', num: '15', text: 'How specific should AI diagnostic suggestions be?', type: 'mc' },
{ id: 'ai_personality', num: '16', text: 'What makes an AI feel like a useful colleague?', type: 'mc' },
]
function AnswerDisplay({ value, type }: { value: string | string[] | undefined; type: string }) {
if (!value || (Array.isArray(value) && value.length === 0)) {
return <p className="text-sm italic text-muted-foreground/60">No answer</p>
}
if (type === 'mc-multi' && Array.isArray(value)) {
return (
<div className="flex flex-wrap gap-1.5">
{value.map((v, i) => (
<span
key={i}
className="inline-block rounded-full bg-primary/10 px-2.5 py-0.5 font-label text-[0.625rem] uppercase tracking-wider text-primary"
>
{v}
</span>
))}
</div>
)
}
if (type === 'rank' && Array.isArray(value)) {
return (
<ol className="space-y-1">
{value.map((v, i) => (
<li key={i} className="flex items-start gap-2 text-sm text-foreground/90">
<span className="font-label text-xs font-bold text-primary">{i + 1}.</span>
{v}
</li>
))}
</ol>
)
}
if (type === 'text') {
return (
<div className="border-l-2 border-primary/30 pl-3">
<p className="text-sm text-foreground/90 whitespace-pre-wrap">{String(value)}</p>
</div>
)
}
return <p className="text-sm text-foreground/90">{String(value)}</p>
}
function ExpandedDetail({ response }: { response: SurveyResponseDetail }) {
return (
<tr>
<td colSpan={6} className="p-0">
<div
className="px-6 py-5"
style={{
background: 'rgba(0, 0, 0, 0.15)',
borderTop: '1px solid rgba(6, 182, 212, 0.1)',
}}
>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{QUESTIONS.map((q) => (
<div
key={q.id}
className="rounded-[10px] p-4"
style={{
background: 'rgba(24, 26, 31, 0.6)',
border: '1px solid var(--glass-border)',
}}
>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary mb-1">
Q{q.num}
</p>
<p className="text-xs text-muted-foreground mb-2">{q.text}</p>
<AnswerDisplay value={response.responses[q.id]} type={q.type} />
</div>
))}
</div>
</div>
</td>
</tr>
)
}
function ResponseRow({
response,
index,
isExpanded,
onToggle,
}: {
response: SurveyResponseDetail
index: number
isExpanded: boolean
onToggle: () => void
}) {
const answeredCount = QUESTIONS.filter((q) => {
const val = response.responses[q.id]
return val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)
}).length
return (
<>
<tr
className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer"
onClick={onToggle}
>
<td className="px-4 py-3 w-8">
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isExpanded && 'rotate-180'
)}
/>
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{index + 1}</td>
<td className="px-4 py-3 text-sm text-foreground">
{response.respondent_name || <span className="text-muted-foreground italic">Anonymous</span>}
</td>
<td className="px-4 py-3">
{response.source === 'invite' ? (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider bg-primary/10 text-primary">
<User className="h-3 w-3" />
Invite
{response.invite_name && (
<span className="text-primary/70">({response.invite_name})</span>
)}
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider bg-[rgba(255,255,255,0.06)] text-muted-foreground">
<Link2 className="h-3 w-3" />
Direct
</span>
)}
</td>
<td className="px-4 py-3 font-label text-xs text-muted-foreground">
{new Date(response.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{answeredCount} / {QUESTIONS.length}
</td>
</tr>
{isExpanded && <ExpandedDetail response={response} />}
</>
)
}
export default function SurveyResponsesPage() {
const [data, setData] = useState<SurveyResponseListResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [exporting, setExporting] = useState(false)
useEffect(() => {
const fetchData = async () => {
try {
const result = await adminApi.listSurveyResponses()
setData(result)
} catch {
setError('Failed to load survey responses')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
const handleExport = async () => {
setExporting(true)
try {
const blob = await adminApi.exportSurveyResponsesCsv()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'survey-responses.csv'
a.click()
URL.revokeObjectURL(url)
} catch {
setError('Export failed')
} finally {
setExporting(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
)
}
if (error && !data) {
return (
<div className="px-6 py-8">
<div className="glass-card-static p-6 text-center text-rose-400">{error}</div>
</div>
)
}
const responses = data?.responses ?? []
return (
<div className="space-y-6 px-6 py-8">
<PageHeader
title="Survey Responses"
description={`${data?.total ?? 0} total responses collected`}
action={
<button
onClick={handleExport}
disabled={exporting || responses.length === 0}
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
>
{exporting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
Export CSV
</button>
}
/>
{error && (
<div className="rounded-[10px] border border-rose-500/20 bg-rose-500/10 px-4 py-2 text-sm text-rose-400">
{error}
</div>
)}
{/* Stat cards */}
<div className="flex gap-4">
<div className="glass-card-static px-5 py-4 flex-1">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">
Total Responses
</p>
<p className="text-2xl font-heading font-bold text-gradient-brand">
{data?.total ?? 0}
</p>
</div>
<div className="glass-card-static px-5 py-4 flex-1">
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">
This Week
</p>
<p className="text-2xl font-heading font-bold text-foreground">
{data?.this_week ?? 0}
</p>
</div>
</div>
{/* Table */}
<div className="glass-card-static overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-border/50">
<th className="px-4 py-3 w-8" />
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
#
</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Respondent
</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Source
</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Date
</th>
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Answered
</th>
</tr>
</thead>
<tbody>
{responses.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-sm text-muted-foreground">
No survey responses yet.
</td>
</tr>
) : (
responses.map((response, index) => (
<ResponseRow
key={response.id}
response={response}
index={index}
isExpanded={expandedId === response.id}
onToggle={() =>
setExpandedId(expandedId === response.id ? null : response.id)
}
/>
))
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -52,6 +52,7 @@ const AdminFeatureFlagsPage = lazy(() => import('@/pages/admin/FeatureFlagsPage'
const AdminSettingsPage = lazy(() => import('@/pages/admin/SettingsPage'))
const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategoriesPage'))
const AdminSurveyInvitesPage = lazy(() => import('@/pages/admin/SurveyInvitesPage'))
const AdminSurveyResponsesPage = lazy(() => import('@/pages/admin/SurveyResponsesPage'))
// Account pages
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
@@ -403,6 +404,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'survey-responses',
element: (
<Suspense fallback={<PageLoader />}>
<AdminSurveyResponsesPage />
</Suspense>
),
},
],
},
// Account routes