feat: 电子签署改为「待签合同」,按员工聚合+催办功能
1. 菜单「电子签署」改名为「待签合同」
2. 页面重构为两个 Tab:
- 待签合同:按员工聚合展示所有未签文件(EsignRecord
PENDING/SIGNING + LaborContract signDate 为空),展开可
查看该员工名下所有待签文件详情
- 签署记录:原有全部签署记录列表(保留筛选功能)
3. 催办功能:点击催办生成一次性自动登录链接(24h有效),
指向员工端签署页,弹窗展示二维码+可复制链接,HR 发给
员工扫码直接进入签署
4. 后端新增接口:
- GET /esign/pending 待签合同按员工聚合列表
- POST /esign/remind 催办生成自动登录链接
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -37,7 +37,7 @@ const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
||||
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
||||
{ path: '/esign', label: '电子签署', icon: PenTool },
|
||||
{ path: '/esign', label: '待签合同', icon: PenTool },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -644,6 +644,12 @@ export const benefitApi = {
|
||||
export const esignApi = {
|
||||
list: (params?: { status?: string; scene?: string }) =>
|
||||
get('/esign', { params: params || {} }).then(unwrap<any[]>()),
|
||||
/** 待签合同列表(按员工聚合) */
|
||||
pending: () =>
|
||||
get('/esign/pending').then(unwrap<any[]>()),
|
||||
/** 催办(生成自动登录链接) */
|
||||
remind: (employeeId: string) =>
|
||||
post('/esign/remind', { employeeId }).then(unwrap<any>()),
|
||||
create: (data: { contractId?: string; employeeId: string; documentTitle: string; documentContent?: string; remark?: string; scene?: string; templateId?: string; templateVars?: Record<string, string> }) =>
|
||||
post('/esign/create', data),
|
||||
detail: (id: string) =>
|
||||
|
||||
+228
-23
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* 电子签署管理页面
|
||||
* 待签合同 / 电子签署管理页面
|
||||
* - Tab1 待签合同:按员工聚合展示所有未签署文件,支持催办(生成二维码/链接)
|
||||
* - Tab2 签署记录:全部签署记录列表(状态/场景/签署方式筛选)
|
||||
* - 发起电子签署(场景选择 + 模板自动渲染 + 组织开关校验)
|
||||
* - 线下手签登记(上传扫描件 + 签署信息 + 证据链)
|
||||
* - 签署记录列表(状态/场景/签署方式筛选)
|
||||
* - 签署详情(含证据链查看)
|
||||
* - 取消签署
|
||||
*/
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, Fragment } 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'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye, Upload, FileCheck, Bell, Copy, Check, ChevronDown, ChevronRight, User } from 'lucide-react'
|
||||
import { esignApi, employeeApi } from '../lib/api-services'
|
||||
import PageGuide from '../components/ui/PageGuide'
|
||||
import Card from '../components/ui/Card'
|
||||
@@ -43,6 +45,7 @@ const SIGN_METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||
|
||||
export default function ESign() {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeTab, setActiveTab] = useState<'pending' | 'records'>('pending')
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterScene, setFilterScene] = useState('')
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
@@ -83,6 +86,26 @@ export default function ESign() {
|
||||
},
|
||||
})
|
||||
|
||||
/** 待签合同列表(按员工聚合) */
|
||||
const { data: pendingList = [], isLoading: pendingLoading } = useQuery<any[]>({
|
||||
queryKey: ['esign-pending'],
|
||||
queryFn: async () => {
|
||||
return await esignApi.pending()
|
||||
},
|
||||
})
|
||||
|
||||
/** 催办(生成自动登录链接) */
|
||||
const remindMutation = useMutation({
|
||||
mutationFn: (employeeId: string) => esignApi.remind(employeeId) as any,
|
||||
onSuccess: (data: any) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['esign-pending'] })
|
||||
toast.success(`催办链接已生成,可复制或展示二维码给员工`, {
|
||||
description: data?.phone ? `员工手机:${data.phone}` : '',
|
||||
})
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '催办失败'),
|
||||
})
|
||||
|
||||
const { data: rosterData = [] } = useQuery<any[]>({
|
||||
queryKey: ['employees-for-esign'],
|
||||
queryFn: async () => {
|
||||
@@ -135,26 +158,51 @@ export default function ESign() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageGuide>
|
||||
通过电子签署模块发起合同、离职协议、规章制度等文件的在线签署。员工在员工端通过手机验证码确认签署,签署全流程自动记录证据链(IP、时间戳、验证码),可作为劳动仲裁举证材料。
|
||||
管理员工合同、协议、规章等文件的签署。未签署的按员工聚合在「待签合同」中催办;已签署的记录在「签署记录」中查看。
|
||||
</PageGuide>
|
||||
<div className="flex items-center gap-2">
|
||||
<PenTool className="h-5 w-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">电子签署</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">在线合同签署,验证码确认 + 证据链留存</p>
|
||||
<h1 className="text-base font-semibold">待签合同</h1>
|
||||
<p className="mt-1 text-sm text-gray-500">合同签署管理,验证码确认 + 证据链留存</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InlineAlert type="info" className="flex items-start gap-2">
|
||||
<Shield className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<span className="font-medium">签署流程</span>
|
||||
<div className="mt-1 text-xs">
|
||||
① HR在系统发起签署(自动渲染模板文件) → ② 员工在员工端查看待签文件 → ③ 员工获取手机验证码 → ④ 验证码确认签署 → ⑤ 自动记录证据链(IP/UA/时间戳/验证码) → ⑥ 合同自动回写签署方式
|
||||
</div>
|
||||
{/* Tab 切换 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-1 border-b">
|
||||
<button
|
||||
onClick={() => setActiveTab('pending')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'pending' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
待签合同
|
||||
{pendingList.length > 0 && <span className="ml-1.5 px-1.5 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-700">{pendingList.length}</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('records')}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${activeTab === 'records' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
签署记录
|
||||
</button>
|
||||
</div>
|
||||
</InlineAlert>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />发起电子签署
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowPaperSign(true)}>
|
||||
<FileCheck className="w-4 h-4 mr-1" />线下手签登记
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Tab1: 待签合同(按员工聚合) ===== */}
|
||||
{activeTab === 'pending' && (
|
||||
<PendingTab pendingList={pendingList} loading={pendingLoading} onRemind={(empId) => remindMutation.mutate(empId)} remindLoading={remindMutation.isPending} remindData={remindMutation.data} onDetail={(id) => setDetailId(id)} />
|
||||
)}
|
||||
|
||||
{/* ===== Tab2: 签署记录 ===== */}
|
||||
{activeTab === 'records' && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
@@ -174,14 +222,6 @@ export default function ESign() {
|
||||
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||
<Plus className="w-4 h-4 mr-1" />发起电子签署
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => setShowPaperSign(true)}>
|
||||
<FileCheck className="w-4 h-4 mr-1" />线下手签登记
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 签署记录列表 */}
|
||||
@@ -274,6 +314,8 @@ export default function ESign() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 发起签署 Modal */}
|
||||
{showCreate && (
|
||||
@@ -734,3 +776,166 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 待签合同 Tab — 按员工聚合展示待签文件,支持催办
|
||||
*/
|
||||
function PendingTab({ pendingList, loading, onRemind, remindLoading, remindData, onDetail }: {
|
||||
pendingList: any[]
|
||||
loading: boolean
|
||||
onRemind: (employeeId: string) => void
|
||||
remindLoading: boolean
|
||||
remindData: any
|
||||
onDetail: (id: string) => void
|
||||
}) {
|
||||
const [expandedEmp, setExpandedEmp] = useState<string | null>(null)
|
||||
const [remindEmpId, setRemindEmpId] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const handleCopy = (url: string) => {
|
||||
navigator.clipboard?.writeText(url)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <Card><div className="text-center py-8 text-gray-400">加载中...</div></Card>
|
||||
}
|
||||
|
||||
if (pendingList.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-8 text-gray-400 text-sm">
|
||||
<CheckCircle2 className="w-8 h-8 mx-auto mb-2 text-green-400" />
|
||||
所有合同均已签署,暂无待签项
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-xs text-gray-500">
|
||||
<th className="py-2 px-3 text-left w-8"></th>
|
||||
<th className="py-2 px-3 text-left">员工</th>
|
||||
<th className="py-2 px-3 text-left">部门</th>
|
||||
<th className="py-2 px-3 text-left">手机号</th>
|
||||
<th className="py-2 px-3 text-center">待签数</th>
|
||||
<th className="py-2 px-3 text-right">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pendingList.map((emp: any) => {
|
||||
const expanded = expandedEmp === emp.employeeId
|
||||
return (
|
||||
<Fragment key={emp.employeeId}>
|
||||
<tr className="border-b last:border-0 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => setExpandedEmp(expanded ? null : emp.employeeId)}
|
||||
>
|
||||
<td className="py-2 px-3">
|
||||
{expanded ? <ChevronDown className="w-4 h-4 text-gray-400" /> : <ChevronRight className="w-4 h-4 text-gray-400" />}
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-3.5 h-3.5 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">{emp.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-500">{emp.department || '—'}</td>
|
||||
<td className="py-2 px-3 text-gray-500">{emp.phone || '—'}</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full text-xs bg-yellow-100 text-yellow-700 font-medium">
|
||||
{emp.pendingItems.length}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setRemindEmpId(emp.employeeId)
|
||||
onRemind(emp.employeeId)
|
||||
}}
|
||||
disabled={remindLoading && remindEmpId === emp.employeeId}
|
||||
>
|
||||
<Bell className="w-3.5 h-3.5 mr-1" />
|
||||
{remindLoading && remindEmpId === emp.employeeId ? '生成中...' : '催办'}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
{/* 展开行:待签文件列表 */}
|
||||
{expanded && (
|
||||
<tr className="bg-gray-50/50">
|
||||
<td></td>
|
||||
<td colSpan={5} className="py-2 px-3">
|
||||
<div className="space-y-1.5">
|
||||
{emp.pendingItems.map((item: any, idx: number) => (
|
||||
<div key={idx} className="flex items-center gap-2 px-3 py-2 bg-white rounded-md border text-xs">
|
||||
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||
<span className="font-medium">{item.title}</span>
|
||||
{SCENE_CONFIG[item.scene] && (
|
||||
<span className={`px-1.5 py-0.5 rounded ${SCENE_CONFIG[item.scene].color}`}>{SCENE_CONFIG[item.scene].label}</span>
|
||||
)}
|
||||
<span className={`px-1.5 py-0.5 rounded ${(SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
|
||||
{(SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
|
||||
</span>
|
||||
<span className="text-gray-400">{new Date(item.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||
{item.type === 'esign' && item.recordId && (
|
||||
<button className="text-primary hover:underline ml-auto" onClick={() => onDetail(item.recordId)}>
|
||||
查看详情
|
||||
</button>
|
||||
)}
|
||||
{item.type === 'contract' && (
|
||||
<span className="text-gray-400 ml-auto">纸质合同未登记签署日期</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 催办结果弹窗:二维码 + 链接 */}
|
||||
{remindData && remindEmpId && (
|
||||
<Modal open onClose={() => { setRemindEmpId(null); }} title="催办 — 发送给员工" size="sm">
|
||||
<div className="py-4">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="p-4 bg-white rounded-xl border-2 border-gray-100 shadow-sm">
|
||||
<QRCodeSVG value={remindData.url} size={200} level="M" />
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-600 text-center">
|
||||
员工 <span className="font-medium">{remindData.employeeName}</span> 扫码后自动登录员工端签署页面
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400">链接 24 小时内有效</p>
|
||||
<div className="mt-3 w-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg">
|
||||
<span className="text-xs text-gray-500 flex-1 truncate">{remindData.url}</span>
|
||||
<button
|
||||
onClick={() => handleCopy(remindData.url)}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 flex-shrink-0"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user