Files
TurboHR/frontend/src/pages/roster/EmployeeProfileShell.tsx
T
freedakgmail ef03b2e5f8 fix: 全部员工显示改为姓名+完整身份证号,不再脱敏
- 花名册列表显示完整身份证号
- 社保公积金月度办理列表姓名下显示身份证号
- 员工详情页显示完整身份证号
- 商业保险列表显示完整身份证号
- 后端去掉 idCardMasked 脱敏逻辑
2026-08-18 07:33:54 +08:00

241 lines
10 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.
/**
* EmployeeProfileShell — 员工档案壳组件
* 提供统一的员工详情页布局:摘要头部 + 标签导航 + 内容区域
* 可被 Roster、Termination、SocialInsurance 等页面复用
*/
import { useState, ReactNode } from 'react'
import { X, Phone, Mail, MapPin, Calendar, Briefcase, DollarSign, AlertTriangle } from 'lucide-react'
import { fmt, DetailTab, TAB_GROUPS } from './shared'
interface EmployeeSummary {
id: string
name: string
department: string
status: string
position?: string
hireDate?: string
monthlySalary?: number
phone?: string
email?: string
city?: string
idCardNumber?: 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.idCardNumber && (
<span className="font-mono">{profile.idCardNumber}</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>
)
}