diff --git a/backend/src/routes/employee.routes.ts b/backend/src/routes/employee.routes.ts index 2b7eb97..cceef7a 100644 --- a/backend/src/routes/employee.routes.ts +++ b/backend/src/routes/employee.routes.ts @@ -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) } diff --git a/frontend/src/components/ui/SignMethodChoice.tsx b/frontend/src/components/ui/SignMethodChoice.tsx new file mode 100644 index 0000000..ba87c36 --- /dev/null +++ b/frontend/src/components/ui/SignMethodChoice.tsx @@ -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 = { + 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 ( + +
+
+
{employeeName} · {SCENE_LABELS[scene] || scene}
+
{documentTitle}
+
+ +
+ +
根据劳动合同法,{SCENE_LABELS[scene] || '文件'}需双方签署确认。请选择签署方式:
+
+ +
+ {/* 电子签署 */} + + + {/* 线下手签 */} + + + {/* 跳过 */} + +
+
+
+ ) +} diff --git a/frontend/src/pages/ESign.tsx b/frontend/src/pages/ESign.tsx index 4d05742..a52c0f3 100644 --- a/frontend/src/pages/ESign.tsx +++ b/frontend/src/pages/ESign.tsx @@ -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(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({ queryKey: ['esign-records', filterStatus, filterScene], queryFn: async () => { @@ -323,10 +345,15 @@ export default function ESign() { {/* 线下手签登记 Modal */} {showPaperSign && ( 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(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>([]) const [uploading, setUploading] = useState(false) diff --git a/frontend/src/pages/Roster.tsx b/frontend/src/pages/Roster.tsx index 05a263d..d12ce8d 100644 --- a/frontend/src/pages/Roster.tsx +++ b/frontend/src/pages/Roster.tsx @@ -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>(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(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() { )} + + {/* 签署方式选择弹窗 */} + {signChoice?.open && ( + setSignChoice(null)} + employeeId={signChoice.employeeId} + employeeName={signChoice.employeeName} + scene={signChoice.scene} + documentTitle={signChoice.documentTitle} + contractId={signChoice.contractId} + remark={signChoice.remark} + actionName={signChoice.actionName} + /> + )} ) }