Files
TurboHR/frontend/src/pages/ESign.tsx
T
freedakgmail eb91c2d8fb fix: 统一风险中心及全应用身份证号显示
后端:
- employee.routes.ts: all-lite 接口加上 idCardNumber 并解密
- special-status.routes.ts: 列表和详情查询加上 idCardNumber 并解密
- esign.routes.ts: 列表/pending/detail/remind 接口加上 idCardNumber 并解密
- risk.service.ts: getMonthlyCalendar 所有事件加上 employeeIdCardNumber
- termination.service.ts: batchTerminatePreview 加上 employeeIdCardNumber
- employee.routes.ts: preview-renew 加上 employeeIdCardNumber

前端:
- Roster.tsx: 批量续签/解聘预检结果加上身份证号
- SpecialStatus.tsx: 列表/下拉/删除确认加上身份证号
- Calendar.tsx: 日历事件姓名后显示身份证号
- ESign.tsx: 签署列表/详情/员工列表/提醒弹窗/下拉加上身份证号
- CommercialInsuranceTab.tsx: 参保选择列表加上身份证号
- OvertimeTab.tsx: 导入预览加上身份证号
2026-08-18 08:14:26 +08:00

1024 lines
49 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 待签合同 / 电子签署管理页面
* - Tab1 待签合同:按员工聚合展示所有未签署文件,支持催办(生成二维码/链接)
* - Tab2 签署记录:全部签署记录列表(状态/场景/签署方式筛选)
* - 发起电子签署(场景选择 + 模板自动渲染 + 组织开关校验)
* - 线下手签登记(上传扫描件 + 签署信息 + 证据链)
* - 签署详情(含证据链查看)
* - 取消签署
*/
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 { 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'
import Button from '../components/ui/Button'
import { Input, Label, Select } from '../components/ui/Input'
import { InlineAlert } from '../components/ui/InlineAlert'
import Modal from '../components/ui/Modal'
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
PENDING: { label: '待签署', color: 'bg-yellow-50 text-yellow-700', icon: <Clock className="w-3 h-3" /> },
SIGNING: { label: '签署中', color: 'bg-blue-50 text-blue-700', icon: <Clock className="w-3 h-3" /> },
COMPLETED: { label: '已完成', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
REJECTED: { label: '已拒绝', color: 'bg-red-50 text-danger', icon: <XCircle className="w-3 h-3" /> },
EXPIRED: { label: '已过期', color: 'bg-gray-100 text-gray-500', icon: <XCircle className="w-3 h-3" /> },
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-500', icon: <XCircle className="w-3 h-3" /> },
}
const SCENE_CONFIG: Record<string, { label: string; color: string }> = {
CONTRACT: { label: '劳动合同', color: 'bg-blue-50 text-blue-600 border border-blue-200' },
RESIGNATION: { label: '离职协议', color: 'bg-orange-50 text-orange-600 border border-orange-200' },
POLICY: { label: '规章制度', color: 'bg-amber-50 text-amber-600 border border-amber-200' },
PAYSLIP: { label: '工资条', color: 'bg-emerald-50 text-emerald-600 border border-emerald-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() {
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<'pending' | 'records'>('pending')
const [filterStatus, setFilterStatus] = useState('')
const [filterScene, setFilterScene] = useState('')
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',
documentTitle: '',
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 () => {
return await esignApi.list({ status: filterStatus || undefined, scene: filterScene || undefined })
},
})
/** 待签合同列表(按员工聚合) */
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 () => {
return await employeeApi.allLite({ status: 'ACTIVE' })
},
enabled: showCreate,
})
const createMutation = useMutation({
mutationFn: async (data: { employeeId: string; scene: string; documentTitle: string; remark?: string }) =>
esignApi.create(data) as any,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
setShowCreate(false)
setFormData({ employeeId: '', scene: 'CONTRACT', documentTitle: '', remark: '' })
toast.success('签署记录已创建,员工可在员工端查看并签署')
},
onError: (err: any) => {
toast.error(err?.response?.data?.error?.message || '创建失败')
},
})
const cancelMutation = useMutation({
mutationFn: (id: string) => esignApi.cancel(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('已取消签署')
},
})
const refreshStatusMutation = useMutation({
mutationFn: (id: string) => esignApi.status(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['esign-records'] })
toast.success('状态已刷新')
},
})
const handleCreate = () => {
if (!formData.employeeId) { toast.error('请选择员工'); return }
if (!formData.documentTitle.trim()) { toast.error('请填写文件标题'); return }
createMutation.mutate(formData)
}
// ===== 签署详情视图 =====
if (detailId) {
return <ESignDetail id={detailId} onBack={() => setDetailId(null)} />
}
return (
<div className="space-y-4">
<PageGuide>
</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>
</div>
</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'}`}
>
{records.length > 0 && <span className="ml-1.5 px-1.5 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">{records.length}</span>}
</button>
</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>
{/* ===== 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
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="h-9 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"
>
<option value=""></option>
{Object.entries(STATUS_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
<select
value={filterScene}
onChange={(e) => setFilterScene(e.target.value)}
className="h-9 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"
>
<option value=""></option>
{Object.entries(SCENE_CONFIG).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
</div>
{/* 签署记录列表 */}
<Card>
{isLoading ? (
<div className="text-center py-8 text-gray-400">...</div>
) : records.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm"></div>
) : (
<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"></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-right"></th>
</tr>
</thead>
<tbody>
{records.map((r: any) => {
const statusCfg = STATUS_CONFIG[r.status] || STATUS_CONFIG.PENDING
return (
<tr key={r.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-2 px-3">
<div className="flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-gray-400 shrink-0" />
<button className="font-medium truncate max-w-[200px] text-left hover:text-primary" onClick={() => setDetailId(r.id)}>
{r.documentTitle}
</button>
{r.scene && SCENE_CONFIG[r.scene] && (
<span className={`px-1.5 py-0.5 rounded text-xs shrink-0 ${SCENE_CONFIG[r.scene].color}`}>{SCENE_CONFIG[r.scene].label}</span>
)}
</div>
{r.remark && <div className="text-xs text-gray-400 mt-0.5">{r.remark}</div>}
</td>
<td className="py-2 px-3">
<div className="flex flex-col gap-0.5">
<span>{r.employee?.name || '—'}</span>
{r.employee?.idCardNumber && <span className="text-gray-400 text-[11px] font-mono">{r.employee.idCardNumber}</span>}
</div>
</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">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs ${statusCfg.color}`}>
{statusCfg.icon}{statusCfg.label}
</span>
</td>
<td className="py-2 px-3 text-gray-500 text-xs">{new Date(r.createdAt).toLocaleString('zh-CN')}</td>
<td className="py-2 px-3 text-gray-500 text-xs">{r.completedAt ? new Date(r.completedAt).toLocaleString('zh-CN') : '—'}</td>
<td className="py-2 px-3 text-right">
<div className="flex items-center justify-end gap-1">
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => setDetailId(r.id)} title="查看详情">
<Eye className="w-3.5 h-3.5" />
</button>
{r.status === 'COMPLETED' && r.signedPdfUrl && (
<a href={r.signedPdfUrl} target="_blank" rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center gap-0.5">
<ExternalLink className="w-3 h-3" />PDF
</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') && (
<>
<button className="text-xs text-gray-400 hover:text-primary" onClick={() => refreshStatusMutation.mutate(r.id)}
title="刷新状态">
<RefreshCw className={`w-3.5 h-3.5 ${refreshStatusMutation.isPending ? 'animate-spin' : ''}`} />
</button>
<button className="text-xs text-gray-400 hover:text-danger" onClick={() => {
if (confirm('确定取消此签署任务吗?')) cancelMutation.mutate(r.id)
}}></button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</Card>
</>
)}
{/* 发起签署 Modal */}
{showCreate && (
<Modal open={true} onClose={() => setShowCreate(false)} 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={formData.employeeId}
onChange={(e) => setFormData({ ...formData, employeeId: e.target.value })}
>
<option value=""></option>
{rosterData.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name}{emp.idCardNumber ? `${emp.idCardNumber}` : ''} - {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={formData.scene}
onChange={(e) => {
const scene = e.target.value
const defaultTitle: Record<string, string> = {
CONTRACT: '劳动合同书',
RESIGNATION: '协商解除劳动合同协议书',
POLICY: '规章制度签收确认书',
PAYSLIP: '工资条确认书',
ONBOARDING: '入职文件签署',
}
setFormData({ ...formData, 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={formData.documentTitle} onChange={(e) => setFormData({ ...formData, documentTitle: e.target.value })}
placeholder="如:2024年度劳动合同" />
</div>
<div>
<Label></Label>
<Input value={formData.remark} onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
placeholder="可选" />
</div>
<div className="text-xs text-gray-400 bg-gray-50 rounded p-2">
<div className="font-medium text-gray-500 mb-1"></div>
<div>1. </div>
<div>2. </div>
<div>3. IP//</div>
{formData.scene === 'CONTRACT' && <div>4. </div>}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={() => setShowCreate(false)}></Button>
<Button size="sm" onClick={handleCreate} disabled={createMutation.isPending}>
{createMutation.isPending ? '创建中...' : '发起签署'}
</Button>
</div>
</div>
</Modal>
)}
{/* 线下手签登记 Modal */}
{showPaperSign && (
<PaperSignModal
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: '' })
}}
/>
)}
</div>
)
}
// ===== 线下手签登记 Modal =====
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: initialEmployeeId || '',
scene: initialScene || 'CONTRACT',
documentTitle: initialDocumentTitle || '',
signedAt: new Date().toISOString().slice(0, 10),
signedLocation: '',
witnessName: '',
witnessPhone: '',
remark: initialRemark || '',
})
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.idCardNumber ? `${emp.idCardNumber}` : ''} - {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" max={new Date().toISOString().slice(0, 10)} 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/TIFF10MB</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 }) {
const { data: detail, isLoading } = useQuery<any>({
queryKey: ['esign-detail', id],
queryFn: () => esignApi.detail(id),
})
const { data: evidence = [] } = useQuery<any[]>({
queryKey: ['esign-evidence', id],
queryFn: () => esignApi.evidence(id),
})
if (isLoading) {
return (
<div className="space-y-4">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
<ChevronLeft className="w-4 h-4" />
</button>
<Card className="p-6"><div className="text-center text-gray-400">...</div></Card>
</div>
)
}
if (!detail) {
return (
<div className="space-y-4">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
<ChevronLeft className="w-4 h-4" />
</button>
<Card className="p-6"><div className="text-center text-gray-400"></div></Card>
</div>
)
}
const statusCfg = STATUS_CONFIG[detail.status] || STATUS_CONFIG.PENDING
const sceneCfg = SCENE_CONFIG[detail.scene] || SCENE_CONFIG.CONTRACT
return (
<div className="space-y-4">
<button onClick={onBack} className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2">
<ChevronLeft className="w-4 h-4" />
</button>
{/* 基本信息 */}
<Card className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center">
<FileText className="w-5 h-5 text-primary" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<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 ${(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).color}`}>
{(SIGN_METHOD_CONFIG[detail.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC).label}
</span>
</div>
<div className="text-xs text-gray-400 mt-0.5">{detail.employee?.name} · {detail.employee?.department}{detail.employee?.idCardNumber && <span className="ml-1 font-mono">{detail.employee.idCardNumber}</span>}</div>
</div>
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs ${statusCfg.color}`}>
{statusCfg.icon}{statusCfg.label}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{new Date(detail.createdAt).toLocaleString('zh-CN')}</div>
</div>
<div>
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'}</div>
</div>
{detail.signMethod === 'PAPER' ? (
<>
<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 || '—'} {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 && (
<div className="col-span-2">
<div className="text-gray-400"></div>
<div className="text-gray-700">{detail.remark}</div>
</div>
)}
</div>
</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 && (
<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-gray-400" />
</div>
<div className="text-xs text-gray-600 max-h-96 overflow-y-auto bg-gray-50 rounded p-3 whitespace-pre-wrap border">
{detail.documentContent}
</div>
</Card>
)}
{/* 证据链 */}
<Card className="p-5">
<div className="text-sm font-medium mb-3 flex items-center gap-1.5">
<Shield className="w-4 h-4 text-primary" />
<span className="text-xs text-gray-400 font-normal">{evidence.length}</span>
</div>
{evidence.length === 0 ? (
<div className="text-xs text-gray-400 text-center py-4"></div>
) : (
<div className="space-y-3">
{evidence.map((ev: any, idx: number) => {
const events = ev.events as any[]
return (
<div key={ev.id} className="border rounded-md p-3">
<div className="text-xs text-gray-400 mb-2"> #{idx + 1} · {new Date(ev.createdAt).toLocaleString('zh-CN')}</div>
<div className="space-y-2">
{events?.map((event: any, i: number) => (
<div key={i} className="flex items-start gap-2 text-xs">
<div className="w-1.5 h-1.5 rounded-full bg-primary mt-1.5 shrink-0" />
<div className="flex-1">
<div className="text-gray-700 font-medium">{event.action}</div>
<div className="text-gray-400 mt-0.5">
{new Date(event.timestamp).toLocaleString('zh-CN')}
{event.ip && ` · IP: ${event.ip}`}
{event.location && ` · ${event.location}`}
</div>
</div>
</div>
))}
</div>
<div className="text-xs text-gray-300 mt-2 font-mono">hash: {ev.hash?.slice(0, 32)}...</div>
</div>
)
})}
</div>
)}
</Card>
</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 queryClient = useQueryClient()
const [expandedEmp, setExpandedEmp] = useState<string | null>(null)
const [remindEmpId, setRemindEmpId] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [signDateEditing, setSignDateEditing] = useState<string | null>(null)
const [signDateValue, setSignDateValue] = useState('')
const [signDateSaving, setSignDateSaving] = useState(false)
/** 保存签署日期 */
const handleSaveSignDate = async (contractId: string) => {
if (!signDateValue) { toast.error('请选择签署日期'); return }
setSignDateSaving(true)
try {
await esignApi.signDate(contractId, signDateValue)
toast.success('签署日期已登记')
setSignDateEditing(null)
setSignDateValue('')
queryClient.invalidateQueries({ queryKey: ['esign-pending'] })
} catch (err: any) {
toast.error(err?.response?.data?.error?.message || '登记失败')
} finally {
setSignDateSaving(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-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>
{emp.idCardNumber && <span className="text-xs text-gray-400 font-mono ml-1">{emp.idCardNumber}</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">
<div className="flex flex-wrap gap-1">
{emp.pendingItems.map((item: any, idx: number) => {
const sceneCfg = SCENE_CONFIG[item.scene]
const methodCfg = SIGN_METHOD_CONFIG[item.signMethod] || SIGN_METHOD_CONFIG.ELECTRONIC
return (
<span key={idx} className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs ${sceneCfg ? sceneCfg.color : 'bg-gray-100 text-gray-600'}`} title={item.title}>
{sceneCfg ? sceneCfg.label : item.title}
<span className={`px-1 rounded text-[10px] ${methodCfg.color}`}>{methodCfg.label}</span>
{item.type === 'contract' && <span className="text-[10px] text-gray-400"></span>}
</span>
)
})}
</div>
</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' && (
<div className="ml-auto flex items-center gap-2">
{signDateEditing === item.contractId ? (
<>
<input
type="date"
max={new Date().toISOString().slice(0, 10)}
value={signDateValue}
onChange={(e) => setSignDateValue(e.target.value)}
className="h-7 px-2 text-xs border rounded"
/>
<button
className="text-xs text-primary hover:underline"
disabled={signDateSaving}
onClick={() => handleSaveSignDate(item.contractId)}
>
{signDateSaving ? '保存中...' : '保存'}
</button>
<button
className="text-xs text-gray-400 hover:text-gray-600"
onClick={() => { setSignDateEditing(null); setSignDateValue('') }}
>
</button>
</>
) : (
<>
<span className="text-gray-400"></span>
<button
className="text-xs text-primary hover:underline"
onClick={() => {
setSignDateEditing(item.contractId)
setSignDateValue(new Date().toISOString().slice(0, 10))
}}
>
</button>
</>
)}
</div>
)}
</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>{remindData.employeeIdCardNumber && <span className="font-mono text-gray-500 ml-1">{remindData.employeeIdCardNumber}</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>
)}
</>
)
}