eb58bc4b09
1. 添加员工和重新入职:入职日期变更时自动同步合同开始日期和结束日期 2. 合同开始日期改为只读显示(始终等于入职日期) 3. 花名册列表增加离职日期列:已离职显示日期,待离职显示预计日期 4. 重新入职弹窗对齐添加员工:移除孕期/医疗期/工伤,合同双向计算,试用期校验 5. 后端 rehireEmployee 重置特殊状态为 false
2092 lines
106 KiB
TypeScript
2092 lines
106 KiB
TypeScript
import { useState, useRef } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus } from 'lucide-react'
|
||
import api from '../lib/api'
|
||
import Card from '../components/ui/Card'
|
||
import Button from '../components/ui/Button'
|
||
import { Input, Label, Select } from '../components/ui/Input'
|
||
import Modal from '../components/ui/Modal'
|
||
import Signal from '../components/ui/Signal'
|
||
import Pagination from '../components/ui/Pagination'
|
||
|
||
// 金额格式化:保留两位小数 + 千分位
|
||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||
|
||
type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary' | 'attendance' | 'training' | 'performance' | 'termination' | 'attachment' | 'evidence'
|
||
|
||
export default function Roster() {
|
||
const queryClient = useQueryClient()
|
||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||
const [search, setSearch] = useState('')
|
||
const [showAddModal, setShowAddModal] = useState(false)
|
||
const [showResignModal, setShowResignModal] = useState(false)
|
||
const [resignEmployee, setResignEmployee] = useState<any>(null)
|
||
const [showRehireModal, setShowRehireModal] = useState(false)
|
||
const [rehireEmployee, setRehireEmployee] = useState<any>(null)
|
||
const [page, setPage] = useState(1)
|
||
const [pageSize, setPageSize] = useState(10)
|
||
|
||
const { data: employees, isLoading } = useQuery<any[]>({
|
||
queryKey: ['roster'],
|
||
queryFn: async () => {
|
||
const res = await api.get('/roster') as any
|
||
return res.data
|
||
},
|
||
})
|
||
|
||
const addMutation = useMutation({
|
||
mutationFn: (data: any) => api.post('/employees', data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||
setShowAddModal(false)
|
||
},
|
||
})
|
||
|
||
const resignMutation = useMutation({
|
||
mutationFn: (data: any) => api.post('/termination/resignation', data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||
setShowResignModal(false)
|
||
setResignEmployee(null)
|
||
},
|
||
})
|
||
|
||
const revokeMutation = useMutation({
|
||
mutationFn: (recordId: string) => api.delete(`/termination/${recordId}/revoke`),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||
},
|
||
})
|
||
|
||
const rehireMutation = useMutation({
|
||
mutationFn: (data: any) => api.post(`/employees/${rehireEmployee?.id}/rehire`, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||
setShowRehireModal(false)
|
||
setRehireEmployee(null)
|
||
},
|
||
})
|
||
|
||
const filtered = employees?.filter((e: any) =>
|
||
!search || e.name.includes(search) || e.department.includes(search)
|
||
) || []
|
||
const paged = filtered.slice((page - 1) * pageSize, page * pageSize)
|
||
|
||
if (selectedId) {
|
||
return <EmployeeProfile employeeId={selectedId} onBack={() => setSelectedId(null)} />
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<h1 className="text-xs font-medium">花名册</h1>
|
||
<div className="flex gap-2 flex-nowrap items-center">
|
||
<Input
|
||
placeholder="搜索姓名/部门"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="!w-64 shrink-0"
|
||
/>
|
||
<Button onClick={() => setShowAddModal(true)} className="shrink-0">
|
||
<Plus className="w-4 h-4 mr-1" /> 添加员工
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="text-center py-8 text-gray-400">加载中...</div>
|
||
) : filtered.length === 0 ? (
|
||
<Card><div className="text-center py-8 text-gray-400">暂无员工</div></Card>
|
||
) : (
|
||
<Card>
|
||
<Pagination page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b text-gray-500">
|
||
<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>
|
||
<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-right">月薪</th>
|
||
<th className="py-2 px-3 text-left">合同状态</th>
|
||
<th className="py-2 px-3 text-center">违纪</th>
|
||
<th className="py-2 px-3 text-center">考勤</th>
|
||
<th className="py-2 px-3 text-center">培训</th>
|
||
<th className="py-2 px-3 text-center">绩效</th>
|
||
<th className="py-2 px-3 text-center">工资条</th>
|
||
<th className="py-2 px-3 text-center">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{paged.map((e: any) => (
|
||
<tr
|
||
key={e.id}
|
||
className="border-b last:border-0 cursor-pointer hover:bg-gray-50"
|
||
onClick={() => setSelectedId(e.id)}
|
||
>
|
||
<td className="py-2 px-3 font-medium">{e.name}</td>
|
||
<td className="py-2 px-3 text-gray-500">{e.department}</td>
|
||
<td className="py-2 px-3">
|
||
<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>
|
||
<td className="py-2 px-3 text-gray-500">{e.hireDate?.toString().slice(0, 10)}</td>
|
||
<td className="py-2 px-3 text-gray-500">
|
||
{e.hasTermination && e.latestTerminationDate ? (
|
||
<span className={e.status === 'RESIGNED' ? 'text-gray-500' : 'text-amber-600'}>
|
||
{e.latestTerminationDate.toString().slice(0, 10)}
|
||
{e.status === 'ACTIVE' && ' (预计)'}
|
||
</span>
|
||
) : (
|
||
<span className="text-gray-300">—</span>
|
||
)}
|
||
</td>
|
||
<td className="py-2 px-3 text-right">¥{fmt(e.monthlySalary)}</td>
|
||
<td className="py-2 px-3">
|
||
{(() => {
|
||
const tagStyles: Record<string, string> = {
|
||
expired: 'bg-red-50 text-danger',
|
||
unsigned_over_year: 'bg-red-50 text-danger',
|
||
unsigned_over_30: 'bg-red-50 text-danger',
|
||
unsigned: 'bg-yellow-50 text-yellow-700',
|
||
expiring: 'bg-yellow-50 text-yellow-700',
|
||
active: 'bg-green-50 text-safe',
|
||
unfixed: 'bg-blue-50 text-blue-700',
|
||
}
|
||
const style = tagStyles[e.contractStatus] || 'bg-gray-100 text-gray-500'
|
||
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{e.contractStatusText || '无合同'}</span>
|
||
})()}
|
||
</td>
|
||
<td className="py-2 px-3 text-center">
|
||
{e.counts?.disciplinaryRecords ? (
|
||
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
|
||
) : <span className="text-gray-300">0</span>}
|
||
</td>
|
||
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.attendanceRecords || 0}</td>
|
||
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.trainingRecords || 0}</td>
|
||
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.performanceRecords || 0}</td>
|
||
<td className="py-2 px-3 text-center text-gray-500">{e.counts?.payslips || 0}</td>
|
||
<td className="py-2 px-3 text-center">
|
||
{e.status === 'ACTIVE' && !e.hasTermination && (
|
||
<button
|
||
className="text-xs text-gray-500 hover:text-danger flex items-center gap-0.5"
|
||
onClick={(ev) => {
|
||
ev.stopPropagation()
|
||
setResignEmployee(e)
|
||
setShowResignModal(true)
|
||
}}
|
||
>
|
||
<UserX className="w-3.5 h-3.5" />离职
|
||
</button>
|
||
)}
|
||
{e.hasTermination && e.status === 'ACTIVE' && (
|
||
<div className="flex items-center justify-center gap-2">
|
||
<span className={`text-xs ${e.latestTerminationType === 'RESIGNATION' ? 'text-blue-600' : 'text-amber-600'}`}>
|
||
{e.latestTerminationType === 'RESIGNATION' ? '待离职' : '待解聘'}
|
||
</span>
|
||
<button
|
||
className="text-xs text-gray-400 hover:text-danger"
|
||
onClick={(ev) => {
|
||
ev.stopPropagation()
|
||
if (e.latestTerminationId && confirm(`确认撤回${e.latestTerminationType === 'RESIGNATION' ? '离职' : '解聘'}记录?`)) {
|
||
revokeMutation.mutate(e.latestTerminationId)
|
||
}
|
||
}}
|
||
>
|
||
撤回
|
||
</button>
|
||
</div>
|
||
)}
|
||
{e.status === 'RESIGNED' && (
|
||
<button
|
||
className="text-xs text-primary hover:text-primary/80 flex items-center gap-0.5"
|
||
onClick={(ev) => {
|
||
ev.stopPropagation()
|
||
setRehireEmployee(e)
|
||
setShowRehireModal(true)
|
||
}}
|
||
>
|
||
<UserPlus className="w-3.5 h-3.5" />重新入职
|
||
</button>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{showAddModal && (
|
||
<AddEmployeeModal
|
||
onClose={() => setShowAddModal(false)}
|
||
onSubmit={(data) => addMutation.mutate(data)}
|
||
loading={addMutation.isPending}
|
||
error={addMutation.error as any}
|
||
/>
|
||
)}
|
||
|
||
{showResignModal && resignEmployee && (
|
||
<ResignModal
|
||
employee={resignEmployee}
|
||
onClose={() => { setShowResignModal(false); setResignEmployee(null) }}
|
||
onSubmit={(data) => resignMutation.mutate(data)}
|
||
loading={resignMutation.isPending}
|
||
error={resignMutation.error as any}
|
||
/>
|
||
)}
|
||
|
||
{showRehireModal && rehireEmployee && (
|
||
<RehireModal
|
||
employee={rehireEmployee}
|
||
onClose={() => { setShowRehireModal(false); setRehireEmployee(null) }}
|
||
onSubmit={(data) => rehireMutation.mutate(data)}
|
||
loading={rehireMutation.isPending}
|
||
error={rehireMutation.error as any}
|
||
/>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function EmployeeProfile({ employeeId, onBack }: { employeeId: string; onBack: () => void }) {
|
||
const [tab, setTab] = useState<DetailTab>('basic')
|
||
const [showEvidence, setShowEvidence] = useState(false)
|
||
|
||
const { data: profile, isLoading } = useQuery<any>({
|
||
queryKey: ['roster-profile', employeeId],
|
||
queryFn: async () => {
|
||
const res = await api.get(`/roster/${employeeId}/profile`) as any
|
||
return res.data
|
||
},
|
||
})
|
||
|
||
const tabs: { key: DetailTab; label: string; icon: any }[] = [
|
||
{ key: 'basic', label: '基本信息', icon: Users },
|
||
{ key: 'contract', label: '劳动合同', icon: FileText },
|
||
{ key: 'payslip', label: '工资条', icon: FileText },
|
||
{ key: 'overtime', label: '加班记录', icon: Calendar },
|
||
{ key: 'disciplinary', label: '违纪记录', icon: AlertTriangle },
|
||
{ key: 'attendance', label: '考勤记录', icon: Calendar },
|
||
{ key: 'training', label: '培训签收', icon: GraduationCap },
|
||
{ key: 'performance', label: '绩效考核', icon: TrendingUp },
|
||
{ key: 'termination', label: '离职/解聘记录', icon: FileText },
|
||
{ key: 'attachment', label: '附件管理', icon: Paperclip },
|
||
{ key: 'evidence', label: '仲裁证据链', icon: Scale },
|
||
]
|
||
|
||
if (isLoading) 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 (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-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">
|
||
{tabs.map((t) => {
|
||
const Icon = t.icon
|
||
return (
|
||
<button
|
||
key={t.key}
|
||
onClick={() => setTab(t.key)}
|
||
className={`px-3 py-1.5 text-xs 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'
|
||
}`}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
{t.label}
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{tab === 'basic' && <BasicInfo profile={profile} />}
|
||
{tab === 'contract' && <ContractInfo employeeId={employeeId} contracts={profile.contracts} hireDate={profile.hireDate} />}
|
||
{tab === 'payslip' && <PayslipInfo payslips={profile.payslips} />}
|
||
{tab === 'overtime' && <OvertimeInfo records={profile.overtimeRecords} />}
|
||
{tab === 'disciplinary' && <DisciplinaryInfo employeeId={employeeId} records={profile.disciplinaryRecords} />}
|
||
{tab === 'attendance' && <AttendanceInfo employeeId={employeeId} records={profile.attendanceRecords} />}
|
||
{tab === 'training' && <TrainingInfo employeeId={employeeId} records={profile.trainingRecords} />}
|
||
{tab === 'performance' && <PerformanceInfo employeeId={employeeId} records={profile.performanceRecords} />}
|
||
{tab === 'termination' && <TerminationInfo employeeId={employeeId} profile={profile} records={profile.terminations} />}
|
||
{tab === 'attachment' && <AttachmentInfo employeeId={employeeId} attachments={profile.attachments} />}
|
||
{tab === 'evidence' && <EvidenceChain employeeId={employeeId} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function BasicInfo({ profile }: { profile: any }) {
|
||
const queryClient = useQueryClient()
|
||
const [editing, setEditing] = useState(false)
|
||
const [form, setForm] = useState({
|
||
department: profile.department || '',
|
||
gender: profile.gender || '男',
|
||
phone: profile.phone || '',
|
||
hireDate: profile.hireDate?.toString().slice(0, 10) || '',
|
||
monthlySalary: profile.monthlySalary || '',
|
||
emergencyContact: profile.emergencyContact || '',
|
||
emergencyPhone: profile.emergencyPhone || '',
|
||
address: profile.address || '',
|
||
bankName: profile.bankName || '',
|
||
bankAccount: profile.bankAccount || '',
|
||
isPregnant: profile.isPregnant || false,
|
||
isInMedicalPeriod: profile.isInMedicalPeriod || false,
|
||
isWorkInjured: profile.isWorkInjured || false,
|
||
socialInsBase: profile.socialInsBase ?? '',
|
||
housingFundBase: profile.housingFundBase ?? '',
|
||
specialDeduction: profile.specialDeduction ?? 0,
|
||
})
|
||
|
||
const updateMutation = useMutation({
|
||
mutationFn: (data: any) => api.put(`/employees/${profile.id}`, data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
|
||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||
setEditing(false)
|
||
},
|
||
})
|
||
|
||
const handleSave = () => {
|
||
const data: any = {
|
||
department: form.department,
|
||
gender: form.gender,
|
||
phone: form.phone || undefined,
|
||
hireDate: new Date(form.hireDate).toISOString(),
|
||
monthlySalary: String(form.monthlySalary),
|
||
emergencyContact: form.emergencyContact || undefined,
|
||
emergencyPhone: form.emergencyPhone || undefined,
|
||
address: form.address || undefined,
|
||
bankName: form.bankName || undefined,
|
||
bankAccount: form.bankAccount || undefined,
|
||
isPregnant: form.isPregnant,
|
||
isInMedicalPeriod: form.isInMedicalPeriod,
|
||
isWorkInjured: form.isWorkInjured,
|
||
socialInsBase: form.socialInsBase === '' ? null : Number(form.socialInsBase),
|
||
housingFundBase: form.housingFundBase === '' ? null : Number(form.housingFundBase),
|
||
specialDeduction: Number(form.specialDeduction) || 0,
|
||
}
|
||
updateMutation.mutate(data)
|
||
}
|
||
|
||
const fields = [
|
||
{ label: '姓名', value: profile.name },
|
||
{ label: '部门', value: profile.department },
|
||
{ label: '性别', value: profile.gender || '未填写' },
|
||
{ label: '身份证号', value: profile.idCardNumber || '未填写' },
|
||
{ label: '手机号', value: profile.phone || '未填写' },
|
||
{ label: '入职日期', value: profile.hireDate?.toString().slice(0, 10) },
|
||
{ label: '月工资', value: `¥${fmt(profile.monthlySalary)}` },
|
||
{ label: '紧急联系人', value: profile.emergencyContact || '未填写' },
|
||
{ label: '紧急联系电话', value: profile.emergencyPhone || '未填写' },
|
||
{ label: '住址', value: profile.address || '未填写' },
|
||
{ label: '开户行', value: profile.bankName || '未填写' },
|
||
{ label: '银行账号', value: profile.bankAccount || '未填写' },
|
||
{ label: '状态', value: profile.status === 'ACTIVE' ? '在职' : '离职' },
|
||
]
|
||
const special = [
|
||
{ label: '孕期', value: profile.isPregnant },
|
||
{ label: '医疗期', value: profile.isInMedicalPeriod },
|
||
{ label: '工伤', value: profile.isWorkInjured },
|
||
]
|
||
return (
|
||
<Card>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-xs font-medium">基本信息</h2>
|
||
{!editing ? (
|
||
<Button size="sm" variant="secondary" onClick={() => setEditing(true)}>编辑</Button>
|
||
) : (
|
||
<div className="flex gap-2">
|
||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>
|
||
{updateMutation.isPending ? '保存中...' : '保存'}
|
||
</Button>
|
||
<Button size="sm" variant="secondary" onClick={() => setEditing(false)}>取消</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!editing ? (
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
{fields.map((f) => (
|
||
<div key={f.label} className="flex justify-between border-b pb-2 text-xs">
|
||
<span className="text-gray-500">{f.label}</span>
|
||
<span className="font-medium">{f.value}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="grid md:grid-cols-2 gap-3">
|
||
<div><Label>姓名(不可编辑)</Label><Input value={profile.name} disabled /></div>
|
||
<div><Label>身份证号(不可编辑)</Label><Input value={profile.idCardNumber || ''} disabled /></div>
|
||
<div><Label>部门</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} /></div>
|
||
<div><Label>性别</Label><Select value={form.gender} onChange={(e) => setForm({ ...form, gender: e.target.value as '男' | '女' })}><option value="男">男</option><option value="女">女</option></Select></div>
|
||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||
<div><Label>入职日期</Label><Input type="date" value={form.hireDate} onChange={(e) => setForm({ ...form, hireDate: e.target.value })} /></div>
|
||
<div><Label>月工资</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: Number(e.target.value) })} /></div>
|
||
<div><Label>紧急联系人</Label><Input value={form.emergencyContact} onChange={(e) => setForm({ ...form, emergencyContact: e.target.value })} placeholder="选填" /></div>
|
||
<div><Label>紧急联系电话</Label><Input value={form.emergencyPhone} onChange={(e) => setForm({ ...form, emergencyPhone: e.target.value })} placeholder="选填" /></div>
|
||
<div className="md:col-span-2"><Label>住址</Label><Input value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} placeholder="选填" /></div>
|
||
<div><Label>开户行</Label><Input value={form.bankName} onChange={(e) => setForm({ ...form, bankName: e.target.value })} placeholder="选填" /></div>
|
||
<div><Label>银行账号</Label><Input value={form.bankAccount} onChange={(e) => setForm({ ...form, bankAccount: e.target.value })} placeholder="选填" /></div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 薪税信息 */}
|
||
<div className="mt-4 pt-4 border-t">
|
||
<h3 className="text-xs font-medium text-gray-600 mb-3">薪税信息</h3>
|
||
{!editing ? (
|
||
<div className="grid md:grid-cols-3 gap-4">
|
||
<div className="flex justify-between border-b pb-2 text-xs">
|
||
<span className="text-gray-500">社保缴费基数</span>
|
||
<span className="font-medium">{profile.socialInsBase ? `¥${fmt(profile.socialInsBase)}` : '未设置'}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-2 text-xs">
|
||
<span className="text-gray-500">公积金缴费基数</span>
|
||
<span className="font-medium">{profile.housingFundBase ? `¥${fmt(profile.housingFundBase)}` : '未设置'}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-2 text-xs">
|
||
<span className="text-gray-500">专项附加扣除</span>
|
||
<span className="font-medium">{profile.specialDeduction ? `¥${fmt(profile.specialDeduction)}/月` : '¥0.00/月'}</span>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="grid md:grid-cols-3 gap-4">
|
||
<div>
|
||
<Label>社保缴费基数</Label>
|
||
<Input type="number" placeholder="按人核定" value={form.socialInsBase} onChange={(e) => setForm({ ...form, socialInsBase: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>公积金缴费基数</Label>
|
||
<Input type="number" placeholder="按人核定" value={form.housingFundBase} onChange={(e) => setForm({ ...form, housingFundBase: e.target.value })} />
|
||
</div>
|
||
<div>
|
||
<Label>专项附加扣除(元/月)</Label>
|
||
<Input type="number" placeholder="子女教育、赡养老人等" value={form.specialDeduction} onChange={(e) => setForm({ ...form, specialDeduction: Number(e.target.value) || 0 })} />
|
||
</div>
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-gray-400 mt-2">社保/公积金基数按上年度月均工资核定,每年7月调整。专项附加扣除由员工在portal端填报,无则为0。</p>
|
||
</div>
|
||
|
||
{/* 特殊状态 */}
|
||
<div className="mt-4 pt-4 border-t">
|
||
<h3 className="text-xs font-medium text-gray-600 mb-3">特殊状态</h3>
|
||
{!editing ? (
|
||
<div className="flex gap-4">
|
||
{special.map((s) => (
|
||
<span key={s.label} className={`px-3 py-1 rounded text-xs ${s.value ? 'bg-red-50 text-danger' : 'bg-gray-50 text-gray-400'}`}>
|
||
{s.label}:{s.value ? '是' : '否'}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="flex gap-4">
|
||
<label className="flex items-center gap-1.5 text-xs">
|
||
<input type="checkbox" checked={form.isPregnant} onChange={(e) => setForm({ ...form, isPregnant: e.target.checked })} />
|
||
孕期/哺乳期
|
||
</label>
|
||
<label className="flex items-center gap-1.5 text-xs">
|
||
<input type="checkbox" checked={form.isInMedicalPeriod} onChange={(e) => setForm({ ...form, isInMedicalPeriod: e.target.checked })} />
|
||
医疗期
|
||
</label>
|
||
<label className="flex items-center gap-1.5 text-xs">
|
||
<input type="checkbox" checked={form.isWorkInjured} onChange={(e) => setForm({ ...form, isWorkInjured: e.target.checked })} />
|
||
工伤
|
||
</label>
|
||
</div>
|
||
)}
|
||
{(profile.isPregnant || profile.isInMedicalPeriod || profile.isWorkInjured) && !editing && (
|
||
<p className="text-xs text-amber-600 mt-2">⚠️ 该员工处于特殊保护期,解聘操作将触发法律风险预警</p>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) {
|
||
const queryClient = useQueryClient()
|
||
const [showForm, setShowForm] = useState(false)
|
||
const [form, setForm] = useState({ contractType: 'FIXED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, signMethod: 'PAPER' as 'PAPER' | 'ELECTRONIC', attachmentUrl: '', electronicContractNo: '', electronicContractUrl: '' })
|
||
const contractFileRef = useRef<HTMLInputElement>(null)
|
||
|
||
const addContractMutation = useMutation({
|
||
mutationFn: (data: any) => api.post('/employees/contracts', { ...data, employeeId }),
|
||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||
})
|
||
|
||
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
const reader = new FileReader()
|
||
reader.onload = (event) => {
|
||
setForm({ ...form, attachmentUrl: event.target?.result as string })
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
const typeMap: Record<string, string> = { FIXED: '固定期限', UNFIXED: '无固定期限', UNSIGNED: '未签订' }
|
||
|
||
// 按劳动合同法自动判断续签和合同类型建议
|
||
const contractAdvice = (() => {
|
||
if (!contracts?.length) return null
|
||
const fixedContracts = contracts.filter((c: any) => c.contractType === 'FIXED')
|
||
const latestContract = contracts[0]
|
||
const isRenewal = !!latestContract?.endDate
|
||
const renewalCount = (latestContract?.renewalCount || 0)
|
||
|
||
// 连续订立二次固定期限劳动合同,第三次应订立无固定期限
|
||
const shouldUnfixed = fixedContracts.length >= 2
|
||
|
||
// 连续工作满十年
|
||
const yearsSinceHire = hireDate ? (Date.now() - new Date(hireDate).getTime()) / (365.25 * 24 * 60 * 60 * 1000) : 0
|
||
const shouldUnfixedByTenure = yearsSinceHire >= 10
|
||
|
||
if (shouldUnfixed || shouldUnfixedByTenure) {
|
||
return {
|
||
isRenewal,
|
||
renewalCount: isRenewal ? renewalCount + 1 : renewalCount,
|
||
suggestedType: 'UNFIXED',
|
||
reason: shouldUnfixed
|
||
? `已连续签订${fixedContracts.length}次固定期限合同,按《劳动合同法》第十四条应订立无固定期限合同`
|
||
: `连续工作满${Math.floor(yearsSinceHire)}年,按《劳动合同法》第十四条应订立无固定期限合同`,
|
||
}
|
||
}
|
||
|
||
if (isRenewal) {
|
||
return {
|
||
isRenewal: true,
|
||
renewalCount: renewalCount + 1,
|
||
suggestedType: 'FIXED',
|
||
reason: `本次为第${renewalCount + 1}次续签`,
|
||
}
|
||
}
|
||
|
||
return null
|
||
})()
|
||
|
||
const handleShowForm = () => {
|
||
if (contractAdvice?.suggestedType) {
|
||
setForm({
|
||
...form,
|
||
contractType: contractAdvice.suggestedType,
|
||
signDate: new Date().toISOString().slice(0, 10),
|
||
startDate: contractAdvice.isRenewal && contracts[0]?.endDate
|
||
? contracts[0].endDate.toString().slice(0, 10)
|
||
: new Date().toISOString().slice(0, 10),
|
||
endDate: '',
|
||
probationMonths: 0,
|
||
probationSalary: 0,
|
||
signMethod: 'PAPER',
|
||
attachmentUrl: '',
|
||
electronicContractNo: '',
|
||
electronicContractUrl: '',
|
||
})
|
||
}
|
||
setShowForm(!showForm)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-xs font-medium">劳动合同({contracts?.length || 0}份)</h2>
|
||
<Button size="sm" onClick={handleShowForm}>新增合同</Button>
|
||
</div>
|
||
|
||
{contractAdvice && !showForm && (
|
||
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs flex items-start gap-2">
|
||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||
<span>{contractAdvice.reason},建议选择「{contractAdvice.suggestedType === 'UNFIXED' ? '无固定期限' : '固定期限'}」</span>
|
||
</div>
|
||
)}
|
||
|
||
{showForm && (
|
||
<Card>
|
||
{contractAdvice && (
|
||
<div className="px-3 py-2 mb-3 rounded-md bg-amber-50 text-amber-700 text-xs flex items-start gap-2">
|
||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||
<span>{contractAdvice.reason}</span>
|
||
</div>
|
||
)}
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<div><Label>合同类型</Label>
|
||
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value })}>
|
||
<option value="FIXED">固定期限</option>
|
||
<option value="UNFIXED">无固定期限</option>
|
||
</Select>
|
||
</div>
|
||
<div><Label>签订日期</Label><Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} /></div>
|
||
<div><Label>合同开始日期 *</Label><Input type="date" value={form.startDate} onChange={(e) => setForm({ ...form, startDate: e.target.value })} /></div>
|
||
{form.contractType === 'FIXED' && (
|
||
<div><Label>合同结束日期</Label><Input type="date" value={form.endDate} onChange={(e) => setForm({ ...form, endDate: e.target.value })} /></div>
|
||
)}
|
||
{form.contractType === 'FIXED' && !contractAdvice?.isRenewal && (
|
||
<>
|
||
<div><Label>试用期(月)</Label><Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} /></div>
|
||
<div><Label>试用期工资</Label><Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} /></div>
|
||
</>
|
||
)}
|
||
<div className="md:col-span-2 border-t pt-3">
|
||
<Label>签订方式</Label>
|
||
<Select value={form.signMethod} onChange={(e) => setForm({ ...form, signMethod: e.target.value as 'PAPER' | 'ELECTRONIC' })}>
|
||
<option value="PAPER">纸质签署</option>
|
||
<option value="ELECTRONIC">电子签署</option>
|
||
</Select>
|
||
</div>
|
||
{form.signMethod === 'PAPER' && (
|
||
<div className="md:col-span-2">
|
||
<Label>合同扫描件 *</Label>
|
||
<input ref={contractFileRef} type="file" className="hidden" onChange={handleContractFileUpload} />
|
||
<div className="flex items-center gap-2">
|
||
<Button size="sm" variant="secondary" onClick={() => contractFileRef.current?.click()}>
|
||
<Paperclip className="w-4 h-4 mr-1" />上传扫描件
|
||
</Button>
|
||
{form.attachmentUrl && <span className="text-xs text-safe">✓ 已上传</span>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{form.signMethod === 'ELECTRONIC' && (
|
||
<>
|
||
<div><Label>电子合同编号 *</Label><Input value={form.electronicContractNo} onChange={(e) => setForm({ ...form, electronicContractNo: e.target.value })} placeholder="如 E-2026-001" /></div>
|
||
<div><Label>电子合同链接 *</Label><Input value={form.electronicContractUrl} onChange={(e) => setForm({ ...form, electronicContractUrl: e.target.value })} placeholder="https://..." /></div>
|
||
</>
|
||
)}
|
||
<div className="md:col-span-2 flex gap-2">
|
||
<Button onClick={() => addContractMutation.mutate(form)} disabled={
|
||
addContractMutation.isPending || !form.startDate ||
|
||
(form.signMethod === 'PAPER' && !form.attachmentUrl) ||
|
||
(form.signMethod === 'ELECTRONIC' && (!form.electronicContractNo || !form.electronicContractUrl))
|
||
}>
|
||
{addContractMutation.isPending ? '保存中...' : '保存'}
|
||
</Button>
|
||
<Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{!contracts?.length ? (
|
||
<Card><div className="text-center py-8 text-gray-400">暂无合同记录</div></Card>
|
||
) : contracts.map((c) => (
|
||
<Card key={c.id}>
|
||
<div className="flex items-start justify-between">
|
||
<div className="grid md:grid-cols-2 gap-4 flex-1 text-xs">
|
||
<div className="flex justify-between"><span className="text-gray-500">合同类型</span><span className="font-medium">{typeMap[c.contractType] || c.contractType}</span></div>
|
||
<div className="flex justify-between"><span className="text-gray-500">签订日期</span><span className="font-medium">{c.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}</span></div>
|
||
<div className="flex justify-between"><span className="text-gray-500">合同开始</span><span className="font-medium">{c.startDate?.toString().slice(0, 10)}</span></div>
|
||
<div className="flex justify-between"><span className="text-gray-500">合同结束</span><span className="font-medium">{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}</span></div>
|
||
<div className="flex justify-between"><span className="text-gray-500">合同期限</span><span className="font-medium">{c.contractYears}年</span></div>
|
||
<div className="flex justify-between"><span className="text-gray-500">试用期</span><span className="font-medium">{c.probationMonths}个月(¥{c.probationSalary})</span></div>
|
||
<div className="flex justify-between"><span className="text-gray-500">签订方式</span><span className="font-medium">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
|
||
<div className="flex justify-between"><span className="text-gray-500">续签次数</span><span className="font-medium">{c.renewalCount}</span></div>
|
||
{c.signMethod === 'PAPER' && (
|
||
<div className="flex justify-between md:col-span-2">
|
||
<span className="text-gray-500">合同扫描件</span>
|
||
{c.attachmentUrl ? (
|
||
<a href={c.attachmentUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
|
||
<Paperclip className="w-3 h-3" />查看扫描件
|
||
</a>
|
||
) : (
|
||
<span className="text-gray-400">未上传</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{c.signMethod === 'ELECTRONIC' && (
|
||
<>
|
||
{c.electronicContractNo && <div className="flex justify-between"><span className="text-gray-500">电子合同编号</span><span className="font-medium">{c.electronicContractNo}</span></div>}
|
||
{c.electronicContractUrl && (
|
||
<div className="flex justify-between md:col-span-2">
|
||
<span className="text-gray-500">电子合同</span>
|
||
<a href={c.electronicContractUrl} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex items-center gap-1">
|
||
<FileText className="w-3 h-3" />查看电子合同
|
||
</a>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ResignModal({ employee, onClose, onSubmit, loading, error }: {
|
||
employee: any
|
||
onClose: () => void
|
||
onSubmit: (data: any) => void
|
||
loading: boolean
|
||
error: any
|
||
}) {
|
||
const [form, setForm] = useState({
|
||
terminationDate: new Date().toISOString().slice(0, 10),
|
||
resignationReason: '个人原因',
|
||
remark: '',
|
||
})
|
||
|
||
const reasons = ['个人原因', '职业发展', '薪资不满意', '家庭原因', '身体原因', '其他']
|
||
|
||
const handleSubmit = () => {
|
||
onSubmit({
|
||
employeeId: employee.id,
|
||
terminationDate: new Date(form.terminationDate).toISOString(),
|
||
resignationReason: form.resignationReason,
|
||
remark: form.remark || undefined,
|
||
})
|
||
}
|
||
|
||
return (
|
||
<Modal open onClose={onClose} title={`办理离职 - ${employee.name}`}>
|
||
<div className="space-y-3">
|
||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
|
||
员工主动离职,不涉及经济补偿金。离职日期可在未来(提前办理),到日期后状态自动变为离职。
|
||
</div>
|
||
<div>
|
||
<Label>员工</Label>
|
||
<div className="text-xs text-gray-600">{employee.name} - {employee.department}</div>
|
||
</div>
|
||
<div>
|
||
<Label>离职日期</Label>
|
||
<Input
|
||
type="date"
|
||
value={form.terminationDate}
|
||
onChange={(e) => setForm({ ...form, terminationDate: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label>离职原因</Label>
|
||
<Select value={form.resignationReason} onChange={(e) => setForm({ ...form, resignationReason: e.target.value })}>
|
||
{reasons.map((r) => <option key={r} value={r}>{r}</option>)}
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label>备注(选填)</Label>
|
||
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="补充说明" />
|
||
</div>
|
||
{error && (
|
||
<div className="text-xs text-danger">
|
||
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
|
||
</div>
|
||
)}
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||
<Button onClick={handleSubmit} disabled={loading}>
|
||
{loading ? '提交中...' : '确认离职'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function RehireModal({ employee, onClose, onSubmit, loading, error }: {
|
||
employee: any
|
||
onClose: () => void
|
||
onSubmit: (data: any) => void
|
||
loading: boolean
|
||
error: any
|
||
}) {
|
||
const todayStr = new Date().toISOString().slice(0, 10)
|
||
const defaultEndDate = (() => {
|
||
const d = new Date()
|
||
d.setFullYear(d.getFullYear() + 3)
|
||
d.setDate(d.getDate() - 1)
|
||
return d.toISOString().slice(0, 10)
|
||
})()
|
||
const [form, setForm] = useState({
|
||
hireDate: todayStr,
|
||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||
signDate: '',
|
||
startDate: todayStr,
|
||
endDate: defaultEndDate,
|
||
contractYears: 3,
|
||
probationMonths: 0,
|
||
probationSalary: 0,
|
||
})
|
||
|
||
// 计算合同月数
|
||
const contractMonths = (() => {
|
||
if (form.contractType !== 'FIXED' || !form.startDate) return 0
|
||
if (form.endDate) {
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(form.endDate)
|
||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||
}
|
||
return form.contractYears * 12
|
||
})()
|
||
|
||
// 试用期上限(劳动合同法第19条)
|
||
const probationMax = (() => {
|
||
if (contractMonths >= 36) return 6
|
||
if (contractMonths >= 12) return 2
|
||
if (contractMonths >= 3) return 1
|
||
return 0
|
||
})()
|
||
|
||
const probationError = (() => {
|
||
if (form.probationMonths <= 0) return ''
|
||
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
|
||
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
|
||
return ''
|
||
})()
|
||
|
||
const monthlySalaryNum = employee?.monthlySalary || 0
|
||
const probationSalaryError = (() => {
|
||
if (form.probationMonths <= 0) return ''
|
||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
||
}
|
||
return ''
|
||
})()
|
||
|
||
// 合同结束日期自动计算
|
||
const handleContractYearsChange = (years: number) => {
|
||
if (!form.startDate || years <= 0) {
|
||
setForm({ ...form, contractYears: years, endDate: '' })
|
||
return
|
||
}
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + years)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
|
||
}
|
||
|
||
// 合同结束日期变更 → 自动计算签约年限
|
||
const handleEndDateChange = (endDate: string) => {
|
||
if (!form.startDate || !endDate) {
|
||
setForm({ ...form, endDate, contractYears: 0 })
|
||
return
|
||
}
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(endDate)
|
||
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
|
||
}
|
||
|
||
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
|
||
const handleHireDateChange = (hireDate: string) => {
|
||
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
|
||
const start = new Date(hireDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + form.contractYears)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
|
||
} else {
|
||
setForm({ ...form, hireDate, startDate: hireDate })
|
||
}
|
||
}
|
||
|
||
// 开始日期变更 → 重新计算结束日期
|
||
const handleStartDateChange = (startDate: string) => {
|
||
if (form.contractType === 'FIXED' && form.contractYears > 0 && startDate) {
|
||
const start = new Date(startDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + form.contractYears)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, startDate, endDate: end.toISOString().slice(0, 10) })
|
||
} else {
|
||
setForm({ ...form, startDate })
|
||
}
|
||
}
|
||
|
||
const handleSubmit = () => {
|
||
const data: any = {
|
||
hireDate: new Date(form.hireDate).toISOString(),
|
||
}
|
||
if (form.contractType !== 'UNSIGNED' && form.startDate) {
|
||
data.contract = {
|
||
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
|
||
startDate: new Date(form.startDate).toISOString(),
|
||
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
|
||
contractType: form.contractType,
|
||
contractYears: form.contractYears,
|
||
probationMonths: form.probationMonths,
|
||
probationSalary: form.probationSalary,
|
||
}
|
||
}
|
||
onSubmit(data)
|
||
}
|
||
|
||
const canSubmit = form.hireDate
|
||
&& (form.contractType === 'UNSIGNED' || form.startDate)
|
||
&& !probationError && !probationSalaryError
|
||
|
||
return (
|
||
<Modal open onClose={onClose} title={`重新入职 - ${employee.name}`}>
|
||
<div className="space-y-3">
|
||
<div className="bg-blue-50 text-blue-700 text-xs px-3 py-2 rounded-md">
|
||
复用员工已有基本信息(姓名、部门、身份证号等),只需填写新入职日期和劳动合同。
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>员工</Label>
|
||
<div className="text-xs text-gray-600 py-1.5">{employee.name}</div>
|
||
</div>
|
||
<div>
|
||
<Label>部门</Label>
|
||
<div className="text-xs text-gray-600 py-1.5">{employee.department}</div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<Label>新入职日期 *</Label>
|
||
<Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} />
|
||
</div>
|
||
<div className="border-t pt-3">
|
||
<Label>合同类型</Label>
|
||
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any, endDate: e.target.value === 'UNFIXED' ? '' : form.endDate })}>
|
||
<option value="FIXED">固定期限</option>
|
||
<option value="UNFIXED">无固定期限</option>
|
||
<option value="UNSIGNED">暂不签订</option>
|
||
</Select>
|
||
</div>
|
||
{form.contractType !== 'UNSIGNED' && (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>签订日期</Label>
|
||
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
|
||
<div className="text-xs text-gray-400 mt-0.5">留空表示尚未签订</div>
|
||
</div>
|
||
<div><Label>合同开始日期</Label><div className="text-xs text-gray-600 py-1.5">{form.startDate || '随入职日期'}</div></div>
|
||
</div>
|
||
{form.contractType === 'FIXED' && (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>签约时长(年)</Label>
|
||
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
|
||
</div>
|
||
<div>
|
||
<Label>合同结束日期</Label>
|
||
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div className="text-xs text-gray-400">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||
</>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>试用期(月)</Label>
|
||
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
|
||
{contractMonths > 0 && (
|
||
<div className="text-xs text-gray-400 mt-0.5">法定上限:{probationMax}个月</div>
|
||
)}
|
||
{probationError && <div className="text-xs text-danger mt-0.5">{probationError}</div>}
|
||
</div>
|
||
<div>
|
||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
|
||
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
|
||
<div className="text-xs text-gray-400 mt-0.5">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||
)}
|
||
{probationSalaryError && <div className="text-xs text-danger mt-0.5">{probationSalaryError}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{error && (
|
||
<div className="text-xs text-danger">
|
||
{(error as any)?.response?.data?.error?.message || '操作失败,请重试'}
|
||
</div>
|
||
)}
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '提交中...' : '确认入职'}</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function AddEmployeeModal({ onClose, onSubmit, loading, error }: {
|
||
onClose: () => void
|
||
onSubmit: (data: any) => void
|
||
loading: boolean
|
||
error: any
|
||
}) {
|
||
const todayStr = new Date().toISOString().slice(0, 10)
|
||
const defaultEndDate = (() => {
|
||
const d = new Date()
|
||
d.setFullYear(d.getFullYear() + 3)
|
||
d.setDate(d.getDate() - 1)
|
||
return d.toISOString().slice(0, 10)
|
||
})()
|
||
const [form, setForm] = useState({
|
||
name: '', department: '', hireDate: todayStr, monthlySalary: '',
|
||
idCardNumber: '', gender: '男' as '男' | '女', phone: '',
|
||
contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED',
|
||
signDate: '', startDate: todayStr, endDate: defaultEndDate,
|
||
contractYears: 3, probationMonths: 0, probationSalary: 0,
|
||
})
|
||
|
||
// 入职日期变更 → 同步合同开始日期 + 重算结束日期
|
||
const handleHireDateChange = (hireDate: string) => {
|
||
if (form.contractType === 'FIXED' && form.contractYears > 0 && hireDate) {
|
||
const start = new Date(hireDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + form.contractYears)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, hireDate, startDate: hireDate, endDate: end.toISOString().slice(0, 10) })
|
||
} else {
|
||
setForm({ ...form, hireDate, startDate: hireDate })
|
||
}
|
||
}
|
||
|
||
// 根据身份证号自动计算性别(第17位:奇数=男,偶数=女)
|
||
const handleIdCardChange = (idCard: string) => {
|
||
let gender = form.gender
|
||
if (idCard.length >= 17) {
|
||
const digit = parseInt(idCard[16])
|
||
if (!isNaN(digit)) gender = digit % 2 === 1 ? '男' : '女'
|
||
}
|
||
setForm({ ...form, idCardNumber: idCard, gender })
|
||
}
|
||
|
||
// 计算合同月数
|
||
const contractMonths = (() => {
|
||
if (form.contractType !== 'FIXED' || !form.startDate) return 0
|
||
if (form.endDate) {
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(form.endDate)
|
||
return Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||
}
|
||
return form.contractYears * 12
|
||
})()
|
||
|
||
// 试用期上限(劳动合同法第19条)
|
||
const probationMax = (() => {
|
||
if (contractMonths >= 36) return 6
|
||
if (contractMonths >= 12) return 2
|
||
if (contractMonths >= 3) return 1
|
||
return 0
|
||
})()
|
||
|
||
const probationError = (() => {
|
||
if (form.probationMonths <= 0) return ''
|
||
if (contractMonths > 0 && contractMonths < 3) return '合同不足3个月,不得约定试用期'
|
||
if (form.probationMonths > probationMax) return `合同${contractMonths}个月,试用期最多${probationMax}个月`
|
||
return ''
|
||
})()
|
||
|
||
const monthlySalaryNum = parseFloat(form.monthlySalary) || 0
|
||
const probationSalaryError = (() => {
|
||
if (form.probationMonths <= 0) return ''
|
||
if (form.probationSalary <= 0) return '有试用期时试用期工资必填'
|
||
if (monthlySalaryNum > 0 && form.probationSalary < monthlySalaryNum * 0.8) {
|
||
return `试用期工资不得低于转正工资的80%(最低¥${(monthlySalaryNum * 0.8).toFixed(0)})`
|
||
}
|
||
return ''
|
||
})()
|
||
|
||
// 合同结束日期自动计算
|
||
const handleContractYearsChange = (years: number) => {
|
||
if (!form.startDate || years <= 0) {
|
||
setForm({ ...form, contractYears: years, endDate: '' })
|
||
return
|
||
}
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + years)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, contractYears: years, endDate: end.toISOString().slice(0, 10) })
|
||
}
|
||
|
||
// 合同结束日期变更 → 自动计算签约年限
|
||
const handleEndDateChange = (endDate: string) => {
|
||
if (!form.startDate || !endDate) {
|
||
setForm({ ...form, endDate, contractYears: 0 })
|
||
return
|
||
}
|
||
const start = new Date(form.startDate)
|
||
const end = new Date(endDate)
|
||
const months = Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))
|
||
setForm({ ...form, endDate, contractYears: Math.max(1, Math.round(months / 12)) })
|
||
}
|
||
|
||
// 开始日期变更 → 重新计算结束日期
|
||
const handleStartDateChange = (startDate: string) => {
|
||
if (form.contractType === 'FIXED' && form.contractYears > 0 && startDate) {
|
||
const start = new Date(startDate)
|
||
const end = new Date(start)
|
||
end.setFullYear(end.getFullYear() + form.contractYears)
|
||
end.setDate(end.getDate() - 1)
|
||
setForm({ ...form, startDate, endDate: end.toISOString().slice(0, 10) })
|
||
} else {
|
||
setForm({ ...form, startDate })
|
||
}
|
||
}
|
||
|
||
const handleSubmit = () => {
|
||
const data: any = {
|
||
name: form.name, department: form.department,
|
||
hireDate: new Date(form.hireDate).toISOString(),
|
||
monthlySalary: form.monthlySalary, gender: form.gender,
|
||
idCardNumber: form.idCardNumber || undefined,
|
||
phone: form.phone || undefined,
|
||
}
|
||
if (form.contractType !== 'UNSIGNED' && form.startDate) {
|
||
data.contract = {
|
||
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
|
||
startDate: new Date(form.startDate).toISOString(),
|
||
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
|
||
contractType: form.contractType, contractYears: form.contractYears,
|
||
probationMonths: form.probationMonths, probationSalary: form.probationSalary,
|
||
}
|
||
}
|
||
onSubmit(data)
|
||
}
|
||
|
||
const canSubmit = form.name && form.department && form.hireDate && form.monthlySalary
|
||
&& form.idCardNumber.length >= 18
|
||
&& (form.contractType === 'UNSIGNED' || form.startDate)
|
||
&& !probationError && !probationSalaryError
|
||
|
||
return (
|
||
<Modal open onClose={onClose} title="添加员工">
|
||
<div className="space-y-3">
|
||
{error && (
|
||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||
{error.response?.data?.error?.message || '操作失败'}
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><Label>姓名 *</Label><Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="员工姓名" /></div>
|
||
<div><Label>部门 *</Label><Input value={form.department} onChange={(e) => setForm({ ...form, department: e.target.value })} placeholder="如:技术部" /></div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><Label>身份证号 *</Label><Input value={form.idCardNumber} onChange={(e) => handleIdCardChange(e.target.value)} placeholder="18位身份证号" maxLength={18} /></div>
|
||
<div><Label>性别</Label><div className="text-xs text-gray-600 py-1.5">{form.idCardNumber.length >= 17 ? form.gender : '由身份证号自动识别'}</div></div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><Label>入职日期 *</Label><Input type="date" value={form.hireDate} onChange={(e) => handleHireDateChange(e.target.value)} /></div>
|
||
<div><Label>月工资 *</Label><Input type="number" value={form.monthlySalary} onChange={(e) => setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" /></div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div><Label>手机号</Label><Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} /></div>
|
||
</div>
|
||
<div className="border-t pt-3">
|
||
<Label>合同类型</Label>
|
||
<Select value={form.contractType} onChange={(e) => setForm({ ...form, contractType: e.target.value as any, endDate: e.target.value === 'UNFIXED' ? '' : form.endDate })}>
|
||
<option value="FIXED">固定期限</option><option value="UNFIXED">无固定期限</option><option value="UNSIGNED">未签合同</option>
|
||
</Select>
|
||
</div>
|
||
{form.contractType !== 'UNSIGNED' && (
|
||
<div className="space-y-3">
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>签订日期</Label>
|
||
<Input type="date" value={form.signDate} onChange={(e) => setForm({ ...form, signDate: e.target.value })} />
|
||
<div className="text-xs text-gray-400 mt-0.5">留空表示尚未签订</div>
|
||
</div>
|
||
<div><Label>合同开始日期</Label><div className="text-xs text-gray-600 py-1.5">{form.startDate || '随入职日期'}</div></div>
|
||
</div>
|
||
{form.contractType === 'FIXED' && (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>签约时长(年)</Label>
|
||
<Input type="number" value={form.contractYears} onChange={(e) => handleContractYearsChange(parseInt(e.target.value) || 0)} min={1} />
|
||
</div>
|
||
<div>
|
||
<Label>合同结束日期</Label>
|
||
<Input type="date" value={form.endDate} onChange={(e) => handleEndDateChange(e.target.value)} />
|
||
</div>
|
||
</div>
|
||
<div className="text-xs text-gray-400">修改签约时长自动计算结束日期,修改结束日期自动计算签约时长</div>
|
||
</>
|
||
)}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<Label>试用期(月)</Label>
|
||
<Input type="number" value={form.probationMonths} onChange={(e) => setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
|
||
{contractMonths > 0 && (
|
||
<div className="text-xs text-gray-400 mt-0.5">法定上限:{probationMax}个月</div>
|
||
)}
|
||
{probationError && <div className="text-xs text-danger mt-0.5">{probationError}</div>}
|
||
</div>
|
||
<div>
|
||
<Label>试用期工资{form.probationMonths > 0 ? ' *' : ''}</Label>
|
||
<Input type="number" value={form.probationSalary} onChange={(e) => setForm({ ...form, probationSalary: parseFloat(e.target.value) || 0 })} disabled={form.probationMonths <= 0} />
|
||
{monthlySalaryNum > 0 && form.probationMonths > 0 && (
|
||
<div className="text-xs text-gray-400 mt-0.5">不低于转正工资80%(≥¥{(monthlySalaryNum * 0.8).toFixed(0)})</div>
|
||
)}
|
||
{probationSalaryError && <div className="text-xs text-danger mt-0.5">{probationSalaryError}</div>}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-end gap-2 pt-2">
|
||
<Button variant="secondary" onClick={onClose}>取消</Button>
|
||
<Button onClick={handleSubmit} disabled={loading || !canSubmit}>{loading ? '保存中...' : '保存'}</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
function AttachmentInfo({ employeeId, attachments }: { employeeId: string; attachments: any[] }) {
|
||
const queryClient = useQueryClient()
|
||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||
const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'EDUCATION' | 'OTHER'>('ID_CARD')
|
||
|
||
const addAttachmentMutation = useMutation({
|
||
mutationFn: (data: any) => api.post('/attachments', data),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||
})
|
||
|
||
const deleteAttachmentMutation = useMutation({
|
||
mutationFn: (id: string) => api.delete(`/attachments/${id}`),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile', employeeId] }),
|
||
})
|
||
|
||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (!file) return
|
||
const reader = new FileReader()
|
||
reader.onload = (event) => {
|
||
const fileUrl = event.target?.result as string
|
||
addAttachmentMutation.mutate({ employeeId, fileName: file.name, fileType, fileUrl, fileSize: file.size })
|
||
}
|
||
reader.readAsDataURL(file)
|
||
}
|
||
|
||
const fileTypeLabels: Record<string, string> = { ID_CARD: '身份证', BANK_CARD: '银行卡', EDUCATION: '学历证书', OTHER: '其他' }
|
||
const fileTypeColors: Record<string, string> = { ID_CARD: 'bg-blue-50 text-blue-600', BANK_CARD: 'bg-green-50 text-safe', EDUCATION: 'bg-amber-50 text-amber-600', OTHER: 'bg-gray-100 text-gray-500' }
|
||
|
||
const formatSize = (bytes: number) => {
|
||
if (!bytes) return '-'
|
||
if (bytes < 1024) return `${bytes}B`
|
||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`
|
||
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<h2 className="text-xs font-medium">附件管理({attachments?.length || 0}个)</h2>
|
||
<Card>
|
||
<div className="flex gap-2 mb-3 flex-nowrap items-center">
|
||
<Select value={fileType} onChange={(e) => setFileType(e.target.value as any)} className="text-xs !w-32 shrink-0">
|
||
<option value="ID_CARD">身份证</option><option value="BANK_CARD">银行卡</option><option value="EDUCATION">学历证书</option><option value="OTHER">其他</option>
|
||
</Select>
|
||
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileUpload} />
|
||
<Button size="sm" variant="secondary" onClick={() => fileInputRef.current?.click()} disabled={addAttachmentMutation.isPending} className="shrink-0 whitespace-nowrap">
|
||
{addAttachmentMutation.isPending ? '上传中...' : '上传附件'}
|
||
</Button>
|
||
</div>
|
||
{attachments?.length ? (
|
||
<div className="space-y-2">
|
||
{attachments.map((att) => (
|
||
<div key={att.id} className="flex items-center justify-between bg-gray-50 rounded p-2.5 text-xs hover:bg-gray-100">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
<Paperclip className="w-4 h-4 text-gray-400 shrink-0" />
|
||
<div className="min-w-0">
|
||
<div className="truncate font-medium text-gray-700">{att.fileName}</div>
|
||
<div className="flex items-center gap-2 mt-0.5">
|
||
<span className={`px-1.5 py-0.5 rounded text-xs ${fileTypeColors[att.fileType] || 'bg-gray-100 text-gray-500'}`}>{fileTypeLabels[att.fileType] || att.fileType}</span>
|
||
<span className="text-gray-400">{formatSize(att.fileSize)}</span>
|
||
<span className="text-gray-400">{new Date(att.createdAt).toLocaleDateString('zh-CN')}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||
<button onClick={() => window.open(att.fileUrl, '_blank')} className="text-gray-400 hover:text-blue-600" title="查看">
|
||
<Eye className="w-4 h-4" />
|
||
</button>
|
||
<a href={att.fileUrl} download={att.fileName} className="text-gray-400 hover:text-blue-600" title="下载">
|
||
<Download className="w-4 h-4" />
|
||
</a>
|
||
<button onClick={() => deleteAttachmentMutation.mutate(att.id)} className="text-gray-300 hover:text-danger" title="删除">
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : <div className="text-gray-400 text-xs text-center py-4">暂无附件</div>}
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PayslipInfo({ payslips }: { payslips: any[] }) {
|
||
if (!payslips?.length) return <Card><div className="text-center py-8 text-gray-400">暂无工资条记录</div></Card>
|
||
const totalBase = payslips.reduce((s, p) => s + (p.baseSalary || 0), 0)
|
||
const totalOT = payslips.reduce((s, p) => s + (p.overtimePay || 0), 0)
|
||
const totalAllow = payslips.reduce((s, p) => s + (p.allowance || 0), 0)
|
||
const totalDed = payslips.reduce((s, p) => s + (p.deduction || 0), 0)
|
||
const totalPay = payslips.reduce((s, p) => s + (p.totalPay || 0), 0)
|
||
return (
|
||
<Card>
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b text-gray-500">
|
||
<th className="py-2 text-left">月份</th>
|
||
<th className="py-2 text-right">基本工资</th>
|
||
<th className="py-2 text-right">加班费</th>
|
||
<th className="py-2 text-right">津贴</th>
|
||
<th className="py-2 text-right">扣款</th>
|
||
<th className="py-2 text-right">应发合计</th>
|
||
<th className="py-2 text-center">确认状态</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{payslips.map((p) => (
|
||
<tr key={p.id} className="border-b last:border-0 hover:bg-gray-50">
|
||
<td className="py-2">{p.month}</td>
|
||
<td className="py-2 text-right text-gray-600">¥{fmt(p.baseSalary)}</td>
|
||
<td className="py-2 text-right text-gray-600">{p.overtimePay > 0 ? `¥${fmt(p.overtimePay)}` : '-'}</td>
|
||
<td className="py-2 text-right text-gray-600">{p.allowance > 0 ? `¥${fmt(p.allowance)}` : '-'}</td>
|
||
<td className="py-2 text-right text-gray-600">{p.deduction > 0 ? `-¥${fmt(p.deduction)}` : '-'}</td>
|
||
<td className="py-2 text-right font-medium text-gray-700">¥{fmt(p.totalPay)}</td>
|
||
<td className="py-2 text-center">
|
||
{p.confirmedAt ? (
|
||
<span className="px-2 py-0.5 rounded bg-green-50 text-safe text-xs">已确认</span>
|
||
) : (
|
||
<span className="px-2 py-0.5 rounded bg-amber-50 text-amber-600 text-xs">未确认</span>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
<tr className="border-t-2 bg-gray-50">
|
||
<td className="py-2 font-medium">合计</td>
|
||
<td className="py-2 text-right font-medium text-gray-600">¥{fmt(totalBase)}</td>
|
||
<td className="py-2 text-right font-medium text-gray-600">{totalOT > 0 ? `¥${fmt(totalOT)}` : '-'}</td>
|
||
<td className="py-2 text-right font-medium text-gray-600">{totalAllow > 0 ? `¥${fmt(totalAllow)}` : '-'}</td>
|
||
<td className="py-2 text-right font-medium text-gray-600">{totalDed > 0 ? `-¥${fmt(totalDed)}` : '-'}</td>
|
||
<td className="py-2 text-right font-bold text-gray-700">¥{fmt(totalPay)}</td>
|
||
<td></td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function OvertimeInfo({ records }: { records: any[] }) {
|
||
if (!records?.length) return <Card><div className="text-center py-8 text-gray-400">暂无加班记录</div></Card>
|
||
const totalPay = records.reduce((sum, o) => sum + (o.totalPay || 0), 0)
|
||
const totalWeekday = records.reduce((sum, o) => sum + (o.weekdayHours || 0), 0)
|
||
const totalWeekend = records.reduce((sum, o) => sum + (o.weekendHours || 0), 0)
|
||
const totalHoliday = records.reduce((sum, o) => sum + (o.holidayHours || 0), 0)
|
||
return (
|
||
<Card>
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b text-gray-500">
|
||
<th className="py-2 text-left">月份</th>
|
||
<th className="py-2 text-right">工作日(h)</th>
|
||
<th className="py-2 text-right">休息日(h)</th>
|
||
<th className="py-2 text-right">节假日(h)</th>
|
||
<th className="py-2 text-right">加班费</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{records.map((o) => (
|
||
<tr key={o.id} className="border-b last:border-0 hover:bg-gray-50">
|
||
<td className="py-2">{o.month}</td>
|
||
<td className="py-2 text-right text-gray-600">{o.weekdayHours || '-'}</td>
|
||
<td className="py-2 text-right text-gray-600">{o.weekendHours || '-'}</td>
|
||
<td className="py-2 text-right text-gray-600">{o.holidayHours || '-'}</td>
|
||
<td className="py-2 text-right font-medium text-gray-700">¥{fmt(o.totalPay)}</td>
|
||
</tr>
|
||
))}
|
||
<tr className="border-t-2 bg-gray-50">
|
||
<td className="py-2 font-medium">合计</td>
|
||
<td className="py-2 text-right font-medium text-gray-600">{totalWeekday}</td>
|
||
<td className="py-2 text-right font-medium text-gray-600">{totalWeekend}</td>
|
||
<td className="py-2 text-right font-medium text-gray-600">{totalHoliday}</td>
|
||
<td className="py-2 text-right font-bold text-gray-700">¥{fmt(totalPay)}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function TerminationInfo({ employeeId, profile, records }: { employeeId: string; profile: any; records: any[] }) {
|
||
const [printRecord, setPrintRecord] = useState<any | null>(null)
|
||
|
||
const reasonMap: Record<string, string> = {
|
||
NEGOTIATED: '协商解除', FAULT: '员工过错', NONFAULT: '非过错解除',
|
||
LAYOFF: '经济性裁员', EXPIRED: '合同到期', ILLEGAL: '违法解除',
|
||
RESIGNATION: '员工主动离职',
|
||
}
|
||
const legalBasisMap: Record<string, string> = {
|
||
NEGOTIATED: '《劳动合同法》第36条', FAULT: '《劳动合同法》第39条',
|
||
NONFAULT: '《劳动合同法》第40条', LAYOFF: '《劳动合同法》第41条',
|
||
EXPIRED: '《劳动合同法》第44条、第46条', ILLEGAL: '《劳动合同法》第87条',
|
||
}
|
||
|
||
const { data: evidenceChain } = useQuery<any>({
|
||
queryKey: ['evidence-chain', employeeId],
|
||
queryFn: async () => {
|
||
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
|
||
return res.data
|
||
},
|
||
enabled: !!printRecord,
|
||
})
|
||
|
||
if (!records?.length) return <Card><div className="text-center py-8 text-gray-400">暂无离职/解聘记录</div></Card>
|
||
|
||
if (printRecord) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<button onClick={() => setPrintRecord(null)} className="text-gray-400 hover:text-gray-600 flex items-center gap-1 text-xs">
|
||
<X className="w-4 h-4" />返回列表
|
||
</button>
|
||
<Button variant="secondary" size="sm" onClick={() => window.print()}>
|
||
<Printer className="w-4 h-4 mr-1" />打印材料
|
||
</Button>
|
||
</div>
|
||
|
||
{/* 1. 解聘通知书 */}
|
||
<div className="border rounded-lg p-6 space-y-3 print:shadow-none">
|
||
<div className="text-center">
|
||
<h2 className="text-base font-bold">解除劳动合同通知书</h2>
|
||
</div>
|
||
<div className="text-xs text-gray-700 space-y-3">
|
||
<p><strong>{profile.name}</strong> 先生/女士:</p>
|
||
<p>
|
||
您于 <strong>{profile.hireDate?.toString().slice(0, 10)}</strong> 入职我公司{profile.department}部门。
|
||
因 <strong>{reasonMap[printRecord.reason] || printRecord.reason}</strong> 原因,公司决定于 <strong>{printRecord.terminationDate?.toString().slice(0, 10)}</strong> 起解除与您的劳动合同。
|
||
</p>
|
||
<p>解除依据:{legalBasisMap[printRecord.reason] || ''}</p>
|
||
<p>经济补偿金:<strong>¥{fmt(printRecord.compensation)}</strong></p>
|
||
{printRecord.remark && <p>备注:{printRecord.remark}</p>}
|
||
<p>请于解除日期前办理工作交接手续,结清相关费用。</p>
|
||
<div className="text-right mt-6 space-y-1">
|
||
<p>公司(盖章)</p>
|
||
<p className="text-gray-400">{printRecord.terminationDate?.toString().slice(0, 10)}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 2. 费用结算明细 */}
|
||
<div className="border rounded-lg p-4 space-y-2">
|
||
<h3 className="text-xs font-medium flex items-center gap-2"><Calculator className="w-4 h-4" />费用结算明细</h3>
|
||
<div className="text-xs space-y-1">
|
||
<div className="flex justify-between"><span>员工</span><span>{profile.name}({profile.department})</span></div>
|
||
<div className="flex justify-between"><span>入职日期</span><span>{profile.hireDate?.toString().slice(0, 10)}</span></div>
|
||
<div className="flex justify-between"><span>月工资</span><span>¥{fmt(profile.monthlySalary)}/月</span></div>
|
||
<div className="flex justify-between"><span>解聘日期</span><span>{printRecord.terminationDate?.toString().slice(0, 10)}</span></div>
|
||
<div className="flex justify-between"><span>解聘原因</span><span>{reasonMap[printRecord.reason] || printRecord.reason}</span></div>
|
||
<div className="flex justify-between border-t pt-2 font-bold text-danger"><span>经济补偿金</span><span>¥{fmt(printRecord.compensation)}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 3. 合规检查清单 */}
|
||
{printRecord.checklist && Object.keys(printRecord.checklist).length > 0 && (
|
||
<div className="border rounded-lg p-4 space-y-2">
|
||
<h3 className="text-xs font-medium flex items-center gap-2"><Shield className="w-4 h-4" />合规检查清单</h3>
|
||
<div className="text-xs space-y-1">
|
||
{Object.entries(printRecord.checklist).map(([key, passed]: [string, any]) => (
|
||
<div key={key} className="flex items-center gap-2">
|
||
<span className={passed ? 'text-safe' : 'text-danger'}>{passed ? '✓' : '✗'}</span>
|
||
<span className={passed ? '' : 'text-gray-500'}>{key}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 4. 风险评估 */}
|
||
{printRecord.riskLevel && printRecord.riskLevel !== 'SAFE' && (
|
||
<div className="border rounded-lg p-4 space-y-2">
|
||
<h3 className="text-xs font-medium flex items-center gap-2"><AlertTriangle className="w-4 h-4" />风险评估</h3>
|
||
<div className="text-xs">
|
||
<div className={`px-3 py-2 rounded-md ${printRecord.riskLevel === 'DANGER' ? 'bg-red-50 text-red-700' : 'bg-yellow-50 text-yellow-800'}`}>
|
||
风险等级:{printRecord.riskLevel === 'DANGER' ? '高风险' : '注意'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 5. 仲裁证据链 */}
|
||
<div className="border rounded-lg p-4 space-y-3">
|
||
<h3 className="text-xs font-medium flex items-center gap-2"><FileText className="w-4 h-4" />仲裁证据链</h3>
|
||
{evidenceChain ? (
|
||
<>
|
||
<div className="text-xs text-gray-500">
|
||
共 {evidenceChain.summary?.total || 0} 条证据,
|
||
已签确认 {evidenceChain.summary?.signed || 0} 条,
|
||
未签 {evidenceChain.summary?.unsigned || 0} 条
|
||
</div>
|
||
{(() => {
|
||
const grouped = (evidenceChain.evidence || []).reduce((acc: Record<string, any[]>, e: any) => {
|
||
(acc[e.category] = acc[e.category] || []).push(e)
|
||
return acc
|
||
}, {})
|
||
return Object.entries(grouped).map(([category, items]) => (
|
||
<div key={category} className="space-y-1">
|
||
<div className="text-xs font-medium text-gray-700">{category}</div>
|
||
{(items as any[]).map((e: any, i: number) => (
|
||
<div key={i} className="text-xs text-gray-600 pl-4 border-l-2 border-gray-200 ml-1">
|
||
<div className="flex items-center gap-2">
|
||
<span>{e.title}</span>
|
||
{e.acknowledged === true && <span className="text-safe">✓已签</span>}
|
||
{e.acknowledged === false && <span className="text-danger">✗未签</span>}
|
||
</div>
|
||
<div className="text-gray-400">{e.description}</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))
|
||
})()}
|
||
</>
|
||
) : (
|
||
<div className="text-xs text-gray-400">加载证据链中...</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
{records.map((t) => (
|
||
<Card key={t.id} className="p-4">
|
||
<div className="space-y-3">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs font-medium text-gray-700">{t.terminationDate?.toString().slice(0, 10)}</span>
|
||
<span className={`px-2 py-0.5 rounded text-xs ${t.type === 'RESIGNATION' ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-600'}`}>{t.type === 'RESIGNATION' ? '主动离职' : '公司解聘'}</span>
|
||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{reasonMap[t.reason] || t.reason}</span>
|
||
{t.type !== 'RESIGNATION' && (
|
||
<span className={`px-2 py-0.5 rounded text-xs ${t.riskLevel === 'SAFE' ? 'bg-green-50 text-safe' : t.riskLevel === 'WARNING' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
|
||
{t.riskLevel === 'SAFE' ? '风险低' : t.riskLevel === 'WARNING' ? '注意' : '高风险'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="grid md:grid-cols-2 gap-3 text-xs">
|
||
{t.type === 'RESIGNATION' ? (
|
||
<>
|
||
<div className="flex justify-between border-b pb-1.5">
|
||
<span className="text-gray-400">离职原因</span>
|
||
<span className="font-medium text-gray-700">{t.resignationReason || '-'}</span>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<>
|
||
<div className="flex justify-between border-b pb-1.5">
|
||
<span className="text-gray-400">经济补偿金</span>
|
||
<span className="font-medium text-gray-700">¥{fmt(t.compensation)}</span>
|
||
</div>
|
||
<div className="flex justify-between border-b pb-1.5">
|
||
<span className="text-gray-400">法律依据</span>
|
||
<span className="font-medium text-gray-700">{legalBasisMap[t.reason] || '-'}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
{t.remark && <div className="text-xs text-gray-500 bg-gray-50 px-2 py-1.5 rounded">{t.remark}</div>}
|
||
<div className="flex justify-end">
|
||
{t.type !== 'RESIGNATION' && (
|
||
<Button variant="secondary" size="sm" onClick={() => setPrintRecord(t)}>
|
||
<Printer className="w-4 h-4 mr-1" />打印解聘材料
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 违纪记录管理 ==========
|
||
|
||
function DisciplinaryInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||
const queryClient = useQueryClient()
|
||
const [showForm, setShowForm] = useState(false)
|
||
const [form, setForm] = useState({ violationDate: '', violationType: 'LATE', description: '', severity: 'WARNING', action: 'ORAL_WARNING', actionDetail: '', employeeAck: false, ackDate: '', ackMethod: 'SIGN', witness: '' })
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (data: any) => api.post(`/roster/${employeeId}/disciplinary`, data),
|
||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||
})
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/disciplinary/${id}`),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
|
||
})
|
||
|
||
const typeMap: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
|
||
const actionMap: Record<string, string> = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' }
|
||
const severityMap: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '重度' }
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-xs font-medium">违纪记录({records?.length || 0}条)</h2>
|
||
<Button size="sm" onClick={() => setShowForm(!showForm)}>新增违纪记录</Button>
|
||
</div>
|
||
|
||
{showForm && (
|
||
<Card>
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<div><Label>违纪日期</Label><Input type="date" value={form.violationDate} onChange={(e) => setForm({ ...form, violationDate: e.target.value })} /></div>
|
||
<div><Label>违纪类型</Label>
|
||
<Select value={form.violationType} onChange={(e) => setForm({ ...form, violationType: e.target.value })}>
|
||
{Object.entries(typeMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</Select>
|
||
</div>
|
||
<div className="md:col-span-2"><Label>违纪事实描述</Label><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="详细描述违纪事实" /></div>
|
||
<div><Label>严重程度</Label>
|
||
<Select value={form.severity} onChange={(e) => setForm({ ...form, severity: e.target.value })}>
|
||
{Object.entries(severityMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</Select>
|
||
</div>
|
||
<div><Label>处理结果</Label>
|
||
<Select value={form.action} onChange={(e) => setForm({ ...form, action: e.target.value })}>
|
||
{Object.entries(actionMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</Select>
|
||
</div>
|
||
<div><Label>处理详情</Label><Input value={form.actionDetail} onChange={(e) => setForm({ ...form, actionDetail: e.target.value })} placeholder="扣款金额/降职说明等" /></div>
|
||
<div><Label>见证人</Label><Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} /></div>
|
||
<div className="flex items-center gap-2 pt-6">
|
||
<input type="checkbox" id="empAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
|
||
<label htmlFor="empAck" className="text-xs">员工已签字确认</label>
|
||
</div>
|
||
{form.employeeAck && <div><Label>确认日期</Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
|
||
<div className="md:col-span-2 flex gap-2">
|
||
<Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.violationDate || !form.description}>
|
||
{createMutation.isPending ? '保存中...' : '保存'}
|
||
</Button>
|
||
<Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{records?.length === 0 ? (
|
||
<Card><div className="text-center py-8 text-gray-400">暂无违纪记录</div></Card>
|
||
) : records?.map((r) => (
|
||
<Card key={r.id} className="p-4">
|
||
<div className="flex justify-between items-start gap-3">
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs font-medium text-gray-700">{r.violationDate?.toString().slice(0, 10)}</span>
|
||
<span className={`px-2 py-0.5 rounded text-xs ${r.severity === 'SEVERE' ? 'bg-red-100 text-red-700' : r.severity === 'SERIOUS' ? 'bg-orange-50 text-orange-600' : 'bg-amber-50 text-amber-600'}`}>
|
||
{severityMap[r.severity] || r.severity}
|
||
</span>
|
||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">{typeMap[r.violationType] || r.violationType}</span>
|
||
</div>
|
||
<div className="text-xs text-gray-600 leading-relaxed">{r.description}</div>
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<span className="text-gray-400">处理方式</span>
|
||
<span className="px-2 py-0.5 rounded bg-blue-50 text-blue-600">{actionMap[r.action] || r.action}</span>
|
||
{r.actionDetail && <span className="text-gray-500">{r.actionDetail}</span>}
|
||
</div>
|
||
<div className="flex items-center gap-4 text-xs pt-1 border-t">
|
||
{r.employeeAck ? (
|
||
<span className="text-safe flex items-center gap-1">
|
||
<Check className="w-3 h-3" />已签字({r.ackDate?.toString().slice(0, 10)})
|
||
</span>
|
||
) : (
|
||
<span className="text-warning flex items-center gap-1">
|
||
<AlertTriangle className="w-3 h-3" />未签字
|
||
</span>
|
||
)}
|
||
{r.witness && <span className="text-gray-400">见证人:{r.witness}</span>}
|
||
{r.ackMethod && <span className="text-gray-400">确认方式:{r.ackMethod === 'SIGN' ? '签字' : r.ackMethod === 'ELECTRONIC' ? '电子' : '拒绝'}</span>}
|
||
</div>
|
||
</div>
|
||
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0">删除</button>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 考勤记录管理 ==========
|
||
|
||
function AttendanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||
const queryClient = useQueryClient()
|
||
const [showForm, setShowForm] = useState(false)
|
||
const [form, setForm] = useState({ date: '', checkInTime: '', checkOutTime: '', status: 'NORMAL', lateMinutes: 0, earlyMinutes: 0, workHours: 8, overtimeHours: 0, remark: '' })
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (data: any) => api.post(`/roster/${employeeId}/attendance`, data),
|
||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||
})
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/attendance/${id}`),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
|
||
})
|
||
|
||
const statusMap: Record<string, string> = { NORMAL: '正常', LATE: '迟到', EARLY_LEAVE: '早退', ABSENT: '旷工', LEAVE: '请假', BUSINESS_TRIP: '出差' }
|
||
const statusColor: Record<string, string> = { NORMAL: 'bg-green-50 text-safe', LATE: 'bg-amber-50 text-warning', EARLY_LEAVE: 'bg-amber-50 text-warning', ABSENT: 'bg-red-50 text-danger', LEAVE: 'bg-blue-50 text-blue-600', BUSINESS_TRIP: 'bg-blue-50 text-blue-600' }
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-xs font-medium">考勤记录({records?.length || 0}条)</h2>
|
||
<Button size="sm" onClick={() => setShowForm(!showForm)}>新增考勤记录</Button>
|
||
</div>
|
||
|
||
{showForm && (
|
||
<Card>
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<div><Label>日期</Label><Input type="date" value={form.date} onChange={(e) => setForm({ ...form, date: e.target.value })} /></div>
|
||
<div><Label>考勤状态</Label>
|
||
<Select value={form.status} onChange={(e) => setForm({ ...form, status: e.target.value })}>
|
||
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</Select>
|
||
</div>
|
||
<div><Label>签到时间</Label><Input type="time" value={form.checkInTime} onChange={(e) => setForm({ ...form, checkInTime: e.target.value })} /></div>
|
||
<div><Label>签退时间</Label><Input type="time" value={form.checkOutTime} onChange={(e) => setForm({ ...form, checkOutTime: e.target.value })} /></div>
|
||
<div><Label>迟到(分钟)</Label><Input type="number" value={form.lateMinutes} onChange={(e) => setForm({ ...form, lateMinutes: Number(e.target.value) })} /></div>
|
||
<div><Label>早退(分钟)</Label><Input type="number" value={form.earlyMinutes} onChange={(e) => setForm({ ...form, earlyMinutes: Number(e.target.value) })} /></div>
|
||
<div><Label>工时(小时)</Label><Input type="number" step="0.5" value={form.workHours} onChange={(e) => setForm({ ...form, workHours: Number(e.target.value) })} /></div>
|
||
<div><Label>加班(小时)</Label><Input type="number" step="0.5" value={form.overtimeHours} onChange={(e) => setForm({ ...form, overtimeHours: Number(e.target.value) })} /></div>
|
||
<div className="md:col-span-2"><Label>备注</Label><Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} /></div>
|
||
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.date}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button></div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{records?.length === 0 ? (
|
||
<Card><div className="text-center py-8 text-gray-400">暂无考勤记录</div></Card>
|
||
) : (
|
||
<Card>
|
||
<table className="w-full text-xs">
|
||
<thead>
|
||
<tr className="border-b 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-center">状态</th>
|
||
<th className="py-2 text-right">工时</th>
|
||
<th className="py-2 text-right">加班</th>
|
||
<th className="py-2"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{records?.map((a) => (
|
||
<tr key={a.id} className="border-b last:border-0">
|
||
<td className="py-2">{a.date?.toString().slice(0, 10)}</td>
|
||
<td className="py-2 text-gray-500">{a.checkInTime || '-'}</td>
|
||
<td className="py-2 text-gray-500">{a.checkOutTime || '-'}</td>
|
||
<td className="py-2 text-center"><span className={`px-2 py-0.5 rounded text-xs ${statusColor[a.status] || 'bg-gray-100'}`}>{statusMap[a.status] || a.status}</span></td>
|
||
<td className="py-2 text-right">{a.workHours}h</td>
|
||
<td className="py-2 text-right">{a.overtimeHours > 0 ? `${a.overtimeHours}h` : '-'}</td>
|
||
<td className="py-2"><button onClick={() => deleteMutation.mutate(a.id)} className="text-xs text-gray-400 hover:text-danger">删除</button></td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</Card>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 培训签收记录管理 ==========
|
||
|
||
function TrainingInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||
const queryClient = useQueryClient()
|
||
const [showForm, setShowForm] = useState(false)
|
||
const [form, setForm] = useState({ trainingDate: '', topic: '', content: '', trainer: '', duration: 1, ackStatus: 'PENDING', ackDate: '', remark: '' })
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (data: any) => api.post(`/roster/${employeeId}/training`, data),
|
||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||
})
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/training/${id}`),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
|
||
})
|
||
|
||
const ackMap: Record<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
|
||
const ackColor: Record<string, string> = { PENDING: 'bg-amber-50 text-warning', SIGNED: 'bg-green-50 text-safe', REFUSED: 'bg-red-50 text-danger' }
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-xs font-medium">培训签收记录({records?.length || 0}条)</h2>
|
||
<Button size="sm" onClick={() => setShowForm(!showForm)}>新增培训记录</Button>
|
||
</div>
|
||
|
||
{showForm && (
|
||
<Card>
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<div><Label>培训日期</Label><Input type="date" value={form.trainingDate} onChange={(e) => setForm({ ...form, trainingDate: e.target.value })} /></div>
|
||
<div><Label>培训主题/制度名称</Label><Input value={form.topic} onChange={(e) => setForm({ ...form, topic: e.target.value })} placeholder="如《员工手册》培训" /></div>
|
||
<div className="md:col-span-2"><Label>培训内容摘要</Label><Input value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })} /></div>
|
||
<div><Label>培训人</Label><Input value={form.trainer} onChange={(e) => setForm({ ...form, trainer: e.target.value })} /></div>
|
||
<div><Label>时长(小时)</Label><Input type="number" step="0.5" value={form.duration} onChange={(e) => setForm({ ...form, duration: Number(e.target.value) })} /></div>
|
||
<div><Label>签收状态</Label>
|
||
<Select value={form.ackStatus} onChange={(e) => setForm({ ...form, ackStatus: e.target.value })}>
|
||
{Object.entries(ackMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</Select>
|
||
</div>
|
||
{form.ackStatus === 'SIGNED' && <div><Label>签收日期</Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
|
||
<div className="md:col-span-2"><Label>备注</Label><Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} /></div>
|
||
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.trainingDate || !form.topic}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button></div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{records?.length === 0 ? (
|
||
<Card><div className="text-center py-8 text-gray-400">暂无培训签收记录</div></Card>
|
||
) : records?.map((r) => (
|
||
<Card key={r.id} className="p-4">
|
||
<div className="flex justify-between items-start gap-3">
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs text-gray-400">{r.trainingDate?.toString().slice(0, 10)}</span>
|
||
<span className={`px-2 py-0.5 rounded text-xs ${r.ackStatus === 'SIGNED' ? 'bg-green-50 text-safe' : r.ackStatus === 'REFUSED' ? 'bg-red-50 text-danger' : 'bg-amber-50 text-amber-600'}`}>
|
||
{r.ackStatus === 'SIGNED' ? '已签收' : r.ackStatus === 'REFUSED' ? '拒绝签收' : '待签收'}
|
||
</span>
|
||
</div>
|
||
<div className="text-xs font-medium text-gray-700">{r.topic}</div>
|
||
{r.content && <div className="text-xs text-gray-500 leading-relaxed">{r.content}</div>}
|
||
<div className="flex items-center gap-4 text-xs pt-1 border-t text-gray-400">
|
||
<span>时长 {r.duration}h</span>
|
||
{r.trainer && <span>培训人:{r.trainer}</span>}
|
||
{r.ackDate && <span className="text-safe">签收日期:{r.ackDate.toString().slice(0, 10)}</span>}
|
||
{r.remark && <span className="text-gray-400">备注:{r.remark}</span>}
|
||
</div>
|
||
</div>
|
||
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0">删除</button>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 绩效记录管理 ==========
|
||
|
||
function PerformanceInfo({ employeeId, records }: { employeeId: string; records: any[] }) {
|
||
const queryClient = useQueryClient()
|
||
const [showForm, setShowForm] = useState(false)
|
||
const [form, setForm] = useState({ period: '', score: 80, grade: 'B', result: 'QUALIFIED', summary: '', improvementPlan: '', employeeAck: false, ackDate: '', reviewer: '' })
|
||
|
||
const createMutation = useMutation({
|
||
mutationFn: (data: any) => api.post(`/roster/${employeeId}/performance`, data),
|
||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
|
||
})
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => api.delete(`/roster/${employeeId}/performance/${id}`),
|
||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['roster-profile'] }),
|
||
})
|
||
|
||
const resultMap: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
|
||
const resultColor: Record<string, string> = { EXCELLENT: 'bg-green-50 text-safe', QUALIFIED: 'bg-blue-50 text-blue-600', NEED_IMPROVE: 'bg-amber-50 text-warning', UNQUALIFIED: 'bg-red-50 text-danger' }
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center">
|
||
<h2 className="text-xs font-medium">绩效考核记录({records?.length || 0}条)</h2>
|
||
<Button size="sm" onClick={() => setShowForm(!showForm)}>新增绩效记录</Button>
|
||
</div>
|
||
|
||
{showForm && (
|
||
<Card>
|
||
<div className="grid md:grid-cols-2 gap-4">
|
||
<div><Label>考核周期</Label><Input value={form.period} onChange={(e) => setForm({ ...form, period: e.target.value })} placeholder="如 2026-07 或 2026-Q3" /></div>
|
||
<div><Label>考核得分</Label><Input type="number" value={form.score} onChange={(e) => setForm({ ...form, score: Number(e.target.value) })} /></div>
|
||
<div><Label>等级</Label>
|
||
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
|
||
<option value="A">A</option><option value="B">B</option><option value="C">C</option><option value="D">D</option>
|
||
</Select>
|
||
</div>
|
||
<div><Label>考核结果</Label>
|
||
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
|
||
{Object.entries(resultMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</Select>
|
||
</div>
|
||
<div className="md:col-span-2"><Label>考核评语</Label><Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} /></div>
|
||
<div className="md:col-span-2"><Label>改进计划(不胜任时填写)</Label><Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="如:调岗至XX岗位,培训XX技能" /></div>
|
||
<div><Label>考核人</Label><Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} /></div>
|
||
<div className="flex items-center gap-2 pt-6">
|
||
<input type="checkbox" id="perfAck" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
|
||
<label htmlFor="perfAck" className="text-xs">员工已签字确认</label>
|
||
</div>
|
||
{form.employeeAck && <div><Label>确认日期</Label><Input type="date" value={form.ackDate} onChange={(e) => setForm({ ...form, ackDate: e.target.value })} /></div>}
|
||
<div className="md:col-span-2 flex gap-2"><Button onClick={() => createMutation.mutate(form)} disabled={createMutation.isPending || !form.period}>{createMutation.isPending ? '保存中...' : '保存'}</Button><Button variant="secondary" onClick={() => setShowForm(false)}>取消</Button></div>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
|
||
{records?.length === 0 ? (
|
||
<Card><div className="text-center py-8 text-gray-400">暂无绩效记录</div></Card>
|
||
) : records?.map((r) => (
|
||
<Card key={r.id} className="p-4">
|
||
<div className="flex justify-between items-start gap-3">
|
||
<div className="flex-1 space-y-2">
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<span className="text-xs font-medium text-gray-700">{r.period}</span>
|
||
<span className={`px-2 py-0.5 rounded text-xs ${r.result === 'EXCELLENT' ? 'bg-green-50 text-safe' : r.result === 'QUALIFIED' ? 'bg-blue-50 text-blue-600' : r.result === 'NEED_IMPROVE' ? 'bg-amber-50 text-amber-600' : 'bg-red-50 text-danger'}`}>
|
||
{resultMap[r.result] || r.result}
|
||
</span>
|
||
<span className="px-2 py-0.5 rounded bg-gray-100 text-gray-600 text-xs">得分 {r.score} · 等级 {r.grade}</span>
|
||
</div>
|
||
{r.summary && <div className="text-xs text-gray-600 leading-relaxed">{r.summary}</div>}
|
||
{r.improvementPlan && (
|
||
<div className="text-xs bg-amber-50 text-amber-700 px-2 py-1.5 rounded leading-relaxed">
|
||
<span className="font-medium">改进计划</span>:{r.improvementPlan}
|
||
</div>
|
||
)}
|
||
<div className="flex items-center gap-4 text-xs pt-1 border-t text-gray-400">
|
||
{r.employeeAck ? (
|
||
<span className="text-safe flex items-center gap-1"><Check className="w-3 h-3" />已签字({r.ackDate?.toString().slice(0, 10)})</span>
|
||
) : (
|
||
<span className="text-warning flex items-center gap-1"><AlertTriangle className="w-3 h-3" />未签字</span>
|
||
)}
|
||
{r.reviewer && <span>考核人:{r.reviewer}</span>}
|
||
</div>
|
||
</div>
|
||
<button onClick={() => deleteMutation.mutate(r.id)} className="text-xs text-gray-300 hover:text-danger shrink-0">删除</button>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ========== 仲裁证据链 ==========
|
||
|
||
function EvidenceChain({ employeeId }: { employeeId: string }) {
|
||
const { data, isLoading } = useQuery<any>({
|
||
queryKey: ['evidence-chain', employeeId],
|
||
queryFn: async () => {
|
||
const res = await api.get(`/roster/${employeeId}/evidence-chain`) as any
|
||
return res.data
|
||
},
|
||
})
|
||
|
||
if (isLoading) return <div className="text-center py-8 text-gray-400">生成证据链中...</div>
|
||
if (!data) return <div className="text-center py-8 text-gray-400">无数据</div>
|
||
|
||
const categoryColor: Record<string, string> = {
|
||
'劳动关系': 'bg-blue-50 text-blue-700 border-blue-200',
|
||
'薪酬发放': 'bg-green-50 text-green-700 border-green-200',
|
||
'考勤记录': 'bg-amber-50 text-amber-700 border-amber-200',
|
||
'违纪处理': 'bg-red-50 text-red-700 border-red-200',
|
||
'培训签收': 'bg-purple-50 text-purple-700 border-purple-200',
|
||
'绩效考核': 'bg-indigo-50 text-indigo-700 border-indigo-200',
|
||
'解聘记录': 'bg-gray-100 text-gray-700 border-gray-300',
|
||
}
|
||
|
||
const handleExport = () => {
|
||
const text = generateEvidenceText(data)
|
||
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `仲裁证据链_${data.employee.name}_${new Date().toISOString().slice(0, 10)}.txt`
|
||
a.click()
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-3">
|
||
<Card>
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-xs font-medium flex items-center gap-2"><Scale className="w-4 h-4" />仲裁证据链</h2>
|
||
<div className="text-xs text-gray-500 mt-1">
|
||
{data.employee.name} · {data.employee.department} · 入职{data.employee.hireDate}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4">
|
||
<div className="text-xs text-center">
|
||
<div className="text-gray-500">证据总数</div>
|
||
<div className="text-xl font-bold">{data.summary.total}</div>
|
||
</div>
|
||
<div className="text-xs text-center">
|
||
<div className="text-gray-500">已签字</div>
|
||
<div className="text-xl font-bold text-safe">{data.summary.signed}</div>
|
||
</div>
|
||
<div className="text-xs text-center">
|
||
<div className="text-gray-500">未签字</div>
|
||
<div className="text-xl font-bold text-warning">{data.summary.unsigned}</div>
|
||
</div>
|
||
<Button onClick={handleExport}>导出证据链</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
<div className="space-y-2">
|
||
{data.evidence.map((e: any, i: number) => (
|
||
<Card key={i}>
|
||
<div className="flex items-start gap-3">
|
||
<span className={`px-2 py-0.5 rounded border text-xs shrink-0 ${categoryColor[e.category] || 'bg-gray-50 text-gray-600 border-gray-200'}`}>
|
||
{e.category}
|
||
</span>
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2">
|
||
<span className="font-medium text-xs">{e.title}</span>
|
||
<span className="text-xs text-gray-400">{e.date}</span>
|
||
{e.acknowledged === true && <span className="text-xs text-safe">✓ 已签字</span>}
|
||
{e.acknowledged === false && <span className="text-xs text-warning">⚠ 未签字</span>}
|
||
</div>
|
||
<div className="text-xs text-gray-600 mt-1">{e.description}</div>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function generateEvidenceText(data: any): string {
|
||
const lines: string[] = []
|
||
lines.push('========================================')
|
||
lines.push(' 劳动仲裁证据链')
|
||
lines.push('========================================')
|
||
lines.push('')
|
||
lines.push(`员工姓名:${data.employee.name}`)
|
||
lines.push(`部门:${data.employee.department}`)
|
||
lines.push(`入职日期:${data.employee.hireDate}`)
|
||
lines.push(`状态:${data.employee.status === 'ACTIVE' ? '在职' : '离职'}`)
|
||
lines.push('')
|
||
lines.push(`证据总数:${data.summary.total} 条`)
|
||
lines.push(`已签字:${data.summary.signed} 条`)
|
||
lines.push(`未签字:${data.summary.unsigned} 条`)
|
||
lines.push('')
|
||
lines.push('----------------------------------------')
|
||
lines.push('')
|
||
|
||
let currentCategory = ''
|
||
data.evidence.forEach((e: any, i: number) => {
|
||
if (e.category !== currentCategory) {
|
||
currentCategory = e.category
|
||
lines.push(`【${currentCategory}】`)
|
||
lines.push('')
|
||
}
|
||
lines.push(`${i + 1}. ${e.title}(${e.date})`)
|
||
lines.push(` ${e.description}`)
|
||
if (e.acknowledged === true) lines.push(' [已签字确认]')
|
||
if (e.acknowledged === false) lines.push(' [未签字]')
|
||
lines.push('')
|
||
})
|
||
|
||
lines.push('----------------------------------------')
|
||
lines.push(`导出时间:${new Date().toLocaleString('zh-CN')}`)
|
||
return lines.join('\n')
|
||
}
|