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) {
|
if (unreadPolicies.length > 0) {
|
||||||
pendingTasks.push({ severity: 'medium', message: `您有 ${unreadPolicies.length} 份制度待阅读确认` })
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -903,4 +911,60 @@ router.post('/leaves/:id/cancel', portalAuth, async (req: any, res, next) => {
|
|||||||
} catch (err) { next(err) }
|
} 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
|
export default router
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const LeaveApproval = lazy(() => import('./pages/LeaveApproval'))
|
|||||||
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
|
const EmployeeHome = lazy(() => import('./pages/portal/EmployeeHome'))
|
||||||
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress'))
|
const OnboardingProgress = lazy(() => import('./pages/portal/OnboardingProgress'))
|
||||||
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply'))
|
const ResignationApply = lazy(() => import('./pages/portal/ResignationApply'))
|
||||||
|
const MyEsign = lazy(() => import('./pages/portal/MyEsign'))
|
||||||
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter'))
|
const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter'))
|
||||||
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
|
const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard'))
|
||||||
const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance'))
|
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/home" element={<PortalLayoutWrapper><EmployeeHome /></PortalLayoutWrapper>} />
|
||||||
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
|
<Route path="/portal/onboarding-progress" element={<PortalLayoutWrapper><OnboardingProgress /></PortalLayoutWrapper>} />
|
||||||
<Route path="/portal/resignation" element={<PortalLayoutWrapper><ResignationApply /></PortalLayoutWrapper>} />
|
<Route path="/portal/resignation" element={<PortalLayoutWrapper><ResignationApply /></PortalLayoutWrapper>} />
|
||||||
|
<Route path="/portal/esign" element={<PortalLayoutWrapper><MyEsign /></PortalLayoutWrapper>} />
|
||||||
|
|
||||||
{/* 兜底 */}
|
{/* 兜底 */}
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const navGroups: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
{ path: '/', label: '工作台', icon: LayoutDashboard },
|
||||||
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
{ path: '/calendar', label: '工作日历', icon: CalendarDays },
|
||||||
|
{ path: '/esign', label: '电子签署', icon: PenTool },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -46,7 +47,6 @@ const navGroups: NavGroup[] = [
|
|||||||
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
|
{ path: '/work-process', label: '用工办理', icon: ClipboardList },
|
||||||
{ path: '/termination', label: '离职管理', icon: UserX },
|
{ path: '/termination', label: '离职管理', icon: UserX },
|
||||||
{ path: '/special-status', label: '特殊员工', icon: Heart },
|
{ path: '/special-status', label: '特殊员工', icon: Heart },
|
||||||
{ path: '/esign', label: '电子签署', icon: PenTool },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1011,4 +1011,13 @@ export const portalApi = {
|
|||||||
/** 撤回休假申请 */
|
/** 撤回休假申请 */
|
||||||
cancelLeave: (id: string) =>
|
cancelLeave: (id: string) =>
|
||||||
portalPost(`/leaves/${id}/cancel`).then(unwrap<any>()),
|
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 { Link } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
|
DollarSign, FileText, CalendarCheck, ScrollText, CalendarClock,
|
||||||
AlertCircle, ChevronRight, UserX,
|
AlertCircle, ChevronRight, UserX, PenTool,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { portalApi } from '../../lib/api-services'
|
import { portalApi } from '../../lib/api-services'
|
||||||
import Card from '../../components/ui/Card'
|
import Card from '../../components/ui/Card'
|
||||||
@@ -19,6 +19,7 @@ const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDig
|
|||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ path: '/portal/payslip', label: '工资条', icon: DollarSign, color: 'bg-emerald-50 text-emerald-600' },
|
{ 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/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/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/leave', label: '休假申请', icon: CalendarClock, color: 'bg-cyan-50 text-cyan-600' },
|
||||||
{ path: '/portal/policies', label: '规章制度', icon: ScrollText, color: 'bg-amber-50 text-amber-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 { 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 = [
|
const navItems = [
|
||||||
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
|
{ path: '/portal/payslip', label: '工资条', icon: DollarSign },
|
||||||
{ path: '/portal/contract', label: '我的合同', icon: FileText },
|
{ path: '/portal/contract', label: '我的合同', icon: FileText },
|
||||||
|
{ path: '/portal/esign', label: '电子签署', icon: PenTool },
|
||||||
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
|
{ path: '/portal/policies', label: '规章制度', icon: ScrollText },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user