Files
TurboHR/frontend/src/pages/roster/ContractInfo.tsx
T
freedakgmail 2968484d2d 优化: 大文件拆分+代码分割+按需加载+console清理+any类型替换
- Money.tsx (2260行→54行): 拆分为 money/ 子目录4个组件, React.lazy二级分割
- AIAssistant.tsx (2038行→63行): 拆分为 ai-assistant/ 子目录6个组件, React.lazy二级分割
- xlsx改为动态导入, OvertimeTab从345KB降至12.7KB
- api-services.ts: 请求参数 any→Record<string,unknown>
- 移除前端3处console.log残留
- 后端console替换为pino logger
- 前后端未使用import/变量清理
- Zod schema验证: termination/platform/special-status/work-process
- 新增 leave.routes.ts, acceptance-test.routes.ts
- UI组件: PageGuide, QueryError, Stepper
2026-08-04 07:53:37 +08:00

348 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
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 { toast } from "sonner"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { employeeApi } 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 } 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<HTMLInputElement>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
const addContractMutation = useMutation({
mutationFn: (data: any) => employeeApi.addContract({ ...data, employeeId }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); setShowForm(false) },
})
const deleteContractMutation = useMutation({
mutationFn: (contractId: string) => employeeApi.removeContract(contractId),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['roster-profile'] }); toast.success('合同已删除') },
})
const handleContractFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
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<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: '',
attachments: [],
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" multiple 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.attachments.length > 0 && <span className="text-xs text-gray-500">{form.attachments.length} </span>}
</div>
{form.attachments.length > 0 && (
<div className="mt-2 space-y-1">
{form.attachments.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button type="button" onClick={() => setPreviewUrl(att.url)} className="text-primary hover:underline flex items-center gap-1 truncate">
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
</button>
<button type="button" onClick={() => setForm(prev => ({ ...prev, attachments: prev.attachments.filter((_, i) => i !== idx) }))} className="text-danger hover:underline ml-2 shrink-0">
<X className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
<p className="text-xs text-gray-400 mt-1"> PDFJPGPNGHEICGIFBMPWebPTIFFWordExcel 10MB</p>
</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={() => {
const payload = {
...form,
attachmentUrl: form.attachments.length > 0 ? JSON.stringify(form.attachments) : '',
signDate: form.signDate ? new Date(form.signDate).toISOString() : null,
startDate: new Date(form.startDate).toISOString(),
endDate: form.endDate ? new Date(form.endDate).toISOString() : null,
}
addContractMutation.mutate(payload)
}} disabled={
addContractMutation.isPending || !form.startDate
}>
{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-3 gap-x-6 gap-y-3 flex-1 text-xs">
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{typeMap[c.contractType] || c.contractType}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.signDate ? c.signDate.toString().slice(0, 10) : '未签订'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.startDate?.toString().slice(0, 10)}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.endDate ? c.endDate.toString().slice(0, 10) : '无固定期限'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.contractYears}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.probationMonths}¥{c.probationSalary}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.signMethod === 'PAPER' ? '纸质' : '电子'}</span></div>
<div className="flex justify-between"><span className="text-gray-500 shrink-0"></span><span className="font-medium text-right truncate ml-2">{c.renewalCount}</span></div>
{c.signMethod === 'PAPER' && (
<div className="md:col-span-3">
<span className="text-gray-500"></span>
{(() => {
let atts: { name: string; url: string }[] = []
try {
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 }]
}
}
if (atts.length === 0) return <span className="text-gray-400 ml-2"></span>
return (
<div className="mt-1 space-y-1">
{atts.map((att, idx) => (
<div key={idx} className="flex items-center justify-between text-xs bg-gray-50 rounded px-2 py-1">
<button onClick={() => setPreviewUrl(att.url)} className="text-primary hover:underline flex items-center gap-1 truncate">
<Paperclip className="w-3 h-3 shrink-0" />{att.name}
</button>
<a href={att.url} download={att.name} className="text-gray-400 hover:text-primary ml-2 shrink-0">
<Download className="w-3 h-3" />
</a>
</div>
))}
</div>
)
})()}
</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-3">
<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>
<button
onClick={async () => { if (await confirm({ title: '删除合同', message: '确定删除此合同记录?' })) deleteContractMutation.mutate(c.id) }}
className="text-gray-400 hover:text-danger shrink-0 ml-2 mt-1"
title="删除合同"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</Card>
))}
{/* 附件预览弹窗 */}
{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'
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }}>
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full h-[90vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between px-4 py-2 border-b">
<span className="text-sm font-medium"></span>
<div className="flex items-center gap-2">
<a href={blobUrl} download="附件" className="text-xs text-primary hover:underline flex items-center gap-1">
<Download className="w-3 h-3" />
</a>
<button onClick={() => { if (blobUrl !== previewUrl) URL.revokeObjectURL(blobUrl); setPreviewUrl(null) }} className="text-gray-400 hover:text-gray-600">
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto flex items-center justify-center p-4">
{isImage ? (
<img src={blobUrl} alt="附件预览" className="max-w-full max-h-full object-contain" />
) : isPdf ? (
<embed src={blobUrl} type="application/pdf" className="w-full h-full" />
) : (
<div className="text-center space-y-3">
<FileText className="w-12 h-12 text-gray-300 mx-auto" />
<p className="text-sm text-gray-500">线</p>
<a href={blobUrl} download="附件" className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<Download className="w-4 h-4" />
</a>
</div>
)}
</div>
</div>
</div>
)
})()}
</div>
)
}