Files
TurboHR/frontend/src/pages/roster/ContractInfo.tsx
T
freedakgmail 9512b555ee feat: 电子签全场景集成+场景筛选+设置开关
- ESignRecord 模型新增 scene 字段(CONTRACT/RESIGNATION/POLICY/PAYSLIP/ONBOARDING)
- Organization 模型新增3个电子签开关:esignPolicyEnabled/esignPayslipEnabled/esignOnboardingEnabled
- 设置页面企业信息新增电子签署设置区域,3个开关各自独立,缺省关闭
- 规章制度签收:开启电子签后,员工阅读确认时自动创建POLICY场景签署记录
- 工资条确认:开启电子签后,员工确认工资条时自动创建PAYSLIP场景签署记录
- 入职文件签署:开启电子签后,HR审批通过入职流程时自动创建ONBOARDING场景签署记录
- 电子签署列表增加场景筛选下拉(全部场景/劳动合同/离职协议/规章制度/工资条/入职文件)
- 管理端和员工端列表均展示场景标签(基于scene字段,替代硬编码判断)
- 合同和离职流程的esign调用已加scene参数
2026-08-05 07:47:00 +08:00

365 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, 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<HTMLInputElement>(null)
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
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<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 className="md:col-span-2">
<div className="px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-xs flex items-start gap-2">
<PenTool className="w-4 h-4 mt-0.5 shrink-0" />
<span></span>
</div>
</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' && (
<div className="flex justify-between md:col-span-3">
<span className="text-gray-500"></span>
<span className="text-primary flex items-center gap-1 text-xs">
<PenTool className="w-3 h-3" />
</span>
</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>
)
}