207 lines
8.4 KiB
TypeScript
207 lines
8.4 KiB
TypeScript
/**
|
||
* 统一风险中心 — 汇总展示合同风险、薪酬风险、社保风险、合规风险
|
||
* 按风险等级分类,支持快速跳转处理
|
||
*/
|
||
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>
|
||
)
|
||
}
|