import { useState, useRef } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { Plus, Search, Paperclip, Trash2, X, FileText, Download, Eye } from 'lucide-react' import { toast } from 'sonner' 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 [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) 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 api.get('/roster', { params }) as any return res }, }) const addMutation = useMutation({ mutationFn: (data: any) => api.post('/employees', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['employees'] }) queryClient.invalidateQueries({ queryKey: ['roster'] }) queryClient.invalidateQueries({ queryKey: ['dashboard'] }) queryClient.invalidateQueries({ queryKey: ['roster-profile'] }) setShowAddModal(false) }, }) const { data: departmentList } = useQuery({ queryKey: ['roster-departments'], queryFn: async () => { const res = await api.get('/roster/departments') as any return res.data || [] }, }) 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 && 工伤}
{/* 分页 */} { setPageSize(s); 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 [form, setForm] = useState({ name: '', department: '', 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, 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, department: e.target.value })} placeholder="如:技术部" />
setForm({ ...form, hireDate: e.target.value })} />
setForm({ ...form, monthlySalary: e.target.value })} placeholder="元" />
setForm({ ...form, phone: e.target.value })} placeholder="选填" maxLength={11} />
{/* 特殊状态 */}
{/* 合同信息 */}
{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' | 'OTHER'>('ID_CARD') const [previewUrl, setPreviewUrl] = useState(null) const { data: employee } = useQuery({ queryKey: ['employee-detail', employeeId], queryFn: async () => { const res = await api.get(`/employees/${employeeId}`) as any return res.data }, }) const { data: attachments } = useQuery({ queryKey: ['employee-attachments', employeeId], queryFn: async () => { const res = await api.get(`/attachments/${employeeId}`) as any return res.data }, }) const addAttachmentMutation = useMutation({ mutationFn: (data: any) => api.post('/attachments', data), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['employee-attachments', employeeId] }), }) const deleteAttachmentMutation = useMutation({ mutationFn: (id: string) => api.delete(`/attachments/${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: '学历证书', 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 ? ( ) : (

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

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