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:
selfrelease
2026-08-16 11:57:43 +08:00
parent ebd47e4500
commit 71f0619d18
4 changed files with 373 additions and 24 deletions
+138
View File
@@ -11,6 +11,7 @@
*/
import { Router, Response, NextFunction } from 'express'
import { z } from 'zod'
import jwt from 'jsonwebtoken'
import multer from 'multer'
import path from 'path'
import fs from 'fs'
@@ -23,6 +24,8 @@ import { renderTemplate, getTemplateById } from '../services/template.service'
const router = Router()
router.use(authMiddleware)
const AUTO_LOGIN_SECRET = process.env.JWT_SECRET || 'dev-secret'
/** 场景与组织电子签开关的映射 */
const SCENE_ORG_FLAG_MAP: Record<string, string | null> = {
CONTRACT: null, // 合同签署不需要额外开关(默认允许)
@@ -74,6 +77,141 @@ router.get('/', async (req: AuthRequest, res: Response, next: NextFunction) => {
} catch (err) { next(err) }
})
/**
* 待签合同列表(按员工聚合)
* GET /esign/pending
*
* 汇总两类未签署记录,按员工聚合:
* 1. EsignRecord 中 status 为 PENDING/SIGNING 的(所有场景)
* 2. LaborContract 中 signDate 为空且员工在职的(纸质合同未登记签署日期)
*
* 返回格式:[{ employeeId, name, department, phone, pendingItems: [{ type, title, scene, signMethod, status, createdAt, recordId, contractId }] }]
*/
router.get('/pending', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const orgId = req.user!.orgId
// 1. 查询未完成的 EsignRecord
const pendingEsign = await prisma.eSignRecord.findMany({
where: {
orgId,
status: { in: ['PENDING', 'SIGNING'] },
},
include: {
employee: { select: { id: true, name: true, department: true, phone: true, status: true } },
},
orderBy: { createdAt: 'desc' },
})
// 2. 查询 signDate 为空的 LaborContract(且员工在职)
const pendingContracts = await prisma.laborContract.findMany({
where: {
orgId,
signDate: null,
employee: { status: 'ACTIVE' },
},
include: {
employee: { select: { id: true, name: true, department: true, phone: true, status: true } },
},
orderBy: { createdAt: 'desc' },
})
// 3. 按员工聚合
const employeeMap = new Map<string, {
employeeId: string
name: string
department: string | null
phone: string | null
pendingItems: any[]
}>()
// 辅助函数:添加员工到 map
const ensureEmployee = (emp: { id: string; name: string; department: string | null; phone: string | null }) => {
if (!employeeMap.has(emp.id)) {
employeeMap.set(emp.id, {
employeeId: emp.id,
name: emp.name,
department: emp.department,
phone: emp.phone,
pendingItems: [],
})
}
return employeeMap.get(emp.id)!
}
// 汇总 EsignRecord
for (const r of pendingEsign) {
const emp = ensureEmployee(r.employee)
emp.pendingItems.push({
type: 'esign',
recordId: r.id,
contractId: r.contractId,
title: r.documentTitle,
scene: r.scene,
signMethod: r.signMethod,
status: r.status,
createdAt: r.createdAt,
})
}
// 汇总 LaborContract(排除已有 EsignRecord 关联的,避免重复)
const esignContractIds = new Set(pendingEsign.filter(r => r.contractId).map(r => r.contractId))
for (const c of pendingContracts) {
if (esignContractIds.has(c.id)) continue // 已有电子签署记录的不重复
const emp = ensureEmployee(c.employee)
emp.pendingItems.push({
type: 'contract',
recordId: null,
contractId: c.id,
title: `${c.contractType}合同`,
scene: 'CONTRACT',
signMethod: c.signMethod,
status: 'PENDING',
createdAt: c.createdAt,
})
}
// 转为数组,按待签数量降序、姓名排序
const result = Array.from(employeeMap.values()).sort((a, b) => {
if (b.pendingItems.length !== a.pendingItems.length) return b.pendingItems.length - a.pendingItems.length
return a.name.localeCompare(b.name)
})
res.json({ success: true, data: result })
} catch (err) { next(err) }
})
/**
* 催办 — 生成员工一次性自动登录链接(指向员工端签署页)
* POST /esign/remind body: { employeeId }
*
* 返回自动登录 URL,HR 可复制或生成二维码发给员工
*/
router.post('/remind', async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { employeeId } = req.body
if (!employeeId) {
return res.status(400).json({ success: false, error: { code: 'BAD_REQUEST', message: '缺少 employeeId' } })
}
const employee = await prisma.employee.findFirst({
where: { id: employeeId, orgId: req.user!.orgId, status: 'ACTIVE' },
select: { id: true, name: true, phone: true, orgId: true },
})
if (!employee) {
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在或已离职' } })
}
// 生成一次性 token(24 小时有效,给员工充足时间签署)
const token = jwt.sign(
{ id: employee.id, orgId: employee.orgId, role: 'EMPLOYEE_AUTO', name: employee.name },
AUTO_LOGIN_SECRET,
{ expiresIn: '24h' },
)
const url = `${process.env.PORTAL_BASE_URL || ''}/portal/auto-login?token=${token}&redirect=/portal/esign`
await auditLog(req, 'REMIND', 'ESIGN', employeeId, { employeeName: employee.name })
res.json({ success: true, data: { url, token, employeeName: employee.name, phone: employee.phone } })
} catch (err) { next(err) }
})
/**
* 发起签署
* - 校验组织电子签开关
@@ -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 },
],
},
{
+6
View File
@@ -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
View File
@@ -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>
)}
</>
)
}