33b4c734aa
后端: - 新增认证(auth)、任务(tasks)、映射(mappings)、对账(reconciliation)、异常(exceptions)、导出(exports) API - 新增核心模块: database, security, permissions, tenant, exceptions, error_handlers - 新增数据模型: user, company, reconciliation_task, field_mapping, uploaded_file 等 - 新增服务层: ai_recognizer, file_parser, file_storage, mapping, reconciliation 等 - 添加数据库迁移脚本 前端: - 新增登录页面和仪表盘页面 - 新增任务列表、任务详情、字段映射页面 - 新增异常处理页面和规则设置页面 - 新增 API 代理路由 /api/[...path] - 新增 UI 组件库 (button, card, dialog, input, table 等) - 新增 auth 组件 (ProtectedRoute, PermissionGate) - 新增 layout 组件 (Header, Sidebar) - 新增 mapping 组件 (FieldMappingTable, AISuggestionPanel) - 新增 API 客户端和 hooks (useAsync, useToast, usePermission 等) - 新增状态管理 (auth-store, company-store, ui-store) - 集成 Tailwind CSS 和 shadcn/ui 组件库 其他: - 添加 Alembic 数据库迁移配置 - 添加初始化示例数据脚本 - 更新项目文档
630 lines
26 KiB
TypeScript
630 lines
26 KiB
TypeScript
"use client";
|
||
|
||
import { Suspense, useState, useEffect } 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 {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
AlertTriangle,
|
||
CheckCircle,
|
||
Clock,
|
||
Eye,
|
||
Search,
|
||
XCircle,
|
||
} from "lucide-react";
|
||
import { api } from "@/lib/api/client";
|
||
|
||
interface ExceptionItem {
|
||
id: number;
|
||
task_id: number;
|
||
exception_type: string;
|
||
severity: "low" | "medium" | "high" | "critical";
|
||
status: "pending" | "processing" | "resolved" | "ignored";
|
||
employee_id: string;
|
||
employee_name: string;
|
||
description: string;
|
||
detail: string | null;
|
||
salary_amount: number | null;
|
||
social_security_amount: number | null;
|
||
tax_amount: number | null;
|
||
bank_amount: number | null;
|
||
difference_amount: number | null;
|
||
resolution: string | null;
|
||
handler: string | null;
|
||
note: string | null;
|
||
suggested_action: string | null;
|
||
created_at: string;
|
||
updated_at: string | null;
|
||
}
|
||
|
||
interface Summary {
|
||
by_status: Record<string, number>;
|
||
by_severity: Record<string, number>;
|
||
by_type: Record<string, number>;
|
||
}
|
||
|
||
const severityConfig = {
|
||
low: { label: "低", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300", icon: "bg-blue-500" },
|
||
medium: { label: "中", color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300", icon: "bg-yellow-500" },
|
||
high: { label: "高", color: "bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300", icon: "bg-orange-500" },
|
||
critical: { label: "严重", color: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300", icon: "bg-red-500" },
|
||
};
|
||
|
||
const statusConfig = {
|
||
pending: { label: "待处理", color: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300" },
|
||
processing: { label: "处理中", color: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300" },
|
||
confirmed: { label: "已确认", color: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300" },
|
||
resolved: { label: "已解决", color: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300" },
|
||
ignored: { label: "已忽略", color: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-500" },
|
||
};
|
||
|
||
const typeLabels: Record<string, string> = {
|
||
amount_mismatch: "金额不一致",
|
||
missing_record: "记录缺失",
|
||
duplicate_record: "重复记录",
|
||
format_error: "格式错误",
|
||
logic_error: "逻辑错误",
|
||
};
|
||
|
||
const container = {
|
||
hidden: { opacity: 0 },
|
||
show: {
|
||
opacity: 1,
|
||
transition: { staggerChildren: 0.06 },
|
||
},
|
||
};
|
||
|
||
const item = {
|
||
hidden: { opacity: 0, y: 8 },
|
||
show: { opacity: 1, y: 0, transition: { duration: 0.3, ease: [0.16, 1, 0.3, 1] as const } },
|
||
};
|
||
|
||
function ExceptionsContent() {
|
||
const searchParams = useSearchParams();
|
||
|
||
const [exceptions, setExceptions] = useState<ExceptionItem[]>([]);
|
||
const [summary, setSummary] = useState<Summary | null>(null);
|
||
const [total, setTotal] = useState(0);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const [taskId, setTaskId] = useState(searchParams.get("task_id") || "");
|
||
const [status, setStatus] = useState(searchParams.get("status") || "");
|
||
const [severity, setSeverity] = useState(searchParams.get("severity") || "");
|
||
const [exceptionType, setExceptionType] = useState(searchParams.get("type") || "");
|
||
const [page, setPage] = useState(Number(searchParams.get("page")) || 1);
|
||
const [pageSize] = useState(20);
|
||
|
||
const [selectedException, setSelectedException] = useState<ExceptionItem | null>(null);
|
||
const [detailOpen, setDetailOpen] = useState(false);
|
||
|
||
useEffect(() => {
|
||
fetchExceptions();
|
||
fetchSummary();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [taskId, status, severity, exceptionType, page]);
|
||
|
||
async function fetchExceptions() {
|
||
setLoading(true);
|
||
try {
|
||
const res = await api.get<{ items: ExceptionItem[]; total: number }>("/api/exceptions/", {
|
||
params: {
|
||
task_id: taskId || undefined,
|
||
status: status || undefined,
|
||
severity: severity || undefined,
|
||
exception_type: exceptionType || undefined,
|
||
page,
|
||
page_size: pageSize,
|
||
},
|
||
});
|
||
setExceptions(res.data.items || []);
|
||
setTotal(res.data.total || 0);
|
||
} catch (error) {
|
||
console.error("加载异常列表失败", error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
|
||
async function fetchSummary() {
|
||
try {
|
||
const res = await api.get<Summary>("/api/exceptions/summary", {
|
||
params: { task_id: taskId || undefined },
|
||
});
|
||
setSummary(res.data);
|
||
} catch (error) {
|
||
console.error("加载汇总失败", error);
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
setPage(1);
|
||
fetchExceptions();
|
||
fetchSummary();
|
||
}
|
||
|
||
function viewDetail(exception: ExceptionItem) {
|
||
setSelectedException(exception);
|
||
setDetailOpen(true);
|
||
}
|
||
|
||
async function updateStatus(id: number, newStatus: string) {
|
||
try {
|
||
await api.patch(`/api/exceptions/${id}`, { status: newStatus });
|
||
fetchExceptions();
|
||
fetchSummary();
|
||
} catch (error) {
|
||
console.error("更新状态失败", error);
|
||
}
|
||
}
|
||
|
||
const totalPages = Math.ceil(total / pageSize);
|
||
|
||
return (
|
||
<div className="min-h-full p-6 lg:p-8 max-w-7xl mx-auto space-y-6">
|
||
{/* Header */}
|
||
<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>
|
||
</motion.div>
|
||
|
||
{/* Summary Cards */}
|
||
{summary && (
|
||
<motion.div
|
||
variants={container}
|
||
initial="hidden"
|
||
animate="show"
|
||
className="grid 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-center justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide">待处理</p>
|
||
<p className="text-2xl font-semibold mt-1">{summary.by_status?.pending || 0}</p>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-gray-100 dark:bg-gray-800">
|
||
<Clock className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
<motion.div variants={item}>
|
||
<Card className="hover-lift">
|
||
<CardContent className="p-5">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide">处理中</p>
|
||
<p className="text-2xl font-semibold mt-1 text-amber-600">{summary.by_status?.processing || 0}</p>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-amber-50 dark:bg-amber-950/30">
|
||
<AlertTriangle 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-center justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide">已解决</p>
|
||
<p className="text-2xl font-semibold mt-1 text-emerald-600">{summary.by_status?.resolved || 0}</p>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-emerald-50 dark:bg-emerald-950/30">
|
||
<CheckCircle 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-center justify-between">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground uppercase tracking-wide">严重异常</p>
|
||
<p className="text-2xl font-semibold mt-1 text-red-600">{summary.by_severity?.critical || 0}</p>
|
||
</div>
|
||
<div className="p-2.5 rounded-lg bg-red-50 dark:bg-red-950/30">
|
||
<XCircle className="w-5 h-5 text-red-600" />
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{/* Filter Card */}
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||
>
|
||
<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>
|
||
<div className="relative">
|
||
<Input
|
||
placeholder="输入任务ID"
|
||
value={taskId}
|
||
onChange={(e) => setTaskId(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||
className="h-9"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-[150px]">
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">状态</label>
|
||
<Select value={status} onValueChange={setStatus}>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue placeholder="全部状态" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="">全部状态</SelectItem>
|
||
<SelectItem value="pending">待处理</SelectItem>
|
||
<SelectItem value="processing">处理中</SelectItem>
|
||
<SelectItem value="resolved">已解决</SelectItem>
|
||
<SelectItem value="ignored">已忽略</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="w-[150px]">
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">严重程度</label>
|
||
<Select value={severity} onValueChange={setSeverity}>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue placeholder="全部程度" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="">全部程度</SelectItem>
|
||
<SelectItem value="low">低</SelectItem>
|
||
<SelectItem value="medium">中</SelectItem>
|
||
<SelectItem value="high">高</SelectItem>
|
||
<SelectItem value="critical">严重</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="w-[180px]">
|
||
<label className="text-caption text-muted-foreground mb-1.5 block">异常类型</label>
|
||
<Select value={exceptionType} onValueChange={setExceptionType}>
|
||
<SelectTrigger className="h-9">
|
||
<SelectValue placeholder="全部类型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="">全部类型</SelectItem>
|
||
<SelectItem value="amount_mismatch">金额不一致</SelectItem>
|
||
<SelectItem value="missing_record">记录缺失</SelectItem>
|
||
<SelectItem value="duplicate_record">重复记录</SelectItem>
|
||
<SelectItem value="format_error">格式错误</SelectItem>
|
||
<SelectItem value="logic_error">逻辑错误</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<Button onClick={handleSearch} className="h-9 btn-press">
|
||
<Search className="w-4 h-4 mr-2" />
|
||
搜索
|
||
</Button>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
{/* Exceptions Table */}
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 8 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.4, delay: 0.3, ease: [0.16, 1, 0.3, 1] }}
|
||
>
|
||
<Card>
|
||
<CardHeader className="pb-4">
|
||
<div className="flex items-center justify-between">
|
||
<CardTitle className="text-lg font-medium">异常列表</CardTitle>
|
||
<Badge variant="secondary">{total} 条记录</Badge>
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="hover:bg-transparent">
|
||
<TableHead className="w-[100px]">状态</TableHead>
|
||
<TableHead>员工</TableHead>
|
||
<TableHead>类型</TableHead>
|
||
<TableHead>严重程度</TableHead>
|
||
<TableHead>描述</TableHead>
|
||
<TableHead className="text-right">差异金额</TableHead>
|
||
<TableHead className="w-[100px]">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{loading ? (
|
||
<TableRow>
|
||
<TableCell colSpan={7} className="text-center py-12">
|
||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||
</svg>
|
||
加载中...
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : exceptions.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={7} className="text-center py-12">
|
||
<div className="w-12 h-12 rounded-full bg-muted mx-auto mb-3 flex items-center justify-center">
|
||
<CheckCircle className="w-6 h-6 text-muted-foreground" />
|
||
</div>
|
||
<p className="text-foreground font-medium">暂无异常记录</p>
|
||
<p className="text-caption text-muted-foreground mt-1">所有记录都匹配成功</p>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
exceptions.map((exception, index) => (
|
||
<motion.div
|
||
key={exception.id}
|
||
initial={{ opacity: 0, y: 4 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.2, delay: index * 0.02 }}
|
||
>
|
||
<TableRow className="group">
|
||
<TableCell>
|
||
<Badge className={statusConfig[exception.status]?.color || "bg-gray-100 text-gray-700"}>
|
||
{statusConfig[exception.status]?.label || exception.status}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div>
|
||
<div className="font-medium">{exception.employee_name}</div>
|
||
<div className="text-xs text-muted-foreground">{exception.employee_id}</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant="outline" className="font-normal">
|
||
{typeLabels[exception.exception_type] || exception.exception_type}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-2">
|
||
<div className={`w-2 h-2 rounded-full ${severityConfig[exception.severity]?.icon || "bg-gray-500"}`} />
|
||
<Badge className={severityConfig[exception.severity]?.color || "bg-gray-100 text-gray-700"}>
|
||
{severityConfig[exception.severity]?.label || exception.severity}
|
||
</Badge>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="max-w-[200px]">
|
||
<span className="truncate block">{exception.description}</span>
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
{exception.difference_amount != null && (
|
||
<span className={exception.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}>
|
||
{exception.difference_amount > 0 ? "+" : ""}
|
||
{exception.difference_amount.toFixed(2)}
|
||
</span>
|
||
)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<Button variant="ghost" size="sm" onClick={() => viewDetail(exception)} className="h-8 w-8 p-0">
|
||
<Eye className="h-4 w-4" />
|
||
</Button>
|
||
{exception.status === "pending" && (
|
||
<Button variant="ghost" size="sm" onClick={() => updateStatus(exception.id, "processing")} className="h-8 px-2">
|
||
处理
|
||
</Button>
|
||
)}
|
||
{exception.status === "processing" && (
|
||
<Button variant="ghost" size="sm" onClick={() => updateStatus(exception.id, "resolved")} className="h-8 px-2">
|
||
完成
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
</motion.div>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="flex items-center justify-between px-6 py-4 border-t">
|
||
<p className="text-caption text-muted-foreground">
|
||
第 {page} 页,共 {totalPages} 页
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={page <= 1}
|
||
onClick={() => setPage(page - 1)}
|
||
className="btn-press"
|
||
>
|
||
上一页
|
||
</Button>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={page >= totalPages}
|
||
onClick={() => setPage(page + 1)}
|
||
className="btn-press"
|
||
>
|
||
下一页
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</Card>
|
||
</motion.div>
|
||
|
||
{/* Detail Dialog */}
|
||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>异常详情</DialogTitle>
|
||
</DialogHeader>
|
||
{selectedException && (
|
||
<div className="space-y-6">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground">员工姓名</p>
|
||
<p className="font-medium">{selectedException.employee_name}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-caption text-muted-foreground">员工ID</p>
|
||
<p className="font-medium">{selectedException.employee_id}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-caption text-muted-foreground">异常类型</p>
|
||
<Badge variant="outline">{typeLabels[selectedException.exception_type] || selectedException.exception_type}</Badge>
|
||
</div>
|
||
<div>
|
||
<p className="text-caption text-muted-foreground">严重程度</p>
|
||
<div className="flex items-center gap-2">
|
||
<div className={`w-2 h-2 rounded-full ${severityConfig[selectedException.severity]?.icon || "bg-gray-500"}`} />
|
||
<Badge className={severityConfig[selectedException.severity]?.color || "bg-gray-100 text-gray-700"}>
|
||
{severityConfig[selectedException.severity]?.label || selectedException.severity}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="border-t pt-4">
|
||
<h4 className="font-medium mb-3">金额详情</h4>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||
{selectedException.salary_amount != null && (
|
||
<div className="p-3 rounded-lg bg-muted/50">
|
||
<p className="text-caption text-muted-foreground">工资</p>
|
||
<p className="font-mono font-medium mt-1">{selectedException.salary_amount.toFixed(2)}</p>
|
||
</div>
|
||
)}
|
||
{selectedException.social_security_amount != null && (
|
||
<div className="p-3 rounded-lg bg-muted/50">
|
||
<p className="text-caption text-muted-foreground">社保</p>
|
||
<p className="font-mono font-medium mt-1">{selectedException.social_security_amount.toFixed(2)}</p>
|
||
</div>
|
||
)}
|
||
{selectedException.tax_amount != null && (
|
||
<div className="p-3 rounded-lg bg-muted/50">
|
||
<p className="text-caption text-muted-foreground">个税</p>
|
||
<p className="font-mono font-medium mt-1">{selectedException.tax_amount.toFixed(2)}</p>
|
||
</div>
|
||
)}
|
||
{selectedException.bank_amount != null && (
|
||
<div className="p-3 rounded-lg bg-muted/50">
|
||
<p className="text-caption text-muted-foreground">银行</p>
|
||
<p className="font-mono font-medium mt-1">{selectedException.bank_amount.toFixed(2)}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{selectedException.difference_amount != null && (
|
||
<div className="mt-4 p-4 rounded-lg bg-muted/50">
|
||
<p className="text-caption text-muted-foreground">差异金额</p>
|
||
<p className={`text-2xl font-mono font-semibold mt-1 ${selectedException.difference_amount > 0 ? "text-red-600" : "text-emerald-600"}`}>
|
||
{selectedException.difference_amount > 0 ? "+" : ""}
|
||
{selectedException.difference_amount.toFixed(2)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="border-t pt-4">
|
||
<div className="space-y-3">
|
||
<div>
|
||
<p className="text-caption text-muted-foreground mb-1">描述</p>
|
||
<p>{selectedException.description}</p>
|
||
</div>
|
||
{selectedException.detail && (
|
||
<div>
|
||
<p className="text-caption text-muted-foreground mb-1">详细信息</p>
|
||
<pre className="text-xs bg-muted p-3 rounded-lg overflow-auto">
|
||
{JSON.stringify(JSON.parse(selectedException.detail), null, 2)}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{selectedException.suggested_action && (
|
||
<div>
|
||
<p className="text-caption text-muted-foreground mb-1">建议操作</p>
|
||
<p>{selectedException.suggested_action}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LoadingFallback() {
|
||
return (
|
||
<div className="min-h-full p-6 lg:p-8 flex items-center justify-center">
|
||
<div className="flex items-center gap-2 text-muted-foreground">
|
||
<svg className="animate-spin w-5 h-5" viewBox="0 0 24 24">
|
||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||
</svg>
|
||
加载中...
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function ExceptionsPage() {
|
||
return (
|
||
<Suspense fallback={<LoadingFallback />}>
|
||
<ExceptionsContent />
|
||
</Suspense>
|
||
);
|
||
} |