Files
TurboHR/frontend/src/pages/tools/AnnualValueReport.tsx
T
2026-07-31 10:41:22 +08:00

365 lines
17 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.
/**
* 年度价值报告页面
* 量化系统为企业创造的价值:ROI + 规避损失 + 节约工时 + 月度时间轴
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { TrendingUp, Save, History, Download, ShieldCheck, Clock, DollarSign, Sparkles, Users, FileText, Calculator, Award } from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts'
import api from '../../lib/api'
import Card from '../../components/ui/Card'
import Button from '../../components/ui/Button'
function fmtMoney(n: number): string {
if (n >= 10000) return `¥${(n / 10000).toFixed(1)}`
return `¥${n.toLocaleString('zh-CN')}`
}
/**
* 年度价值报告页面
*/
export default function AnnualValueReport() {
const queryClient = useQueryClient()
const currentYear = new Date().getFullYear()
const [year, setYear] = useState(currentYear)
const [showHistory, setShowHistory] = useState(false)
const { data: report, isLoading } = useQuery<any>({
queryKey: ['annual-value', year],
queryFn: async () => {
const res = await api.get(`/dashboard/annual-value?year=${year}`) as any
return res.data
},
})
const { data: history } = useQuery<any>({
queryKey: ['annual-value-history'],
queryFn: async () => {
const res = await api.get('/dashboard/annual-value/history') as any
return res.data
},
enabled: showHistory,
})
const saveMutation = useMutation({
mutationFn: async () => {
const res = await api.post('/dashboard/annual-value/save', { year }) as any
return res.data
},
onSuccess: () => {
toast.success('报告已保存')
queryClient.invalidateQueries({ queryKey: ['annual-value-history'] })
},
onError: () => toast.error('保存失败'),
})
if (isLoading) {
return <div className="text-center py-8 text-gray-500">...</div>
}
if (!report) return null
const metricCards = [
{ label: '规避损失', value: fmtMoney(report.lossAvoided || 0), icon: ShieldCheck, color: 'text-safe', bg: 'bg-green-50' },
{ label: '节约工时', value: `${report.timeSaved || 0}h`, icon: Clock, color: 'text-blue-600', bg: 'bg-blue-50' },
{ label: '节约成本', value: fmtMoney(report.costSaved || 0), icon: DollarSign, color: 'text-primary', bg: 'bg-indigo-50' },
{ label: '总价值', value: fmtMoney(report.totalValue || 0), icon: TrendingUp, color: 'text-amber-600', bg: 'bg-amber-50' },
]
const stats = [
{ label: '处理风险', value: report.metrics?.risksResolved ?? 0, icon: ShieldCheck, color: 'text-danger' },
{ label: 'AI 咨询', value: report.metrics?.aiQueries ?? 0, icon: Sparkles, color: 'text-primary' },
{ label: '合同审查', value: report.metrics?.contractsReviewed ?? 0, icon: FileText, color: 'text-blue-600' },
{ label: '签订合同', value: report.metrics?.contractsSigned ?? 0, icon: FileText, color: 'text-safe' },
{ label: '算薪批次', value: report.metrics?.payrollProcessed ?? 0, icon: Calculator, color: 'text-amber-600' },
{ label: '在管员工', value: report.metrics?.employeesManaged ?? 0, icon: Users, color: 'text-gray-700' },
{ label: '解聘处理', value: report.metrics?.terminations ?? 0, icon: Users, color: 'text-orange-600' },
{ label: '制度公示', value: report.metrics?.policiesPublished ?? 0, icon: FileText, color: 'text-purple-600' },
]
const timelineData = (report.timeline || []).map((t: any) => ({
name: `${t.month}`,
risks: t.value,
event: t.event,
}))
return (
<div className="space-y-3">
{/* 标题栏 */}
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Award className="h-5 w-5 text-primary" />
<h1 className="text-base font-semibold"></h1>
</div>
<p className="mt-1 text-sm text-gray-500">{year} · </p>
</div>
<div className="flex items-center gap-2">
<select
value={year}
onChange={(e) => setYear(parseInt(e.target.value))}
className="px-2 py-1 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
>
{Array.from({ length: 5 }, (_, i) => currentYear - i).map((y) => (
<option key={y} value={y}>{y} </option>
))}
</select>
<Button variant="secondary" size="sm" onClick={() => setShowHistory(!showHistory)}>
<History className="w-4 h-4 mr-1" />
{showHistory ? '收起' : '历史'}
</Button>
<Button size="sm" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>
<Save className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 年度总价值概览 */}
<Card className="bg-gradient-to-r from-primary/10 to-amber-50 border-primary/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-primary/10">
<TrendingUp className="w-8 h-8 text-primary" />
</div>
<div>
<div className="text-xs text-gray-500"></div>
<div className="text-3xl font-bold text-primary">{fmtMoney(report.totalValue)}</div>
</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-safe">{fmtMoney(report.adjustedLossAvoided || 0)}</div>
</div>
</div>
<p className="mt-3 text-sm text-gray-700">{report.summary}</p>
</Card>
{/* 4 核心指标 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{metricCards.map((m) => {
const Icon = m.icon
return (
<Card key={m.label} className="flex items-center gap-2.5">
<div className={`flex items-center justify-center w-9 h-9 rounded-lg ${m.bg}`}>
<Icon className={`w-5 h-5 ${m.color}`} />
</div>
<div>
<div className={`text-base font-bold ${m.color}`}>{m.value}</div>
<div className="text-xs text-gray-500">{m.label}</div>
</div>
</Card>
)
})}
</div>
{/* 历史报告 */}
{showHistory && (
<Card>
<h2 className="text-sm font-medium mb-2 flex items-center gap-1.5">
<History className="w-4 h-4 text-gray-500" />
</h2>
{history && history.length > 0 ? (
<div className="space-y-1.5">
{history.map((r: any) => (
<div key={r.id} className="flex items-center justify-between p-2 rounded-md bg-gray-50 text-xs">
<div className="flex items-center gap-2">
<span className="font-medium">{r.year} </span>
<span className="font-bold text-primary"> {fmtMoney(r.totalValue)}</span>
<span className="text-gray-500">{r.summary}</span>
</div>
<span className="text-gray-400">{new Date(r.createdAt).toLocaleDateString('zh-CN')}</span>
</div>
))}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-3"></div>
)}
</Card>
)}
{/* 月度风险趋势 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<ResponsiveContainer width="100%" height={180}>
<BarChart data={timelineData}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<YAxis tick={{ fontSize: 11 }} axisLine={false} tickLine={false} />
<Tooltip
formatter={(v: any) => [`${v}`, '风险']}
contentStyle={{ fontSize: 12, borderRadius: 6, border: '1px solid #e5e7eb' }}
/>
<Bar dataKey="risks" radius={[4, 4, 0, 0]}>
{timelineData.map((d: any, i: number) => (
<Cell key={i} fill={d.risks > 5 ? '#EF4444' : d.risks > 0 ? '#F59E0B' : '#16A34A'} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* 8 项运营统计 */}
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{stats.map((s) => {
const Icon = s.icon
return (
<div key={s.label} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
<Icon className={`w-4 h-4 ${s.color}`} />
<div>
<div className="text-sm font-bold">{s.value}</div>
<div className="text-xs text-gray-500">{s.label}</div>
</div>
</div>
)
})}
</div>
</Card>
{/* 员工规避损失明细 */}
{report.employeeDetails && report.employeeDetails.length > 0 && (
<Card>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-200 text-gray-500">
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-center py-2 px-2 font-medium"></th>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-left py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{report.employeeDetails.map((emp: any, idx: number) => (
<tr key={emp.employeeId || idx} className="border-b border-gray-50">
<td className="py-2 px-2 font-medium">{emp.name}</td>
<td className="py-2 px-2 text-gray-600">{emp.department}</td>
<td className="py-2 px-2 text-center">{emp.riskCount}</td>
<td className="py-2 px-2 text-right font-bold text-safe">¥{emp.adjustedLoss.toLocaleString()}</td>
<td className="py-2 px-2">
<div className="space-y-0.5">
{emp.details.map((d: any, i: number) => (
<div key={i} className="text-safe font-medium">{d.resolutionMethod}</div>
))}
</div>
</td>
<td className="py-2 px-2">
<div className="space-y-0.5">
{emp.details.map((d: any, i: number) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-gray-700">{d.title}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-500">{d.legalBasis}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-600">¥{d.estimatedLoss.toLocaleString()}</span>
</div>
))}
</div>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-200 font-bold">
<td className="py-2 px-2" colSpan={3}></td>
<td className="py-2 px-2 text-right text-safe">¥{(report.adjustedLossAvoided || 0).toLocaleString()}</td>
<td className="py-2 px-2"></td>
</tr>
</tfoot>
</table>
</div>
</Card>
)}
{/* 未解决风险列表 */}
{report.pendingRiskDetails && report.pendingRiskDetails.length > 0 && (
<Card>
<h2 className="text-sm font-medium mb-3 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-danger" />
({report.pendingRiskDetails.length} )
</h2>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-gray-200 text-gray-500">
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-left py-2 px-2 font-medium"></th>
<th className="text-center py-2 px-2 font-medium"></th>
<th className="text-right py-2 px-2 font-medium"></th>
<th className="text-left py-2 px-2 font-medium"></th>
</tr>
</thead>
<tbody>
{report.pendingRiskDetails.map((emp: any, idx: number) => (
<tr key={idx} className="border-b border-gray-50">
<td className="py-2 px-2 font-medium">{emp.name}</td>
<td className="py-2 px-2 text-gray-600">{emp.department}</td>
<td className="py-2 px-2 text-center">{emp.riskCount}</td>
<td className="py-2 px-2 text-right font-bold text-danger">¥{emp.totalLoss.toLocaleString()}</td>
<td className="py-2 px-2">
<div className="space-y-0.5">
{emp.details.map((d: any, i: number) => (
<div key={i} className="flex items-center gap-1.5">
<span className="text-gray-700">{d.title}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-500">{d.legalBasis}</span>
<span className="text-gray-400">·</span>
<span className="text-gray-600">¥{d.estimatedLoss.toLocaleString()}</span>
</div>
))}
</div>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-200 font-bold">
<td className="py-2 px-2" colSpan={3}></td>
<td className="py-2 px-2 text-right text-danger">
¥{report.pendingRiskDetails.reduce((s: number, e: any) => s + e.totalLoss, 0).toLocaleString()}
</td>
<td className="py-2 px-2"></td>
</tr>
</tfoot>
</table>
</div>
</Card>
)}
{/* 价值计算说明 */}
<Card className="bg-gray-50">
<h2 className="text-sm font-medium mb-2"></h2>
<div className="space-y-1 text-xs text-gray-600">
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-safe mt-1 flex-shrink-0" />
<span><b></b></span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-blue-500 mt-1 flex-shrink-0" />
<span><b></b>AI × 0.5h + × 1h + × 2h + × 1h</span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
<span><b></b> × 100 /hHR </span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1 flex-shrink-0" />
<span><b></b> + </span>
</div>
<div className="flex items-start gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-gray-300 mt-1 flex-shrink-0" />
<span><b></b>1182N123472N2487</span>
</div>
</div>
</Card>
</div>
)
}