Files
TurboHR/frontend/src/pages/roster/EvidenceChain.tsx
T
freedakgmail a2e9ba55c2 feat: 20260809 系统优化 - 全部28项问题修复(P0×6+P1×16+P2×6)
P0: 福利批量参保/离职证明下载防乱码/考勤模板合并Sheet/补卡修改/附件在线查看删除
P1: 分页pageSize修复/离职导出筛选/撤回删除草稿/加班费自动计算/考勤加班汇总/证据链异常详情/制度催办/模板导入Word/社保封顶保底/校验字段提示/职务字段/社保费用明细/弹窗防误关/身份证查重/证明员工下拉/培训批量
P2: 离职流程去重/社保基数覆盖输入/薪税入口改名/添加员工引导/绩效模板清理
2026-08-09 11:59:02 +08:00

260 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react"
import { toast } from "sonner"
import { useQuery } from "@tanstack/react-query"
import { rosterApi, evidenceApi } from '../../lib/api-services'
import { useAuthStore } from "../../store/authStore"
import Card from "../../components/ui/Card"
import Button from "../../components/ui/Button"
import { AlertTriangle, Scale, ShieldCheck } from "lucide-react"
// ========== 仲裁证据链 ==========
export default function EvidenceChain({ employeeId }: { employeeId: string }) {
const [verifyResult, setVerifyResult] = useState<any>(null)
const [verifying, setVerifying] = useState(false)
const { data, isLoading } = useQuery<any>({
queryKey: ['evidence-chain', employeeId],
queryFn: async () => {
return await rosterApi.evidenceChain(employeeId)
},
})
const handleVerify = async () => {
setVerifying(true)
try {
const records = await evidenceApi.byEmployee(employeeId)
const results: any[] = []
for (const r of records) {
const result = await evidenceApi.verify(r.id)
results.push({ id: r.id, category: r.category, refId: r.refId, ...result })
}
setVerifyResult({ total: records.length, valid: results.filter(r => r.valid).length, invalid: results.filter(r => !r.valid).length, details: results })
toast.success(`验证完成:${results.filter(r => r.valid).length}/${results.length} 条有效`)
} catch {
toast.error('验证失败')
} finally {
setVerifying(false)
}
}
if (isLoading) return <div className="text-center py-8 text-gray-400">...</div>
if (!data) return <div className="text-center py-8 text-gray-400"></div>
if (!data.evidence || data.evidence.length === 0) return (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xs font-medium flex items-center gap-2"><Scale className="w-4 h-4" /></h2>
<div className="text-xs text-gray-500 mt-1">
{data.employee?.name || '未知'} · {data.employee?.department || '未知'} · {data.employee?.hireDate ? data.employee.hireDate.toString().slice(0, 10) : '未知'}
</div>
</div>
</div>
</Card>
<Card><div className="text-center py-8 text-gray-400"></div></Card>
</div>
)
const categoryColor: Record<string, string> = {
'劳动关系': 'bg-blue-50 text-blue-700 border-blue-200',
'薪酬发放': 'bg-green-50 text-green-700 border-green-200',
'考勤记录': 'bg-amber-50 text-amber-700 border-amber-200',
'违纪处理': 'bg-red-50 text-red-700 border-red-200',
'培训签收': 'bg-purple-50 text-purple-700 border-purple-200',
'绩效考核': 'bg-indigo-50 text-indigo-700 border-indigo-200',
'解聘记录': 'bg-gray-100 text-gray-700 border-gray-300',
}
const handleExport = async () => {
try {
const token = useAuthStore.getState().accessToken
const res = await fetch(`/api/v1/roster/${employeeId}/evidence-chain/export`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('导出失败')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `仲裁证据链_${data.employee.name}_${new Date().toISOString().slice(0, 10)}.xlsx`
a.click()
URL.revokeObjectURL(url)
} catch {
toast.error('导出失败')
}
}
const riskStyle: Record<string, string> = {
DANGER: 'bg-red-50 border-red-300 text-red-700',
HIGH: 'bg-orange-50 border-orange-300 text-orange-700',
MEDIUM: 'bg-amber-50 border-amber-300 text-amber-700',
}
const riskIcon: Record<string, string> = {
DANGER: '🔴',
HIGH: '🟠',
MEDIUM: '🟡',
}
return (
<div className="space-y-3">
<Card>
<div className="flex items-center justify-between">
<div>
<h2 className="text-xs font-medium flex items-center gap-2"><Scale className="w-4 h-4" /></h2>
<div className="text-xs text-gray-500 mt-1">
{data.employee.name} · {data.employee.department} · {data.employee.hireDate}
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold">{data.summary.total}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-safe">{data.summary.signed}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-warning">{data.summary.unsigned}</div>
</div>
{data.summary.riskCount > 0 && (
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-xl font-bold text-danger">{data.summary.riskCount}</div>
</div>
)}
<Button onClick={handleExport}></Button>
<Button variant="secondary" onClick={handleVerify} disabled={verifying}>
<ShieldCheck className="w-4 h-4 mr-1" />
{verifying ? '验证中...' : '验证完整性'}
</Button>
</div>
</div>
</Card>
{data.risks && data.risks.length > 0 && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-danger" />
{data.risks.length}
</h3>
<div className="space-y-2">
{data.risks.map((r: any, i: number) => (
<div key={i} className={`border rounded-lg p-3 ${riskStyle[r.level] || 'bg-gray-50 border-gray-200 text-gray-600'}`}>
<div className="flex items-center gap-2">
<span>{riskIcon[r.level] || '⚠'}</span>
<span className="font-medium text-xs">{r.title}</span>
<span className="text-xs opacity-70">{r.category}</span>
</div>
<div className="text-xs mt-1 opacity-90">{r.description}</div>
</div>
))}
</div>
</Card>
)}
{verifyResult && (
<Card>
<h3 className="text-xs font-medium mb-3 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-safe" />
</h3>
<div className="flex items-center gap-4 mb-3">
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold">{verifyResult.total}</div>
</div>
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold text-safe">{verifyResult.valid}</div>
</div>
{verifyResult.invalid > 0 && (
<div className="text-xs text-center">
<div className="text-gray-500"></div>
<div className="text-lg font-bold text-danger">{verifyResult.invalid}</div>
</div>
)}
</div>
{verifyResult.invalid > 0 && (
<div className="space-y-1">
{verifyResult.details.filter((r: any) => !r.valid).map((r: any, i: number) => (
<div key={i} className="text-xs border rounded p-2 bg-red-50 border-red-200 text-red-700">
<span className="font-medium">{r.category}</span>
{r.refId && <span className="text-xs opacity-70 ml-2">ID: {r.refId}</span>}
<div className="mt-0.5 opacity-90"> {r.expectedHash?.slice(0, 16)}... {r.actualHash?.slice(0, 16)}...</div>
</div>
))}
</div>
)}
{verifyResult.invalid === 0 && (
<div className="text-xs text-safe flex items-center gap-1">
<ShieldCheck className="w-3.5 h-3.5" />
</div>
)}
</Card>
)}
<div className="space-y-2">
{data.evidence.map((e: any, i: number) => (
<Card key={i} className={e.riskLevel === 'HIGH' ? 'border-orange-300' : ''}>
<div className="flex items-start gap-3">
<span className={`px-2 py-0.5 rounded border text-xs shrink-0 ${categoryColor[e.category] || 'bg-gray-50 text-gray-600 border-gray-200'}`}>
{e.category}
</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-xs">{e.title}</span>
<span className="text-xs text-gray-400">{e.date}</span>
{e.acknowledged === true && <span className="text-xs text-safe"> </span>}
{e.acknowledged === false && <span className="text-xs text-warning"> </span>}
{e.riskLevel === 'HIGH' && <span className="text-xs text-danger"> </span>}
</div>
<div className="text-xs text-gray-600 mt-1">{e.description}</div>
</div>
</div>
</Card>
))}
</div>
</div>
)
}
export function generateEvidenceText(data: any): string {
const lines: string[] = []
lines.push('========================================')
lines.push(' 劳动仲裁证据链')
lines.push('========================================')
lines.push('')
lines.push(`员工姓名:${data.employee.name}`)
lines.push(`部门:${data.employee.department}`)
lines.push(`入职日期:${data.employee.hireDate}`)
lines.push(`状态:${data.employee.status === 'ACTIVE' ? '在职' : '离职'}`)
lines.push('')
lines.push(`证据总数:${data.summary.total}`)
lines.push(`已签字:${data.summary.signed}`)
lines.push(`未签字:${data.summary.unsigned}`)
lines.push('')
lines.push('----------------------------------------')
lines.push('')
let currentCategory = ''
data.evidence.forEach((e: any, i: number) => {
if (e.category !== currentCategory) {
currentCategory = e.category
lines.push(`${currentCategory}`)
lines.push('')
}
lines.push(`${i + 1}. ${e.title}${e.date}`)
lines.push(` ${e.description}`)
if (e.acknowledged === true) lines.push(' [已签字确认]')
if (e.acknowledged === false) lines.push(' [未签字]')
lines.push('')
})
lines.push('----------------------------------------')
lines.push(`导出时间:${new Date().toLocaleString('zh-CN')}`)
return lines.join('\n')
}