diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index 977166f..df6f298 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4bfb4cb..0ebd8e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> } /> } /> + } /> {/* 兜底 */} } /> diff --git a/frontend/src/components/layout/SidebarNav.tsx b/frontend/src/components/layout/SidebarNav.tsx index 374d027..71dce94 100644 --- a/frontend/src/components/layout/SidebarNav.tsx +++ b/frontend/src/components/layout/SidebarNav.tsx @@ -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 }, ], }, { diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index b68ea48..1740517 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -1011,4 +1011,13 @@ export const portalApi = { /** 撤回休假申请 */ cancelLeave: (id: string) => portalPost(`/leaves/${id}/cancel`).then(unwrap()), + /** 我的电子签署列表 */ + myEsignList: () => + portalGet('/esign').then(unwrap()), + /** 电子签署详情 */ + esignDetail: (id: string) => + portalGet(`/esign/${id}`).then(unwrap()), + /** 签署操作 */ + signEsign: (id: string) => + portalPost(`/esign/${id}/sign`).then(unwrap()), } diff --git a/frontend/src/pages/portal/EmployeeHome.tsx b/frontend/src/pages/portal/EmployeeHome.tsx index df1d4ba..3a6b02a 100644 --- a/frontend/src/pages/portal/EmployeeHome.tsx +++ b/frontend/src/pages/portal/EmployeeHome.tsx @@ -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' }, diff --git a/frontend/src/pages/portal/MyEsign.tsx b/frontend/src/pages/portal/MyEsign.tsx new file mode 100644 index 0000000..7c0f94c --- /dev/null +++ b/frontend/src/pages/portal/MyEsign.tsx @@ -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 = { + PENDING: { label: '待签署', color: 'bg-amber-50 text-amber-700', icon: }, + COMPLETED: { label: '已签署', color: 'bg-green-50 text-safe', icon: }, + CANCELLED: { label: '已取消', color: 'bg-gray-100 text-gray-400', icon: }, + EXPIRED: { label: '已过期', color: 'bg-red-50 text-red-600', icon: }, +} + +export default function MyEsign() { + const queryClient = useQueryClient() + const [selectedId, setSelectedId] = useState(null) + + const { data: list = [], isLoading } = useQuery({ + queryKey: ['portal-esign'], + queryFn: () => portalApi.myEsignList(), + }) + + const { data: detail, isLoading: detailLoading } = useQuery({ + 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 ( +
+ + + {detailLoading ? ( + +
+
+
+
+
+ + ) : detail ? ( + +
+
+ +
+
+

{detail.documentTitle}

+
+ 发起时间:{new Date(detail.createdAt).toLocaleString('zh-CN')} +
+
+ {st && ( + + {st.icon}{st.label} + + )} +
+ + {detail.remark && ( +
+ {detail.remark} +
+ )} + + {detail.documentContent && ( +
+ {detail.documentContent} +
+ )} + + {detail.contractId && ( +
+ 关联合同 +
+ )} + + {/* 签署操作 */} +
+ {detail.status === 'PENDING' ? ( + + ) : detail.status === 'COMPLETED' ? ( +
+ 已于 {detail.completedAt ? new Date(detail.completedAt).toLocaleString('zh-CN') : '—'} 完成签署 +
+ ) : ( +
+ 当前状态:{st?.label} +
+ )} +
+
+ ) : ( + + + + )} +
+ ) + } + + // 列表页 + return ( +
+
+ +

电子签署

+ {pendingCount > 0 && ( + + {pendingCount} 项待签 + + )} +
+ + {isLoading ? ( +
加载中...
+ ) : list.length === 0 ? ( + + + + ) : ( +
+ {list.map((r: any) => { + const st = STATUS_MAP[r.status] || STATUS_MAP.PENDING + return ( + +
setSelectedId(r.id)} className="flex items-center gap-3 p-4"> +
+ +
+
+
+ {r.documentTitle} + {r.contractId && ( + 劳动合同 + )} +
+
+ {new Date(r.createdAt).toLocaleDateString('zh-CN')} +
+
+ + {st.icon}{st.label} + +
+
+ ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/portal/PortalNav.tsx b/frontend/src/pages/portal/PortalNav.tsx index 57aa894..b2dee6b 100644 --- a/frontend/src/pages/portal/PortalNav.tsx +++ b/frontend/src/pages/portal/PortalNav.tsx @@ -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 }, ]