eb91c2d8fb
后端: - 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: 导入预览加上身份证号
1024 lines
49 KiB
TypeScript
1024 lines
49 KiB
TypeScript
/**
|
||
* 待签合同 / 电子签署管理页面
|
||
* - 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/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 }) {
|
||
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>
|
||
)}
|
||
</>
|
||
)
|
||
}
|