feat: 花名册操作联动电子签署 + 修复重复创建

## 修复
- POST /contracts 不再自动创建签署记录(由前端根据signMethod决定),避免与ContractInfo重复创建

## 新增 SignMethodChoice 通用组件
- 花名册操作成功后弹窗选择签署方式:电子签/线下手签/稍后处理
- 电子签:自动创建ESignRecord(PENDING),员工在员工端签署
- 线下手签:跳转签署页面,预填员工/场景/标题,上传扫描件登记
- 稍后处理:跳过,可后续在签署页面手动发起

## 花名册4项操作联动签署方式选择
- 重新入职:操作成功后弹窗(scene=CONTRACT,劳动合同)
- 合同续签:操作成功后弹窗(scene=CONTRACT,续签劳动合同)
- 薪酬变更:操作成功后弹窗(scene=POLICY,薪酬调整确认书)
- 调岗调动:操作成功后弹窗(scene=POLICY,调岗确认书)

## ESign页面支持URL参数
- 读取 ?action=paper-sign 自动打开线下手签登记Modal
- 预填employeeId/scene/documentTitle

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-15 16:45:53 +08:00
parent 434c63c6d3
commit 3da61d5a09
4 changed files with 279 additions and 27 deletions
+41 -7
View File
@@ -6,7 +6,8 @@
* - 签署详情(含证据链查看)
* - 取消签署
*/
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useSearchParams } from 'react-router-dom'
import { toast } from 'sonner'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye, Upload, FileCheck } from 'lucide-react'
@@ -47,6 +48,7 @@ export default function ESign() {
const [showCreate, setShowCreate] = useState(false)
const [showPaperSign, setShowPaperSign] = useState(false)
const [detailId, setDetailId] = useState<string | null>(null)
const [searchParams] = useSearchParams()
const [formData, setFormData] = useState({
employeeId: '',
scene: 'CONTRACT',
@@ -54,6 +56,26 @@ export default function ESign() {
remark: '',
})
// 从 URL 参数读取,自动打开线下手签登记
useEffect(() => {
const action = searchParams.get('action')
if (action === 'paper-sign') {
setShowPaperSign(true)
// 预填表单
const employeeId = searchParams.get('employeeId')
const scene = searchParams.get('scene') as string
const documentTitle = searchParams.get('documentTitle') as string
if (employeeId || scene || documentTitle) {
setFormData({
employeeId: employeeId || '',
scene: scene || 'CONTRACT',
documentTitle: documentTitle || '',
remark: '花名册操作跳转登记',
})
}
}
}, [searchParams])
const { data: records = [], isLoading } = useQuery<any[]>({
queryKey: ['esign-records', filterStatus, filterScene],
queryFn: async () => {
@@ -323,10 +345,15 @@ export default function ESign() {
{/* 线下手签登记 Modal */}
{showPaperSign && (
<PaperSignModal
onClose={() => setShowPaperSign(false)}
initialEmployeeId={formData.employeeId}
initialScene={formData.scene as any}
initialDocumentTitle={formData.documentTitle}
initialRemark={formData.remark}
onClose={() => { setShowPaperSign(false); setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' }) }}
onSuccess={() => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
setShowPaperSign(false)
setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
}}
/>
)}
@@ -335,17 +362,24 @@ export default function ESign() {
}
// ===== 线下手签登记 Modal =====
function PaperSignModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
function PaperSignModal({ onClose, onSuccess, initialEmployeeId, initialScene, initialDocumentTitle, initialRemark }: {
onClose: () => void
onSuccess: () => void
initialEmployeeId?: string
initialScene?: string
initialDocumentTitle?: string
initialRemark?: string
}) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [form, setForm] = useState({
employeeId: '',
scene: 'CONTRACT',
documentTitle: '',
employeeId: initialEmployeeId || '',
scene: initialScene || 'CONTRACT',
documentTitle: initialDocumentTitle || '',
signedAt: new Date().toISOString().slice(0, 10),
signedLocation: '',
witnessName: '',
witnessPhone: '',
remark: '',
remark: initialRemark || '',
})
const [scanFiles, setScanFiles] = useState<Array<{ name: string; url: string }>>([])
const [uploading, setUploading] = useState(false)
+70 -8
View File
@@ -21,6 +21,7 @@ import PageGuide from '../components/ui/PageGuide'
import EmployeeProfile from './roster/EmployeeProfile'
import { InlineAlert } from '../components/ui/InlineAlert'
import { AddEmployeeModal, ResignModal, RehireModal, SalaryChangeModal, DeptChangeModal, ConfirmModal } from './roster/modals'
import SignMethodChoice from '../components/ui/SignMethodChoice'
import { ImportSettings } from './Settings'
import QueryError from '../components/ui/QueryError'
@@ -95,6 +96,17 @@ export default function Roster() {
const [page, setPage] = useState(1)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
// 签署方式选择弹窗
const [signChoice, setSignChoice] = useState<{
open: boolean
employeeId: string
employeeName: string
scene: 'CONTRACT' | 'RESIGNATION' | 'POLICY' | 'PAYSLIP' | 'ONBOARDING'
documentTitle: string
contractId?: string
remark?: string
actionName: string
} | null>(null)
const [batchRenewYears, setBatchRenewYears] = useState(3)
const [previewData, setPreviewData] = useState<any>(null)
const [filterStatus, setFilterStatus] = useState('')
@@ -157,20 +169,15 @@ export default function Roster() {
const addMutation = useMutation({
mutationFn: async (data: any) => {
const res = await employeeApi.create(data)
// 如果返回了 esignRecord,说明自动创建了电子签署
return res
},
onSuccess: (res: any) => {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['roster'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
localStorage.removeItem('add-employee-draft')
setShowAddModal(false)
const esignCreated = res?.data?.esignRecord
toast.success(esignCreated ? '员工已添加,劳动合同电子签署已自动发起' : '员工已添加', {
action: esignCreated ? { label: '查看签署', onClick: () => navigate('/esign') } : undefined,
})
toast.success('员工已添加,请前往员工档案签订合同')
},
onError: (err: any) => toastError(err, '创建失败'),
})
@@ -191,7 +198,7 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('已创建离职草稿,离职协议电子签署已自动发起', {
action: { label: '前往处理', onClick: () => navigate('/termination') },
action: { label: '查看签署', onClick: () => navigate('/esign') },
})
setShowResignModal(false)
setResignEmployee(null)
@@ -214,6 +221,16 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowRehireModal(false)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: rehireEmployee?.id || '',
employeeName: rehireEmployee?.name || '',
scene: 'CONTRACT',
documentTitle: `${rehireEmployee?.name || ''}的劳动合同`,
remark: '重新入职时发起',
actionName: '重新入职',
})
setRehireEmployee(null)
},
})
@@ -225,6 +242,16 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowSalaryModal(false)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: salaryEmployee?.id || '',
employeeName: salaryEmployee?.name || '',
scene: 'POLICY',
documentTitle: `${salaryEmployee?.name || ''}的薪酬调整确认书`,
remark: '薪酬变更时发起',
actionName: '薪酬变更',
})
setSalaryEmployee(null)
},
})
@@ -236,6 +263,16 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['roster-profile'] })
setShowDeptModal(false)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: deptEmployee?.id || '',
employeeName: deptEmployee?.name || '',
scene: 'POLICY',
documentTitle: `${deptEmployee?.name || ''}的调岗确认书`,
remark: '部门/岗位调动时发起',
actionName: '调岗调动',
})
setDeptEmployee(null)
},
})
@@ -347,6 +384,16 @@ export default function Roster() {
queryClient.invalidateQueries({ queryKey: ['work-processes'] })
queryClient.invalidateQueries({ queryKey: ['roster'] })
setShowRenewModal(false)
// 弹出签署方式选择
setSignChoice({
open: true,
employeeId: renewEmployee?.id || '',
employeeName: renewEmployee?.name || '',
scene: 'CONTRACT',
documentTitle: `${renewEmployee?.name || ''}的续签劳动合同`,
remark: '合同续签时发起',
actionName: '合同续签',
})
setRenewEmployee(null)
setRenewNewStartDate('')
setRenewNewEndDate('')
@@ -1449,6 +1496,21 @@ export default function Roster() {
</div>
</Modal>
)}
{/* 签署方式选择弹窗 */}
{signChoice?.open && (
<SignMethodChoice
open={signChoice.open}
onClose={() => setSignChoice(null)}
employeeId={signChoice.employeeId}
employeeName={signChoice.employeeName}
scene={signChoice.scene}
documentTitle={signChoice.documentTitle}
contractId={signChoice.contractId}
remark={signChoice.remark}
actionName={signChoice.actionName}
/>
)}
</div>
)
}