5278190750
- 后端: 凭证生成引擎、金蝶导出器、凭证模板服务 - 后端: 成本分析服务、AI问答服务 - 后端: 科目映射CRUD API、分析API、QA API - 后端: 集成测试(认证/任务/凭证) 49个测试全部通过 - 前端: 凭证管理、成本分析、导出中心、知识库、系统设置页面 - 前端: AuthGuard认证守卫、Dashboard AI聊天功能 - 前端: Playwright E2E测试 16 passed, 1 skipped - 基础设施: Docker Compose、Nginx反向代理、.env.example - 文档: 用户手册、管理员手册、发布检查清单
633 lines
23 KiB
TypeScript
633 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { useSearchParams } from "next/navigation";
|
||
import { motion } from "motion/react";
|
||
import {
|
||
Card,
|
||
CardContent,
|
||
CardHeader,
|
||
CardTitle,
|
||
} from "@/components/ui/card";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
TrendingUp,
|
||
TrendingDown,
|
||
Users,
|
||
Wallet,
|
||
Download,
|
||
Loader2,
|
||
Sparkles,
|
||
MessageSquare,
|
||
Send,
|
||
PiggyBank,
|
||
Building2,
|
||
} from "lucide-react";
|
||
import {
|
||
BarChart,
|
||
Bar,
|
||
PieChart,
|
||
Pie,
|
||
Cell,
|
||
XAxis,
|
||
YAxis,
|
||
CartesianGrid,
|
||
Tooltip,
|
||
ResponsiveContainer,
|
||
Legend,
|
||
} from "recharts";
|
||
import { api } from "@/lib/api/client";
|
||
|
||
interface CostSummary {
|
||
total_cost: number;
|
||
salary_cost: number;
|
||
social_security_cost: number;
|
||
fund_cost: number;
|
||
employee_count: number;
|
||
}
|
||
|
||
interface DepartmentCost {
|
||
department: string;
|
||
employee_count: number;
|
||
salary_cost: number;
|
||
social_security_cost: number;
|
||
fund_cost: number;
|
||
total_cost: number;
|
||
}
|
||
|
||
interface ExpenseBreakdown {
|
||
expense_type: string;
|
||
amount: number;
|
||
}
|
||
|
||
interface FullAnalysis {
|
||
summary: CostSummary;
|
||
departments: DepartmentCost[];
|
||
expenses: ExpenseBreakdown[];
|
||
changes?: Record<string, any>;
|
||
ai_summary?: string;
|
||
}
|
||
|
||
interface SuggestedQuestions {
|
||
questions: string[];
|
||
}
|
||
|
||
interface QAResponse {
|
||
answer: string;
|
||
data_points: string[];
|
||
}
|
||
|
||
const COLORS = ["#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", "#06b6d4"];
|
||
|
||
const container = {
|
||
hidden: { opacity: 0 },
|
||
show: {
|
||
opacity: 1,
|
||
transition: { staggerChildren: 0.08 },
|
||
},
|
||
};
|
||
|
||
const item = {
|
||
hidden: { opacity: 0, y: 12 },
|
||
show: {
|
||
opacity: 1,
|
||
y: 0,
|
||
transition: { duration: 0.4, ease: [0.16, 1, 0.3, 1] as const },
|
||
},
|
||
};
|
||
|
||
function formatCurrency(value: number): string {
|
||
return new Intl.NumberFormat("zh-CN", {
|
||
style: "currency",
|
||
currency: "CNY",
|
||
minimumFractionDigits: 2,
|
||
}).format(value);
|
||
}
|
||
|
||
function formatNumber(value: number): string {
|
||
return new Intl.NumberFormat("zh-CN").format(value);
|
||
}
|
||
|
||
export default function AnalysisPage() {
|
||
const searchParams = useSearchParams();
|
||
const [analysis, setAnalysis] = useState<FullAnalysis | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [taskId, setTaskId] = useState(searchParams.get("task_id") || "");
|
||
const [prevTaskId, setPrevTaskId] = useState(searchParams.get("prev_task_id") || "");
|
||
|
||
const [questions, setQuestions] = useState<string[]>([]);
|
||
const [answer, setAnswer] = useState<string>("");
|
||
const [askLoading, setAskLoading] = useState(false);
|
||
const [customQuestion, setCustomQuestion] = useState("");
|
||
|
||
const loadAnalysis = useCallback(async () => {
|
||
if (!taskId) {
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
const params: Record<string, any> = {};
|
||
if (prevTaskId) params.prev_task_id = prevTaskId;
|
||
const res = await api.get<FullAnalysis>(
|
||
`/api/analysis/labor-cost/${taskId}`,
|
||
{ params }
|
||
);
|
||
setAnalysis(res.data);
|
||
} catch (error) {
|
||
console.error("加载成本分析失败:", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [taskId, prevTaskId]);
|
||
|
||
const loadQuestions = useCallback(async () => {
|
||
if (!taskId) return;
|
||
try {
|
||
const res = await api.get<SuggestedQuestions>(
|
||
`/api/qa/suggested-questions`,
|
||
{ params: { task_id: taskId, context: "cost_analysis" } }
|
||
);
|
||
setQuestions(res.data.questions || []);
|
||
} catch (error) {
|
||
console.error("加载建议问题失败:", error);
|
||
}
|
||
}, [taskId]);
|
||
|
||
useEffect(() => {
|
||
loadAnalysis();
|
||
loadQuestions();
|
||
}, [loadAnalysis, loadQuestions]);
|
||
|
||
async function askQuestion(question: string) {
|
||
if (!question || !taskId) return;
|
||
setAskLoading(true);
|
||
setAnswer("");
|
||
try {
|
||
const res = await api.post<QAResponse>(`/api/qa/ask`, {
|
||
task_id: Number(taskId),
|
||
question,
|
||
context: "cost_analysis",
|
||
});
|
||
setAnswer(res.data.answer);
|
||
} catch (error) {
|
||
console.error("提问失败:", error);
|
||
setAnswer("暂时无法回答该问题,请稍后重试。");
|
||
} finally {
|
||
setAskLoading(false);
|
||
}
|
||
}
|
||
|
||
function handleExport() {
|
||
if (!taskId) return;
|
||
const token = localStorage.getItem("auth_token");
|
||
const companyId = localStorage.getItem("company_id");
|
||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/analysis/labor-cost/${taskId}/export`;
|
||
fetch(url, {
|
||
headers: {
|
||
Authorization: `Bearer ${token}`,
|
||
"X-Company-ID": companyId || "",
|
||
},
|
||
})
|
||
.then((res) => res.blob())
|
||
.then((blob) => {
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = `cost_analysis_${taskId}.xlsx`;
|
||
a.click();
|
||
URL.revokeObjectURL(a.href);
|
||
});
|
||
}
|
||
|
||
const deptChartData = analysis?.departments.map((d) => ({
|
||
name: d.department,
|
||
工资: d.salary_cost,
|
||
社保: d.social_security_cost,
|
||
公积金: d.fund_cost,
|
||
})) || [];
|
||
|
||
const expenseChartData = analysis?.expenses.map((e) => ({
|
||
name: e.expense_type,
|
||
value: e.amount,
|
||
})) || [];
|
||
|
||
return (
|
||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||
<motion.div
|
||
initial={{ opacity: 0, y: -8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4, ease: [0.16, 1, 0.3, 1] }}
|
||
className="flex items-center justify-between"
|
||
>
|
||
<div>
|
||
<h1 className="text-heading-1 text-foreground">人工成本分析</h1>
|
||
<p className="text-muted-foreground mt-1">基于对账数据的多维度成本分析</p>
|
||
</div>
|
||
{analysis && (
|
||
<Button variant="outline" onClick={handleExport} className="btn-press">
|
||
<Download className="w-4 h-4 mr-2" />
|
||
导出 Excel
|
||
</Button>
|
||
)}
|
||
</motion.div>
|
||
|
||
<Card>
|
||
<CardContent className="p-4">
|
||
<div className="flex flex-wrap items-end gap-3">
|
||
<div className="flex-1 min-w-[180px]">
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">任务ID</label>
|
||
<Input
|
||
placeholder="输入对账任务ID"
|
||
value={taskId}
|
||
onChange={(e) => setTaskId(e.target.value)}
|
||
className="h-9"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-[180px]">
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">上月任务ID(可选,用于环比)</label>
|
||
<Input
|
||
placeholder="输入上月任务ID"
|
||
value={prevTaskId}
|
||
onChange={(e) => setPrevTaskId(e.target.value)}
|
||
className="h-9"
|
||
/>
|
||
</div>
|
||
<Button onClick={loadAnalysis} className="h-9 btn-press">
|
||
分析
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{loading ? (
|
||
<div className="flex items-center justify-center py-20">
|
||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||
</div>
|
||
) : !analysis ? (
|
||
<Card>
|
||
<CardContent className="p-8 text-center">
|
||
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-4 flex items-center justify-center">
|
||
<Wallet className="w-6 h-6 text-muted-foreground" />
|
||
</div>
|
||
<p className="text-body text-muted-foreground mb-2">
|
||
请输入任务ID开始成本分析
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
) : (
|
||
<>
|
||
<motion.div
|
||
variants={container}
|
||
initial="hidden"
|
||
animate="show"
|
||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"
|
||
>
|
||
<motion.div variants={item}>
|
||
<Card className="hover-lift">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">人工成本总额</p>
|
||
<p className="text-2xl font-semibold tracking-tight text-foreground">
|
||
{formatCurrency(analysis.summary.total_cost)}
|
||
</p>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-indigo-50 dark:bg-indigo-950/30">
|
||
<Wallet className="w-5 h-5 text-indigo-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div variants={item}>
|
||
<Card className="hover-lift">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">工资成本</p>
|
||
<p className="text-2xl font-semibold tracking-tight text-foreground">
|
||
{formatCurrency(analysis.summary.salary_cost)}
|
||
</p>
|
||
<p className="text-caption text-muted-foreground mt-1">
|
||
占比 {((analysis.summary.salary_cost / analysis.summary.total_cost) * 100).toFixed(1)}%
|
||
</p>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
|
||
<PiggyBank className="w-5 h-5 text-emerald-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div variants={item}>
|
||
<Card className="hover-lift">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">社保+公积金</p>
|
||
<p className="text-2xl font-semibold tracking-tight text-foreground">
|
||
{formatCurrency(analysis.summary.social_security_cost + analysis.summary.fund_cost)}
|
||
</p>
|
||
<p className="text-caption text-muted-foreground mt-1">
|
||
占比 {(((analysis.summary.social_security_cost + analysis.summary.fund_cost) / analysis.summary.total_cost) * 100).toFixed(1)}%
|
||
</p>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30">
|
||
<Building2 className="w-5 h-5 text-amber-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div variants={item}>
|
||
<Card className="hover-lift">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide mb-1">员工人数</p>
|
||
<p className="text-2xl font-semibold tracking-tight text-foreground">
|
||
{formatNumber(analysis.summary.employee_count)}
|
||
</p>
|
||
<p className="text-caption text-muted-foreground mt-1">
|
||
人均 {formatCurrency(analysis.summary.total_cost / (analysis.summary.employee_count || 1))}
|
||
</p>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-violet-50 dark:bg-violet-950/30">
|
||
<Users className="w-5 h-5 text-violet-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
</motion.div>
|
||
|
||
{analysis.ai_summary && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, delay: 0.3 }}
|
||
>
|
||
<Card className="border-primary/20 bg-primary/5">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-start gap-3">
|
||
<div className="p-2 rounded-lg bg-primary/10">
|
||
<Sparkles className="w-5 h-5 text-primary" />
|
||
</div>
|
||
<div>
|
||
<h3 className="font-medium text-foreground mb-1">AI 成本分析摘要</h3>
|
||
<p className="text-body text-foreground/80">{analysis.ai_summary}</p>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
)}
|
||
|
||
{analysis.changes && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, delay: 0.35 }}
|
||
>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg font-medium">环比变化</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||
{[
|
||
{ label: "总成本", data: analysis.changes.total_cost },
|
||
{ label: "工资成本", data: analysis.changes.salary_cost },
|
||
{ label: "社保成本", data: analysis.changes.social_security_cost },
|
||
{ label: "公积金成本", data: analysis.changes.fund_cost },
|
||
].map((item) => {
|
||
const change = item.data?.amount_change || 0;
|
||
const ratio = item.data?.ratio_change || 0;
|
||
const isUp = change > 0;
|
||
return (
|
||
<div key={item.label} className="p-4 rounded-lg bg-muted/50">
|
||
<p className="text-caption text-muted-foreground mb-1">{item.label}</p>
|
||
<p className="text-xl font-semibold text-foreground">
|
||
{formatCurrency(item.data?.current || 0)}
|
||
</p>
|
||
<div className="flex items-center gap-1 mt-2">
|
||
{isUp ? (
|
||
<TrendingUp className="w-3 h-3 text-red-500" />
|
||
) : (
|
||
<TrendingDown className="w-3 h-3 text-emerald-500" />
|
||
)}
|
||
<span className={`text-sm ${isUp ? "text-red-500" : "text-emerald-500"}`}>
|
||
{isUp ? "+" : ""}{formatCurrency(change)} ({ratio}%)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
)}
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, delay: 0.4 }}
|
||
>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg font-medium">部门成本拆分</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{deptChartData.length > 0 ? (
|
||
<ResponsiveContainer width="100%" height={300}>
|
||
<BarChart data={deptChartData}>
|
||
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||
<YAxis tick={{ fontSize: 12 }} />
|
||
<Tooltip
|
||
formatter={(value: any) => formatCurrency(Number(value))}
|
||
contentStyle={{ borderRadius: "8px", border: "1px solid hsl(var(--border))" }}
|
||
/>
|
||
<Legend />
|
||
<Bar dataKey="工资" stackId="a" fill="#6366f1" />
|
||
<Bar dataKey="社保" stackId="a" fill="#10b981" />
|
||
<Bar dataKey="公积金" stackId="a" fill="#f59e0b" />
|
||
</BarChart>
|
||
</ResponsiveContainer>
|
||
) : (
|
||
<p className="text-muted-foreground text-center py-12">暂无部门数据</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, delay: 0.45 }}
|
||
>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg font-medium">费用科目拆分</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
{expenseChartData.length > 0 ? (
|
||
<ResponsiveContainer width="100%" height={300}>
|
||
<PieChart>
|
||
<Pie
|
||
data={expenseChartData}
|
||
cx="50%"
|
||
cy="50%"
|
||
outerRadius={100}
|
||
dataKey="value"
|
||
label={(entry) => `${entry.name}: ${formatCurrency(entry.value)}`}
|
||
>
|
||
{expenseChartData.map((_, index) => (
|
||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||
))}
|
||
</Pie>
|
||
<Tooltip formatter={(value: any) => formatCurrency(Number(value))} />
|
||
</PieChart>
|
||
</ResponsiveContainer>
|
||
) : (
|
||
<p className="text-muted-foreground text-center py-12">暂无费用科目数据</p>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
</div>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, delay: 0.5 }}
|
||
>
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-lg font-medium">部门明细表</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="hover:bg-transparent">
|
||
<TableHead>部门</TableHead>
|
||
<TableHead className="text-right">人数</TableHead>
|
||
<TableHead className="text-right">工资成本</TableHead>
|
||
<TableHead className="text-right">社保成本</TableHead>
|
||
<TableHead className="text-right">公积金成本</TableHead>
|
||
<TableHead className="text-right">合计</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{analysis.departments.map((dept) => (
|
||
<TableRow key={dept.department}>
|
||
<TableCell className="font-medium">{dept.department}</TableCell>
|
||
<TableCell className="text-right">{dept.employee_count}</TableCell>
|
||
<TableCell className="text-right font-mono">{formatCurrency(dept.salary_cost)}</TableCell>
|
||
<TableCell className="text-right font-mono">{formatCurrency(dept.social_security_cost)}</TableCell>
|
||
<TableCell className="text-right font-mono">{formatCurrency(dept.fund_cost)}</TableCell>
|
||
<TableCell className="text-right font-mono font-semibold">{formatCurrency(dept.total_cost)}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.5, delay: 0.55 }}
|
||
>
|
||
<Card>
|
||
<CardHeader>
|
||
<div className="flex items-center gap-2">
|
||
<MessageSquare className="w-5 h-5 text-primary" />
|
||
<CardTitle className="text-lg font-medium">AI 问答</CardTitle>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="space-y-4">
|
||
{questions.length > 0 && (
|
||
<div className="flex flex-wrap gap-2">
|
||
{questions.map((q, i) => (
|
||
<Button
|
||
key={i}
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => askQuestion(q)}
|
||
disabled={askLoading}
|
||
className="btn-press"
|
||
>
|
||
{q}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
<Input
|
||
placeholder="输入您的问题..."
|
||
value={customQuestion}
|
||
onChange={(e) => setCustomQuestion(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && customQuestion) {
|
||
askQuestion(customQuestion);
|
||
setCustomQuestion("");
|
||
}
|
||
}}
|
||
/>
|
||
<Button
|
||
onClick={() => {
|
||
if (customQuestion) {
|
||
askQuestion(customQuestion);
|
||
setCustomQuestion("");
|
||
}
|
||
}}
|
||
disabled={askLoading || !customQuestion}
|
||
className="btn-press"
|
||
>
|
||
<Send className="w-4 h-4 mr-1" />
|
||
提问
|
||
</Button>
|
||
</div>
|
||
|
||
{askLoading && (
|
||
<div className="flex items-center gap-2 text-muted-foreground">
|
||
<Loader2 className="w-4 h-4 animate-spin" />
|
||
正在思考...
|
||
</div>
|
||
)}
|
||
|
||
{answer && !askLoading && (
|
||
<div className="p-4 rounded-lg bg-muted/50 border border-border">
|
||
<div className="flex items-start gap-2">
|
||
<Sparkles className="w-4 h-4 text-primary mt-0.5 shrink-0" />
|
||
<p className="text-body text-foreground/90">{answer}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|