feat: AI chat conclusion + survey completion & management (#95)
* fix: increase assistant chat input height from 1 to 3 rows Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Anthropic prompt caching to assistant chat Cache the static system prompt and conversation history prefix across turns, reducing input token costs by ~80% on multi-turn conversations. RAG context is intentionally uncached since it changes per query. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add Microsoft Learn MCP integration + refine assistant system prompt - Integrate Microsoft Learn MCP server via Anthropic's MCP connector for real-time documentation lookups (docs search, fetch, code samples) - Refine system prompt: clear persona, structured answer guidelines, when to use RAG flows vs Microsoft Learn, guardrails against fabrication - Add ENABLE_MCP_MICROSOFT_LEARN config toggle (default: True) - Fix bugs from prior edit: wrong MCP URL, broken indentation, undefined usage/token variables, NOT_GIVEN for disabled MCP params - Log MCP tool usage and cache performance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: AI chat session conclusion + survey completion & management AI Assistant - Conclude Session: - 3-step modal: select outcome (resolved/escalated/paused), add notes, AI-generated summary - AI generates structured ticket notes from conversation transcript (PSA-ready format) - Copy to clipboard for pasting into ticketing systems - "Resume in New Chat" for paused sessions (pre-loads context into new chat) - Backend: POST /chats/{id}/conclude endpoint, conclusion_summary/outcome/concluded_at fields - Migration 048: add conclusion fields to assistant_chats Survey Completion Flow: - Email-to-self option after submission (branded HTML email with formatted responses) - Finish button navigates to /survey/thank-you page - Thank you page with close-window message and feedback email callout - Already-submitted state updated with same messaging - Backend: POST /survey/email-copy public endpoint Survey Admin Management: - Read/unread indicators (cyan dot, bold name, auto-mark on expand) - Unread count stat card - Per-row context menu: mark read/unread, archive/unarchive, delete - Bulk actions bar: select all, mark read/unread, archive, delete - Show Archived toggle to filter archived responses - Backend: 7 new admin endpoints (read, unread, archive, unarchive, delete, bulk) - Migration 049: add is_read, archived_at to survey_responses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: initialize VerifyEmailPage state from token to avoid setState in effect Moves the no-token error case from useEffect into initial state to satisfy the react-hooks/set-state-in-effect ESLint rule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #95.
This commit is contained in:
@@ -1,8 +1,24 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } 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 {
|
||||
ChevronDown,
|
||||
Download,
|
||||
User,
|
||||
Link2,
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
Square,
|
||||
MoreHorizontal,
|
||||
Circle,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
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' },
|
||||
@@ -70,7 +86,7 @@ function AnswerDisplay({ value, type }: { value: string | string[] | undefined;
|
||||
function ExpandedDetail({ response }: { response: SurveyResponseDetail }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-0">
|
||||
<td colSpan={8} className="p-0">
|
||||
<div
|
||||
className="px-6 py-5"
|
||||
style={{
|
||||
@@ -106,25 +122,57 @@ function ResponseRow({
|
||||
response,
|
||||
index,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onMarkRead,
|
||||
onArchive,
|
||||
onDelete,
|
||||
}: {
|
||||
response: SurveyResponseDetail
|
||||
index: number
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
onToggle: () => void
|
||||
onSelect: () => void
|
||||
onMarkRead: () => void
|
||||
onArchive: () => void
|
||||
onDelete: () => 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
|
||||
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'border-b border-border/50 transition-colors cursor-pointer',
|
||||
!response.is_read && 'bg-primary/[0.03]',
|
||||
'hover:bg-[rgba(255,255,255,0.02)]'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 w-8">
|
||||
{/* Checkbox */}
|
||||
<td className="px-2 py-3 w-8" onClick={e => { e.stopPropagation(); onSelect() }}>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="h-4 w-4 text-primary cursor-pointer" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-muted-foreground/40 cursor-pointer hover:text-muted-foreground" />
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Unread dot */}
|
||||
<td className="px-1 py-3 w-6" onClick={onToggle}>
|
||||
{!response.is_read && (
|
||||
<Circle className="h-2.5 w-2.5 fill-primary text-primary" />
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Expand chevron */}
|
||||
<td className="px-2 py-3 w-8" onClick={onToggle}>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
@@ -132,11 +180,11 @@ function ResponseRow({
|
||||
)}
|
||||
/>
|
||||
</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">
|
||||
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>{index + 1}</td>
|
||||
<td className={cn('px-4 py-3 text-sm', !response.is_read ? 'text-foreground font-medium' : 'text-foreground')} onClick={onToggle}>
|
||||
{response.respondent_name || <span className="text-muted-foreground italic">Anonymous</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-3" onClick={onToggle}>
|
||||
{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" />
|
||||
@@ -152,16 +200,57 @@ function ResponseRow({
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-label text-xs text-muted-foreground">
|
||||
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>
|
||||
{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">
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground" onClick={onToggle}>
|
||||
{answeredCount} / {QUESTIONS.length}
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-3 py-3 w-10 relative">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setShowMenu(!showMenu) }}
|
||||
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||||
<div
|
||||
className="absolute right-3 top-full z-50 mt-1 w-44 rounded-xl py-1 shadow-xl"
|
||||
style={{ background: 'rgba(24, 26, 31, 0.95)', border: '1px solid var(--glass-border)', backdropFilter: 'blur(16px)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => { onMarkRead(); setShowMenu(false) }}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
|
||||
>
|
||||
{response.is_read ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
{response.is_read ? 'Mark Unread' : 'Mark Read'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onArchive(); setShowMenu(false) }}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
|
||||
>
|
||||
{response.archived_at ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
|
||||
{response.archived_at ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
<div className="my-1 border-t" style={{ borderColor: 'var(--glass-border)' }} />
|
||||
<button
|
||||
onClick={() => { onDelete(); setShowMenu(false) }}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && <ExpandedDetail response={response} />}
|
||||
</>
|
||||
@@ -174,20 +263,24 @@ export default function SurveyResponsesPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const result = await adminApi.listSurveyResponses(showArchived)
|
||||
setData(result)
|
||||
} catch {
|
||||
setError('Failed to load survey responses')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [showArchived])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const result = await adminApi.listSurveyResponses()
|
||||
setData(result)
|
||||
} catch {
|
||||
setError('Failed to load survey responses')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
setLoading(true)
|
||||
fetchData()
|
||||
}, [])
|
||||
}, [fetchData])
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
@@ -206,6 +299,112 @@ export default function SurveyResponsesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkRead = async (id: string, currentlyRead: boolean) => {
|
||||
try {
|
||||
if (currentlyRead) {
|
||||
await adminApi.markResponseUnread(id)
|
||||
} else {
|
||||
await adminApi.markResponseRead(id)
|
||||
}
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
unread: prev.unread + (currentlyRead ? 1 : -1),
|
||||
responses: prev.responses.map(r => r.id === id ? { ...r, is_read: !currentlyRead } : r),
|
||||
} : prev)
|
||||
} catch {
|
||||
toast.error('Failed to update read status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string, currentlyArchived: boolean) => {
|
||||
try {
|
||||
if (currentlyArchived) {
|
||||
await adminApi.unarchiveResponse(id)
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: null } : r),
|
||||
} : prev)
|
||||
} else {
|
||||
await adminApi.archiveResponse(id)
|
||||
if (!showArchived) {
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
total: prev.total - 1,
|
||||
responses: prev.responses.filter(r => r.id !== id),
|
||||
} : prev)
|
||||
} else {
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: new Date().toISOString() } : r),
|
||||
} : prev)
|
||||
}
|
||||
}
|
||||
toast.success(currentlyArchived ? 'Response unarchived' : 'Response archived')
|
||||
} catch {
|
||||
toast.error('Failed to update archive status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Permanently delete this response? This cannot be undone.')) return
|
||||
try {
|
||||
await adminApi.deleteResponse(id)
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
total: prev.total - 1,
|
||||
responses: prev.responses.filter(r => r.id !== id),
|
||||
} : prev)
|
||||
setSelectedIds(prev => { const next = new Set(prev); next.delete(id); return next })
|
||||
toast.success('Response deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete response')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkAction = async (action: string) => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (action === 'delete' && !confirm(`Permanently delete ${selectedIds.size} response(s)?`)) return
|
||||
|
||||
try {
|
||||
await adminApi.bulkActionResponses(action, Array.from(selectedIds))
|
||||
setSelectedIds(new Set())
|
||||
fetchData()
|
||||
toast.success(`${action.replace('_', ' ')} applied to ${selectedIds.size} response(s)`)
|
||||
} catch {
|
||||
toast.error('Bulk action failed')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const responses = data?.responses ?? []
|
||||
if (selectedIds.size === responses.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(responses.map(r => r.id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-mark as read when expanding
|
||||
const handleExpand = (id: string) => {
|
||||
const newId = expandedId === id ? null : id
|
||||
setExpandedId(newId)
|
||||
if (newId) {
|
||||
const resp = data?.responses.find(r => r.id === newId)
|
||||
if (resp && !resp.is_read) {
|
||||
handleMarkRead(newId, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -230,18 +429,33 @@ export default function SurveyResponsesPage() {
|
||||
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>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Archive toggle */}
|
||||
<button
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] px-3 py-2 text-xs font-medium transition-colors border',
|
||||
showArchived
|
||||
? 'bg-primary/10 text-primary border-primary/20'
|
||||
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
{showArchived ? 'Showing Archived' : 'Show Archived'}
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -269,14 +483,82 @@ export default function SurveyResponsesPage() {
|
||||
{data?.this_week ?? 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">
|
||||
Unread
|
||||
</p>
|
||||
<p className={cn(
|
||||
'text-2xl font-heading font-bold',
|
||||
(data?.unread ?? 0) > 0 ? 'text-primary' : 'text-foreground'
|
||||
)}>
|
||||
{data?.unread ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-2.5"
|
||||
style={{ background: 'rgba(6, 182, 212, 0.08)', border: '1px solid rgba(6, 182, 212, 0.15)' }}
|
||||
>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => handleBulkAction('mark_read')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Mark Read
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkAction('mark_unread')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
Mark Unread
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkAction('archive')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</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-2 py-3 w-8">
|
||||
<button onClick={toggleSelectAll} className="text-muted-foreground/40 hover:text-muted-foreground">
|
||||
{selectedIds.size > 0 && selectedIds.size === responses.length ? (
|
||||
<CheckSquare className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-1 py-3 w-6" />
|
||||
<th className="px-2 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>
|
||||
@@ -292,13 +574,14 @@ export default function SurveyResponsesPage() {
|
||||
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Answered
|
||||
</th>
|
||||
<th className="px-3 py-3 w-10" />
|
||||
</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 colSpan={9} className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
{showArchived ? 'No archived responses.' : 'No survey responses yet.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -308,9 +591,12 @@ export default function SurveyResponsesPage() {
|
||||
response={response}
|
||||
index={index}
|
||||
isExpanded={expandedId === response.id}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === response.id ? null : response.id)
|
||||
}
|
||||
isSelected={selectedIds.has(response.id)}
|
||||
onToggle={() => handleExpand(response.id)}
|
||||
onSelect={() => toggleSelect(response.id)}
|
||||
onMarkRead={() => handleMarkRead(response.id, response.is_read)}
|
||||
onArchive={() => handleArchive(response.id, !!response.archived_at)}
|
||||
onDelete={() => handleDelete(response.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user