Files
TurboHR/frontend/src/pages/social-insurance/CommercialInsuranceTab.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

365 lines
18 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 { Plus, Settings as SettingsIcon, X, Shield, UserPlus } from 'lucide-react'
import { commercialInsuranceApi, employeeApi } from '../../lib/api-services'
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 INSURANCE_TYPES: Record<string, { label: string; color: string }> = {
ACCIDENT: { label: '意外伤害险', color: 'bg-orange-50 text-orange-700 border border-orange-200' },
SUPPLEMENTARY_MEDICAL: { label: '补充医疗保险', color: 'bg-blue-50 text-blue-700 border border-blue-200' },
EMPLOYER_LIABILITY: { label: '雇主责任险', color: 'bg-purple-50 text-purple-700 border border-purple-200' },
CRITICAL_ILLNESS: { label: '重大疾病险', color: 'bg-rose-50 text-rose-700 border border-rose-200' },
GROUP_LIFE: { label: '团体寿险', color: 'bg-teal-50 text-teal-700 border border-teal-200' },
OTHER: { label: '其他', color: 'bg-gray-50 text-gray-700 border border-gray-200' },
}
const DEFAULT_PLAN = {
name: '', type: 'ACCIDENT', provider: '', policyNo: '', premium: 0, coverageAmount: 0,
effectiveFrom: new Date().toISOString().slice(0, 10), effectiveTo: '', description: '',
}
/**
* 商险管理 Tab — 管理商业保险(意外险、补充医疗、雇主责任险等)
* 支持查看商险方案、参保人员、保单信息
*/
export default function CommercialInsuranceTab() {
const queryClient = useQueryClient()
const confirm = useConfirm()
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, 10))
const { data: plans = [], isLoading } = useQuery<any[]>({
queryKey: ['commercial-insurance-plans'],
queryFn: async () => {
return await commercialInsuranceApi.plans()
},
})
const { data: enrollments = [], isLoading: enrollLoading } = useQuery<any[]>({
queryKey: ['commercial-insurance-enrollments', selectedPlanId],
queryFn: async () => {
if (!selectedPlanId) return []
return await commercialInsuranceApi.enrollments(selectedPlanId)
},
enabled: !!selectedPlanId,
})
const savePlanMutation = useMutation({
mutationFn: async (data: any) => {
if (editingPlan) {
return commercialInsuranceApi.savePlan(data, editingPlan.id) as any
}
return commercialInsuranceApi.savePlan(data) as any
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-plans'] })
setShowAddPlan(false)
setEditingPlan(null)
setNewPlan({ ...DEFAULT_PLAN })
toast.success(editingPlan ? '商险方案已更新' : '商险方案已创建')
},
onError: () => toast.error('保存失败'),
})
const deletePlanMutation = useMutation({
mutationFn: (id: string) => commercialInsuranceApi.removePlan(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-plans'] })
setSelectedPlanId(null)
toast.success('商险方案已删除')
},
})
const { data: rosterData } = useQuery<any[]>({
queryKey: ['employees-for-commercial-insurance'],
queryFn: async () => {
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showEnrollModal,
})
const enrollMutation = useMutation({
mutationFn: async (data: { employeeIds: string[]; effectiveFrom?: string }) =>
commercialInsuranceApi.enroll(selectedPlanId!, data) as any,
onSuccess: (res: any) => {
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-enrollments'] })
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-employee-summary'] })
setShowEnrollModal(false)
setEnrollEmployeeIds([])
toast.success(`已添加 ${res.data?.enrolled || 0} 名员工`)
},
onError: () => toast.error('参保失败'),
})
const handleEdit = (plan: any) => {
setEditingPlan(plan)
setNewPlan({ ...plan })
setShowAddPlan(true)
}
const handleSave = () => {
if (!newPlan.name?.trim()) { toast.error('请填写方案名称'); return }
if (!newPlan.provider?.trim()) { toast.error('请填写保险公司'); return }
savePlanMutation.mutate(newPlan)
}
if (isLoading) return <Card><div className="text-center py-8 text-gray-400">...</div></Card>
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium"></h2>
</div>
<Button size="sm" onClick={() => { setEditingPlan(null); setNewPlan({ ...DEFAULT_PLAN }); setShowAddPlan(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<InlineAlert type="info">
</InlineAlert>
{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 typeCfg = INSURANCE_TYPES[plan.type] || INSURANCE_TYPES.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 ${typeCfg.color}`}>{typeCfg.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">{plan.provider}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700 font-mono">{plan.policyNo || '—'}</span></div>
<div className="flex justify-between"><span>()</span><span className="text-gray-700">¥{fmt(plan.premium)}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">¥{fmt(plan.coverageAmount)}</span></div>
<div className="flex justify-between"><span></span><span className="text-gray-700">{plan.effectiveFrom} ~ {plan.effectiveTo || '长期'}</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" onClick={() => { setEnrollEmployeeIds([]); setEnrollEffectiveFrom(new Date().toISOString().slice(0, 10)); setShowEnrollModal(true) }}>
<UserPlus className="w-4 h-4 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-right"></th>
<th className="py-2 text-left"></th>
<th className="py-2 text-left"></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-400 font-mono text-xs">{e.idCardMasked || '—'}</td>
<td className="py-2 text-right">¥{fmt(e.premium || 0)}</td>
<td className="py-2 text-gray-500 text-xs">{e.effectiveFrom || '—'}</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>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
{/* 批量参保 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="date" value={enrollEffectiveFrom} onChange={(e) => setEnrollEffectiveFrom(e.target.value)} className="!w-40" />
</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>
)}
{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.type}
onChange={(e) => setNewPlan({ ...newPlan, type: e.target.value })}
>
{Object.entries(INSURANCE_TYPES).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
<div>
<Label> *</Label>
<Input value={newPlan.provider} onChange={(e) => setNewPlan({ ...newPlan, provider: e.target.value })} placeholder="如:中国人寿" />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input value={newPlan.policyNo} onChange={(e) => setNewPlan({ ...newPlan, policyNo: e.target.value })} placeholder="保单编号" />
</div>
<div>
<Label>/</Label>
<Input type="number" value={newPlan.premium} onChange={(e) => setNewPlan({ ...newPlan, premium: parseFloat(e.target.value) || 0 })} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={newPlan.coverageAmount} onChange={(e) => setNewPlan({ ...newPlan, coverageAmount: parseFloat(e.target.value) || 0 })} />
</div>
<div>
<Label></Label>
<Input type="date" value={newPlan.effectiveTo} onChange={(e) => setNewPlan({ ...newPlan, effectiveTo: e.target.value })} />
</div>
</div>
<div>
<Label></Label>
<Input type="date" value={newPlan.effectiveFrom} onChange={(e) => setNewPlan({ ...newPlan, effectiveFrom: e.target.value })} />
</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>
)}
</div>
)
}