feat: 线下手签完整流程 + 证据链体系
## 数据库 - ESignRecord 增加 signMethod 字段(ELECTRONIC/PAPER) - 增加线下手签专用字段:signedAt、signedLocation、witnessName、witnessPhone、scanFileUrls ## 后端 - esign.routes.ts 新增线下手签接口: - POST /esign/paper-upload:上传签署扫描件(multer多文件,10MB限制) - POST /esign/paper-sign:线下手签登记(创建COMPLETED记录+证据链+回写合同) - esign.service.ts:autoCreateEsignRecord 设置 signMethod=ELECTRONIC - 静态文件服务复用 /uploads 统一映射 ## 前端管理端 - ESign.tsx 新增"线下手签登记"按钮和 Modal: - 选择员工、文件类型、签署日期、地点、见证人 - 上传签署扫描件(多文件) - 登记后自动创建证据链 - 列表增加"签署方式"列(电子签/线下手签标签) - 详情页增加签署方式标签 + 线下手签信息卡片 + 扫描件列表 ## 前端员工端 - MyEsign.tsx 列表增加线下手签标签 - 详情页增加线下手签信息卡片(签署日期/地点/见证人/扫描件) - 签署操作区域:线下手签记录显示"线下手签已登记",不显示验证码签署 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1528,6 +1528,7 @@ model ESignRecord {
|
|||||||
employeeId String
|
employeeId String
|
||||||
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
|
||||||
scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING
|
scene String @default("CONTRACT") // CONTRACT | RESIGNATION | POLICY | PAYSLIP | ONBOARDING
|
||||||
|
signMethod String @default("ELECTRONIC") // ELECTRONIC=电子签署 | PAPER=线下手签
|
||||||
flowId String? // 易签宝流程ID
|
flowId String? // 易签宝流程ID
|
||||||
documentTitle String // 文件标题
|
documentTitle String // 文件标题
|
||||||
documentContent String? // 文件内容(HTML/PDF base64)
|
documentContent String? // 文件内容(HTML/PDF base64)
|
||||||
@@ -1537,12 +1538,19 @@ model ESignRecord {
|
|||||||
initiatedBy String // 发起人(HR用户ID)
|
initiatedBy String // 发起人(HR用户ID)
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
expiredAt DateTime?
|
expiredAt DateTime?
|
||||||
callbackData Json? // 易签宝回调数据
|
callbackData Json? // 易签宝回调数据 / 线下手签证据
|
||||||
remark String?
|
remark String?
|
||||||
createdBy String
|
createdBy String
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
// 线下手签专用字段
|
||||||
|
signedAt DateTime? // 线下签署日期
|
||||||
|
signedLocation String? // 签署地点
|
||||||
|
witnessName String? // 见证人姓名
|
||||||
|
witnessPhone String? // 见证人手机号
|
||||||
|
scanFileUrls Json? // 线下签署扫描件URL列表 [{name, url}]
|
||||||
|
|
||||||
@@index([orgId, status])
|
@@index([orgId, status])
|
||||||
@@index([employeeId])
|
@@index([employeeId])
|
||||||
@@index([contractId])
|
@@index([contractId])
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
*/
|
*/
|
||||||
import { Router, Response, NextFunction } from 'express'
|
import { Router, Response, NextFunction } from 'express'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
import multer from 'multer'
|
||||||
|
import path from 'path'
|
||||||
|
import fs from 'fs'
|
||||||
import prisma from '../lib/prisma'
|
import prisma from '../lib/prisma'
|
||||||
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
import { authMiddleware, AuthRequest } from '../middleware/auth'
|
||||||
import { auditLog } from '../middleware/auditLog'
|
import { auditLog } from '../middleware/auditLog'
|
||||||
@@ -317,4 +320,161 @@ router.post('/:id/cancel', async (req: AuthRequest, res: Response, next: NextFun
|
|||||||
} catch (err) { next(err) }
|
} catch (err) { next(err) }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ========== 线下手签登记 ==========
|
||||||
|
|
||||||
|
/** 线下签署扫描件上传目录 */
|
||||||
|
const paperSignDir = path.join(process.cwd(), 'uploads', 'paper-sign')
|
||||||
|
if (!fs.existsSync(paperSignDir)) fs.mkdirSync(paperSignDir, { recursive: true })
|
||||||
|
|
||||||
|
const paperSignUpload = multer({
|
||||||
|
storage: multer.diskStorage({
|
||||||
|
destination: paperSignDir,
|
||||||
|
filename: (_req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname)
|
||||||
|
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: { fileSize: 10 * 1024 * 1024 },
|
||||||
|
fileFilter: (_req, file, cb) => {
|
||||||
|
const allowed = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp', '.webp', '.tiff', '.tif']
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase()
|
||||||
|
if (allowed.includes(ext)) cb(null, true)
|
||||||
|
else cb(new Error('仅支持 JPG/PNG/PDF/BMP/WEBP/TIFF 格式'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传线下签署扫描件
|
||||||
|
* 支持多文件上传,返回文件URL列表
|
||||||
|
*/
|
||||||
|
router.post('/paper-upload', authMiddleware, paperSignUpload.array('files', 10), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const files = req.files as Express.Multer.File[]
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return res.status(400).json({ success: false, error: { code: 'NO_FILE', message: '请选择文件' } })
|
||||||
|
}
|
||||||
|
const fileUrls = files.map(f => ({
|
||||||
|
name: f.originalname,
|
||||||
|
url: `/uploads/paper-sign/${f.filename}`,
|
||||||
|
size: f.size,
|
||||||
|
}))
|
||||||
|
res.json({ success: true, data: fileUrls })
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 线下手签登记 Schema */
|
||||||
|
const paperSignSchema = z.object({
|
||||||
|
employeeId: z.string().min(1),
|
||||||
|
contractId: z.string().optional(),
|
||||||
|
scene: z.string().default('CONTRACT'),
|
||||||
|
documentTitle: z.string().min(1),
|
||||||
|
signedAt: z.string().min(1), // 签署日期
|
||||||
|
signedLocation: z.string().optional(), // 签署地点
|
||||||
|
witnessName: z.string().optional(), // 见证人姓名
|
||||||
|
witnessPhone: z.string().optional(), // 见证人手机号
|
||||||
|
scanFileUrls: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
url: z.string(),
|
||||||
|
})).min(1, '至少上传一份签署扫描件'),
|
||||||
|
remark: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 线下手签登记
|
||||||
|
* - 创建 ESignRecord(signMethod=PAPER, status=COMPLETED)
|
||||||
|
* - 保存签署信息(签署日期/地点/见证人/扫描件)
|
||||||
|
* - 创建证据链(线下签署登记事件)
|
||||||
|
* - 回写合同签署方式
|
||||||
|
*/
|
||||||
|
router.post('/paper-sign', authMiddleware, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const data = paperSignSchema.parse(req.body)
|
||||||
|
|
||||||
|
// 获取员工信息
|
||||||
|
const employee = await prisma.employee.findFirst({
|
||||||
|
where: { id: data.employeeId, orgId: req.user!.orgId },
|
||||||
|
select: { id: true, name: true, department: true },
|
||||||
|
})
|
||||||
|
if (!employee) {
|
||||||
|
return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: '员工不存在' } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建签署记录(线下手签直接为 COMPLETED 状态)
|
||||||
|
const record = await prisma.eSignRecord.create({
|
||||||
|
data: {
|
||||||
|
orgId: req.user!.orgId,
|
||||||
|
contractId: data.contractId || null,
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
scene: data.scene,
|
||||||
|
signMethod: 'PAPER',
|
||||||
|
documentTitle: data.documentTitle,
|
||||||
|
status: 'COMPLETED',
|
||||||
|
initiatedBy: req.user!.id,
|
||||||
|
createdBy: req.user!.id,
|
||||||
|
remark: data.remark || null,
|
||||||
|
completedAt: new Date(data.signedAt),
|
||||||
|
expiredAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 线下签署记录保留1年
|
||||||
|
// 线下手签专用字段
|
||||||
|
signedAt: new Date(data.signedAt),
|
||||||
|
signedLocation: data.signedLocation || null,
|
||||||
|
witnessName: data.witnessName || null,
|
||||||
|
witnessPhone: data.witnessPhone || null,
|
||||||
|
scanFileUrls: data.scanFileUrls as any,
|
||||||
|
// 签署证据
|
||||||
|
callbackData: {
|
||||||
|
signMethod: 'PAPER',
|
||||||
|
signedAt: data.signedAt,
|
||||||
|
signedLocation: data.signedLocation,
|
||||||
|
witnessName: data.witnessName,
|
||||||
|
witnessPhone: data.witnessPhone,
|
||||||
|
scanFileCount: data.scanFileUrls.length,
|
||||||
|
registeredBy: req.user!.id,
|
||||||
|
registeredAt: new Date().toISOString(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 创建证据链 — 线下签署登记
|
||||||
|
await createEvidence({
|
||||||
|
orgId: req.user!.orgId,
|
||||||
|
category: 'CONTRACT_SIGN',
|
||||||
|
refId: record.id,
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
events: [{
|
||||||
|
action: `线下手签登记:${data.documentTitle}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.headers['user-agent'] as string,
|
||||||
|
location: `签署日期:${data.signedAt.slice(0, 10)},签署地点:${data.signedLocation || '未填写'},见证人:${data.witnessName || '无'},扫描件:${data.scanFileUrls.length}份`,
|
||||||
|
}],
|
||||||
|
createdBy: req.user!.id,
|
||||||
|
}).catch(() => {})
|
||||||
|
|
||||||
|
// 回写合同签署方式
|
||||||
|
if (data.contractId) {
|
||||||
|
await prisma.laborContract.update({
|
||||||
|
where: { id: data.contractId },
|
||||||
|
data: {
|
||||||
|
signMethod: 'PAPER',
|
||||||
|
// 第一份扫描件作为合同附件
|
||||||
|
attachmentUrl: data.scanFileUrls[0]?.url || null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditLog(req, 'PAPER_SIGN_REGISTER', 'ESIGN_RECORD', record.id, {
|
||||||
|
employeeId: data.employeeId,
|
||||||
|
documentTitle: data.documentTitle,
|
||||||
|
signedAt: data.signedAt,
|
||||||
|
scanFileCount: data.scanFileUrls.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: record,
|
||||||
|
message: '线下手签登记成功,证据链已记录',
|
||||||
|
})
|
||||||
|
} catch (err) { next(err) }
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export async function autoCreateEsignRecord(params: {
|
|||||||
contractId: contractId || null,
|
contractId: contractId || null,
|
||||||
employeeId,
|
employeeId,
|
||||||
scene,
|
scene,
|
||||||
|
signMethod: 'ELECTRONIC',
|
||||||
documentTitle,
|
documentTitle,
|
||||||
documentContent: documentContent || null,
|
documentContent: documentContent || null,
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
|
|||||||
@@ -651,6 +651,15 @@ export const esignApi = {
|
|||||||
get(`/esign/${id}/evidence`).then(unwrap<any[]>()),
|
get(`/esign/${id}/evidence`).then(unwrap<any[]>()),
|
||||||
cancel: (id: string) =>
|
cancel: (id: string) =>
|
||||||
post(`/esign/${id}/cancel`),
|
post(`/esign/${id}/cancel`),
|
||||||
|
/** 上传线下签署扫描件 */
|
||||||
|
uploadPaperSign: (files: File[]) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
files.forEach(f => formData.append('files', f))
|
||||||
|
return post('/esign/paper-upload', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(unwrap<any>())
|
||||||
|
},
|
||||||
|
/** 线下手签登记 */
|
||||||
|
paperSign: (data: Record<string, unknown>) =>
|
||||||
|
post('/esign/paper-sign', data).then(unwrap<any>()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 离职相关 ==========
|
// ========== 离职相关 ==========
|
||||||
|
|||||||
+278
-15
@@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* 电子签署管理页面
|
* 电子签署管理页面
|
||||||
* - 发起签署(场景选择 + 模板自动渲染 + 组织开关校验)
|
* - 发起电子签署(场景选择 + 模板自动渲染 + 组织开关校验)
|
||||||
* - 签署记录列表(状态/场景筛选)
|
* - 线下手签登记(上传扫描件 + 签署信息 + 证据链)
|
||||||
|
* - 签署记录列表(状态/场景/签署方式筛选)
|
||||||
* - 签署详情(含证据链查看)
|
* - 签署详情(含证据链查看)
|
||||||
* - 取消签署
|
* - 取消签署
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye } from 'lucide-react'
|
import { PenTool, Plus, RefreshCw, ExternalLink, FileText, AlertCircle, Shield, ChevronLeft, Clock, CheckCircle2, XCircle, Eye, Upload, FileCheck } from 'lucide-react'
|
||||||
import { esignApi, employeeApi } from '../lib/api-services'
|
import { esignApi, employeeApi } from '../lib/api-services'
|
||||||
import PageGuide from '../components/ui/PageGuide'
|
import PageGuide from '../components/ui/PageGuide'
|
||||||
import Card from '../components/ui/Card'
|
import Card from '../components/ui/Card'
|
||||||
@@ -34,11 +35,17 @@ const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
|
|||||||
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
|
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIGN_METHOD_CONFIG: Record<string, { label: string; color: string }> = {
|
||||||
|
ELECTRONIC: { label: '电子签', color: 'bg-blue-50 text-blue-600' },
|
||||||
|
PAPER: { label: '线下手签', color: 'bg-orange-50 text-orange-600' },
|
||||||
|
}
|
||||||
|
|
||||||
export default function ESign() {
|
export default function ESign() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [filterStatus, setFilterStatus] = useState('')
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
const [filterScene, setFilterScene] = useState('')
|
const [filterScene, setFilterScene] = useState('')
|
||||||
const [showCreate, setShowCreate] = useState(false)
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [showPaperSign, setShowPaperSign] = useState(false)
|
||||||
const [detailId, setDetailId] = useState<string | null>(null)
|
const [detailId, setDetailId] = useState<string | null>(null)
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
employeeId: '',
|
employeeId: '',
|
||||||
@@ -145,9 +152,14 @@ export default function ESign() {
|
|||||||
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={() => setShowCreate(true)}>
|
<div className="flex gap-2">
|
||||||
<Plus className="w-4 h-4 mr-1" />发起签署
|
<Button size="sm" onClick={() => setShowCreate(true)}>
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* 签署记录列表 */}
|
{/* 签署记录列表 */}
|
||||||
@@ -164,6 +176,7 @@ export default function ESign() {
|
|||||||
<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-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-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-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-left">完成时间</th>
|
||||||
@@ -189,6 +202,11 @@ export default function ESign() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3">{r.employee?.name || '—'}</td>
|
<td className="py-2 px-3">{r.employee?.name || '—'}</td>
|
||||||
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
|
<td className="py-2 px-3 text-gray-500">{r.employee?.department || '—'}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs ${(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
|
||||||
|
{(SIGN_METHOD_CONFIG[r.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>
|
||||||
{statusCfg.icon}{statusCfg.label}
|
{statusCfg.icon}{statusCfg.label}
|
||||||
@@ -207,6 +225,12 @@ export default function ESign() {
|
|||||||
<ExternalLink className="w-3 h-3" />PDF
|
<ExternalLink className="w-3 h-3" />PDF
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
{r.status === 'COMPLETED' && r.signMethod === 'PAPER' && (r as any).scanFileUrls && (
|
||||||
|
<a href={(r as any).scanFileUrls[0]?.url} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="text-xs text-primary hover:underline flex items-center gap-0.5">
|
||||||
|
<ExternalLink className="w-3 h-3" />扫描件
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
|
{(r.status === 'PENDING' || r.status === 'SIGNING') && (
|
||||||
<>
|
<>
|
||||||
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => refreshStatusMutation.mutate(r.id)}
|
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => refreshStatusMutation.mutate(r.id)}
|
||||||
@@ -295,10 +319,208 @@ export default function ESign() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 线下手签登记 Modal */}
|
||||||
|
{showPaperSign && (
|
||||||
|
<PaperSignModal
|
||||||
|
onClose={() => setShowPaperSign(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
|
||||||
|
setShowPaperSign(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 线下手签登记 Modal =====
|
||||||
|
function PaperSignModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
employeeId: '',
|
||||||
|
scene: 'CONTRACT',
|
||||||
|
documentTitle: '',
|
||||||
|
signedAt: new Date().toISOString().slice(0, 10),
|
||||||
|
signedLocation: '',
|
||||||
|
witnessName: '',
|
||||||
|
witnessPhone: '',
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
const [scanFiles, setScanFiles] = useState<Array<{ name: string; url: string }>>([])
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
const { data: rosterData = [] } = useQuery<any[]>({
|
||||||
|
queryKey: ['employees-for-paper-sign'],
|
||||||
|
queryFn: () => employeeApi.allLite({ status: 'ACTIVE' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const paperSignMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => esignApi.paperSign(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('线下手签登记成功,证据链已记录')
|
||||||
|
onSuccess()
|
||||||
|
},
|
||||||
|
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '登记失败'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
if (!files || files.length === 0) return
|
||||||
|
const allowedExts = ['.jpg', '.jpeg', '.png', '.pdf', '.bmp', '.webp', '.tiff', '.tif']
|
||||||
|
const validFiles: File[] = []
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const ext = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
|
||||||
|
if (!allowedExts.includes(ext)) {
|
||||||
|
toast.error(`不支持的格式: ${file.name}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.error(`文件过大: ${file.name}(最大10MB)`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validFiles.push(file)
|
||||||
|
}
|
||||||
|
if (validFiles.length === 0) return
|
||||||
|
|
||||||
|
setUploading(true)
|
||||||
|
try {
|
||||||
|
const res = await esignApi.uploadPaperSign(validFiles)
|
||||||
|
setScanFiles(prev => [...prev, ...res])
|
||||||
|
toast.success(`已上传 ${res.length} 个文件`)
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error?.message || '上传失败')
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.employeeId) { toast.error('请选择员工'); return }
|
||||||
|
if (!form.documentTitle.trim()) { toast.error('请填写文件标题'); return }
|
||||||
|
if (!form.signedAt) { toast.error('请选择签署日期'); return }
|
||||||
|
if (scanFiles.length === 0) { toast.error('请至少上传一份签署扫描件'); return }
|
||||||
|
paperSignMutation.mutate({ ...form, scanFileUrls: scanFiles })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={true} onClose={onClose} title="线下手签登记" size="md">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<InlineAlert type="info">
|
||||||
|
登记员工线下手签的合同/协议,上传签署后的纸质文件扫描件。系统自动记录证据链(签署日期、地点、见证人、扫描件),与电子签署具有同等证据效力。
|
||||||
|
</InlineAlert>
|
||||||
|
<div>
|
||||||
|
<Label>签署员工 *</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||||
|
value={form.employeeId}
|
||||||
|
onChange={(e) => setForm({ ...form, employeeId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">请选择员工</option>
|
||||||
|
{rosterData.map((emp: any) => (
|
||||||
|
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>文件类型 *</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-700 shadow-sm outline-none transition focus:border-primary focus:ring-2 focus:ring-primary/10"
|
||||||
|
value={form.scene}
|
||||||
|
onChange={(e) => {
|
||||||
|
const scene = e.target.value
|
||||||
|
const defaultTitle: Record<string, string> = {
|
||||||
|
CONTRACT: '劳动合同书',
|
||||||
|
RESIGNATION: '解除劳动合同协议书',
|
||||||
|
POLICY: '规章制度签收确认书',
|
||||||
|
PAYSLIP: '工资条确认书',
|
||||||
|
ONBOARDING: '入职文件',
|
||||||
|
}
|
||||||
|
setForm({ ...form, scene, documentTitle: defaultTitle[scene] || '' })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>文件标题 *</Label>
|
||||||
|
<Input value={form.documentTitle} onChange={(e) => setForm({ ...form, documentTitle: e.target.value })}
|
||||||
|
placeholder="如:2024年度劳动合同" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label>签署日期 *</Label>
|
||||||
|
<Input type="date" value={form.signedAt} onChange={(e) => setForm({ ...form, signedAt: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>签署地点</Label>
|
||||||
|
<Input value={form.signedLocation} onChange={(e) => setForm({ ...form, signedLocation: e.target.value })}
|
||||||
|
placeholder="如:公司会议室" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>见证人姓名</Label>
|
||||||
|
<Input value={form.witnessName} onChange={(e) => setForm({ ...form, witnessName: e.target.value })}
|
||||||
|
placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>见证人手机号</Label>
|
||||||
|
<Input value={form.witnessPhone} onChange={(e) => setForm({ ...form, witnessPhone: e.target.value })}
|
||||||
|
placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>签署扫描件 *</Label>
|
||||||
|
<div className="border-2 border-dashed border-gray-200 rounded-md p-4 text-center">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".jpg,.jpeg,.png,.pdf,.bmp,.webp,.tiff,.tif"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="text-sm text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4 inline mr-1" />
|
||||||
|
{uploading ? '上传中...' : '点击上传扫描件'}
|
||||||
|
</button>
|
||||||
|
<div className="text-xs text-gray-400 mt-1">支持 JPG/PNG/PDF/BMP/WEBP/TIFF,单个最大10MB</div>
|
||||||
|
</div>
|
||||||
|
{scanFiles.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{scanFiles.map((f, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-2 py-1.5">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
|
||||||
|
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
|
||||||
|
<button
|
||||||
|
onClick={() => setScanFiles(prev => prev.filter((_, idx) => idx !== i))}
|
||||||
|
className="text-gray-400 hover:text-danger"
|
||||||
|
>删除</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>备注</Label>
|
||||||
|
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })}
|
||||||
|
placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={onClose}>取消</Button>
|
||||||
|
<Button size="sm" onClick={handleSubmit} disabled={paperSignMutation.isPending || uploading}>
|
||||||
|
{paperSignMutation.isPending ? '登记中...' : '确认登记'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 签署详情组件 =====
|
// ===== 签署详情组件 =====
|
||||||
function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
||||||
const { data: detail, isLoading } = useQuery<any>({
|
const { data: detail, isLoading } = useQuery<any>({
|
||||||
@@ -351,6 +573,9 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-base font-semibold">{detail.documentTitle}</h2>
|
<h2 className="text-base font-semibold">{detail.documentTitle}</h2>
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs ${sceneCfg.color}`}>{sceneCfg.label}</span>
|
<span className={`px-1.5 py-0.5 rounded text-xs ${sceneCfg.color}`}>{sceneCfg.label}</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs ${(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
|
||||||
|
{(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-400 mt-0.5">签署人:{detail.employee?.name} · {detail.employee?.department}</div>
|
<div className="text-xs text-gray-400 mt-0.5">签署人:{detail.employee?.name} · {detail.employee?.department}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,14 +593,33 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
|||||||
<div className="text-gray-400">完成时间</div>
|
<div className="text-gray-400">完成时间</div>
|
||||||
<div className="text-gray-700">{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}</div>
|
<div className="text-gray-700">{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{detail.signMethod === 'PAPER' ? (
|
||||||
<div className="text-gray-400">过期时间</div>
|
<>
|
||||||
<div className="text-gray-700">{detail.expiredAt ? new Date(detail.expiredAt).toLocaleString('zh-CN') : '—'}</div>
|
<div>
|
||||||
</div>
|
<div className="text-gray-400">签署日期</div>
|
||||||
<div>
|
<div className="text-gray-700">{detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}</div>
|
||||||
<div className="text-gray-400">员工手机</div>
|
</div>
|
||||||
<div className="text-gray-700">{detail.employee?.phone ? detail.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '—'}</div>
|
<div>
|
||||||
</div>
|
<div className="text-gray-400">签署地点</div>
|
||||||
|
<div className="text-gray-700">{detail.signedLocation || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-400">见证人</div>
|
||||||
|
<div className="text-gray-700">{detail.witnessName || '—'} {detail.witnessPhone ? `· ${detail.witnessPhone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-400">过期时间</div>
|
||||||
|
<div className="text-gray-700">{detail.expiredAt ? new Date(detail.expiredAt).toLocaleString('zh-CN') : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-400">员工手机</div>
|
||||||
|
<div className="text-gray-700">{detail.employee?.phone ? detail.employee.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : '—'}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{detail.remark && (
|
{detail.remark && (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<div className="text-gray-400">备注</div>
|
<div className="text-gray-400">备注</div>
|
||||||
@@ -385,6 +629,25 @@ function ESignDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 线下手签扫描件 */}
|
||||||
|
{detail.signMethod === 'PAPER' && detail.scanFileUrls && (detail.scanFileUrls as any[]).length > 0 && (
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||||
|
<FileCheck className="w-4 h-4 text-orange-500" />签署扫描件
|
||||||
|
<span className="text-xs text-gray-400 font-normal">({(detail.scanFileUrls as any[]).length}份)</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(detail.scanFileUrls as any[]).map((f: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-3 py-2">
|
||||||
|
<FileText className="w-4 h-4 text-gray-400 shrink-0" />
|
||||||
|
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
|
||||||
|
<ExternalLink className="w-3 h-3 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 文件内容预览 */}
|
{/* 文件内容预览 */}
|
||||||
{detail.documentContent && (
|
{detail.documentContent && (
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ const SCENE_LABELS: Record<string, { label: string; color: string }> = {
|
|||||||
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
|
ONBOARDING: { label: '入职文件', color: 'bg-purple-50 text-purple-600 border border-purple-200' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SIGN_METHOD_LABELS: Record<string, { label: string; color: string }> = {
|
||||||
|
ELECTRONIC: { label: '电子签', color: 'bg-blue-50 text-blue-600' },
|
||||||
|
PAPER: { label: '线下手签', color: 'bg-orange-50 text-orange-600' },
|
||||||
|
}
|
||||||
|
|
||||||
export default function MyEsign() {
|
export default function MyEsign() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
@@ -92,7 +97,8 @@ export default function MyEsign() {
|
|||||||
// ===== 详情页 =====
|
// ===== 详情页 =====
|
||||||
if (selectedId) {
|
if (selectedId) {
|
||||||
const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null
|
const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null
|
||||||
const canSign = detail?.status === 'PENDING'
|
const canSign = detail?.status === 'PENDING' && detail?.signMethod !== 'PAPER'
|
||||||
|
const isPaperSign = detail?.signMethod === 'PAPER'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -156,6 +162,40 @@ export default function MyEsign() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 线下手签信息 */}
|
||||||
|
{isPaperSign && (
|
||||||
|
<Card className="p-5">
|
||||||
|
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
|
||||||
|
<FileText className="w-4 h-4 text-orange-500" />线下手签信息
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-400">签署日期</div>
|
||||||
|
<div className="text-gray-700">{detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-400">签署地点</div>
|
||||||
|
<div className="text-gray-700">{detail.signedLocation || '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-gray-400">见证人</div>
|
||||||
|
<div className="text-gray-700">{detail.witnessName || '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detail.scanFileUrls && (detail.scanFileUrls as any[]).length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<div className="text-xs text-gray-400">签署扫描件({(detail.scanFileUrls as any[]).length}份)</div>
|
||||||
|
{(detail.scanFileUrls as any[]).map((f: any, i: number) => (
|
||||||
|
<div key={i} className="flex items-center gap-2 text-xs bg-gray-50 rounded px-3 py-2">
|
||||||
|
<FileText className="w-4 h-4 text-gray-400 shrink-0" />
|
||||||
|
<a href={f.url} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline flex-1 truncate">{f.name}</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 签署操作 */}
|
{/* 签署操作 */}
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
{canSign ? (
|
{canSign ? (
|
||||||
@@ -212,10 +252,17 @@ export default function MyEsign() {
|
|||||||
) : detail.status === 'COMPLETED' ? (
|
) : detail.status === 'COMPLETED' ? (
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<CheckCircle2 className="w-10 h-10 text-safe mx-auto" />
|
<CheckCircle2 className="w-10 h-10 text-safe mx-auto" />
|
||||||
<div className="text-sm font-medium text-gray-700">签署已完成</div>
|
<div className="text-sm font-medium text-gray-700">
|
||||||
<div className="text-xs text-gray-400">
|
{isPaperSign ? '线下手签已登记' : '签署已完成'}
|
||||||
完成时间:{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{isPaperSign
|
||||||
|
? `签署日期:${detail.signedAt ? new Date(detail.signedAt).toLocaleDateString('zh-CN') : '—'}`
|
||||||
|
: `完成时间:${detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}`}
|
||||||
|
</div>
|
||||||
|
{isPaperSign && detail.signedLocation && (
|
||||||
|
<div className="text-xs text-gray-400">签署地点:{detail.signedLocation}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center text-xs text-gray-400">
|
<div className="text-center text-xs text-gray-400">
|
||||||
@@ -272,6 +319,11 @@ export default function MyEsign() {
|
|||||||
{r.scene && SCENE_LABELS[r.scene] && (
|
{r.scene && SCENE_LABELS[r.scene] && (
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs flex-shrink-0 ${SCENE_LABELS[r.scene].color}`}>{SCENE_LABELS[r.scene].label}</span>
|
<span className={`px-1.5 py-0.5 rounded text-xs flex-shrink-0 ${SCENE_LABELS[r.scene].color}`}>{SCENE_LABELS[r.scene].label}</span>
|
||||||
)}
|
)}
|
||||||
|
{r.signMethod === 'PAPER' && (
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-xs flex-shrink-0 ${(SIGN_METHOD_LABELS[r.signMethod] || SIGN_METHOD_LABELS.ELECTRONIC).color}`}>
|
||||||
|
{(SIGN_METHOD_LABELS[r.signMethod] || SIGN_METHOD_LABELS.ELECTRONIC).label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
||||||
|
|||||||
Reference in New Issue
Block a user