import { useState, useRef } from "react" import mammoth from "mammoth" import api from '../../lib/api' import { toast } from "sonner" import { useMutation, useQueryClient } from "@tanstack/react-query" import { employeeApi, esignApi } from '../../lib/api-services' import { useConfirm } from '../../hooks/useConfirm' import Card from "../../components/ui/Card" import Button from "../../components/ui/Button" import { Input, Label, Select } from "../../components/ui/Input" import { FileText, AlertTriangle, X, Paperclip, Trash2, Info, Download, PenTool } from "lucide-react" export default function ContractInfo({ employeeId, contracts, hireDate }: { employeeId: string; contracts: any[]; hireDate: string }) { const queryClient = useQueryClient() const confirm = useConfirm() 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: '', attachments: [] as { name: string; url: string }[], electronicContractNo: '', electronicContractUrl: '' }) const contractFileRef = useRef(null) const supplementFileRefs = useRef>({}) const [previewUrl, setPreviewUrl] = useState(null) const [previewName, setPreviewName] = useState('附件') const [wordHtml, setWordHtml] = useState(null) const uploadAttachmentMutation = useMutation({ mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => { await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl }) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['employee-detail'] }) toast.success('附件已更新') }, onError: () => toast.error('上传失败'), }) const deleteAttachmentMutation = useMutation({ mutationFn: async ({ contractId, attachmentUrl }: { contractId: string; attachmentUrl: string }) => { await api.patch(`/employees/contracts/${contractId}/attachment`, { attachmentUrl }) }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['employee-detail'] }) toast.success('附件已删除') }, onError: () => toast.error('删除失败'), }) const handleDeleteAttachment = async (contractId: string, atts: { name: string; url: string }[], idx: number) => { if (!await confirm({ title: '删除附件', message: '确定删除此附件?删除后不可恢复。' })) return const newAtts = atts.filter((_, i) => i !== idx) deleteAttachmentMutation.mutate({ contractId, attachmentUrl: newAtts.length > 0 ? JSON.stringify(newAtts) : '' }) } const handleSupplementUpload = (e: React.ChangeEvent, contractId: string, existingAtts: { name: string; url: string }[]) => { 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 const validFiles: File[] = [] 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 } validFiles.push(file) } if (validFiles.length === 0) return const promises = validFiles.map(file => new Promise<{ name: string; url: string }>((resolve) => { const reader = new FileReader() reader.onload = (event) => { resolve({ name: file.name, url: event.target?.result as string }) } reader.onerror = () => { toast.error(`读取文件失败: ${file.name}`) resolve({ name: file.name, url: '' }) } reader.readAsDataURL(file) })) Promise.all(promises).then(atts => { const validAtts = atts.filter(a => a.url) if (validAtts.length === 0) return const merged = [...existingAtts, ...validAtts] uploadAttachmentMutation.mutate({ contractId, attachmentUrl: JSON.stringify(merged) }) }) e.target.value = '' } const addContractMutation = useMutation({ mutationFn: async (data: any) => { const res = await employeeApi.addContract({ ...data, employeeId }) as any const contractId = res?.data?.id if (data.signMethod === 'ELECTRONIC' && contractId) { try { await esignApi.create({ contractId, employeeId, documentTitle: `${data.contractType === 'UNFIXED' ? '无固定期限' : '固定期限'}劳动合同`, remark: '合同创建时自动发起', scene: 'CONTRACT', }) toast.success('合同已保存,电子签署记录已创建') } catch { toast.success('合同已保存,电子签署记录创建失败(可稍后手动发起)') } } else { toast.success('合同已保存') } return res }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); queryClient.invalidateQueries({ queryKey: ['esign-records'] }); setShowForm(false) }, }) const deleteContractMutation = useMutation({ mutationFn: (contractId: string) => employeeApi.removeContract(contractId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已作废') }, }) const handleContractFileUpload = (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) => { setForm(prev => ({ ...prev, attachments: [...prev.attachments, { name: file.name, url: event.target?.result as string }], })) } reader.readAsDataURL(file) } // 清空 input 以便重复选择同一文件 e.target.value = '' } const typeMap: Record = { 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: '', attachments: [], electronicContractNo: '', electronicContractUrl: '', }) } setShowForm(!showForm) } return (

劳动合同({contracts?.length || 0}份)

{contractAdvice && !showForm && (
{contractAdvice.reason},建议选择「{contractAdvice.suggestedType === 'UNFIXED' ? '无固定期限' : '固定期限'}」
)} {showForm && ( {contractAdvice && (
{contractAdvice.reason}
)}
setForm({ ...form, signDate: e.target.value })} />
setForm({ ...form, startDate: e.target.value })} />
{form.contractType === 'FIXED' && (
setForm({ ...form, endDate: e.target.value })} />
)} {form.contractType === 'FIXED' && !contractAdvice?.isRenewal && ( <>
setForm({ ...form, probationMonths: parseInt(e.target.value) || 0 })} min={0} max={6} />
setForm({ ...form, probationSalary: parseInt(e.target.value) || 0 })} />
)}
{form.signMethod === 'PAPER' && (
{form.attachments.length > 0 && {form.attachments.length} 个文件}
{form.attachments.length > 0 && (
{form.attachments.map((att, idx) => (
))}
)}

支持 PDF、JPG、PNG、HEIC、GIF、BMP、WebP、TIFF、Word、Excel 等格式,每个文件最大 10MB

)} {form.signMethod === 'ELECTRONIC' && (
保存合同后将自动创建电子签署记录,对接易签宝后系统会自动生成签署链接并发送给员工。当前为框架预留阶段,签署记录创建后可在「电子签署」页面查看。
)}
)} {!contracts?.length ? (
暂无合同记录
) : contracts.map((c) => (
合同类型{typeMap[c.contractType] || c.contractType}
签订日期{c.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}
合同开始{c.startDate?.toString().slice(0, 10)}
合同结束{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}
合同期限{c.contractYears}年
试用期{c.probationMonths}个月(¥{c.probationSalary})
签订方式{c.signMethod === 'PAPER' ? '纸质' : '电子'}
续签次数{c.renewalCount}
{c.signMethod === 'PAPER' && (
合同附件 {(() => { let atts: { name: string; url: string }[] = [] try { if (!c.attachmentUrl) throw new Error('empty') const parsed = JSON.parse(c.attachmentUrl) atts = Array.isArray(parsed) ? parsed : [{ name: '附件', url: c.attachmentUrl }] } catch { if (c.attachmentUrl) { const mime = c.attachmentUrl.match(/data:(.*?);/)?.[1] || '' const ext = mime.split('/')[1] || '文件' atts = [{ name: `附件.${ext}`, url: c.attachmentUrl }] } } return (
{atts.length === 0 && 未上传} {atts.map((att, idx) => (
))} handleSupplementUpload(e, c.id, atts)} />
) })()}
)} {c.signMethod === 'ELECTRONIC' && (
签署方式 电子签署(记录已创建,请在「电子签署」页面查看进度)
)}
))} {/* 附件预览弹窗 */} {previewUrl && (() => { // 将 data URL 转为 blob URL,解决浏览器阻止 iframe 加载 data URL 的问题 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' const isWord = mime === 'application/msword' || mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || previewName.endsWith('.doc') || previewName.endsWith('.docx') // 如果是 Word 文件且尚未转换,异步转换 if (isWord && !wordHtml) { fetch(blobUrl) .then(r => r.arrayBuffer()) .then(buf => mammoth.convertToHtml({ arrayBuffer: buf })) .then(result => setWordHtml(result.value)) .catch(() => setWordHtml('

Word 文件转换失败,请下载查看

')) } const closePreview = () => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl) setPreviewUrl(null) setWordHtml(null) } return (
e.stopPropagation()}>
附件预览 - {previewName}
{isImage ? ( 附件预览 ) : isPdf ? ( ) : isWord ? ( wordHtml ? (
) : (

正在转换 Word 文档...

) ) : (

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

)}
) })()}
) }