上传完整的WebUI前端仓库

This commit is contained in:
墨梓柒
2026-01-13 06:24:35 +08:00
parent a9187dc312
commit 812296590e
184 changed files with 47854 additions and 1 deletions

View File

@@ -0,0 +1,8 @@
/**
* 问卷组件导出
*/
export { SurveyRenderer } from './survey-renderer'
export { SurveyQuestion } from './survey-question'
export { SurveyResults } from './survey-results'
export type { SurveyRendererProps } from './survey-renderer'

View File

@@ -0,0 +1,247 @@
/**
* 单个问题渲染组件
*/
import { useState } from 'react'
import { cn } from '@/lib/utils'
import { Label } from '@/components/ui/label'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Checkbox } from '@/components/ui/checkbox'
import { Slider } from '@/components/ui/slider'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Star } from 'lucide-react'
import type { SurveyQuestion as SurveyQuestionType } from '@/types/survey'
interface SurveyQuestionProps {
question: SurveyQuestionType
value: string | string[] | number | undefined
onChange: (value: string | string[] | number) => void
error?: string
disabled?: boolean
}
export function SurveyQuestion({
question,
value,
onChange,
error,
disabled = false
}: SurveyQuestionProps) {
const [hoverRating, setHoverRating] = useState<number | null>(null)
// 如果问题设置了只读,则禁用输入
const isDisabled = disabled || question.readOnly
const renderQuestion = () => {
switch (question.type) {
case 'single':
return (
<RadioGroup
value={value as string || ''}
onValueChange={onChange}
disabled={isDisabled}
className="space-y-2"
>
{question.options?.map((option) => (
<div key={option.id} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`${question.id}-${option.id}`} />
<Label
htmlFor={`${question.id}-${option.id}`}
className="cursor-pointer font-normal"
>
{option.label}
</Label>
</div>
))}
</RadioGroup>
)
case 'multiple': {
const selectedValues = (value as string[]) || []
return (
<div className="space-y-2">
{question.options?.map((option) => (
<div key={option.id} className="flex items-center space-x-2">
<Checkbox
id={`${question.id}-${option.id}`}
checked={selectedValues.includes(option.value)}
disabled={isDisabled || (
question.maxSelections !== undefined &&
selectedValues.length >= question.maxSelections &&
!selectedValues.includes(option.value)
)}
onCheckedChange={(checked) => {
if (checked) {
onChange([...selectedValues, option.value])
} else {
onChange(selectedValues.filter(v => v !== option.value))
}
}}
/>
<Label
htmlFor={`${question.id}-${option.id}`}
className="cursor-pointer font-normal"
>
{option.label}
</Label>
</div>
))}
{question.maxSelections && (
<p className="text-xs text-muted-foreground">
{question.maxSelections}
</p>
)}
</div>
)
}
case 'text':
return (
<Input
value={value as string || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder || '请输入...'}
disabled={isDisabled}
readOnly={question.readOnly}
maxLength={question.maxLength}
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
/>
)
case 'textarea':
return (
<div className="space-y-1">
<Textarea
value={value as string || ''}
onChange={(e) => onChange(e.target.value)}
placeholder={question.placeholder || '请输入...'}
disabled={isDisabled}
readOnly={question.readOnly}
maxLength={question.maxLength}
rows={4}
className={cn(question.readOnly && "bg-muted cursor-not-allowed")}
/>
{question.maxLength && (
<p className="text-xs text-muted-foreground text-right">
{(value as string || '').length} / {question.maxLength}
</p>
)}
</div>
)
case 'rating': {
const ratingValue = (value as number) || 0
const displayRating = hoverRating !== null ? hoverRating : ratingValue
return (
<div className="flex items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
<button
key={star}
type="button"
disabled={isDisabled}
className={cn(
"p-1 transition-colors focus:outline-none focus:ring-2 focus:ring-ring rounded",
isDisabled && "cursor-not-allowed opacity-50"
)}
onMouseEnter={() => !isDisabled && setHoverRating(star)}
onMouseLeave={() => setHoverRating(null)}
onClick={() => !isDisabled && onChange(star)}
>
<Star
className={cn(
"h-6 w-6 transition-colors",
star <= displayRating
? "fill-yellow-400 text-yellow-400"
: "text-muted-foreground"
)}
/>
</button>
))}
{ratingValue > 0 && (
<span className="ml-2 text-sm text-muted-foreground">
{ratingValue} / 5
</span>
)}
</div>
)
}
case 'scale': {
const min = question.min ?? 1
const max = question.max ?? 10
const step = question.step ?? 1
const scaleValue = (value as number) ?? min
return (
<div className="space-y-4">
<Slider
value={[scaleValue]}
onValueChange={([val]) => onChange(val)}
min={min}
max={max}
step={step}
disabled={isDisabled}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{question.minLabel || min}</span>
<span className="font-medium text-foreground">{scaleValue}</span>
<span>{question.maxLabel || max}</span>
</div>
</div>
)
}
case 'dropdown':
return (
<Select
value={value as string || ''}
onValueChange={onChange}
disabled={isDisabled}
>
<SelectTrigger>
<SelectValue placeholder={question.placeholder || '请选择...'} />
</SelectTrigger>
<SelectContent>
{question.options?.map((option) => (
<SelectItem key={option.id} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)
default:
return <div className="text-muted-foreground"></div>
}
}
return (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-base font-medium">
{question.title}
{question.required && (
<span className="text-destructive ml-1">*</span>
)}
</Label>
{question.description && (
<p className="text-sm text-muted-foreground">{question.description}</p>
)}
</div>
{renderQuestion()}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,407 @@
/**
* 问卷渲染器组件
* 读取 JSON 配置并展示问卷界面
*/
import { useState, useCallback, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Loader2, CheckCircle2, AlertCircle, ChevronLeft, ChevronRight } from 'lucide-react'
import { SurveyQuestion } from './survey-question'
import { submitSurvey, checkUserSubmission } from '@/lib/survey-api'
import type { SurveyConfig, QuestionAnswer } from '@/types/survey'
export interface SurveyRendererProps {
/** 问卷配置 */
config: SurveyConfig
/** 初始答案(用于预填充,如自动填写版本号) */
initialAnswers?: QuestionAnswer[]
/** 提交成功回调 */
onSubmitSuccess?: (submissionId: string) => void
/** 提交失败回调 */
onSubmitError?: (error: string) => void
/** 是否显示进度条 */
showProgress?: boolean
/** 是否分页显示(每页一题) */
paginateQuestions?: boolean
/** 自定义类名 */
className?: string
}
type AnswerMap = Record<string, string | string[] | number | undefined>
export function SurveyRenderer({
config,
initialAnswers,
onSubmitSuccess,
onSubmitError,
showProgress = true,
paginateQuestions = false,
className
}: SurveyRendererProps) {
// 将 initialAnswers 转换为 AnswerMap
const getInitialAnswerMap = useCallback((): AnswerMap => {
if (!initialAnswers || initialAnswers.length === 0) return {}
return initialAnswers.reduce((acc, answer) => {
acc[answer.questionId] = answer.value
return acc
}, {} as AnswerMap)
}, [initialAnswers])
const [answers, setAnswers] = useState<AnswerMap>(() => getInitialAnswerMap())
const [errors, setErrors] = useState<Record<string, string>>({})
const [currentPage, setCurrentPage] = useState(0)
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [submissionId, setSubmissionId] = useState<string | null>(null)
const [hasAlreadySubmitted, setHasAlreadySubmitted] = useState(false)
const [isCheckingSubmission, setIsCheckingSubmission] = useState(true)
// 当 initialAnswers 变化时更新答案(合并而非替换)
useEffect(() => {
if (initialAnswers && initialAnswers.length > 0) {
setAnswers(prev => ({
...prev,
...getInitialAnswerMap()
}))
}
}, [initialAnswers, getInitialAnswerMap])
// 检查是否已提交过
useEffect(() => {
const checkSubmission = async () => {
if (!config.settings?.allowMultiple) {
const result = await checkUserSubmission(config.id)
if (result.success && result.hasSubmitted) {
setHasAlreadySubmitted(true)
}
}
setIsCheckingSubmission(false)
}
checkSubmission()
}, [config.id, config.settings?.allowMultiple])
// 检查问卷是否在有效期内
const isWithinTimeRange = useCallback(() => {
const now = new Date()
if (config.settings?.startTime && new Date(config.settings.startTime) > now) {
return false
}
if (config.settings?.endTime && new Date(config.settings.endTime) < now) {
return false
}
return true
}, [config.settings?.startTime, config.settings?.endTime])
// 计算进度
const answeredCount = config.questions.filter(q => {
const answer = answers[q.id]
if (answer === undefined || answer === null) return false
if (Array.isArray(answer)) return answer.length > 0
if (typeof answer === 'string') return answer.trim() !== ''
return true
}).length
const progress = (answeredCount / config.questions.length) * 100
// 更新答案
const handleAnswerChange = useCallback((questionId: string, value: string | string[] | number) => {
setAnswers(prev => ({ ...prev, [questionId]: value }))
// 清除该问题的错误
setErrors(prev => {
const newErrors = { ...prev }
delete newErrors[questionId]
return newErrors
})
}, [])
// 验证答案
const validateAnswers = useCallback(() => {
const newErrors: Record<string, string> = {}
for (const question of config.questions) {
if (question.required) {
const answer = answers[question.id]
if (answer === undefined || answer === null) {
newErrors[question.id] = '此题为必填项'
continue
}
if (Array.isArray(answer) && answer.length === 0) {
newErrors[question.id] = '请至少选择一项'
continue
}
if (typeof answer === 'string' && answer.trim() === '') {
newErrors[question.id] = '此题为必填项'
continue
}
}
// 文本长度验证
if (question.minLength && typeof answers[question.id] === 'string') {
const text = answers[question.id] as string
if (text.length < question.minLength) {
newErrors[question.id] = `至少需要 ${question.minLength} 个字符`
}
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [config.questions, answers])
// 提交问卷
const handleSubmit = useCallback(async () => {
if (!validateAnswers()) {
// 如果是分页模式,跳转到第一个有错误的问题
if (paginateQuestions) {
const firstErrorIndex = config.questions.findIndex(q => errors[q.id])
if (firstErrorIndex >= 0) {
setCurrentPage(firstErrorIndex)
}
}
return
}
setIsSubmitting(true)
setSubmitError(null)
try {
// 构建答案列表
const answerList: QuestionAnswer[] = config.questions
.filter(q => answers[q.id] !== undefined)
.map(q => ({
questionId: q.id,
value: answers[q.id]!
}))
const result = await submitSurvey(
config.id,
config.version,
answerList,
{ allowMultiple: config.settings?.allowMultiple }
)
if (result.success && result.submissionId) {
setIsSubmitted(true)
setSubmissionId(result.submissionId)
onSubmitSuccess?.(result.submissionId)
} else {
const error = result.error || '提交失败'
setSubmitError(error)
onSubmitError?.(error)
}
} catch (error) {
const errorMsg = error instanceof Error ? error.message : '提交失败'
setSubmitError(errorMsg)
onSubmitError?.(errorMsg)
} finally {
setIsSubmitting(false)
}
}, [validateAnswers, paginateQuestions, config, answers, errors, onSubmitSuccess, onSubmitError])
// 分页导航
const goToPage = useCallback((page: number) => {
if (page >= 0 && page < config.questions.length) {
setCurrentPage(page)
}
}, [config.questions.length])
// 检查中
if (isCheckingSubmission) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardContent className="py-12 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
// 已提交过
if (hasAlreadySubmitted && !config.settings?.allowMultiple) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardHeader>
<CardTitle>{config.title}</CardTitle>
</CardHeader>
<CardContent className="py-8">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
</CardContent>
</Card>
)
}
// 不在有效期内
if (!isWithinTimeRange()) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardHeader>
<CardTitle>{config.title}</CardTitle>
</CardHeader>
<CardContent className="py-8">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
</CardContent>
</Card>
)
}
// 提交成功
if (isSubmitted) {
return (
<Card className={cn("w-full max-w-2xl mx-auto", className)}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-6 w-6" />
</CardTitle>
</CardHeader>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
{config.settings?.thankYouMessage || '感谢你的参与!'}
</p>
{submissionId && (
<p className="text-center text-xs text-muted-foreground mt-4">
{submissionId}
</p>
)}
</CardContent>
</Card>
)
}
// 问卷展示
const questionsToShow = paginateQuestions
? [config.questions[currentPage]]
: config.questions
return (
<div className={cn("h-full flex flex-col", className)}>
{/* 问卷头部 */}
<div className="rounded-lg border bg-card p-4 sm:p-6 mb-4 shrink-0">
<h2 className="text-xl font-semibold">{config.title}</h2>
{config.description && (
<p className="text-muted-foreground mt-1 text-sm">{config.description}</p>
)}
{showProgress && (
<div className="space-y-1 pt-3">
<div className="flex justify-between text-xs text-muted-foreground">
<span></span>
<span>{answeredCount} / {config.questions.length}</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
</div>
{/* 问卷内容 - 可滚动区域 */}
<ScrollArea className="flex-1 min-h-0">
<div className="space-y-4 pr-4">
{questionsToShow.map((question, index) => (
<div
key={question.id}
className={cn(
"p-4 rounded-lg border bg-card",
errors[question.id] ? "border-destructive bg-destructive/5" : "border-border"
)}
>
{paginateQuestions && (
<div className="text-xs text-muted-foreground mb-2">
{currentPage + 1} / {config.questions.length}
</div>
)}
{!paginateQuestions && (
<div className="text-xs text-muted-foreground mb-2">
{index + 1}.
</div>
)}
<SurveyQuestion
question={question}
value={answers[question.id]}
onChange={(value) => handleAnswerChange(question.id, value)}
error={errors[question.id]}
disabled={isSubmitting}
/>
</div>
))}
{submitError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{submitError}</AlertDescription>
</Alert>
)}
{/* 提交按钮区域 */}
<div className="flex justify-between items-center py-4">
{paginateQuestions ? (
<>
<Button
variant="outline"
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 0 || isSubmitting}
>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
{currentPage === config.questions.length - 1 ? (
<Button
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
) : (
<Button
onClick={() => goToPage(currentPage + 1)}
disabled={isSubmitting}
>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
)}
</>
) : (
<>
<div className="text-sm text-muted-foreground">
{Object.keys(errors).length > 0 && (
<span className="text-destructive">
{Object.keys(errors).length}
</span>
)}
</div>
<Button
onClick={handleSubmit}
disabled={isSubmitting}
size="lg"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
</Button>
</>
)}
</div>
</div>
</ScrollArea>
</div>
)
}

View File

@@ -0,0 +1,292 @@
/**
* 问卷结果查看组件
* 展示问卷统计数据和用户提交记录
*/
import { useState, useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Progress } from '@/components/ui/progress'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Badge } from '@/components/ui/badge'
import { Loader2, Users, FileText, Clock, Star, BarChart3 } from 'lucide-react'
import { getSurveyStats, getUserSubmissions } from '@/lib/survey-api'
import type { SurveyConfig, SurveyStats, StoredSubmission } from '@/types/survey'
interface SurveyResultsProps {
/** 问卷配置 */
config: SurveyConfig
/** 是否显示用户提交记录 */
showUserSubmissions?: boolean
/** 自定义类名 */
className?: string
}
export function SurveyResults({
config,
showUserSubmissions = true,
className
}: SurveyResultsProps) {
const [stats, setStats] = useState<SurveyStats | null>(null)
const [userSubmissions, setUserSubmissions] = useState<StoredSubmission[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchData = async () => {
setIsLoading(true)
setError(null)
try {
// 获取统计数据
const statsResult = await getSurveyStats(config.id)
if (statsResult.success && statsResult.stats) {
setStats(statsResult.stats)
}
// 获取用户提交记录
if (showUserSubmissions) {
const submissionsResult = await getUserSubmissions(config.id)
if (submissionsResult.success && submissionsResult.submissions) {
setUserSubmissions(submissionsResult.submissions)
}
}
} catch (err) {
setError(err instanceof Error ? err.message : '加载数据失败')
} finally {
setIsLoading(false)
}
}
fetchData()
}, [config.id, showUserSubmissions])
if (isLoading) {
return (
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
<CardContent className="py-12 flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
)
}
if (error) {
return (
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
<CardContent className="py-12 text-center text-muted-foreground">
{error}
</CardContent>
</Card>
)
}
return (
<Card className={cn("w-full max-w-3xl mx-auto", className)}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
{config.title} -
</CardTitle>
{config.description && (
<CardDescription>{config.description}</CardDescription>
)}
</CardHeader>
<CardContent>
{/* 概览统计 */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="p-4 rounded-lg bg-muted/50 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
<FileText className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold">
{stats?.totalSubmissions || 0}
</div>
</div>
<div className="p-4 rounded-lg bg-muted/50 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
<Users className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div className="text-2xl font-bold">
{stats?.uniqueUsers || 0}
</div>
</div>
<div className="p-4 rounded-lg bg-muted/50 text-center">
<div className="flex items-center justify-center gap-2 text-muted-foreground mb-1">
<Clock className="h-4 w-4" />
<span className="text-sm"></span>
</div>
<div className="text-sm font-medium">
{stats?.lastSubmissionAt
? new Date(stats.lastSubmissionAt).toLocaleDateString()
: '-'
}
</div>
</div>
</div>
<Tabs defaultValue="stats" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="stats"></TabsTrigger>
{showUserSubmissions && (
<TabsTrigger value="submissions"></TabsTrigger>
)}
</TabsList>
<TabsContent value="stats" className="mt-4">
<ScrollArea className="max-h-[60vh]">
<div className="space-y-6 pr-4">
{config.questions.map((question, index) => {
const qStats = stats?.questionStats[question.id]
return (
<div key={question.id} className="p-4 rounded-lg border">
<div className="text-xs text-muted-foreground mb-1">
{index + 1}
</div>
<div className="font-medium mb-3">{question.title}</div>
{qStats ? (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
{qStats.answered}
</div>
{/* 选择题统计 */}
{qStats.optionCounts && question.options && (
<div className="space-y-2">
{question.options.map(option => {
const count = qStats.optionCounts?.[option.value] || 0
const percentage = qStats.answered > 0
? (count / qStats.answered) * 100
: 0
return (
<div key={option.id} className="space-y-1">
<div className="flex justify-between text-sm">
<span>{option.label}</span>
<span className="text-muted-foreground">
{count} ({percentage.toFixed(1)}%)
</span>
</div>
<Progress value={percentage} className="h-2" />
</div>
)
})}
</div>
)}
{/* 评分/量表统计 */}
{qStats.average !== undefined && (
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-400" />
<span className="text-sm">
{qStats.average.toFixed(2)}
</span>
</div>
)}
{/* 文本答案样本 */}
{qStats.sampleAnswers && qStats.sampleAnswers.length > 0 && (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
</div>
<div className="space-y-1">
{qStats.sampleAnswers.map((answer, i) => (
<div
key={i}
className="text-sm p-2 bg-muted/50 rounded text-muted-foreground"
>
"{answer}"
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="text-sm text-muted-foreground">
</div>
)}
</div>
)
})}
</div>
</ScrollArea>
</TabsContent>
{showUserSubmissions && (
<TabsContent value="submissions" className="mt-4">
<ScrollArea className="max-h-[60vh]">
{userSubmissions.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">
</div>
) : (
<div className="space-y-4 pr-4">
{userSubmissions.map((submission) => (
<div key={submission.id} className="p-4 rounded-lg border">
<div className="flex items-center justify-between mb-3">
<Badge variant="outline">
{new Date(submission.submittedAt).toLocaleString()}
</Badge>
<span className="text-xs text-muted-foreground">
ID: {submission.id}
</span>
</div>
<div className="space-y-2">
{submission.answers.map((answer) => {
const question = config.questions.find(
q => q.id === answer.questionId
)
if (!question) return null
// 格式化答案显示
let displayValue: string
if (Array.isArray(answer.value)) {
const labels = answer.value.map(v => {
const opt = question.options?.find(o => o.value === v)
return opt?.label || v
})
displayValue = labels.join('、')
} else if (typeof answer.value === 'number') {
displayValue = answer.value.toString()
} else {
const opt = question.options?.find(
o => o.value === answer.value
)
displayValue = opt?.label || answer.value
}
return (
<div key={answer.questionId} className="text-sm">
<span className="text-muted-foreground">
{question.title}
</span>
<span>{displayValue}</span>
</div>
)
})}
</div>
</div>
))}
</div>
)}
</ScrollArea>
</TabsContent>
)}
</Tabs>
</CardContent>
</Card>
)
}