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:
@@ -38,6 +38,9 @@ enum ContractType {
|
|||||||
UNSIGNED
|
UNSIGNED
|
||||||
LABOR
|
LABOR
|
||||||
INTERNSHIP
|
INTERNSHIP
|
||||||
|
DISPATCH
|
||||||
|
OUTSOURCING
|
||||||
|
PARTTIME
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SignMethod {
|
enum SignMethod {
|
||||||
|
|||||||
@@ -1201,8 +1201,11 @@ router.get('/contract-types', authMiddleware, (_req: AuthRequest, res) => {
|
|||||||
const types = [
|
const types = [
|
||||||
{ value: 'FIXED', label: '劳动合同-固定期', hasEndDate: true },
|
{ value: 'FIXED', label: '劳动合同-固定期', hasEndDate: true },
|
||||||
{ value: 'UNFIXED', label: '劳动合同-无固定期', hasEndDate: false },
|
{ value: 'UNFIXED', label: '劳动合同-无固定期', hasEndDate: false },
|
||||||
{ value: 'LABOR', label: '劳务协议', hasEndDate: false },
|
{ value: 'LABOR', label: '劳务协议', hasEndDate: true },
|
||||||
{ value: 'INTERNSHIP', label: '实习协议', hasEndDate: false },
|
{ value: 'INTERNSHIP', label: '实习协议', hasEndDate: true },
|
||||||
|
{ value: 'DISPATCH', label: '劳务派遣', hasEndDate: true },
|
||||||
|
{ value: 'OUTSOURCING', label: '业务外包', hasEndDate: true },
|
||||||
|
{ value: 'PARTTIME', label: '兼职协议', hasEndDate: true },
|
||||||
{ value: 'UNSIGNED', label: '未签合同', hasEndDate: false },
|
{ value: 'UNSIGNED', label: '未签合同', hasEndDate: false },
|
||||||
]
|
]
|
||||||
res.json({ success: true, data: types })
|
res.json({ success: true, data: types })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
import { useConfirm } from '../hooks/useConfirm'
|
||||||
@@ -13,6 +13,7 @@ import Modal from '../components/ui/Modal'
|
|||||||
import Pagination from '../components/ui/Pagination'
|
import Pagination from '../components/ui/Pagination'
|
||||||
import { fmt, terminateReasonMap, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './roster/shared'
|
import { fmt, terminateReasonMap, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './roster/shared'
|
||||||
import EmployeeProfile from './roster/EmployeeProfile'
|
import EmployeeProfile from './roster/EmployeeProfile'
|
||||||
|
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
|
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal } from './roster/modals'
|
||||||
import { ImportSettings } from './Settings'
|
import { ImportSettings } from './Settings'
|
||||||
|
|
||||||
@@ -219,6 +220,23 @@ export default function Roster() {
|
|||||||
|
|
||||||
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
|
const hasActiveFilters = search || filterStatus || filterContractStatus || filterDepartment
|
||||||
|
|
||||||
|
/** 计算合同风险统计(基于当前页数据) */
|
||||||
|
const riskStats = useMemo(() => {
|
||||||
|
const expiring = employees.filter((e: any) => e.contractStatus === 'expiring').length
|
||||||
|
const expired = employees.filter((e: any) => e.contractStatus === 'expired').length
|
||||||
|
const unsigned = employees.filter((e: any) => ['unsigned', 'unsigned_over_30', 'unsigned_over_year'].includes(e.contractStatus)).length
|
||||||
|
const probation = employees.filter((e: any) => e.probationInfo?.isProbation && e.probationInfo?.isExpiring).length
|
||||||
|
return { expiring, expired, unsigned, probation }
|
||||||
|
}, [employees])
|
||||||
|
|
||||||
|
/** 快捷筛选标签 */
|
||||||
|
const quickFilters = [
|
||||||
|
{ key: 'expiring', label: '即将到期', count: riskStats.expiring, color: 'amber', filter: { status: '', contractStatus: 'expiring', department: '' } },
|
||||||
|
{ key: 'expired', label: '已到期', count: riskStats.expired, color: 'rose', filter: { status: '', contractStatus: 'expired', department: '' } },
|
||||||
|
{ key: 'unsigned', label: '未签合同', count: riskStats.unsigned, color: 'rose', filter: { status: '', contractStatus: 'unsigned', department: '' } },
|
||||||
|
{ key: 'probation', label: '试用期即将到期', count: riskStats.probation, color: 'amber', filter: { status: 'PROBATION', contractStatus: '', department: '' } },
|
||||||
|
].filter(f => f.count > 0)
|
||||||
|
|
||||||
const { data: departmentList } = useQuery<string[]>({
|
const { data: departmentList } = useQuery<string[]>({
|
||||||
queryKey: ['roster-departments'],
|
queryKey: ['roster-departments'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@@ -332,6 +350,44 @@ export default function Roster() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 合同风险提示 */}
|
||||||
|
{!isLoading && (riskStats.expired > 0 || riskStats.unsigned > 0) && (
|
||||||
|
<InlineAlert
|
||||||
|
type="warning"
|
||||||
|
title="合同风险提醒"
|
||||||
|
closable
|
||||||
|
>
|
||||||
|
{riskStats.expired > 0 && <span>当前页有 <strong className="text-danger">{riskStats.expired}</strong> 人合同已到期;</span>}
|
||||||
|
{riskStats.unsigned > 0 && <span><strong className="text-danger">{riskStats.unsigned}</strong> 人未签合同;</span>}
|
||||||
|
<span className="text-gray-500">请及时处理以避免法律风险。</span>
|
||||||
|
</InlineAlert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 快捷筛选标签 */}
|
||||||
|
{!isLoading && quickFilters.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-400">快捷筛选:</span>
|
||||||
|
{quickFilters.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.key}
|
||||||
|
onClick={() => {
|
||||||
|
setFilterStatus(f.filter.status)
|
||||||
|
setFilterContractStatus(f.filter.contractStatus)
|
||||||
|
setFilterDepartment(f.filter.department)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs font-medium border transition-all hover:shadow-sm ${
|
||||||
|
f.color === 'rose'
|
||||||
|
? 'border-rose-200 bg-rose-50 text-rose-700 hover:bg-rose-100'
|
||||||
|
: 'border-amber-200 bg-amber-50 text-amber-700 hover:bg-amber-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}({f.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="rounded-lg border border-gray-200 bg-white py-16 text-center text-sm text-gray-400">加载中...</div>
|
<div className="rounded-lg border border-gray-200 bg-white py-16 text-center text-sm text-gray-400">加载中...</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
@@ -422,6 +478,9 @@ export default function Roster() {
|
|||||||
UNFIXED: { label: '劳动合同-无固定期', style: 'bg-purple-50 text-purple-700 border border-purple-200' },
|
UNFIXED: { label: '劳动合同-无固定期', style: 'bg-purple-50 text-purple-700 border border-purple-200' },
|
||||||
LABOR: { label: '劳务协议', style: 'bg-amber-50 text-amber-700 border border-amber-200' },
|
LABOR: { label: '劳务协议', style: 'bg-amber-50 text-amber-700 border border-amber-200' },
|
||||||
INTERNSHIP: { label: '实习协议', style: 'bg-teal-50 text-teal-700 border border-teal-200' },
|
INTERNSHIP: { label: '实习协议', style: 'bg-teal-50 text-teal-700 border border-teal-200' },
|
||||||
|
DISPATCH: { label: '劳务派遣', style: 'bg-cyan-50 text-cyan-700 border border-cyan-200' },
|
||||||
|
OUTSOURCING: { label: '业务外包', style: 'bg-slate-50 text-slate-700 border border-slate-200' },
|
||||||
|
PARTTIME: { label: '兼职协议', style: 'bg-indigo-50 text-indigo-700 border border-indigo-200' },
|
||||||
UNSIGNED: { label: '未签合同', style: 'bg-gray-100 text-gray-500 border border-gray-200' },
|
UNSIGNED: { label: '未签合同', style: 'bg-gray-100 text-gray-500 border border-gray-200' },
|
||||||
}
|
}
|
||||||
const ct = e.latestContract?.contractType
|
const ct = e.latestContract?.contractType
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState, useEffect, useRef } from 'react'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useConfirm } from '../hooks/useConfirm'
|
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 api from '../lib/api'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
@@ -15,7 +16,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
|||||||
export default function SocialInsurance() {
|
export default function SocialInsurance() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const confirm = useConfirm()
|
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 [city, setCity] = useState<string>('北京')
|
||||||
const [showAddCity, setShowAddCity] = useState(false)
|
const [showAddCity, setShowAddCity] = useState(false)
|
||||||
const [newCityName, setNewCityName] = useState('')
|
const [newCityName, setNewCityName] = useState('')
|
||||||
@@ -374,7 +375,7 @@ export default function SocialInsurance() {
|
|||||||
|
|
||||||
{/* Tab 切换 + 城市选择 */}
|
{/* Tab 切换 + 城市选择 */}
|
||||||
<div className="flex items-center gap-4 border-b">
|
<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
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
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) }}
|
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>
|
</button>
|
||||||
))}
|
))}
|
||||||
{(tab === 'social' || tab === 'housing') && (
|
{(tab === 'social' || tab === 'housing') && (
|
||||||
@@ -1001,9 +1002,9 @@ export default function SocialInsurance() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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) {
|
if (!monthlyProcessed) {
|
||||||
return <div className="text-center py-8 text-gray-400 text-sm">选择月份后点击「获取」按钮,获取当月增减员及在保人员列表</div>
|
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} />
|
<SpecialDeductionTab month={deductionMonth} setMonth={setDeductionMonth} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ========== 商险管理 Tab ========== */}
|
||||||
|
{tab === 'commercial' && (
|
||||||
|
<CommercialInsuranceTab />
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
社保/公积金基数按上年度月均工资核定,每人不同,在员工基本信息中设置。比例和基数上下限按版本管理,通常每年7月调整。
|
||||||
发薪批次计算时按批次月份自动匹配对应版本配置。
|
发薪批次计算时按批次月份自动匹配对应版本配置。
|
||||||
@@ -1618,3 +1624,276 @@ function SpecialDeductionTab({ month, setMonth }: { month: string; setMonth: (m:
|
|||||||
</Card>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { useState, useMemo, useEffect } from 'react'
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
|
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download, Plus, Edit, Send, CheckCircle, XCircle, Play, Ban, CheckCheck } from 'lucide-react'
|
||||||
|
import { Stepper } from '../components/ui/Stepper'
|
||||||
|
import { InlineAlert } from '../components/ui/InlineAlert'
|
||||||
import jsPDF from 'jspdf'
|
import jsPDF from 'jspdf'
|
||||||
import api from '../lib/api'
|
import api from '../lib/api'
|
||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
@@ -976,15 +978,16 @@ export default function Termination() {
|
|||||||
{/* 左侧:向导 */}
|
{/* 左侧:向导 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
||||||
{/* 进度条 */}
|
{/* 步骤条 */}
|
||||||
<div className="flex items-center gap-1">
|
<Stepper
|
||||||
{STEPS.map((_s, i) => (
|
steps={STEPS.map((title: string, i: number) => ({
|
||||||
<div key={i} className="flex items-center">
|
key: String(i),
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${i <= step ? 'bg-primary' : 'bg-gray-300'}`} />
|
title,
|
||||||
{i < STEPS.length - 1 && <div className={`w-6 h-0.5 ${i < step ? 'bg-primary' : 'bg-gray-300'}`} />}
|
status: i < step ? 'complete' : i === step ? 'current' : 'pending',
|
||||||
</div>
|
}))}
|
||||||
))}
|
onStepClick={(key) => { const i = parseInt(key); if (i <= step) setStep(i) }}
|
||||||
</div>
|
className="mb-4"
|
||||||
|
/>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<div className="mb-2 text-xs text-gray-500">Step {step + 1}/6:{STEPS[step]}</div>
|
<div className="mb-2 text-xs text-gray-500">Step {step + 1}/6:{STEPS[step]}</div>
|
||||||
@@ -1161,10 +1164,9 @@ export default function Termination() {
|
|||||||
{riskAssessment && riskAssessment.warnings.length > 0 && (
|
{riskAssessment && riskAssessment.warnings.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{riskAssessment.warnings.map((w, i) => (
|
{riskAssessment.warnings.map((w, i) => (
|
||||||
<div key={i} className="flex items-center gap-2 px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
<InlineAlert key={i} type="error">
|
||||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
|
||||||
{w}
|
{w}
|
||||||
</div>
|
</InlineAlert>
|
||||||
))}
|
))}
|
||||||
<label className="flex items-center gap-2 text-xs px-3 py-2 rounded-md bg-yellow-50 text-yellow-800">
|
<label className="flex items-center gap-2 text-xs px-3 py-2 rounded-md bg-yellow-50 text-yellow-800">
|
||||||
<input type="checkbox" checked={acknowledgeRisk} onChange={(e) => setAcknowledgeRisk(e.target.checked)} />
|
<input type="checkbox" checked={acknowledgeRisk} onChange={(e) => setAcknowledgeRisk(e.target.checked)} />
|
||||||
@@ -1178,10 +1180,9 @@ export default function Termination() {
|
|||||||
{/* Step 3: 合规检查 */}
|
{/* Step 3: 合规检查 */}
|
||||||
{step === 2 && (
|
{step === 2 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md flex items-start gap-2">
|
<InlineAlert type="info">
|
||||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
系统已根据员工记录自动预填部分检查项,您可逐项调整后继续。
|
||||||
<div>系统已根据员工记录自动预填部分检查项,您可逐项调整后继续。</div>
|
</InlineAlert>
|
||||||
</div>
|
|
||||||
{checklistItems?.map((item) => {
|
{checklistItems?.map((item) => {
|
||||||
const checked = checklist[item.key] || false
|
const checked = checklist[item.key] || false
|
||||||
const isAutoChecked = item.autoChecked !== null && item.autoChecked !== undefined
|
const isAutoChecked = item.autoChecked !== null && item.autoChecked !== undefined
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
/** EmployeeProfile 组件 - 员工详情页 */
|
/** EmployeeProfile 组件 - 员工详情页 */
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import api from '../../lib/api'
|
import api from '../../lib/api'
|
||||||
import { fmt, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './shared'
|
import { DetailTab, TAB_COUNT_KEYS } from './shared'
|
||||||
|
import EmployeeProfileShell from './EmployeeProfileShell'
|
||||||
import BasicInfo from './BasicInfo'
|
import BasicInfo from './BasicInfo'
|
||||||
import ContractInfo from './ContractInfo'
|
import ContractInfo from './ContractInfo'
|
||||||
import PayslipSocialInfo from './PayslipSocialInfo'
|
import PayslipSocialInfo from './PayslipSocialInfo'
|
||||||
@@ -15,7 +15,7 @@ import ChangeHistoryTab from './ChangeHistoryTab'
|
|||||||
import EvidenceChain from './EvidenceChain'
|
import EvidenceChain from './EvidenceChain'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 员工详情档案页
|
* 员工详情档案页 — 使用 EmployeeProfileShell 壳组件统一布局
|
||||||
*/
|
*/
|
||||||
export default function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: () => void }) {
|
export default function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: () => void }) {
|
||||||
const [tab, setTab] = useState<DetailTab>('basic')
|
const [tab, setTab] = useState<DetailTab>('basic')
|
||||||
@@ -51,58 +51,28 @@ export default function EmployeeProfile({ employeeId, onBack }: { employeeId: st
|
|||||||
if (!profile) return <div className="text-center py-8 text-gray-400">员工不存在</div>
|
if (!profile) return <div className="text-center py-8 text-gray-400">员工不存在</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-120px)]">
|
<EmployeeProfileShell
|
||||||
<div className="flex items-center gap-3 shrink-0 pb-3">
|
profile={profile}
|
||||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
onBack={onBack}
|
||||||
<X className="w-5 h-5" />
|
getTabCount={getTabCount}
|
||||||
</button>
|
activeTab={tab}
|
||||||
<h1 className="text-xs font-medium">{profile.name} - 完整档案</h1>
|
onTabChange={setTab}
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${profile.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
|
isActive={isActive}
|
||||||
{profile.status === 'ACTIVE' ? '在职' : '离职'}
|
hiddenTabs={HIDDEN_FOR_RESIGNED}
|
||||||
</span>
|
>
|
||||||
</div>
|
{(activeTab) => (
|
||||||
|
<>
|
||||||
<div className="flex gap-1 border-b overflow-x-auto shrink-0">
|
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
|
||||||
{TAB_GROUPS.map((group) => (
|
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
|
||||||
<div key={group.group} className="flex items-center">
|
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
|
||||||
{group.tabs
|
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
||||||
.filter((t) => isActive || !HIDDEN_FOR_RESIGNED.includes(t.key))
|
{activeTab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
|
||||||
.map((t) => {
|
{activeTab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
|
||||||
const count = getTabCount(t.key)
|
{activeTab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
|
||||||
return (
|
{activeTab === 'evidence' && <EvidenceChain employeeId={employeeId} />}
|
||||||
<button
|
{activeTab === 'history' && <ChangeHistoryTab profile={profile} />}
|
||||||
key={t.key}
|
</>
|
||||||
onClick={() => setTab(t.key)}
|
)}
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap flex items-center gap-1 ${
|
</EmployeeProfileShell>
|
||||||
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
{count > 0 && (
|
|
||||||
<span className={`ml-0.5 px-1.5 py-0.5 rounded-full text-[10px] leading-none ${
|
|
||||||
tab === t.key ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-500'
|
|
||||||
}`}>
|
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto pt-3">
|
|
||||||
{tab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
|
|
||||||
{tab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
|
|
||||||
{tab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
|
|
||||||
{tab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
|
||||||
{tab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
|
|
||||||
{tab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
|
|
||||||
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
|
|
||||||
{tab === 'evidence' && <EvidenceChain employeeId={employeeId} />}
|
|
||||||
{tab === 'history' && <ChangeHistoryTab profile={profile} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
/**
|
||||||
|
* EmployeeProfileShell — 员工档案壳组件
|
||||||
|
* 提供统一的员工详情页布局:摘要头部 + 标签导航 + 内容区域
|
||||||
|
* 可被 Roster、Termination、SocialInsurance 等页面复用
|
||||||
|
*/
|
||||||
|
import { useState, ReactNode } from 'react'
|
||||||
|
import { X, Phone, Mail, MapPin, Calendar, Briefcase, DollarSign, FileText, AlertTriangle } from 'lucide-react'
|
||||||
|
import { fmt, DetailTab, TAB_GROUPS, TAB_COUNT_KEYS } from './shared'
|
||||||
|
|
||||||
|
interface EmployeeSummary {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
department: string
|
||||||
|
status: string
|
||||||
|
position?: string
|
||||||
|
hireDate?: string
|
||||||
|
monthlySalary?: number
|
||||||
|
phone?: string
|
||||||
|
email?: string
|
||||||
|
city?: string
|
||||||
|
idCardMasked?: string
|
||||||
|
probationInfo?: {
|
||||||
|
isProbation: boolean
|
||||||
|
isExpiring: boolean
|
||||||
|
daysToConfirm: number
|
||||||
|
}
|
||||||
|
contractStatus?: string
|
||||||
|
latestContract?: {
|
||||||
|
contractType?: string
|
||||||
|
endDate?: string
|
||||||
|
}
|
||||||
|
hasTermination?: boolean
|
||||||
|
latestTerminationStatus?: string
|
||||||
|
latestTerminationType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmployeeProfileShellProps {
|
||||||
|
/** 员工摘要数据 */
|
||||||
|
profile: EmployeeSummary
|
||||||
|
/** 返回按钮回调 */
|
||||||
|
onBack: () => void
|
||||||
|
/** 标签计数获取函数 */
|
||||||
|
getTabCount?: (key: DetailTab) => number
|
||||||
|
/** 当前激活标签 */
|
||||||
|
activeTab?: DetailTab
|
||||||
|
/** 标签切换回调 */
|
||||||
|
onTabChange?: (tab: DetailTab) => void
|
||||||
|
/** 是否为在职状态 */
|
||||||
|
isActive?: boolean
|
||||||
|
/** 隐藏的标签列表(如离职员工隐藏考勤绩效) */
|
||||||
|
hiddenTabs?: DetailTab[]
|
||||||
|
/** 自定义标签渲染(覆盖默认 TAB_GROUPS) */
|
||||||
|
tabGroups?: typeof TAB_GROUPS
|
||||||
|
/** 内容区域渲染函数 */
|
||||||
|
children?: (tab: DetailTab) => ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合同类型标签映射 */
|
||||||
|
const CONTRACT_TYPE_LABELS: Record<string, { label: string; style: string }> = {
|
||||||
|
FIXED: { label: '固定期限', style: 'bg-blue-50 text-blue-700 border border-blue-200' },
|
||||||
|
UNFIXED: { label: '无固定期', style: 'bg-purple-50 text-purple-700 border border-purple-200' },
|
||||||
|
LABOR: { label: '劳务协议', style: 'bg-amber-50 text-amber-700 border border-amber-200' },
|
||||||
|
INTERNSHIP: { label: '实习协议', style: 'bg-teal-50 text-teal-700 border border-teal-200' },
|
||||||
|
DISPATCH: { label: '劳务派遣', style: 'bg-cyan-50 text-cyan-700 border border-cyan-200' },
|
||||||
|
OUTSOURCING: { label: '业务外包', style: 'bg-slate-50 text-slate-700 border border-slate-200' },
|
||||||
|
PARTTIME: { label: '兼职协议', style: 'bg-indigo-50 text-indigo-700 border border-indigo-200' },
|
||||||
|
UNSIGNED: { label: '未签合同', style: 'bg-gray-100 text-gray-500 border border-gray-200' },
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合同状态标签映射 */
|
||||||
|
const CONTRACT_STATUS_LABELS: Record<string, { label: string; style: string }> = {
|
||||||
|
expired: { label: '已过期', style: 'bg-red-50 text-rose-700' },
|
||||||
|
unsigned_over_year: { label: '未签署(超1年)', style: 'bg-red-50 text-rose-700' },
|
||||||
|
unsigned_over_30: { label: '未签署(超30天)', style: 'bg-red-50 text-rose-700' },
|
||||||
|
unsigned: { label: '未签署', style: 'bg-yellow-50 text-amber-700' },
|
||||||
|
expiring: { label: '即将到期', style: 'bg-yellow-50 text-amber-700' },
|
||||||
|
active: { label: '正常', style: 'bg-green-50 text-emerald-700' },
|
||||||
|
unfixed: { label: '正常', style: 'bg-green-50 text-emerald-700' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmployeeProfileShell({
|
||||||
|
profile,
|
||||||
|
onBack,
|
||||||
|
getTabCount,
|
||||||
|
activeTab: controlledTab,
|
||||||
|
onTabChange,
|
||||||
|
isActive = true,
|
||||||
|
hiddenTabs = [],
|
||||||
|
tabGroups = TAB_GROUPS,
|
||||||
|
children,
|
||||||
|
}: EmployeeProfileShellProps) {
|
||||||
|
const [internalTab, setInternalTab] = useState<DetailTab>('basic')
|
||||||
|
const tab = controlledTab ?? internalTab
|
||||||
|
const setTab = onTabChange ?? setInternalTab
|
||||||
|
|
||||||
|
const contractType = profile.latestContract?.contractType
|
||||||
|
const contractTypeCfg = CONTRACT_TYPE_LABELS[contractType || ''] || CONTRACT_TYPE_LABELS.UNSIGNED
|
||||||
|
const contractStatusCfg = CONTRACT_STATUS_LABELS[profile.contractStatus || ''] || { label: '无合同', style: 'bg-gray-100 text-gray-500' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-120px)]">
|
||||||
|
{/* 头部:返回按钮 + 员工摘要卡片 */}
|
||||||
|
<div className="shrink-0 pb-3">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<button onClick={onBack} className="text-gray-400 hover:text-gray-600 transition-colors" aria-label="返回列表">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-sm font-semibold">{profile.name} — 完整档案</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 员工摘要卡片 */}
|
||||||
|
<div className="rounded-lg border border-gray-200 bg-white p-4">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
{/* 左侧:基本信息 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 头像占位 */}
|
||||||
|
<div className="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center text-primary font-medium text-lg shrink-0">
|
||||||
|
{profile.name?.charAt(0) || '?'}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-sm">{profile.name}</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${profile.status === 'ACTIVE' ? 'bg-green-50 text-emerald-700' : 'bg-gray-100 text-gray-500'}`}>
|
||||||
|
{profile.status === 'ACTIVE' ? '在职' : profile.status === 'PRE_HIRE' ? '预入职' : '离职'}
|
||||||
|
</span>
|
||||||
|
{profile.probationInfo?.isProbation && (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${
|
||||||
|
profile.probationInfo.isExpiring
|
||||||
|
? 'bg-orange-50 text-orange-700 border border-orange-200'
|
||||||
|
: 'bg-amber-50 text-amber-700 border border-amber-200'
|
||||||
|
}`}>
|
||||||
|
试用期{profile.probationInfo.isExpiring ? `即将到期(${profile.probationInfo.daysToConfirm}天)` : `剩${profile.probationInfo.daysToConfirm}天`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{profile.hasTermination && profile.status === 'ACTIVE' && profile.latestTerminationStatus !== 'CANCELLED' && profile.latestTerminationStatus !== 'COMPLETED' && (
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${profile.latestTerminationType === 'RESIGNATION' ? 'bg-blue-50 text-blue-700' : 'bg-amber-50 text-amber-700'}`}>
|
||||||
|
{profile.latestTerminationType === 'RESIGNATION' ? '待离职' : '待解聘'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-500 flex-wrap">
|
||||||
|
{profile.department && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Briefcase className="w-3 h-3" />{profile.department}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{profile.position && (
|
||||||
|
<span className="text-gray-400">{profile.position}</span>
|
||||||
|
)}
|
||||||
|
{profile.hireDate && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Calendar className="w-3 h-3" />入职 {profile.hireDate.toString().slice(0, 10)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{profile.monthlySalary != null && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<DollarSign className="w-3 h-3" />¥{fmt(profile.monthlySalary)}/月
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* 联系方式 */}
|
||||||
|
<div className="flex items-center gap-3 text-xs text-gray-400 flex-wrap">
|
||||||
|
{profile.phone && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Phone className="w-3 h-3" />{profile.phone}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{profile.email && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Mail className="w-3 h-3" />{profile.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{profile.city && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3 h-3" />{profile.city}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{profile.idCardMasked && (
|
||||||
|
<span className="font-mono">{profile.idCardMasked}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 右侧:合同状态 */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${contractTypeCfg.style}`}>
|
||||||
|
{contractTypeCfg.label}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${contractStatusCfg.style}`}>
|
||||||
|
{contractStatusCfg.label}
|
||||||
|
</span>
|
||||||
|
{(profile.contractStatus === 'expired' || profile.contractStatus === 'unsigned_over_year' || profile.contractStatus === 'unsigned_over_30') && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-rose-600">
|
||||||
|
<AlertTriangle className="w-3 h-3" />需处理
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标签导航 */}
|
||||||
|
<div className="flex gap-1 border-b overflow-x-auto shrink-0">
|
||||||
|
{tabGroups.map((group) => (
|
||||||
|
<div key={group.group} className="flex items-center">
|
||||||
|
{group.tabs
|
||||||
|
.filter((t) => isActive || !hiddenTabs.includes(t.key))
|
||||||
|
.map((t) => {
|
||||||
|
const count = getTabCount?.(t.key) ?? 0
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap flex items-center gap-1 ${
|
||||||
|
tab === t.key ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
{count > 0 && (
|
||||||
|
<span className={`ml-0.5 px-1.5 py-0.5 rounded-full text-[10px] leading-none ${
|
||||||
|
tab === t.key ? 'bg-primary/10 text-primary' : 'bg-gray-100 text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
<div className="flex-1 overflow-y-auto pt-3">
|
||||||
|
{children?.(tab)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user