feat: 电子签署导航调整+员工手机端电子签署功能
- 管理端导航:电子签署移到首页分组(工作日历下) - 员工端新增电子签署页面(MyEsign.tsx): - 查看自己的签署记录列表,支持待签/已签/已取消状态 - 查看签署详情,含文件内容、关联合同标识 - 确认签署操作(框架阶段,对接易签宝后跳转签署页面) - 员工端导航(PortalNav)增加电子签署入口 - 员工首页(EmployeeHome)增加电子签署快捷入口 - 员工首页待办事项增加待签署文件提醒 - 后端portal路由新增3个接口:GET /esign、GET /esign/:id、POST /esign/:id/sign
This commit is contained in:
@@ -705,6 +705,14 @@ router.get('/home/overview', portalAuth, async (req: any, res, next) => {
|
||||
if (unreadPolicies.length > 0) {
|
||||
pendingTasks.push({ severity: 'medium', message: `您有 ${unreadPolicies.length} 份制度待阅读确认` })
|
||||
}
|
||||
// 待签署文件
|
||||
const pendingEsign = await prisma.eSignRecord.findMany({
|
||||
where: { employeeId, orgId, status: 'PENDING' },
|
||||
select: { id: true, documentTitle: true },
|
||||
})
|
||||
if (pendingEsign.length > 0) {
|
||||
pendingTasks.push({ severity: 'high', message: `您有 ${pendingEsign.length} 份文件待签署(${pendingEsign.map(e => e.documentTitle).join('、')})` })
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -903,4 +911,60 @@ router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// ========== 员工端:电子签署 ==========
|
||||
// 查看自己的签署记录列表
|
||||
router.get('/esign', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
const records = await prisma.eSignRecord.findMany({
|
||||
where: { employeeId, orgId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
res.json({ success: true, data: records })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 查看签署详情
|
||||
router.get('/esign/:id', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
where: { id: req.params.id, employeeId, orgId },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在' } })
|
||||
res.json({ success: true, data: record })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
// 员工签署操作(预留:对接易签宝后跳转到签署页面或提交签署结果)
|
||||
router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => {
|
||||
try {
|
||||
const { id: employeeId, orgId } = req.employee
|
||||
const record = await prisma.eSignRecord.findFirst({
|
||||
where: { id: req.params.id, employeeId, orgId, status: 'PENDING' },
|
||||
})
|
||||
if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } })
|
||||
|
||||
// 预留:对接易签宝后,此处应跳转到易签宝签署页面或接收签署结果
|
||||
// 当前框架阶段:直接标记为已签署
|
||||
const updated = await prisma.eSignRecord.update({
|
||||
where: { id: record.id },
|
||||
data: {
|
||||
status: 'COMPLETED',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// 如果关联了合同,更新合同签署信息
|
||||
if (record.contractId) {
|
||||
await prisma.laborContract.update({
|
||||
where: { id: record.contractId },
|
||||
data: { signMethod: 'ELECTRONIC' },
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ success: true, data: updated, message: '签署成功' })
|
||||
} catch (err) { next(err) }
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -50,6 +50,7 @@ const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
|
||||
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
|
||||
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress'))
|
||||
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply'))
|
||||
const MyEsign = lazy(() => import('./pages/portal/MyEsign'))
|
||||
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter'))
|
||||
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
|
||||
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
|
||||
@@ -224,6 +225,7 @@ export default function App() {
|
||||
<Route path="/portal/home" element={<PortalLayoutWrapper><EmployeeHome /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/resignation" element={<PortalLayoutWrapper><ResignationApply /></PortalLayoutWrapper>} />
|
||||
<Route path="/portal/esign" element={<PortalLayoutWrapper><MyEsign /></PortalLayoutWrapper>} />
|
||||
|
||||
{/* 兜底 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -37,6 +37,7 @@ const navGroups: NavGroup[] = [
|
||||
items: [
|
||||
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
||||
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
||||
{ path: '/esign', label: '电子签署', icon: PenTool },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -46,7 +47,6 @@ const navGroups: NavGroup[] = [
|
||||
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
|
||||
{ path: '/termination', label: '离职管理', icon: UserX },
|
||||
{ path: '/special-status', label: '特殊员工', icon: Heart },
|
||||
{ path: '/esign', label: '电子签署', icon: PenTool },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1011,4 +1011,13 @@ export const portalApi = {
|
||||
/** 撤回休假申请 */
|
||||
cancelLeave: (id: string) =>
|
||||
portalPost(`/leaves/${id}/cancel`).then(unwrap<any>()),
|
||||
/** 我的电子签署列表 */
|
||||
myEsignList: () =>
|
||||
portalGet('/esign').then(unwrap<any[]>()),
|
||||
/** 电子签署详情 */
|
||||
esignDetail: (id: string) =>
|
||||
portalGet(`/esign/${id}`).then(unwrap<any>()),
|
||||
/** 签署操作 */
|
||||
signEsign: (id: string) =>
|
||||
portalPost(`/esign/${id}/sign`).then(unwrap<any>()),
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
|
||||
AlertCircle, ChevronRight, UserX,
|
||||
AlertCircle, ChevronRight, UserX, PenTool,
|
||||
} from 'lucide-react'
|
||||
import { portalApi } from '../../lib/api-services'
|
||||
import Card from '../../components/ui/Card'
|
||||
@@ -19,6 +19,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
||||
const QUICK_ACTIONS = [
|
||||
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
|
||||
{ path: '/portal/contract', label: '我的合同', icon: FileText, color: 'bg-blue-50 text-blue-600' },
|
||||
{ path: '/portal/esign', label: '电子签署', icon: PenTool, color: 'bg-violet-50 text-violet-600' },
|
||||
{ path: '/portal/attendance', label: '我的考勤', icon: CalendarCheck, color: 'bg-purple-50 text-purple-600' },
|
||||
{ path: '/portal/leave', label: '休假申请', icon: CalendarClock, color: 'bg-cyan-50 text-cyan-600' },
|
||||
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-600' },
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* 员工端 — 电子签署页面
|
||||
* 查看自己的待签/已签文件,进行签署操作
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import { PenTool, FileText, CheckCircle2, Clock, XCircle, ChevronLeft } from 'lucide-react'
|
||||
import { portalApi } from '../../lib/api-services'
|
||||
import Card from '../../components/ui/Card'
|
||||
import Button from '../../components/ui/Button'
|
||||
import EmptyState from '../../components/ui/EmptyState'
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
PENDING: { label: '待签署', color: 'bg-amber-50 text-amber-700', icon: <Clock className="w-3 h-3" /> },
|
||||
COMPLETED: { label: '已签署', color: 'bg-green-50 text-safe', icon: <CheckCircle2 className="w-3 h-3" /> },
|
||||
CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-400', icon: <XCircle className="w-3 h-3" /> },
|
||||
EXPIRED: { label: '已过期', color: 'bg-red-50 text-red-600', icon: <XCircle className="w-3 h-3" /> },
|
||||
}
|
||||
|
||||
export default function MyEsign() {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
|
||||
const { data: list = [], isLoading } = useQuery<any[]>({
|
||||
queryKey: ['portal-esign'],
|
||||
queryFn: () => portalApi.myEsignList(),
|
||||
})
|
||||
|
||||
const { data: detail, isLoading: detailLoading } = useQuery<any>({
|
||||
queryKey: ['portal-esign-detail', selectedId],
|
||||
queryFn: () => portalApi.esignDetail(selectedId!),
|
||||
enabled: !!selectedId,
|
||||
})
|
||||
|
||||
const signMutation = useMutation({
|
||||
mutationFn: (id: string) => portalApi.signEsign(id),
|
||||
onSuccess: () => {
|
||||
toast.success('签署成功')
|
||||
queryClient.invalidateQueries({ queryKey: ['portal-esign'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['portal-esign-detail', selectedId] })
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.response?.data?.error?.message || '签署失败'),
|
||||
})
|
||||
|
||||
const pendingCount = list.filter((r: any) => r.status === 'PENDING').length
|
||||
|
||||
// 详情页
|
||||
if (selectedId) {
|
||||
const st = detail ? STATUS_MAP[detail.status] || STATUS_MAP.PENDING : null
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
onClick={() => setSelectedId(null)}
|
||||
className="flex items-center gap-1 text-gray-500 hover:text-gray-700 text-sm py-2"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />返回列表
|
||||
</button>
|
||||
|
||||
{detailLoading ? (
|
||||
<Card className="p-6">
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-6 bg-gray-100 rounded w-2/3" />
|
||||
<div className="h-4 bg-gray-100 rounded w-1/3" />
|
||||
<div className="h-20 bg-gray-100 rounded w-full" />
|
||||
</div>
|
||||
</Card>
|
||||
) : detail ? (
|
||||
<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 min-w-0">
|
||||
<h1 className="text-base font-semibold truncate">{detail.documentTitle}</h1>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
发起时间:{new Date(detail.createdAt).toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
{st && (
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs ${st.color}`}>
|
||||
{st.icon}{st.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail.remark && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-gray-50 text-xs text-gray-600">
|
||||
{detail.remark}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.documentContent && (
|
||||
<div className="mb-4 text-sm text-gray-700 whitespace-pre-wrap leading-relaxed border rounded-md p-3 max-h-60 overflow-y-auto">
|
||||
{detail.documentContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detail.contractId && (
|
||||
<div className="mb-4 text-xs text-blue-600 flex items-center gap-1">
|
||||
<FileText className="w-3.5 h-3.5" />关联合同
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 签署操作 */}
|
||||
<div className="border-t pt-4">
|
||||
{detail.status === 'PENDING' ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => signMutation.mutate(detail.id)}
|
||||
disabled={signMutation.isPending}
|
||||
>
|
||||
<PenTool className="w-4 h-4 mr-1" />
|
||||
{signMutation.isPending ? '签署中...' : '确认签署'}
|
||||
</Button>
|
||||
) : detail.status === 'COMPLETED' ? (
|
||||
<div className="text-center text-xs text-gray-500">
|
||||
已于 {detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'} 完成签署
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-xs text-gray-400">
|
||||
当前状态:{st?.label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-6">
|
||||
<EmptyState title="记录不存在" description="该签署记录可能已被删除" />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 列表页
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<PenTool className="w-5 h-5 text-primary" />
|
||||
<h1 className="text-base font-bold">电子签署</h1>
|
||||
{pendingCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs bg-amber-100 text-amber-700">
|
||||
<Clock className="w-3 h-3" />{pendingCount} 项待签
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-400 text-sm">加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
<Card className="p-6">
|
||||
<EmptyState title="暂无签署任务" description="没有需要您签署的文件" />
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{list.map((r: any) => {
|
||||
const st = STATUS_MAP[r.status] || STATUS_MAP.PENDING
|
||||
return (
|
||||
<Card key={r.id} className="cursor-pointer hover:shadow-md transition-shadow">
|
||||
<div onClick={() => setSelectedId(r.id)} className="flex items-center gap-3 p-4">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 ${
|
||||
r.status === 'PENDING' ? 'bg-amber-100' : r.status === 'COMPLETED' ? 'bg-green-100' : 'bg-gray-100'
|
||||
}`}>
|
||||
<FileText className={`w-5 h-5 ${
|
||||
r.status === 'PENDING' ? 'text-amber-600' : r.status === 'COMPLETED' ? 'text-green-600' : 'text-gray-400'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium truncate min-w-0">{r.documentTitle}</span>
|
||||
{r.contractId && (
|
||||
<span className="px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-600 border border-blue-200 flex-shrink-0">劳动合同</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs flex-shrink-0 ${st.color}`}>
|
||||
{st.icon}{st.label}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,11 +3,12 @@
|
||||
*/
|
||||
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { DollarSign, FileText, ScrollText, LogOut } from 'lucide-react'
|
||||
import { DollarSign, FileText, ScrollText, LogOut, PenTool } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
|
||||
{ path: '/portal/contract', label: '我的合同', icon: FileText },
|
||||
{ path: '/portal/esign', label: '电子签署', icon: PenTool },
|
||||
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user