Sprint 3: 员工目录优化 + Profile Shell + 离职工作流重构 + 合同类型扩充 + 月度社保重构 + 商险管理Tab

S3-1: Roster.tsx 添加 InlineAlert 合同风险提示 + 快捷筛选标签
S3-2: 新建 EmployeeProfileShell.tsx 统一员工详情布局,重构 EmployeeProfile.tsx
S3-3: Termination.tsx 集成 Stepper 步骤条 + InlineAlert 风险提示
S3-4: schema.prisma ContractType 枚举扩充 DISPATCH/OUTSOURCING/PARTTIME + 后端接口 + 前端选择器
S3-5: SocialInsurance.tsx 月度办理 Tab 使用 InlineAlert 替换原始提示
S3-6: SocialInsurance.tsx 新增商险管理 Tab(方案CRUD + 参保人员列表)
This commit is contained in:
selfrelease
2026-07-31 18:07:49 +08:00
parent 55b6867c9c
commit 9946197d20
7 changed files with 636 additions and 81 deletions
+285 -6
View File
@@ -2,7 +2,8 @@ import { useState, useEffect, useRef } from 'react'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useConfirm } from '../hooks/useConfirm'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles, Upload, X } from 'lucide-react'
import { Calculator, Info, Check, Settings as SettingsIcon, Plus, History, Download, AlertCircle, Clock, MapPin, Sparkles, Upload, X, Shield } from 'lucide-react'
import { InlineAlert } from '../components/ui/InlineAlert'
import api from '../lib/api'
import { useAuthStore } from '../store/authStore'
import Card from '../components/ui/Card'
@@ -15,7 +16,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
export default function SocialInsurance() {
const queryClient = useQueryClient()
const confirm = useConfirm()
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction'>('monthly')
const [tab, setTab] = useState<'monthly' | 'social' | 'housing' | 'deduction' | 'commercial'>('monthly')
const [city, setCity] = useState<string>('北京')
const [showAddCity, setShowAddCity] = useState(false)
const [newCityName, setNewCityName] = useState('')
@@ -374,7 +375,7 @@ export default function SocialInsurance() {
{/* Tab 切换 + 城市选择 */}
<div className="flex items-center gap-4 border-b">
{(['monthly', 'social', 'housing', 'deduction'] as const).map((t) => (
{(['monthly', 'social', 'housing', 'deduction', 'commercial'] as const).map((t) => (
<button
key={t}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
@@ -382,7 +383,7 @@ export default function SocialInsurance() {
}`}
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null); setMonthlyProcessed(false); setProcessStatus(null) }}
>
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : '专项附加扣除'}
{t === 'monthly' ? '月度办理' : t === 'social' ? '社保' : t === 'housing' ? '公积金' : t === 'deduction' ? '专项附加扣除' : '商险管理'}
</button>
))}
{(tab === 'social' || tab === 'housing') && (
@@ -1001,9 +1002,9 @@ export default function SocialInsurance() {
)}
</div>
)}
<div className="bg-blue-50 text-blue-700 text-sm px-3 py-2 rounded-md mb-3">
<InlineAlert type="info" className="mb-3">
///
</div>
</InlineAlert>
{(() => {
if (!monthlyProcessed) {
return <div className="text-center py-8 text-gray-400 text-sm"></div>
@@ -1138,6 +1139,11 @@ export default function SocialInsurance() {
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
)}
{/* ========== 商险管理 Tab ========== */}
{tab === 'commercial' && (
<CommercialInsuranceTab />
)}
<p className="text-sm text-gray-400">
/7
@@ -1618,3 +1624,276 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m:
</Card>
)
}
/**
* 商险管理 Tab — 管理商业保险(意外险、补充医疗、雇主责任险等)
* 支持查看商险方案、参保人员、保单信息
*/
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>({
name: '',
type: 'ACCIDENT',
provider: '',
policyNo: '',
premium: 0,
coverageAmount: 0,
effectiveFrom: new Date().toISOString().slice(0, 10),
effectiveTo: '',
description: '',
})
/** 商险类型映射 */
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 { data: plans = [], isLoading } = useQuery<any[]>({
queryKey: ['commercial-insurance-plans'],
queryFn: async () => {
const res = await api.get('/commercial-insurance/plans') as any
return res.data || []
},
})
/** 获取选中方案的参保人员 */
const { data: enrollments = [], isLoading: enrollLoading } = useQuery<any[]>({
queryKey: ['commercial-insurance-enrollments', selectedPlanId],
queryFn: async () => {
if (!selectedPlanId) return []
const res = await api.get(`/commercial-insurance/plans/${selectedPlanId}/enrollments`) as any
return res.data || []
},
enabled: !!selectedPlanId,
})
/** 创建/更新商险方案 */
const savePlanMutation = useMutation({
mutationFn: async (data: any) => {
if (editingPlan) {
return api.put(`/commercial-insurance/plans/${editingPlan.id}`, data) as any
}
return api.post('/commercial-insurance/plans', data) as any
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-plans'] })
setShowAddPlan(false)
setEditingPlan(null)
setNewPlan({ name: '', type: 'ACCIDENT', provider: '', policyNo: '', premium: 0, coverageAmount: 0, effectiveFrom: new Date().toISOString().slice(0, 10), effectiveTo: '', description: '' })
toast.success(editingPlan ? '商险方案已更新' : '商险方案已创建')
},
onError: () => toast.error('保存失败'),
})
/** 删除商险方案 */
const deletePlanMutation = useMutation({
mutationFn: (id: string) => api.delete(`/commercial-insurance/plans/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['commercial-insurance-plans'] })
setSelectedPlanId(null)
toast.success('商险方案已删除')
},
})
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({ name: '', type: 'ACCIDENT', provider: '', policyNo: '', premium: 0, coverageAmount: 0, effectiveFrom: new Date().toISOString().slice(0, 10), effectiveTo: '', description: '' }); 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>
<h3 className="text-sm font-medium mb-3">{enrollments.length}</h3>
{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>
)}
{/* 新增/编辑方案弹窗 */}
{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>
)
}