feat: 优化退休提醒功能 - 个性化退休年龄计算
- 添加 FemaleWorkerType enum (CADRE/WORKER) 和 Employee.femaleWorkerType 字段 - 新增 calcIndividualRetireAge: 按个人出生日期逐人计算退休年龄 - 重写 calcRetirementDaysLeft: 基于个人出生日期+性别+岗位类型 - updateEmployeesRetirementDays 使用 femaleWorkerType 区分女干部/女工人 - 前端新增/编辑员工表单添加女性岗位类型选择器 - 前端政策展示改为改革规则卡片(基准→目标年龄) - 前端距退休天数改为退休日期显示(XXXX年XX月XX日) - 移除AI退休政策计算相关代码和手动刷新路由 - 导入支持女性岗位类型列
This commit is contained in:
@@ -844,6 +844,7 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
const [form, setForm] = useState({
|
||||
department: profile.department || '',
|
||||
gender: profile.gender || '男',
|
||||
femaleWorkerType: profile.femaleWorkerType || '',
|
||||
phone: profile.phone || '',
|
||||
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
|
||||
monthlySalary: profile.monthlySalary || '',
|
||||
@@ -873,6 +874,7 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
const data: any = {
|
||||
department: form.department,
|
||||
gender: form.gender,
|
||||
femaleWorkerType: form.femaleWorkerType || undefined,
|
||||
phone: form.phone || undefined,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: String(form.monthlySalary),
|
||||
@@ -895,6 +897,9 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
{ label: '姓名', value: profile.name },
|
||||
{ label: '部门', value: profile.department },
|
||||
{ label: '性别', value: profile.gender || '未填写' },
|
||||
...(profile.gender === '女'
|
||||
? [{ label: '女性岗位', value: profile.femaleWorkerType === 'CADRE' ? '干部/管理岗' : profile.femaleWorkerType === 'WORKER' ? '工人/操作岗' : '未填写' }]
|
||||
: []),
|
||||
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
|
||||
{ label: '手机号', value: profile.phone || '未填写' },
|
||||
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
|
||||
@@ -906,7 +911,26 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
{ label: '银行账号', value: profile.bankAccount || '未填写' },
|
||||
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
|
||||
...(profile.retirementDaysLeft != null
|
||||
? [{ label: '距退休', value: profile.retirementDaysLeft > 0 ? `${profile.retirementDaysLeft}天` : '已到退休年龄' }]
|
||||
? (() => {
|
||||
if (profile.retirementDaysLeft <= 0) return [{ label: '距退休', value: '已到退休年龄' }]
|
||||
if (profile.birthDate) {
|
||||
const bd = new Date(profile.birthDate)
|
||||
const gender = profile.gender || '男'
|
||||
const fwt = profile.femaleWorkerType || null
|
||||
const baseAge = gender === '男' ? 60 : (fwt === 'WORKER' ? 50 : 55)
|
||||
const delayInterval = gender === '男' ? 4 : (fwt === 'WORKER' ? 2 : 4)
|
||||
const maxDelay = gender === '男' ? 36 : (fwt === 'WORKER' ? 60 : 36)
|
||||
const baseRetireDate = new Date(bd)
|
||||
baseRetireDate.setFullYear(baseRetireDate.getFullYear() + baseAge)
|
||||
const reformStart = new Date(2025, 0, 1)
|
||||
const monthsSince = Math.max(0, (baseRetireDate.getFullYear() - reformStart.getFullYear()) * 12 + (baseRetireDate.getMonth() - reformStart.getMonth()))
|
||||
const delayMonths = Math.min(maxDelay, Math.floor(monthsSince / delayInterval))
|
||||
const retireDate = new Date(baseRetireDate)
|
||||
retireDate.setMonth(retireDate.getMonth() + delayMonths)
|
||||
return [{ label: '退休日期', value: `${retireDate.getFullYear()}年${retireDate.getMonth() + 1}月${retireDate.getDate()}日` }]
|
||||
}
|
||||
return [{ label: '距退休', value: `${profile.retirementDaysLeft}天` }]
|
||||
})()
|
||||
: []),
|
||||
...(profile.status !== 'ACTIVE' && profile.terminations && profile.terminations.length > 0
|
||||
? [{ label: '离职日期', value: profile.terminations
|
||||
@@ -951,6 +975,9 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
<div><Label>身份证号(不可编辑)</Label><Input value={profile.idCardNumber || ''} disabled /></div>
|
||||
<div><Label>部门</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} /></div>
|
||||
<div><Label>性别</Label><Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}><option value="男">男</option><option value="女">女</option></Select></div>
|
||||
{form.gender === '女' && (
|
||||
<div><Label>女性岗位类型</Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value="">未选择</option><option value="CADRE">干部/管理岗</option><option value="WORKER">工人/操作岗</option></Select></div>
|
||||
)}
|
||||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||||
<div><Label>入职日期</Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
|
||||
<div><Label>月工资</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
|
||||
@@ -1795,7 +1822,7 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
})()
|
||||
const [form, setForm] = useState({
|
||||
name: '', department: '', hireDate: todayStr, monthlySalary: '',
|
||||
idCardNumber: '', gender: '男' as '男' | '女', phone: '',
|
||||
idCardNumber: '', gender: '男' as '男' | '女', femaleWorkerType: '' as '' | 'CADRE' | 'WORKER', phone: '',
|
||||
city: '北京',
|
||||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||||
signDate: '', startDate: todayStr, endDate: defaultEndDate,
|
||||
@@ -1895,6 +1922,7 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
name: form.name, department: form.department,
|
||||
hireDate: new Date(form.hireDate).toISOString(),
|
||||
monthlySalary: form.monthlySalary, gender: form.gender,
|
||||
femaleWorkerType: form.gender === '女' && form.femaleWorkerType ? form.femaleWorkerType : undefined,
|
||||
idCardNumber: form.idCardNumber || undefined,
|
||||
phone: form.phone || undefined,
|
||||
socialInsBase: form.socialInsBase ? parseFloat(form.socialInsBase) : undefined,
|
||||
@@ -1936,6 +1964,9 @@ function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||||
<div><Label>部门 *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
|
||||
<div><Label>身份证号 *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位" maxLength={18} /></div>
|
||||
<div><Label>性别</Label><div className="text-sm text-gray-600 py-2">{form.idCardNumber.length >= 17 ? form.gender : '自动识别'}</div></div>
|
||||
{form.gender === '女' && (
|
||||
<div><Label>女性岗位类型</Label><Select value={form.femaleWorkerType} onChange={(e) => setForm({ ...form, femaleWorkerType: e.target.value as '' | 'CADRE' | 'WORKER' })}><option value="">未选择</option><option value="CADRE">干部/管理岗</option><option value="WORKER">工人/操作岗</option></Select></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div><Label>入职日期 *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle, RefreshCw } from 'lucide-react'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet, Clock, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -148,8 +148,6 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle: (v: boolean) => void }) {
|
||||
const queryClient = useQueryClient()
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [streamContent, setStreamContent] = useState('')
|
||||
|
||||
const { data: policyData, isLoading } = useQuery<any>({
|
||||
queryKey: ['retirement-policy'],
|
||||
@@ -170,55 +168,33 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
|
||||
onError: () => toast.error('确认失败'),
|
||||
})
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
setStreamContent('')
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const baseUrl = import.meta.env.DEV ? 'http://localhost:3000/api/v1' : '/api/v1'
|
||||
const res = await fetch(`${baseUrl}/settings/retirement-policy/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
})
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buf = ''
|
||||
while (reader) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buf += decoder.decode(value, { stream: true })
|
||||
const lines = buf.split('\n')
|
||||
buf = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const msg = JSON.parse(line.slice(6))
|
||||
if (msg.type === 'chunk') {
|
||||
setStreamContent(prev => prev + msg.content)
|
||||
} else if (msg.type === 'done') {
|
||||
toast.success(msg.message || '已获取最新政策')
|
||||
setRefreshing(false)
|
||||
setStreamContent('')
|
||||
queryClient.invalidateQueries({ queryKey: ['retirement-policy'] })
|
||||
} else if (msg.type === 'error') {
|
||||
toast.error(msg.message || '获取失败')
|
||||
setRefreshing(false)
|
||||
setStreamContent('')
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('获取失败')
|
||||
setRefreshing(false)
|
||||
setStreamContent('')
|
||||
}
|
||||
}
|
||||
|
||||
const confirmed = policyData?.confirmed
|
||||
const pending = policyData?.pending
|
||||
|
||||
const renderPolicyRules = () => (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-600">改革规则(基准退休年龄 → 目标退休年龄)</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
|
||||
<div className="text-xs text-gray-500">男性职工</div>
|
||||
<div className="text-sm font-semibold text-gray-700">60岁 → 63岁</div>
|
||||
<div className="text-xs text-gray-400">每4个月延1个月</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
|
||||
<div className="text-xs text-gray-500">女性干部</div>
|
||||
<div className="text-sm font-semibold text-gray-700">55岁 → 58岁</div>
|
||||
<div className="text-xs text-gray-400">每4个月延1个月</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-md p-2 text-center border border-gray-200">
|
||||
<div className="text-xs text-gray-500">女性工人</div>
|
||||
<div className="text-sm font-semibold text-gray-700">50岁 → 55岁</div>
|
||||
<div className="text-xs text-gray-400">每2个月延1个月</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">个人退休年龄根据出生年月逐人计算,达到基准退休年龄的时间点不同,延迟月数也不同。</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-6 pt-6 border-t">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -232,9 +208,6 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
|
||||
{confirming ? '确认中...' : '确认生效'}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="secondary" onClick={handleRefresh} disabled={refreshing}>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />{refreshing ? '获取中...' : '重新获取'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -262,13 +235,7 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
|
||||
确认于 {new Date(confirmed.confirmedAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div>男性退休年龄:<b>{confirmed.maleRetireAge}岁</b> 女性干部:<b>{confirmed.femaleRetireAge}岁</b> 女性工人:<b>{confirmed.femaleWorkerAge}岁</b></div>
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">查看完整政策内容</summary>
|
||||
<div className="mt-2 whitespace-pre-wrap text-xs max-h-48 overflow-y-auto bg-white rounded p-2 border">{confirmed.content}</div>
|
||||
</details>
|
||||
</div>
|
||||
{renderPolicyRules()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -279,35 +246,18 @@ function RetirementSection({ enabled, onToggle }: { enabled: boolean; onToggle:
|
||||
<AlertCircle className="w-4 h-4 text-orange-600" />
|
||||
<span className="text-sm font-medium text-orange-800">检测到新政策版本(v{pending.version}),请确认后生效</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 space-y-1">
|
||||
<div>男性退休年龄:<b>{pending.maleRetireAge}岁</b> 女性干部:<b>{pending.femaleRetireAge}岁</b> 女性工人:<b>{pending.femaleWorkerAge}岁</b></div>
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">查看完整政策内容</summary>
|
||||
<div className="mt-2 whitespace-pre-wrap text-xs max-h-48 overflow-y-auto bg-white rounded p-2 border">{pending.content}</div>
|
||||
</details>
|
||||
</div>
|
||||
{renderPolicyRules()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无政策 */}
|
||||
{!confirmed && !pending && !isLoading && (
|
||||
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-center">
|
||||
<p className="text-sm text-gray-500">暂无退休政策数据,点击上方"重新获取"按钮获取最新政策</p>
|
||||
<p className="text-sm text-gray-500">暂无退休政策数据,系统将自动获取最新政策</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <p className="text-sm text-gray-400 text-center">加载中...</p>}
|
||||
|
||||
{/* 流式输出 */}
|
||||
{streamContent && (
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<RefreshCw className="w-4 h-4 text-blue-600 animate-spin" />
|
||||
<span className="text-sm font-medium text-blue-800">AI 正在获取最新政策...</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-xs max-h-64 overflow-y-auto bg-white rounded p-2 border">{streamContent}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user