Files
TurboHR/frontend/src/pages/compliance/RiskCenter.tsx
T
2026-08-01 06:45:59 +08:00

207 lines
8.4 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, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import {
ShieldAlert, AlertTriangle, Clock, Users, FileText,
TrendingDown, Calendar, ChevronRight, Filter,
} from 'lucide-react'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
import { InlineAlert } from '../../components/ui/InlineAlert'
import api from '../../lib/api'
/** 风险等级配置 */
const RISK_LEVELS: Record<string, { label: string; color: string; bg: string }> = {
HIGH: { label: '高风险', color: 'text-rose-600', bg: 'bg-rose-50 border-rose-200' },
MEDIUM: { label: '中风险', color: 'text-amber-600', bg: 'bg-amber-50 border-amber-200' },
LOW: { label: '低风险', color: 'text-blue-600', bg: 'bg-blue-50 border-blue-200' },
}
/** 风险类型配置 */
const RISK_TYPES: Record<string, { label: string; icon: typeof ShieldAlert; link: string }> = {
CONTRACT_EXPIRE: { label: '合同到期', icon: FileText, link: '/roster' },
CONTRACT_UNSIGNED: { label: '未签合同', icon: FileText, link: '/roster' },
PROBATION_EXPIRE: { label: '试用期到期', icon: Clock, link: '/roster' },
SALARY_BELOW_MIN: { label: '工资低于最低标准', icon: TrendingDown, link: '/money' },
SOCIAL_INSURANCE_GAP: { label: '社保断缴', icon: ShieldAlert, link: '/social' },
TERMINATION_RISK: { label: '离职风险', icon: Users, link: '/termination' },
POLICY_UNREAD: { label: '制度未阅读', icon: FileText, link: '/policies' },
}
export default function RiskCenter() {
const [filterLevel, setFilterLevel] = useState<string>('ALL')
const [filterType, setFilterType] = useState<string>('ALL')
/** 获取风险列表 */
const { data: risks = [], isLoading } = useQuery<any[]>({
queryKey: ['risk-center'],
queryFn: async () => {
const res = await api.get('/compliance/risks') as any
return res.data || []
},
})
/** 按级别统计 */
const stats = useMemo(() => {
const high = risks.filter((r: any) => r.level === 'HIGH').length
const medium = risks.filter((r: any) => r.level === 'MEDIUM').length
const low = risks.filter((r: any) => r.level === 'LOW').length
return { high, medium, low, total: risks.length }
}, [risks])
/** 按类型统计 */
const typeStats = useMemo(() => {
const map: Record<string, number> = {}
risks.forEach((r: any) => {
map[r.type] = (map[r.type] || 0) + 1
})
return map
}, [risks])
/** 筛选后的风险列表 */
const filteredRisks = useMemo(() => {
return risks.filter((r: any) => {
if (filterLevel !== 'ALL' && r.level !== filterLevel) return false
if (filterType !== 'ALL' && r.type !== filterType) return false
return true
})
}, [risks, filterLevel, filterType])
return (
<div className="space-y-4">
{/* 页头 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldAlert className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-0.5 text-sm text-gray-500"></p>
</div>
</div>
</div>
{/* 风险概览卡片 */}
<div className="grid grid-cols-4 gap-3">
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-gray-900 mt-1">{stats.total}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-rose-600 mt-1">{stats.high}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-amber-600 mt-1">{stats.medium}</div>
</Card>
<Card className="p-3">
<div className="text-xs text-gray-400"></div>
<div className="text-2xl font-bold text-blue-600 mt-1">{stats.low}</div>
</Card>
</div>
{/* 高风险告警 */}
{stats.high > 0 && (
<InlineAlert type="error">
{stats.high}
</InlineAlert>
)}
{/* 风险类型分布 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{Object.entries(RISK_TYPES).map(([key, cfg]) => {
const count = typeStats[key] || 0
if (count === 0) return null
const Icon = cfg.icon
return (
<Link
key={key}
to={cfg.link}
className="flex items-center gap-2 p-2.5 rounded-lg border border-gray-100 hover:border-primary/30 hover:bg-primary/5 transition-colors"
>
<Icon className="w-4 h-4 text-gray-400" />
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-600 truncate">{cfg.label}</div>
<div className="text-sm font-bold text-gray-900">{count}</div>
</div>
<ChevronRight className="w-3 h-3 text-gray-300" />
</Link>
)
})}
{Object.values(typeStats).every((v) => v === 0) && (
<div className="col-span-full text-center py-4 text-sm text-gray-400"></div>
)}
</div>
</Card>
{/* 筛选器 */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-1 text-sm text-gray-400">
<Filter className="w-4 h-4" />
</div>
<div className="flex gap-1">
<button
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterLevel === 'ALL' ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterLevel('ALL')}
></button>
{Object.entries(RISK_LEVELS).map(([key, cfg]) => (
<button
key={key}
className={`px-3 py-1 rounded-md text-xs transition-colors ${filterLevel === key ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
onClick={() => setFilterLevel(key)}
>{cfg.label}</button>
))}
</div>
</div>
{/* 风险列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : filteredRisks.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
{risks.length === 0 ? '暂无风险项,一切正常' : '当前筛选条件下无匹配项'}
</div>
) : (
<div className="space-y-2">
{filteredRisks.map((r: any, i: number) => {
const levelCfg = RISK_LEVELS[r.level] || RISK_LEVELS.LOW
const typeCfg = RISK_TYPES[r.type] || { label: r.type, icon: AlertTriangle, link: '/' }
const Icon = typeCfg.icon
return (
<Link
key={i}
to={typeCfg.link}
className={`flex items-start gap-3 p-3 rounded-lg border ${levelCfg.bg} hover:shadow-sm transition-shadow`}
>
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${levelCfg.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-xs font-medium ${levelCfg.color}`}>{levelCfg.label}</span>
<span className="text-xs text-gray-400">{typeCfg.label}</span>
</div>
<div className="text-sm text-gray-700 mt-0.5">{r.message}</div>
{r.employeeName && (
<div className="text-xs text-gray-400 mt-0.5">
{r.employeeName} · {r.department || ''}
</div>
)}
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 mt-1" />
</Link>
)
})}
</div>
)}
</Card>
</div>
)
}