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:
@@ -1,9 +1,9 @@
|
||||
/** EmployeeProfile 组件 - 员工详情页 */
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
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 ContractInfo from './ContractInfo'
|
||||
import PayslipSocialInfo from './PayslipSocialInfo'
|
||||
@@ -15,7 +15,7 @@ import ChangeHistoryTab from './ChangeHistoryTab'
|
||||
import EvidenceChain from './EvidenceChain'
|
||||
|
||||
/**
|
||||
* 员工详情档案页
|
||||
* 员工详情档案页 — 使用 EmployeeProfileShell 壳组件统一布局
|
||||
*/
|
||||
export default function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: () => void }) {
|
||||
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>
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-120px)]">
|
||||
<div className="flex items-center gap-3 shrink-0 pb-3">
|
||||
<button onClick={onBack} className="text-gray-400 hover:text-gray-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<h1 className="text-xs font-medium">{profile.name} - 完整档案</h1>
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${profile.status === 'ACTIVE' ? 'bg-green-50 text-safe' : 'bg-gray-100 text-gray-500'}`}>
|
||||
{profile.status === 'ACTIVE' ? '在职' : '离职'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 border-b overflow-x-auto shrink-0">
|
||||
{TAB_GROUPS.map((group) => (
|
||||
<div key={group.group} className="flex items-center">
|
||||
{group.tabs
|
||||
.filter((t) => isActive || !HIDDEN_FOR_RESIGNED.includes(t.key))
|
||||
.map((t) => {
|
||||
const count = getTabCount(t.key)
|
||||
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">
|
||||
{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>
|
||||
<EmployeeProfileShell
|
||||
profile={profile}
|
||||
onBack={onBack}
|
||||
getTabCount={getTabCount}
|
||||
activeTab={tab}
|
||||
onTabChange={setTab}
|
||||
isActive={isActive}
|
||||
hiddenTabs={HIDDEN_FOR_RESIGNED}
|
||||
>
|
||||
{(activeTab) => (
|
||||
<>
|
||||
{activeTab === 'basic' && <BasicInfo profile={profile} employeeId={employeeId} attachments={profile.attachments} />}
|
||||
{activeTab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
|
||||
{activeTab === 'payslip' && <PayslipSocialInfo payslips={profile.payslips} socialInsRecords={profile.socialInsRecords} housingFundRecords={profile.housingFundRecords} monthlyProcessRecords={profile.monthlyProcessRecords} />}
|
||||
{activeTab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
||||
{activeTab === 'attendance' && <AttendanceOvertimeInfo employeeId={employeeId} attendanceRecords={profile.attendanceRecords} overtimeRecords={profile.overtimeRecords} trainingRecords={profile.trainingRecords} />}
|
||||
{activeTab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
|
||||
{activeTab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
|
||||
{activeTab === 'evidence' && <EvidenceChain employeeId={employeeId} />}
|
||||
{activeTab === 'history' && <ChangeHistoryTab profile={profile} />}
|
||||
</>
|
||||
)}
|
||||
</EmployeeProfileShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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