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:
@@ -312,18 +312,8 @@ router.post('/contracts', authMiddleware, async (req: AuthRequest, res, next) =>
|
||||
createdBy: req.user!.id,
|
||||
}).catch(() => {})
|
||||
|
||||
// 自动创建劳动合同电子签署记录
|
||||
const esignRecord = await autoCreateEsignRecord({
|
||||
orgId: req.user!.orgId,
|
||||
employeeId: data.employeeId,
|
||||
scene: 'CONTRACT',
|
||||
documentTitle: `${emp?.name || ''}的劳动合同`,
|
||||
contractId: result.id,
|
||||
remark: '新增员工合同时自动发起',
|
||||
createdBy: req.user!.id,
|
||||
})
|
||||
|
||||
res.json({ success: true, data: result, esignRecord })
|
||||
// 注意:电子签署记录由前端根据 signMethod 决定是否创建,避免重复
|
||||
res.json({ success: true, data: result })
|
||||
} catch (err) {
|
||||
next(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 签署方式选择弹窗
|
||||
* 花名册操作(重新入职/合同续签/薪酬变更/调岗调动)成功后,
|
||||
* 弹窗让HR选择是走电子签署还是线下手签。
|
||||
*
|
||||
* - 电子签:自动创建电子签署记录(status=PENDING),员工在员工端签署
|
||||
* - 线下手签:跳转到电子签署页面的线下手签登记
|
||||
* - 跳过:不创建签署记录
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { toast } from 'sonner'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { PenTool, FileCheck, SkipForward, Shield } from 'lucide-react'
|
||||
import { esignApi } from '../../lib/api-services'
|
||||
import Modal from './Modal'
|
||||
import Button from './Button'
|
||||
|
||||
interface SignMethodChoiceProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
/** 员工ID */
|
||||
employeeId: string
|
||||
/** 员工姓名(用于显示) */
|
||||
employeeName: string
|
||||
/** 签署场景 */
|
||||
scene: 'CONTRACT' | 'RESIGNATION' | 'POLICY' | 'PAYSLIP' | 'ONBOARDING'
|
||||
/** 文件标题 */
|
||||
documentTitle: string
|
||||
/** 关联合同ID(可选) */
|
||||
contractId?: string
|
||||
/** 备注 */
|
||||
remark?: string
|
||||
/** 操作名称(如"重新入职"、"合同续签") */
|
||||
actionName: string
|
||||
}
|
||||
|
||||
const SCENE_LABELS: Record<string, string> = {
|
||||
CONTRACT: '劳动合同',
|
||||
RESIGNATION: '离职协议',
|
||||
POLICY: '规章制度',
|
||||
PAYSLIP: '工资条',
|
||||
ONBOARDING: '入职文件',
|
||||
}
|
||||
|
||||
export default function SignMethodChoice({
|
||||
open,
|
||||
onClose,
|
||||
employeeId,
|
||||
employeeName,
|
||||
scene,
|
||||
documentTitle,
|
||||
contractId,
|
||||
remark,
|
||||
actionName,
|
||||
}: SignMethodChoiceProps) {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const createEsignMutation = useMutation({
|
||||
mutationFn: (data: any) => esignApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||
toast.success('电子签署已发起,员工可在员工端查看并签署', {
|
||||
action: { label: '查看签署', onClick: () => navigate('/esign') },
|
||||
})
|
||||
onClose()
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '创建失败'),
|
||||
})
|
||||
|
||||
/** 选择电子签署 */
|
||||
const handleEsign = () => {
|
||||
setCreating(true)
|
||||
createEsignMutation.mutate({
|
||||
employeeId,
|
||||
contractId,
|
||||
scene,
|
||||
documentTitle,
|
||||
remark: remark || `${actionName}时自动发起`,
|
||||
})
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
/** 选择线下手签 → 跳转到签署页面 */
|
||||
const handlePaperSign = () => {
|
||||
onClose()
|
||||
// 通过 URL 参数传递信息,签署页面读取后自动打开线下手签登记
|
||||
const params = new URLSearchParams({
|
||||
action: 'paper-sign',
|
||||
employeeId,
|
||||
scene,
|
||||
documentTitle,
|
||||
})
|
||||
if (contractId) params.set('contractId', contractId)
|
||||
navigate(`/esign?${params.toString()}`)
|
||||
}
|
||||
|
||||
/** 跳过 */
|
||||
const handleSkip = () => {
|
||||
toast.info('已跳过签署,可稍后在「电子签署」页面手动发起')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={`${actionName}成功 — 选择签署方式`} size="sm">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600 bg-gray-50 rounded-md p-3">
|
||||
<div className="font-medium text-gray-700">{employeeName} · {SCENE_LABELS[scene] || scene}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{documentTitle}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 flex items-start gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 mt-0.5 shrink-0 text-primary" />
|
||||
<div>根据劳动合同法,{SCENE_LABELS[scene] || '文件'}需双方签署确认。请选择签署方式:</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 电子签署 */}
|
||||
<button
|
||||
onClick={handleEsign}
|
||||
disabled={creating}
|
||||
className="w-full flex items-center gap-3 p-3 border-2 border-blue-200 bg-blue-50/50 rounded-lg hover:border-blue-400 hover:bg-blue-50 transition text-left disabled:opacity-50"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 flex items-center justify-center shrink-0">
|
||||
<PenTool className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800">电子签署</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">自动创建签署记录,员工在员工端通过验证码签署</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 线下手签 */}
|
||||
<button
|
||||
onClick={handlePaperSign}
|
||||
className="w-full flex items-center gap-3 p-3 border-2 border-orange-200 bg-orange-50/50 rounded-lg hover:border-orange-400 hover:bg-orange-50 transition text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center shrink-0">
|
||||
<FileCheck className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-800">线下手签登记</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">跳转到签署页面,上传签署后的纸质文件扫描件</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* 跳过 */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="w-full flex items-center gap-3 p-3 border-2 border-gray-200 bg-gray-50/50 rounded-lg hover:border-gray-300 hover:bg-gray-50 transition text-left"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
|
||||
<SkipForward className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-600">稍后处理</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">跳过签署,可稍后在「电子签署」页面手动发起</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user