Files
TurboHR/frontend/src/pages/Roster.tsx
T
freedakgmail eb58bc4b09 优化: 合同开始日期随入职日期自动同步
1. 添加员工和重新入职:入职日期变更时自动同步合同开始日期和结束日期
2. 合同开始日期改为只读显示(始终等于入职日期)
3. 花名册列表增加离职日期列:已离职显示日期,待离职显示预计日期
4. 重新入职弹窗对齐添加员工:移除孕期/医疗期/工伤,合同双向计算,试用期校验
5. 后端 rehireEmployee 重置特殊状态为 false
2026-07-23 18:34:12 +08:00

2092 lines
106 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">/7portal端填报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')
}