Files
TurboHR/frontend/src/pages/tools/AnnualValueReport.tsx
T
selfrelease d79e3baa34 feat: 系统优化Phase2 - 面包屑导航/侧边栏间距/制度公示阅读签收/模板变量中文化/通知类型补全
- 面包屑导航组件,集成至TopNav header
- 侧边栏菜单分组间距增大,分组间分隔线
- 制度公示员工阅读签收:PolicyReadRecord模型、portal路由、管理端阅读统计
- 修复Policies.tsx民主程序推进bug(字段名/API路径/参数)
- 用工文本模板变量名英文转中文显示
- 通知类型TYPE_LABELS补全(RISK_ALERT/SOCIAL_INS/OVERTIME_ALERT/PAYSLIP_READY)
- 通知示例数据补充
- h2标题统一为text-sm font-medium
- 新增run.md
2026-07-26 20:32:38 +08:00

252 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.
/**
* 年度价值报告页面
* 量化系统为企业创造的价值: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>
{/* ROI 横幅 */}
<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"> (ROI)</div>
<div className="text-3xl font-bold text-primary">{report.roi}%</div>
</div>
</div>
<div className="text-right">
<div className="text-xs text-gray-500"></div>
<div className="text-2xl font-bold text-amber-600">{fmtMoney(report.totalValue)}</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">ROI {r.roi}%</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>
{/* 价值计算说明 */}
<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-400 mt-1 flex-shrink-0" />
<span><b>ROI</b> ÷ ¥12,000× 100%</span>
</div>
</div>
</Card>
</div>
)
}