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
This commit is contained in:
selfrelease
2026-07-26 20:32:38 +08:00
parent 9cb0d1f63b
commit d79e3baa34
71 changed files with 18561 additions and 3230 deletions
+265 -12
View File
@@ -2,8 +2,8 @@ import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from 'recharts'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight } from 'lucide-react'
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, RadialBarChart, RadialBar, PolarAngleAxis } from 'recharts'
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle, Download, ChevronRight, CalendarDays, TrendingDown, ShieldCheck, Lightbulb, BookOpen, Sparkles } from 'lucide-react'
import api from '../lib/api'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
@@ -56,6 +56,31 @@ export default function Dashboard() {
},
})
const currentMonth = new Date().toISOString().slice(0, 7)
const { data: calendarData } = useQuery<any>({
queryKey: ['calendar', currentMonth],
queryFn: async () => {
const res = await api.get(`/dashboard/calendar?month=${currentMonth}`) as any
return res.data
},
})
const { data: costAnalysis } = useQuery<any>({
queryKey: ['cost-analysis', currentMonth],
queryFn: async () => {
const res = await api.get(`/dashboard/cost-analysis?month=${currentMonth}`) as any
return res.data
},
})
const { data: complianceScore } = useQuery<any>({
queryKey: ['compliance-score'],
queryFn: async () => {
const res = await api.get('/dashboard/compliance-score') as any
return res.data
},
})
const resolveMutation = useMutation({
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
@@ -175,6 +200,13 @@ export default function Dashboard() {
{ key: 'task' as const, label: '月度任务', icon: ListTodo, badge: taskTodos.length },
]
const priorityConfig: Record<string, { label: string; color: string; bg: string }> = {
URGENT: { label: '紧急', color: 'text-red-700', bg: 'bg-red-100' },
HIGH: { label: '高', color: 'text-orange-700', bg: 'bg-orange-100' },
MEDIUM: { label: '中', color: 'text-amber-700', bg: 'bg-amber-100' },
LOW: { label: '低', color: 'text-gray-600', bg: 'bg-gray-100' },
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
@@ -191,6 +223,33 @@ export default function Dashboard() {
</Button>
</div>
{/* 紧急风险横幅 */}
{data.urgentRisk && (
<Link to={data.urgentRisk.actionUrl}>
<Card className="border-red-300 bg-red-50 hover:bg-red-100 transition-colors cursor-pointer">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-full bg-red-200 flex-shrink-0">
<AlertTriangle className="w-5 h-5 text-red-700" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-bold text-red-800"></span>
<span className="px-1.5 py-0.5 rounded text-xs bg-red-200 text-red-800"> {fmt(data.urgentRisk.estimatedLoss)}</span>
{data.urgentRisk.daysUntilDeadline !== null && (
<span className={`px-1.5 py-0.5 rounded text-xs ${data.urgentRisk.daysUntilDeadline <= 0 ? 'bg-red-300 text-red-900' : 'bg-red-200 text-red-800'}`}>
{data.urgentRisk.daysUntilDeadline <= 0 ? '已逾期' : `${data.urgentRisk.daysUntilDeadline}天后截止`}
</span>
)}
</div>
<div className="text-sm text-red-700 mt-0.5 truncate">{data.urgentRisk.title}</div>
<div className="text-xs text-red-600 mt-0.5">{data.urgentRisk.description}</div>
</div>
<ArrowRight className="w-5 h-5 text-red-600 flex-shrink-0" />
</div>
</Card>
</Link>
)}
{/* Tab 导航 */}
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
@@ -218,6 +277,96 @@ export default function Dashboard() {
{/* 概览 Tab */}
{activeTab === 'overview' && (
<div className="space-y-3">
{/* 合规健康度评分 + AI 建议卡片流 */}
{complianceScore && (
<div className="space-y-3">
{/* 评分环 + 5 维度 */}
<Card>
<div className="flex items-center gap-4">
{/* SVG 环形评分 */}
<div className="relative w-28 h-28 shrink-0">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
innerRadius="70%"
outerRadius="100%"
data={[{ value: complianceScore.overallScore, fill: complianceScore.level === 'safe' ? '#16A34A' : complianceScore.level === 'warning' ? '#D97706' : '#C00000' }]}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis type="number" domain={[0, 100]} tick={false} />
<RadialBar background dataKey="value" cornerRadius={8} />
</RadialBarChart>
</ResponsiveContainer>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className={`text-2xl font-bold ${complianceScore.level === 'safe' ? 'text-safe' : complianceScore.level === 'warning' ? 'text-warning' : 'text-danger'}`}>{complianceScore.overallScore}</span>
<span className="text-xs text-gray-500">{complianceScore.levelLabel}</span>
</div>
</div>
{/* 5 维度评分 */}
<div className="flex-1 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{complianceScore.dimensions.map((dim: any) => (
<Link key={dim.key} to={dim.key === 'contract' ? '/roster' : dim.key === 'policy' ? '/policies' : dim.key === 'attendance' ? '/attendance' : dim.key === 'salary' ? '/money' : '/social'} className="flex items-center justify-between p-2 rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${dim.score >= 85 ? 'bg-safe' : dim.score >= 60 ? 'bg-warning' : 'bg-danger'}`} />
<span className="text-xs text-gray-700">{dim.name}</span>
</div>
<div className="flex items-center gap-1.5">
{dim.todoCount > 0 && <span className="text-xs text-gray-400">{dim.todoCount}</span>}
<span className={`text-sm font-bold ${dim.score >= 85 ? 'text-safe' : dim.score >= 60 ? 'text-warning' : 'text-danger'}`}>{dim.score}</span>
</div>
</Link>
))}
</div>
</div>
</Card>
{/* AI 建议卡片流 */}
{complianceScore.suggestions.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-sm font-medium text-gray-700">
<Sparkles className="w-4 h-4 text-primary" />
AI
</div>
{complianceScore.suggestions.map((s: any, i: number) => {
const suggestionConfig: Record<string, { icon: typeof AlertTriangle; bg: string; border: string; iconColor: string; label: string; labelBg: string }> = {
danger: { icon: AlertTriangle, bg: 'bg-red-50', border: 'border-red-200', iconColor: 'text-red-600', label: '高风险', labelBg: 'bg-red-100 text-red-700' },
warning: { icon: AlertCircle, bg: 'bg-amber-50', border: 'border-amber-200', iconColor: 'text-amber-600', label: '中风险', labelBg: 'bg-amber-100 text-amber-700' },
value: { icon: ShieldCheck, bg: 'bg-green-50', border: 'border-green-200', iconColor: 'text-safe', label: '价值', labelBg: 'bg-green-100 text-safe' },
knowledge: { icon: BookOpen, bg: 'bg-purple-50', border: 'border-purple-200', iconColor: 'text-purple-600', label: '知识', labelBg: 'bg-purple-100 text-purple-700' },
}
const config = suggestionConfig[s.type] || { icon: Lightbulb, bg: 'bg-gray-50', border: 'border-gray-200', iconColor: 'text-gray-600', label: '', labelBg: '' }
const Icon = config.icon
return (
<Link key={i} to={s.actionUrl}>
<Card className={`${config.bg} ${config.border} border hover:shadow-md transition-shadow cursor-pointer`}>
<div className="flex items-start gap-3">
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${config.iconColor} bg-white/60 flex-shrink-0`}>
<Icon className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${config.labelBg}`}>{config.label}</span>
<span className="text-sm font-medium text-gray-900 truncate">{s.title}</span>
{s.estimatedLoss && s.estimatedLoss > 0 && (
<span className="text-xs text-red-600 font-bold"> {fmt(s.estimatedLoss)}</span>
)}
</div>
<p className="text-xs text-gray-600 mt-0.5">{s.description}</p>
</div>
<div className="flex items-center gap-1 text-xs text-primary flex-shrink-0">
{s.actionLabel}
<ArrowRight className="w-3 h-3" />
</div>
</div>
</Card>
</Link>
)
})}
</div>
)}
</div>
)}
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{stats.map((stat) => {
@@ -239,7 +388,7 @@ export default function Dashboard() {
{/* 当月人力成本 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Wallet className="w-4 h-4 text-primary" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><Wallet className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{payroll?.month}</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
@@ -259,7 +408,7 @@ export default function Dashboard() {
{/* 年度累计人力成本 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{yearCost?.year}</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
@@ -309,7 +458,7 @@ export default function Dashboard() {
{/* 本月工作动态 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><Briefcase className="w-4 h-4" /></h2>
<span className="text-xs text-gray-500">{activities?.month}</span>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
@@ -328,7 +477,7 @@ export default function Dashboard() {
{/* 风险分布 */}
<Card>
<h2 className="font-medium mb-3"></h2>
<h2 className="text-sm font-medium mb-3"></h2>
<div className="flex items-center gap-4">
<div className="w-32 h-32 shrink-0">
<ResponsiveContainer width="100%" height="100%">
@@ -407,8 +556,21 @@ export default function Dashboard() {
<Link key={r.id} to={r.actionUrl} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
<AlertCircle className={`w-4 h-4 flex-shrink-0 ${r.level === 'high' ? 'text-danger' : 'text-warning'}`} />
<div className="flex-1 min-w-0">
<div className="truncate text-gray-800">{r.title}</div>
{r.employeeName && <div className="text-gray-500">{r.employeeName}</div>}
<div className="flex items-center gap-1.5">
<span className="truncate text-gray-800">{r.title}</span>
{r.priority && priorityConfig[r.priority] && (
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[r.priority].bg} ${priorityConfig[r.priority].color}`}>
{priorityConfig[r.priority].label}
</span>
)}
</div>
<div className="flex items-center gap-2 text-gray-500">
{r.employeeName && <span>{r.employeeName}</span>}
{r.estimatedLoss > 0 && <span className="text-red-600"> {fmt(r.estimatedLoss)}</span>}
{r.daysUntilDeadline !== null && r.daysUntilDeadline <= 7 && (
<span className="text-red-600">{r.daysUntilDeadline <= 0 ? '已逾期' : `${r.daysUntilDeadline}`}</span>
)}
</div>
</div>
<ArrowRight className="w-3 h-3 text-gray-500" />
</Link>
@@ -420,6 +582,84 @@ export default function Dashboard() {
)}
</Card>
</div>
{/* HR 月度日历 + 人力成本分析 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* 月度日历 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><CalendarDays className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{currentMonth}</span>
</div>
{calendarData?.events && calendarData.events.length > 0 ? (
<div className="space-y-1.5 max-h-64 overflow-y-auto">
{calendarData.events.slice(0, 10).map((ev: any, i: number) => (
<Link key={i} to={ev.actionUrl || '/'} className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-gray-50 text-xs">
<div className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${
ev.priority === 'high' ? 'bg-red-500' : ev.priority === 'medium' ? 'bg-amber-500' : 'bg-gray-400'
}`} />
<span className="text-gray-500 w-20 flex-shrink-0">{ev.date.slice(5)}</span>
<span className="text-gray-800 truncate flex-1">{ev.title}</span>
</Link>
))}
{calendarData.events.length > 10 && (
<div className="text-xs text-gray-500 text-center pt-1"> {calendarData.events.length - 10} </div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-4"></div>
)}
</Card>
{/* 人力成本分析 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium flex items-center gap-1.5"><TrendingUp className="w-4 h-4 text-primary" /></h2>
<span className="text-xs text-gray-400">{currentMonth}</span>
</div>
{costAnalysis ? (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
<div className={`p-2 rounded-lg ${costAnalysis.monthOnMonth?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
<div className="text-xs text-gray-500"></div>
<div className={`text-sm font-bold ${costAnalysis.monthOnMonth?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
{costAnalysis.monthOnMonth?.change >= 0 ? '+' : ''}{costAnalysis.monthOnMonth?.changePercent?.toFixed(1)}%
</div>
<div className="text-xs text-gray-500">
{costAnalysis.monthOnMonth?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.monthOnMonth?.change || 0))}
</div>
</div>
<div className={`p-2 rounded-lg ${costAnalysis.yearOnYear?.change >= 0 ? 'bg-red-50' : 'bg-green-50'}`}>
<div className="text-xs text-gray-500"></div>
<div className={`text-sm font-bold ${costAnalysis.yearOnYear?.change >= 0 ? 'text-red-600' : 'text-green-600'}`}>
{costAnalysis.yearOnYear?.change >= 0 ? '+' : ''}{costAnalysis.yearOnYear?.changePercent?.toFixed(1)}%
</div>
<div className="text-xs text-gray-500">
{costAnalysis.yearOnYear?.change >= 0 ? '↑' : '↓'} {fmt(Math.abs(costAnalysis.yearOnYear?.change || 0))}
</div>
</div>
</div>
<div className="flex items-center justify-between border-t pt-2">
<span className="text-xs text-gray-600"></span>
<span className="text-sm font-bold text-primary">{fmt(costAnalysis.current?.perCapita || 0)}</span>
</div>
{costAnalysis.factors && costAnalysis.factors.length > 0 && (
<div className="space-y-1 border-t pt-2">
<div className="text-xs font-medium text-gray-600"></div>
{costAnalysis.factors.map((f: any, i: number) => (
<div key={i} className="flex items-start gap-1.5 text-xs">
<span className={`w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0 ${f.impact >= 0 ? 'bg-red-500' : 'bg-green-500'}`} />
<span className="text-gray-600 flex-1">{f.description}</span>
</div>
))}
</div>
)}
</div>
) : (
<div className="text-xs text-gray-500 text-center py-4"></div>
)}
</Card>
</div>
</div>
)}
@@ -427,7 +667,7 @@ export default function Dashboard() {
{activeTab === 'payroll' && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><Calculator className="w-4 h-4" /></h2>
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={handleExportPayroll} disabled={!payroll || payroll.payslipCount === 0}>
<Download className="w-4 h-4 mr-1" />
@@ -533,7 +773,7 @@ export default function Dashboard() {
{/* 待办列表 */}
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<h2 className="text-sm font-medium">{activeTab === 'risk' ? '风险提醒' : '月度任务'}</h2>
<span className="text-xs text-gray-500">{filteredTodos.length} </span>
</div>
@@ -588,7 +828,20 @@ export default function Dashboard() {
<Link to={todo.actionUrl} className="flex items-center gap-2.5 flex-1">
<TodoIcon type={todo.type} level={todo.level} />
<div className="flex flex-col">
<span className="text-xs text-gray-800">{todo.title}</span>
<div className="flex items-center gap-1.5">
<span className="text-xs text-gray-800">{todo.title}</span>
{todo.priority && priorityConfig[todo.priority] && (
<span className={`px-1 py-0.5 rounded text-xs font-medium ${priorityConfig[todo.priority].bg} ${priorityConfig[todo.priority].color}`}>
{priorityConfig[todo.priority].label}
</span>
)}
{todo.estimatedLoss > 0 && (
<span className="text-xs text-red-600 font-medium"> {fmt(todo.estimatedLoss)}</span>
)}
{todo.daysUntilDeadline !== null && todo.daysUntilDeadline <= 3 && (
<span className="text-xs text-red-600">{todo.daysUntilDeadline <= 0 ? '已逾期' : `${todo.daysUntilDeadline}`}</span>
)}
</div>
<span className="text-xs text-gray-500 flex items-center gap-1"><Clock className="w-3 h-3" />{todo.description}</span>
</div>
</Link>
@@ -622,7 +875,7 @@ export default function Dashboard() {
{data.resolvedTodos && data.resolvedTodos.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-3">
<h2 className="font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" /></h2>
<h2 className="text-sm font-medium flex items-center gap-1.5"><CheckSquare className="w-4 h-4 text-safe" /></h2>
<span className="text-xs text-gray-500">{data.resolvedTodos.length} </span>
</div>
<div className="space-y-1.5">