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:
@@ -1,8 +1,12 @@
|
|||||||
"""Admin endpoints for managing survey invites."""
|
"""Admin endpoints for managing survey invites and responses."""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.database import get_db
|
||||||
from app.core.email import EmailService
|
from app.core.email import EmailService
|
||||||
from app.models.survey_invite import SurveyInvite
|
from app.models.survey_invite import SurveyInvite
|
||||||
|
from app.models.survey_response import SurveyResponse
|
||||||
from app.models.user import User
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -80,3 +90,148 @@ async def list_survey_invites(
|
|||||||
)
|
)
|
||||||
invites = result.scalars().all()
|
invites = result.scalars().all()
|
||||||
return [_build_invite_response(i) for i in invites]
|
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"},
|
||||||
|
)
|
||||||
|
|||||||
@@ -44,3 +44,20 @@ class SurveyInviteStatus(BaseModel):
|
|||||||
"""Public invite status check — minimal info."""
|
"""Public invite status check — minimal info."""
|
||||||
name: str
|
name: str
|
||||||
status: 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
|
||||||
|
|||||||
@@ -113,3 +113,59 @@ async def test_create_and_list_invites(client, admin_auth_headers):
|
|||||||
assert list_res.status_code == 200
|
assert list_res.status_code == 200
|
||||||
invites = list_res.json()
|
invites = list_res.json()
|
||||||
assert len(invites) >= 1
|
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
|
||||||
|
|||||||
747
docs/plans/2026-03-05-admin-survey-responses.md
Normal file
747
docs/plans/2026-03-05-admin-survey-responses.md
Normal 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>"
|
||||||
|
```
|
||||||
@@ -32,6 +32,21 @@ export interface SurveyInviteResponse {
|
|||||||
survey_url: string
|
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 = {
|
export const adminApi = {
|
||||||
// Dashboard
|
// Dashboard
|
||||||
getDashboardMetrics: () =>
|
getDashboardMetrics: () =>
|
||||||
@@ -158,6 +173,12 @@ export const adminApi = {
|
|||||||
api.get<SurveyInviteResponse[]>('/admin/survey-invites').then(r => r.data),
|
api.get<SurveyInviteResponse[]>('/admin/survey-invites').then(r => r.data),
|
||||||
createSurveyInvite: (data: { recipient_name: string; recipient_email?: string; send_email?: boolean }) =>
|
createSurveyInvite: (data: { recipient_name: string; recipient_email?: string; send_email?: boolean }) =>
|
||||||
api.post<SurveyInviteResponse>('/admin/survey-invites', data).then(r => r.data),
|
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
|
export default adminApi
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
MessageSquareText,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -23,6 +24,7 @@ const navItems = [
|
|||||||
{ path: '/admin/settings', label: 'Settings', icon: Settings },
|
{ path: '/admin/settings', label: 'Settings', icon: Settings },
|
||||||
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
|
{ path: '/admin/categories', label: 'Categories', icon: FolderTree },
|
||||||
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
|
{ path: '/admin/survey-invites', label: 'Survey Invites', icon: ClipboardList },
|
||||||
|
{ path: '/admin/survey-responses', label: 'Survey Responses', icon: MessageSquareText },
|
||||||
]
|
]
|
||||||
|
|
||||||
interface AdminSidebarProps {
|
interface AdminSidebarProps {
|
||||||
|
|||||||
@@ -96,16 +96,14 @@ const SLIDES: SurveySlideData[] = [
|
|||||||
{
|
{
|
||||||
id: 'ranking',
|
id: 'ranking',
|
||||||
questions: [
|
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: [
|
items: [
|
||||||
"Windows Server / Active Directory",
|
"Likelihood of being the root cause",
|
||||||
"Microsoft 365 / Exchange",
|
"How fast I can test or rule it out",
|
||||||
"Networking (DNS, DHCP, VPN, Firewall)",
|
"Blast radius — impact if it's the problem",
|
||||||
"Security / Compliance",
|
"How recently something changed",
|
||||||
"Virtualization (Hyper-V, VMware)",
|
"Tool / access availability right now",
|
||||||
"Backup & Disaster Recovery",
|
"Past experience with similar symptoms"
|
||||||
"Cloud (Azure / AWS)",
|
|
||||||
"Endpoint Management"
|
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
322
frontend/src/pages/admin/SurveyResponsesPage.tsx
Normal file
322
frontend/src/pages/admin/SurveyResponsesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ const AdminFeatureFlagsPage = lazy(() => import('@/pages/admin/FeatureFlagsPage'
|
|||||||
const AdminSettingsPage = lazy(() => import('@/pages/admin/SettingsPage'))
|
const AdminSettingsPage = lazy(() => import('@/pages/admin/SettingsPage'))
|
||||||
const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategoriesPage'))
|
const AdminGlobalCategoriesPage = lazy(() => import('@/pages/admin/GlobalCategoriesPage'))
|
||||||
const AdminSurveyInvitesPage = lazy(() => import('@/pages/admin/SurveyInvitesPage'))
|
const AdminSurveyInvitesPage = lazy(() => import('@/pages/admin/SurveyInvitesPage'))
|
||||||
|
const AdminSurveyResponsesPage = lazy(() => import('@/pages/admin/SurveyResponsesPage'))
|
||||||
|
|
||||||
// Account pages
|
// Account pages
|
||||||
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
|
const AccountLayout = lazy(() => import('@/components/account/AccountLayout'))
|
||||||
@@ -403,6 +404,14 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'survey-responses',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<AdminSurveyResponsesPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Account routes
|
// Account routes
|
||||||
|
|||||||
Reference in New Issue
Block a user