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

@@ -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