import { useState, useRef } from 'react' import { usePageSize } from '../hooks/usePageSize' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Plus, Search, Paperclip, Trash2, X, FileText, Download, AlertTriangle } from 'lucide-react' import { toast } from 'sonner' import { rosterApi, employeeApi, attachmentApi } from '../lib/api-services' 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 EmptyState from '../components/ui/EmptyState' import Pagination from '../components/ui/Pagination' interface EmployeeItem { id: string name: string department: string hireDate: string status: string contractStatus: string contractStatusText: string riskLevel: 'high' | 'medium' | 'low' | 'safe' isPregnant: boolean isInMedicalPeriod: boolean isWorkInjured: boolean } interface EmployeeListResponse { items: EmployeeItem[] total: number page: number pageSize: number totalPages: number } export default function Contracts() { const queryClient = useQueryClient() const [search, setSearch] = useState('') const [filterDepartment, setFilterDepartment] = useState('') const [filterContractStatus, setFilterContractStatus] = useState('') const pageSize = usePageSize() const [page, setPage] = useState(1) const [showAddModal, setShowAddModal] = useState(false) const [selectedEmpId, setSelectedEmpId] = useState(null) const { data, isLoading } = useQuery({ queryKey: ['employees', search, filterDepartment, filterContractStatus, page, pageSize], queryFn: async () => { const params: any = { search, page, pageSize } if (filterDepartment) params.department = filterDepartment if (filterContractStatus) params.contractStatus = filterContractStatus const res = await rosterApi.list({ search, page, pageSize, department: filterDepartment || undefined, contractStatus: filterContractStatus || undefined } as any) as any return res }, }) const addMutation = useMutation({ mutationFn: (data: any) => employeeApi.create(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['employees'] }) queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) localStorage.removeItem('add-employee-draft') setShowAddModal(false) }, }) const { data: departmentList } = useQuery({ queryKey: ['roster-departments'], queryFn: () => rosterApi.departments(), }) return (

合同管理

集中管理用工关系(劳动合同/劳务协议/实习协议)、签署状态及到期风险

{/* 搜索栏 */}
{ setSearch(e.target.value); setPage(1) }} className="pl-9" />
{(search || filterDepartment || filterContractStatus) && ( )}
{/* 员工列表 */} {isLoading ? (
加载中...
) : !data || data.items.length === 0 ? ( setShowAddModal(true)} /> ) : ( <>
{data.items.map((emp) => ( setSelectedEmpId(emp.id)}> ))}
姓名 部门 入职日期 合同状态 特殊状态
{emp.name} {emp.department} {emp.hireDate} {(() => { const tagStyles: Record = { 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[emp.contractStatus] || 'bg-gray-100 text-gray-600' return {emp.contractStatusText} })()}
{emp.isPregnant && 孕期} {emp.isInMedicalPeriod && 医疗期} {emp.isWorkInjured && 工伤}
{/* 分页 */} setPage(1)} /> )}
{/* 添加员工 Modal */} setShowAddModal(false)} onSubmit={(data) => addMutation.mutate(data)} loading={addMutation.isPending} error={addMutation.error as any} /> {/* 员工详情抽屉 */} {selectedEmpId && ( setSelectedEmpId(null)} /> )}
) } function AddEmployeeModal({ open, onClose, onSubmit, loading, error }: { open: boolean onClose: () => void onSubmit: (data: any) => void loading: boolean error: any }) { // 拉取组织架构部门列表,用于部门下拉选择 const { data: departments = [] } = useQuery({ queryKey: ['departments'], queryFn: () => api.get('/departments').then(r => r.data), }) // 构建部门树形下拉选项(带层级缩进) const deptOptions: { id: string; label: string; level: number }[] = [] const buildDeptOptions = (items: any[], parentId: string | null, level: number) => { items.filter(d => d.parentId === parentId).sort((a, b) => a.sortOrder - b.sortOrder).forEach(d => { deptOptions.push({ id: d.id, label: d.name, level }) buildDeptOptions(items, d.id, level + 1) }) } buildDeptOptions(departments, null, 0) // 手机号查重 const [phoneDuplicate, setPhoneDuplicate] = useState<{ exists: boolean; employee?: any } | null>(null) const [form, setForm] = useState({ name: '', department: '', position: '', hireDate: '', monthlySalary: '', gender: '男' as '男' | '女', phone: '', isPregnant: false, isInMedicalPeriod: false, isWorkInjured: false, contractType: 'FIXED' as 'FIXED' | 'UNFIXED' | 'UNSIGNED', signDate: '', startDate: '', endDate: '', contractYears: 3, probationMonths: 0, probationSalary: 0, }) const handleSubmit = () => { const data: any = { name: form.name, department: form.department, position: form.position || undefined, hireDate: new Date(form.hireDate).toISOString(), monthlySalary: form.monthlySalary, gender: form.gender, phone: form.phone || undefined, isPregnant: form.isPregnant, isInMedicalPeriod: form.isInMedicalPeriod, isWorkInjured: form.isWorkInjured, } 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) } return (
{error && (
{error.response?.data?.error?.message || '操作失败'}
)}
setForm({ ...form, name: e.target.value })} placeholder="员工姓名" />
setForm({ ...form, position: e.target.value })} placeholder="如:前端工程师" />
setForm({ ...form, hireDate: e.target.value })} />
setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
{ const phone = e.target.value.replace(/\D/g, '').slice(0, 11) setForm({ ...form, phone }) setPhoneDuplicate(null) if (phone.length === 11) { employeeApi.checkPhone(phone).then((data: { exists: boolean; employee?: any }) => { setPhoneDuplicate(data) }).catch(() => {}) } }} placeholder="选填" maxLength={11} />
{phoneDuplicate?.exists && (
该手机号已存在:{phoneDuplicate.employee?.name}({phoneDuplicate.employee?.department}),请确认是否重复录入
)} {/* 特殊状态 */}
{/* 合同信息 */}
{form.contractType !== 'UNSIGNED' && (
setForm({ ...form, signDate: e.target.value })} />
setForm({ ...form, startDate: e.target.value })} />
{form.contractType === 'FIXED' && (
setForm({ ...form, endDate: e.target.value })} />
setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} />
)}
)}
) } function EmployeeDetailDrawer({ employeeId, onClose }: { employeeId: string; onClose: () => void }) { const queryClient = useQueryClient() const fileInputRef = useRef(null) const [fileType, setFileType] = useState<'ID_CARD' | 'BANK_CARD' | 'CONTRACT_SCAN' | 'EDUCATION' | 'TERMINATION_DOC' | 'RETIREMENT_DOC' | 'INJURY_CERT' | 'MEDICAL_CERT' | 'PREGNANCY_CERT' | 'OTHER'>('ID_CARD') const [previewUrl, setPreviewUrl] = useState(null) const { data: employee } = useQuery({ queryKey: ['employee-detail', employeeId], queryFn: async () => { return await employeeApi.detail(employeeId) }, }) const { data: attachments } = useQuery({ queryKey: ['employee-attachments', employeeId], queryFn: async () => { return await attachmentApi.list(employeeId) }, }) const addAttachmentMutation = useMutation({ mutationFn: (data: any) => attachmentApi.add(data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }), }) const deleteAttachmentMutation = useMutation({ mutationFn: (id: string) => attachmentApi.remove(id), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }), }) const handleFileUpload = (e: React.ChangeEvent) => { const files = e.target.files if (!files || files.length === 0) return const allowedExts = ['.pdf', '.jpg', '.jpeg', '.png', '.heic', '.gif', '.bmp', '.webp', '.doc', '.docx', '.xls', '.xlsx', '.tiff', '.tif'] const maxSize = 10 * 1024 * 1024 for (const file of Array.from(files)) { const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) if (!allowedExts.includes(ext)) { toast.error(`不支持的文件格式: ${file.name}`) continue } if (file.size > maxSize) { toast.error(`文件过大: ${file.name}(最大 10MB)`) continue } 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) } e.target.value = '' } const fileTypeLabels: Record = { ID_CARD: '身份证', BANK_CARD: '银行卡', CONTRACT_SCAN: '合同附件', EDUCATION: '学历证书', TERMINATION_DOC: '解除文件', RETIREMENT_DOC: '退休档案', INJURY_CERT: '工伤认定', MEDICAL_CERT: '医疗期证明', PREGNANCY_CERT: '三期证明', OTHER: '其他', } const emp = employee?.data || employee return (
{emp && ( <>

{emp.name}

{emp.department}
入职日期:{emp.hireDate?.slice(0, 10)}
性别:{emp.gender || '-'}
手机:{emp.phone || '-'}
状态:{emp.status === 'ACTIVE' ? '在职' : '离职'}
{(emp.isPregnant || emp.isInMedicalPeriod || emp.isWorkInjured) && (
{emp.isPregnant && 孕期} {emp.isInMedicalPeriod && 医疗期} {emp.isWorkInjured && 工伤}
)}
{emp.contracts && emp.contracts.length > 0 && (

合同信息

{emp.contracts.map((c: any) => (
{(() => { const typeLabel = c.contractType === 'FIXED' ? '固定期限' : c.contractType === 'UNFIXED' ? '无固定期限' : '未签' const typeStyle = c.contractType === 'UNSIGNED' ? 'bg-red-50 text-danger' : 'bg-blue-50 text-blue-700' return {typeLabel} })()}
{c.startDate?.slice(0, 10)} ~ {c.endDate?.slice(0, 10) || '无固定期限'}
))}
)} )}

附件管理

支持 PDF、图片、Word、Excel 等格式,每个文件最大 10MB

{attachments && attachments.length > 0 ? (
{attachments.map((att: any) => (
{fileTypeLabels[att.fileType] || att.fileType} · {new Date(att.createdAt).toLocaleDateString('zh-CN')}
))}
) : (
暂无附件
)}
{/* 附件预览弹窗 */} {previewUrl && (() => { const dataToBlobUrl = (dataUrl: string) => { try { const arr = dataUrl.split(',') const mime = arr[0].match(/:(.*?);/)?.[1] || 'application/octet-stream' const bstr = atob(arr[1]) const u8 = new Uint8Array(bstr.length) for (let i = 0; i < bstr.length; i++) u8[i] = bstr.charCodeAt(i) return URL.createObjectURL(new Blob([u8], { type: mime })) } catch { return dataUrl } } const blobUrl = previewUrl.startsWith('data:') ? dataToBlobUrl(previewUrl) : previewUrl const mime = previewUrl.startsWith('data:') ? previewUrl.match(/data:(.*?);/)?.[1] || '' : '' const isImage = mime.startsWith('image/') const isPdf = mime === 'application/pdf' return (
{ if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
e.stopPropagation()}>
附件预览
下载
{isImage ? ( 附件预览 ) : isPdf ? ( ) : (

此文件格式不支持在线预览

点击下载查看
)}
) })()} ) }