2968484d2d
- 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
348 lines
19 KiB
TypeScript
348 lines
19 KiB
TypeScript
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">支持 PDF、JPG、PNG、HEIC、GIF、BMP、WebP、TIFF、Word、Excel 等格式,每个文件最大 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>
|
||
)
|
||
}
|