Files
TurboHR/frontend/src/pages/EmployeeBenefits.tsx
T
freedakgmail 86e5526a83 fix: 优化文档16项问题修复
- 问题1/3: 绩效考核/培训记录员工姓名可点击跳转员工详情页
- 问题2: 离职证明模板支持自定义+员工端下载
- 问题4(P0): 修复工资填写后数据归零问题
- 问题5: 社保添加员工参保信息列表
- 问题6(P0): 商业保险支持为员工参保
- 问题7(P0): 员工福利支持为员工添加福利
- 问题8: 规章制度支持导入Word文档
- 问题9: 文本模板下载Word增加HTML格式
- 问题10: 模板下载变量替换修复(排除token参数)
- 问题11(P0): 电子签署发起时员工下拉框有选项
- 问题12: 新增绩效记录添加考评人选项
- 问题13: 违纪记录添加处罚执行细节
- 问题14: 特殊员工列表添加查看详情按钮和姓名链接
- 问题15: 员工福利汇总正确显示参保人员
- 问题16(P0): 证据链验证修复(递归排序key+自动修复历史哈希)
2026-08-11 22:02:19 +08:00

460 lines
22 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.
import { useState } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Gift, Plus, Settings as SettingsIcon, X, Users } from 'lucide-react'
import { benefitApi, employeeApi } from '../lib/api-services'
import PageGuide from '../components/ui/PageGuide'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
import { Input, Label } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import Modal from '../components/ui/Modal'
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
const BENEFIT_CATEGORIES: Record<string, { label: string; color: string }> = {
TRANSPORT: { label: '交通补贴', color: 'bg-blue-50 text-blue-700 border border-blue-200' },
MEAL: { label: '餐补', color: 'bg-orange-50 text-orange-700 border border-orange-200' },
HOUSING: { label: '住房补贴', color: 'bg-teal-50 text-teal-700 border border-teal-200' },
COMMUNICATION: { label: '通讯补贴', color: 'bg-purple-50 text-purple-700 border border-purple-200' },
HEALTH_CHECK: { label: '体检', color: 'bg-green-50 text-green-700 border border-green-200' },
HOLIDAY: { label: '节日福利', color: 'bg-red-50 text-red-700 border border-red-200' },
BIRTHDAY: { label: '生日福利', color: 'bg-pink-50 text-pink-700 border border-pink-200' },
OTHER: { label: '其他', color: 'bg-gray-50 text-gray-700 border border-gray-200' },
}
const FREQUENCY_LABELS: Record<string, string> = {
MONTHLY: '每月',
QUARTERLY: '每季',
YEARLY: '每年',
ONE_TIME: '一次性',
}
const DEFAULT_PLAN = {
name: '', category: 'TRANSPORT', amount: 0, frequency: 'MONTHLY',
taxDeductible: false, description: '',
}
export default function EmployeeBenefits() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [tab, setTab] = useState<'plans' | 'summary'>('plans')
const [showAddPlan, setShowAddPlan] = useState(false)
const [editingPlan, setEditingPlan] = useState<any>(null)
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null)
const [newPlan, setNewPlan] = useState<any>({ ...DEFAULT_PLAN })
const [showEnrollModal, setShowEnrollModal] = useState(false)
const [enrollEmployeeIds, setEnrollEmployeeIds] = useState<string[]>([])
const [enrollEffectiveFrom, setEnrollEffectiveFrom] = useState(new Date().toISOString().slice(0, 7))
const { data: plans = [], isLoading } = useQuery<any[]>({
queryKey: ['benefit-plans'],
queryFn: async () => {
return await benefitApi.plans()
},
})
const { data: enrollments = [], isLoading: enrollLoading } = useQuery<any[]>({
queryKey: ['benefit-enrollments', selectedPlanId],
queryFn: async () => {
if (!selectedPlanId) return []
return await benefitApi.enrollments(selectedPlanId)
},
enabled: !!selectedPlanId,
})
const { data: employeeSummary = [] } = useQuery<any[]>({
queryKey: ['benefit-employee-summary'],
queryFn: async () => {
return await benefitApi.employeeSummary()
},
enabled: tab === 'summary',
})
const { data: rosterData } = useQuery<any[]>({
queryKey: ['employees-for-benefit'],
queryFn: async () => {
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showEnrollModal,
})
const savePlanMutation = useMutation({
mutationFn: async (data: any) => {
if (editingPlan) {
return benefitApi.savePlan(data, editingPlan.id) as any
}
return benefitApi.savePlan(data) as any
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['benefit-plans'] })
setShowAddPlan(false)
setEditingPlan(null)
setNewPlan({ ...DEFAULT_PLAN })
toast.success(editingPlan ? '福利方案已更新' : '福利方案已创建')
},
onError: () => toast.error('保存失败'),
})
const deletePlanMutation = useMutation({
mutationFn: (id: string) => benefitApi.removePlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['benefit-plans'] })
setSelectedPlanId(null)
toast.success('福利方案已删除')
},
})
const enrollMutation = useMutation({
mutationFn: async (data: { employeeIds: string[]; effectiveFrom: string }) =>
benefitApi.enroll(selectedPlanId!, data) as any,
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['benefit-enrollments'] })
queryClient.invalidateQueries({ queryKey: ['benefit-employee-summary'] })
setShowEnrollModal(false)
setEnrollEmployeeIds([])
toast.success(`已添加 ${res.data?.enrolled || 0} 名员工`)
},
onError: () => toast.error('参保失败'),
})
const terminateMutation = useMutation({
mutationFn: (enrollmentId: string) => benefitApi.terminateEnrollment(enrollmentId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['benefit-enrollments'] })
queryClient.invalidateQueries({ queryKey: ['benefit-employee-summary'] })
toast.success('已终止福利')
},
})
const handleEdit = (plan: any) => {
setEditingPlan(plan)
setNewPlan({ ...plan })
setShowAddPlan(true)
}
const handleSave = () => {
if (!newPlan.name?.trim()) { toast.error('请填写方案名称'); return }
savePlanMutation.mutate(newPlan)
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Gift className="h-5 w-5 text-primary" />
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
</div>
<PageGuide>
<span className="text-primary"> /</span>
</PageGuide>
{/* Tab 切换 */}
<div className="flex items-center gap-4 border-b">
{(['plans', 'summary'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
}`}
onClick={() => setTab(t)}
>
{t === 'plans' ? '福利方案' : '员工汇总'}
</button>
))}
{tab === 'plans' && (
<Button size="sm" className="ml-auto" onClick={() => { setEditingPlan(null); setNewPlan({ ...DEFAULT_PLAN }); setShowAddPlan(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
)}
</div>
{/* ========== 福利方案 Tab ========== */}
{tab === 'plans' && (
<>
{isLoading ? (
<Card><div className="text-center py-8 text-gray-400">...</div></Card>
) : plans.length === 0 ? (
<Card><div className="text-center py-8 text-gray-400 text-sm"></div></Card>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{plans.map((plan: any) => {
const catCfg = BENEFIT_CATEGORIES[plan.category] || BENEFIT_CATEGORIES.OTHER
const isSelected = selectedPlanId === plan.id
return (
<Card
key={plan.id}
className={`cursor-pointer transition-all ${isSelected ? 'ring-2 ring-primary/20' : 'hover:shadow-md'}`}
>
<div onClick={() => setSelectedPlanId(isSelected ? null : plan.id)}>
<div className="flex items-start justify-between mb-2">
<div>
<span className={`px-2 py-0.5 rounded text-xs ${catCfg.color}`}>{catCfg.label}</span>
<h3 className="text-sm font-medium mt-1">{plan.name}</h3>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => handleEdit(plan)}>
<SettingsIcon className="w-3.5 h-3.5" />
</button>
<button className="text-xs text-gray-400 hover:text-danger" onClick={async () => {
if (await confirm({ title: '确认删除', message: `确定删除福利方案「${plan.name}」吗?` })) {
deletePlanMutation.mutate(plan.id)
}
}}>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="space-y-1 text-xs text-gray-500">
<div className="flex justify-between"><span></span><span className="text-gray-700">¥{fmt(plan.amount)} / {FREQUENCY_LABELS[plan.frequency] || plan.frequency}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan.taxDeductible ? '是' : '否'}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan._count?.enrollments || 0} </span></div>
</div>
{plan.description && <p className="text-xs text-gray-400 mt-2 line-clamp-2">{plan.description}</p>}
</div>
</Card>
)
})}
</div>
)}
{/* 参保人员 */}
{selectedPlanId && (
<Card>
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium">{enrollments.length}</h3>
<Button size="sm" variant="secondary" onClick={() => { setEnrollEmployeeIds([]); setShowEnrollModal(true) }}>
<Users className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
{enrollLoading ? (
<div className="text-center py-4 text-gray-400 text-sm">...</div>
) : enrollments.length === 0 ? (
<div className="text-center py-4 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
</tr>
</thead>
<tbody>
{enrollments.map((e: any) => (
<tr key={e.id || e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{e.name}</td>
<td className="py-2 text-gray-500">{e.department}</td>
<td className="py-2 text-gray-500 text-xs">{e.effectiveFrom || '—'}</td>
<td className="py-2 text-gray-500 text-xs">{e.effectiveTo || '至今'}</td>
<td className="py-2">
<span className={`px-2 py-0.5 rounded text-xs ${e.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
{e.status === 'ACTIVE' ? '有效' : '已终止'}
</span>
</td>
<td className="py-2 text-right">
{e.status === 'ACTIVE' && (
<button className="text-xs text-gray-400 hover:text-danger" onClick={async () => {
if (await confirm({ title: '确认终止', message: `确定终止${e.name}的福利吗?` })) {
terminateMutation.mutate(e.id)
}
}}></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
</>
)}
{/* ========== 员工汇总 Tab ========== */}
{tab === 'summary' && (
<Card>
{employeeSummary.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-xs text-gray-500">
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-right"></th>
</tr>
</thead>
<tbody>
{employeeSummary.map((e: any) => (
<tr key={e.employeeId} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 font-medium">{e.name}</td>
<td className="py-2 text-gray-500">{e.department}</td>
<td className="py-2">
<div className="flex flex-wrap gap-1">
{e.benefits.map((b: any, i: number) => (
<span key={i} className={`px-1.5 py-0.5 rounded text-xs ${BENEFIT_CATEGORIES[b.category]?.color || BENEFIT_CATEGORIES.OTHER.color}`}>
{b.planName} ¥{fmt(b.amount)}
</span>
))}
</div>
</td>
<td className="py-2 text-right font-medium text-primary">¥{fmt(e.totalMonthly)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* 新增/编辑方案 Modal */}
{showAddPlan && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30" onClick={() => setShowAddPlan(false)}>
<Card className="w-full max-w-lg mx-4">
<div onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-medium">{editingPlan ? '编辑福利方案' : '新增福利方案'}</h3>
<button onClick={() => setShowAddPlan(false)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4" /></button>
</div>
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={newPlan.name} onChange={(e) => setNewPlan({ ...newPlan, name: e.target.value })} placeholder="如:2024年度交通补贴" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.category}
onChange={(e) => setNewPlan({ ...newPlan, category: e.target.value })}
>
{Object.entries(BENEFIT_CATEGORIES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.frequency}
onChange={(e) => setNewPlan({ ...newPlan, frequency: e.target.value })}
>
{Object.entries(FREQUENCY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={newPlan.amount} onChange={(e) => setNewPlan({ ...newPlan, amount: parseFloat(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<select
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
value={newPlan.taxDeductible ? 'true' : 'false'}
onChange={(e) => setNewPlan({ ...newPlan, taxDeductible: e.target.value === 'true' })}
>
<option value="false"></option>
<option value="true"></option>
</select>
</div>
</div>
<div>
<Label></Label>
<Input value={newPlan.description || ''} onChange={(e) => setNewPlan({ ...newPlan, description: e.target.value })} placeholder="适用条件、发放规则等" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowAddPlan(false)}></Button>
<Button size="sm" onClick={handleSave} disabled={savePlanMutation.isPending}>
{savePlanMutation.isPending ? '保存中...' : '保存'}
</Button>
</div>
</div>
</div>
</Card>
</div>
)}
{/* 批量参保 Modal */}
{showEnrollModal && (
<Modal open={true} onClose={() => setShowEnrollModal(false)} title="为员工添加福利" size="lg">
<div className="space-y-3">
<InlineAlert type="info">
</InlineAlert>
<div className="flex items-center gap-2">
<Label className="shrink-0"></Label>
<Input type="month" value={enrollEffectiveFrom} onChange={(e) => setEnrollEffectiveFrom(e.target.value)} className="!w-32" />
</div>
<div className="max-h-80 overflow-y-auto border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b text-xs text-gray-500">
<th className="py-2 px-3 text-left w-8">
<input type="checkbox" checked={enrollEmployeeIds.length === (rosterData?.length || 0) && enrollEmployeeIds.length > 0}
onChange={(e) => {
if (e.target.checked) {
setEnrollEmployeeIds(rosterData?.map((emp: any) => emp.id) || [])
} else {
setEnrollEmployeeIds([])
}
}} />
</th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
<th className="py-2 px-3 text-left"></th>
</tr>
</thead>
<tbody>
{rosterData?.map((emp: any) => (
<tr key={emp.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3">
<input type="checkbox" checked={enrollEmployeeIds.includes(emp.id)}
onChange={(e) => {
if (e.target.checked) setEnrollEmployeeIds([...enrollEmployeeIds, emp.id])
else setEnrollEmployeeIds(enrollEmployeeIds.filter((id) => id !== emp.id))
}} />
</td>
<td className="py-2 px-3 font-medium">{emp.name}</td>
<td className="py-2 px-3 text-gray-500">{emp.department}</td>
<td className="py-2 px-3">
<span className="px-2 py-0.5 rounded text-xs bg-green-50 text-safe"></span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400"> {enrollEmployeeIds.length} </span>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setShowEnrollModal(false)}></Button>
<Button size="sm" onClick={() => enrollMutation.mutate({ employeeIds: enrollEmployeeIds, effectiveFrom: enrollEffectiveFrom })}
disabled={enrollEmployeeIds.length === 0 || enrollMutation.isPending}>
{enrollMutation.isPending ? '参保中...' : '确认参保'}
</Button>
</div>
</div>
</div>
</Modal>
)}
</div>
)
}