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:
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* 年度价值报告页面
|
||||
* 量化系统为企业创造的价值: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 元/h(HR 平均时薪参考)</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 用工体检诊断页面
|
||||
* 6 维度深度诊断 + 历史报告
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { Stethoscope, Save, History, CheckCircle2, AlertCircle, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } from 'recharts'
|
||||
import api from '../../lib/api'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
/**
|
||||
* 用工体检诊断页面
|
||||
*/
|
||||
export default function HealthCheck() {
|
||||
const queryClient = useQueryClient()
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [expandedDim, setExpandedDim] = useState<string | null>(null)
|
||||
|
||||
const { data: healthCheck, isLoading } = useQuery<any>({
|
||||
queryKey: ['health-check'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard/health-check') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: history } = useQuery<any>({
|
||||
queryKey: ['health-check-history'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard/health-check/history') as any
|
||||
return res.data
|
||||
},
|
||||
enabled: showHistory,
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await api.post('/dashboard/health-check/save') as any
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('诊断报告已保存')
|
||||
queryClient.invalidateQueries({ queryKey: ['health-check-history'] })
|
||||
},
|
||||
onError: () => toast.error('保存失败'),
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
}
|
||||
|
||||
if (!healthCheck) return null
|
||||
|
||||
const levelConfig: Record<string, { color: string; bg: string; text: string; icon: typeof CheckCircle2; label: string }> = {
|
||||
safe: { color: '#16A34A', bg: 'bg-green-50', text: 'text-safe', icon: CheckCircle2, label: '健康' },
|
||||
warning: { color: '#D97706', bg: 'bg-amber-50', text: 'text-warning', icon: AlertCircle, label: '中等风险' },
|
||||
danger: { color: '#C00000', bg: 'bg-red-50', text: 'text-danger', icon: AlertTriangle, label: '高风险' },
|
||||
}
|
||||
const level = levelConfig[healthCheck.level] || levelConfig.warning
|
||||
const LevelIcon = level.icon
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Stethoscope className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">用工体检诊断</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">{healthCheck.year} 年度 · 6 维度深度诊断</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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={level.bg}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-28 h-28 shrink-0">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadialBarChart
|
||||
innerRadius="70%"
|
||||
outerRadius="100%"
|
||||
data={[{ value: healthCheck.totalScore, fill: level.color }]}
|
||||
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 ${level.text}`}>{healthCheck.totalScore}</span>
|
||||
<span className="text-xs text-gray-500">{level.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<LevelIcon className={`w-4 h-4 ${level.text}`} />
|
||||
<span className="text-sm font-medium">诊断总结</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-700">{healthCheck.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 历史报告 */}
|
||||
{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) => {
|
||||
const lc = levelConfig[r.level] || levelConfig.warning
|
||||
return (
|
||||
<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 ${lc.text}`}>{r.totalScore} 分</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>
|
||||
)}
|
||||
|
||||
{/* 6 维度诊断 */}
|
||||
<div className="space-y-2">
|
||||
{healthCheck.dimensions.map((dim: any) => {
|
||||
const dimLevel = dim.score >= 85 ? 'safe' : dim.score >= 60 ? 'warning' : 'danger'
|
||||
const dc = levelConfig[dimLevel] || levelConfig.warning
|
||||
const DimIcon = dc.icon
|
||||
const isExpanded = expandedDim === dim.key
|
||||
return (
|
||||
<Card key={dim.key}>
|
||||
<button
|
||||
onClick={() => setExpandedDim(isExpanded ? null : dim.key)}
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${dc.bg}`}>
|
||||
<DimIcon className={`w-4 h-4 ${dc.text}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{dim.name}</div>
|
||||
<div className="text-xs text-gray-500">{dim.findings[0]}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-lg font-bold ${dc.text}`}>{dim.score}</span>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-3 pt-3 border-t space-y-3">
|
||||
{/* 发现的问题 */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-700 mb-1">诊断发现</div>
|
||||
<div className="space-y-1">
|
||||
{dim.findings.map((f: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<span className={`w-1.5 h-1.5 rounded-full mt-1 flex-shrink-0 ${dim.score >= 85 ? 'bg-safe' : dim.score >= 60 ? 'bg-warning' : 'bg-danger'}`} />
|
||||
{f}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 建议 */}
|
||||
{dim.recommendations.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-primary mb-1">整改建议</div>
|
||||
<div className="space-y-1">
|
||||
{dim.recommendations.map((r: string, i: number) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
|
||||
{r}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 医疗期计算器
|
||||
* 根据员工工龄和地区计算法定医疗期天数
|
||||
* 法律依据:《企业职工患病或非因工负伤医疗期规定》(劳部发[1994]479号)
|
||||
* 上海特殊规定:沪府发[2015]40号
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Calculator, HeartPulse, Info } from 'lucide-react'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
interface MedicalPeriodResult {
|
||||
totalMonths: number
|
||||
cumulativeDays: number
|
||||
actualDays: number
|
||||
endDate: string
|
||||
legalBasis: string
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算医疗期
|
||||
* @param workYears 本单位工作年限
|
||||
* @param region 地区(上海/全国)
|
||||
* @param sickDays 累计病休天数
|
||||
* @param startDate 开始病休日期
|
||||
*/
|
||||
function calculateMedicalPeriod(
|
||||
workYears: number,
|
||||
region: 'shanghai' | 'national',
|
||||
sickDays: number,
|
||||
startDate: string,
|
||||
): MedicalPeriodResult | null {
|
||||
if (!startDate || workYears < 0) return null
|
||||
|
||||
let totalMonths: number
|
||||
let cumulativeDays: number
|
||||
let legalBasis: string
|
||||
const notes: string[] = []
|
||||
|
||||
if (region === 'shanghai') {
|
||||
// 上海特殊规定:直接按工龄分档
|
||||
if (workYears < 1) {
|
||||
totalMonths = 3
|
||||
cumulativeDays = 6 * 30 // 6个月周期
|
||||
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
|
||||
} else if (workYears < 4) {
|
||||
totalMonths = 3
|
||||
cumulativeDays = 6 * 30
|
||||
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
|
||||
} else if (workYears < 10) {
|
||||
totalMonths = 6
|
||||
cumulativeDays = 12 * 30
|
||||
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
|
||||
} else {
|
||||
totalMonths = 9
|
||||
cumulativeDays = 18 * 30
|
||||
legalBasis = '《上海市关于本市劳动者在履行劳动合同期间患病或者非因工负伤的医疗期标准的规定》'
|
||||
}
|
||||
notes.push('上海地区适用特殊规定,医疗期不按累计病休天数折算')
|
||||
} else {
|
||||
// 全国通用规定:劳部发[1994]479号
|
||||
if (workYears < 5) {
|
||||
totalMonths = 3
|
||||
cumulativeDays = 6 * 30 // 6个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
} else if (workYears < 10) {
|
||||
totalMonths = 6
|
||||
cumulativeDays = 12 * 30 // 12个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
} else if (workYears < 15) {
|
||||
totalMonths = 9
|
||||
cumulativeDays = 15 * 30 // 15个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
} else if (workYears < 20) {
|
||||
totalMonths = 12
|
||||
cumulativeDays = 18 * 30 // 18个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
} else {
|
||||
totalMonths = 24
|
||||
cumulativeDays = 30 * 30 // 30个月内累计病休
|
||||
legalBasis = '《企业职工患病或非因工负伤医疗期规定》第三条(劳部发[1994]479号)'
|
||||
}
|
||||
notes.push(`在 ${cumulativeDays / 30} 个月的累计周期内,病休累计不超过 ${totalMonths} 个月即享有医疗期保护`)
|
||||
}
|
||||
|
||||
// 计算实际可用天数
|
||||
const actualDays = Math.max(0, totalMonths * 30 - sickDays)
|
||||
|
||||
// 计算医疗期结束日期
|
||||
const start = new Date(startDate)
|
||||
const endDate = new Date(start)
|
||||
endDate.setMonth(endDate.getMonth() + totalMonths)
|
||||
|
||||
notes.push('医疗期内企业不得解除劳动合同(法定情形除外)')
|
||||
notes.push('医疗期满后不能从事原工作也不能从事另行安排的工作,企业可提前30天通知或支付代通知金解除')
|
||||
|
||||
return {
|
||||
totalMonths,
|
||||
cumulativeDays,
|
||||
actualDays,
|
||||
endDate: endDate.toISOString().slice(0, 10),
|
||||
legalBasis,
|
||||
notes,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 医疗期计算器页面
|
||||
*/
|
||||
export default function MedicalPeriodCalculator() {
|
||||
const [region, setRegion] = useState<'national' | 'shanghai'>('national')
|
||||
const [workYears, setWorkYears] = useState('')
|
||||
const [sickDays, setSickDays] = useState('0')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [result, setResult] = useState<MedicalPeriodResult | null>(null)
|
||||
|
||||
const handleCalculate = () => {
|
||||
const years = parseFloat(workYears) || 0
|
||||
const days = parseInt(sickDays) || 0
|
||||
const r = calculateMedicalPeriod(years, region, days, startDate)
|
||||
setResult(r)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setRegion('national')
|
||||
setWorkYears('')
|
||||
setSickDays('0')
|
||||
setStartDate('')
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<HeartPulse className="h-5 w-5 text-primary" />
|
||||
<h1 className="text-base font-semibold">医疗期计算器</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-3">
|
||||
{/* 地区选择 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">所在地区</label>
|
||||
<select
|
||||
value={region}
|
||||
onChange={(e) => setRegion(e.target.value as 'national' | 'shanghai')}
|
||||
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="national">全国(通用规定)</option>
|
||||
<option value="shanghai">上海(特殊规定)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 工龄输入 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">本单位工作年限(年)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={workYears}
|
||||
onChange={(e) => setWorkYears(e.target.value)}
|
||||
placeholder="如:5.5"
|
||||
step="0.5"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 病休开始日期 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">开始病休日期</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 累计病休天数 */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">已累计病休天数</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sickDays}
|
||||
onChange={(e) => setSickDays(e.target.value)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
className="w-full px-3 py-2 text-sm border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleCalculate} className="flex-1">
|
||||
<Calculator className="w-4 h-4 mr-1" />
|
||||
立即计算
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={handleReset}>
|
||||
重置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 计算结果 */}
|
||||
{result && (
|
||||
<Card className="border-primary/20 bg-primary/5">
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-medium flex items-center gap-1.5">
|
||||
<Info className="w-4 h-4 text-primary" />
|
||||
计算结果
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg bg-white">
|
||||
<div className="text-xs text-gray-500">法定医疗期</div>
|
||||
<div className="text-lg font-bold text-primary">{result.totalMonths} 个月</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-white">
|
||||
<div className="text-xs text-gray-500">累计计算周期</div>
|
||||
<div className="text-lg font-bold text-primary">{result.cumulativeDays / 30} 个月</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-white">
|
||||
<div className="text-xs text-gray-500">剩余可用天数</div>
|
||||
<div className={`text-lg font-bold ${result.actualDays > 0 ? 'text-safe' : 'text-danger'}`}>
|
||||
{result.actualDays} 天
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-white">
|
||||
<div className="text-xs text-gray-500">医疗期截止日</div>
|
||||
<div className="text-sm font-bold text-gray-800">{result.endDate}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 法律依据 */}
|
||||
<div className="p-2 rounded-md bg-amber-50 text-xs text-amber-800">
|
||||
<span className="font-medium">法律依据:</span>{result.legalBasis}
|
||||
</div>
|
||||
|
||||
{/* 注意事项 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">注意事项</div>
|
||||
{result.notes.map((note, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-primary mt-1 flex-shrink-0" />
|
||||
{note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 工龄分档表 */}
|
||||
<Card>
|
||||
<h2 className="text-sm font-medium mb-2">医疗期分档表(全国通用)</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-gray-500">
|
||||
<th className="py-2 pr-3">工作年限</th>
|
||||
<th className="py-2 pr-3">医疗期</th>
|
||||
<th className="py-2 pr-3">累计周期</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<tr><td className="py-2 pr-3">不满 5 年</td><td className="py-2 pr-3">3 个月</td><td className="py-2 pr-3">6 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">5-10 年</td><td className="py-2 pr-3">6 个月</td><td className="py-2 pr-3">12 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">10-15 年</td><td className="py-2 pr-3">9 个月</td><td className="py-2 pr-3">15 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">15-20 年</td><td className="py-2 pr-3">12 个月</td><td className="py-2 pr-3">18 个月</td></tr>
|
||||
<tr><td className="py-2 pr-3">20 年以上</td><td className="py-2 pr-3">24 个月</td><td className="py-2 pr-3">30 个月</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user