From 3afb3831725260063f39b1c05d2ce00439445fa0 Mon Sep 17 00:00:00 2001 From: freedakgmail Date: Wed, 5 Aug 2026 08:39:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9F=B9=E8=AE=AD/=E7=BB=A9=E6=95=88/?= =?UTF-8?q?=E8=BF=9D=E7=BA=AA=E5=91=98=E5=B7=A5=E7=AB=AF=E7=AD=BE=E6=94=B6?= =?UTF-8?q?+=E7=AE=A1=E7=90=86=E7=AB=AF=E7=AD=BE=E5=AD=97=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=8F=AA=E8=AF=BB=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: portal新增培训/绩效/违纪列表查看+签收接口,签收时创建证据链 - 管理端: 弹窗去掉HR手动签字勾选,改为只读显示签字状态 - 绩效列表新增签字状态列 - 员工端: 新增MyRecords页面,支持培训签收/拒绝、绩效签字、违纪签字 - 员工端导航新增'我的记录'入口 - EvidenceCategory新增TRAINING/PERFORMANCE类型 --- backend/src/routes/portal.routes.ts | 138 +++++++++++ backend/src/services/evidence.service.ts | 2 + frontend/src/App.tsx | 2 + frontend/src/lib/api-services.ts | 21 ++ frontend/src/pages/portal/MyRecords.tsx | 227 ++++++++++++++++++ frontend/src/pages/portal/PortalNav.tsx | 3 +- .../src/pages/roster/DisciplinaryRecords.tsx | 17 +- .../src/pages/roster/PerformanceRecords.tsx | 25 +- frontend/src/pages/roster/TrainingRecords.tsx | 20 +- 9 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 frontend/src/pages/portal/MyRecords.tsx diff --git a/backend/src/routes/portal.routes.ts b/backend/src/routes/portal.routes.ts index fa6db6e..d16590c 100644 --- a/backend/src/routes/portal.routes.ts +++ b/backend/src/routes/portal.routes.ts @@ -1007,4 +1007,142 @@ router.post('/esign/:id/sign', portalAuth, async (req: any, res, next) => { } catch (err) { next(err) } }) +// ========== 员工端:培训签收 ========== +// 查看自己的培训记录 +router.get('/training', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const records = await prisma.trainingRecord.findMany({ + where: { employeeId, orgId }, + orderBy: { trainingDate: 'desc' }, + }) + res.json({ success: true, data: records }) + } catch (err) { next(err) } +}) + +// 培训签收 +router.post('/training/:id/sign', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const record = await prisma.trainingRecord.findFirst({ + where: { id: req.params.id, employeeId, orgId, ackStatus: 'PENDING' }, + }) + if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签收' } }) + + const updated = await prisma.trainingRecord.update({ + where: { id: record.id }, + data: { ackStatus: 'SIGNED', ackDate: new Date() }, + }) + + await createEvidence({ + orgId, + category: 'TRAINING', + refId: record.id, + employeeId, + events: [{ action: `培训签收:${record.topic}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], + createdBy: employeeId, + }).catch(() => {}) + + res.json({ success: true, data: updated, message: '签收成功' }) + } catch (err) { next(err) } +}) + +// 培训拒绝签收 +router.post('/training/:id/refuse', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const record = await prisma.trainingRecord.findFirst({ + where: { id: req.params.id, employeeId, orgId, ackStatus: 'PENDING' }, + }) + if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已处理' } }) + + const updated = await prisma.trainingRecord.update({ + where: { id: record.id }, + data: { ackStatus: 'REFUSED', ackDate: new Date() }, + }) + + res.json({ success: true, data: updated, message: '已拒绝签收' }) + } catch (err) { next(err) } +}) + +// ========== 员工端:绩效签字 ========== +// 查看自己的绩效记录 +router.get('/performance', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const records = await prisma.performanceRecord.findMany({ + where: { employeeId, orgId }, + orderBy: { period: 'desc' }, + }) + res.json({ success: true, data: records }) + } catch (err) { next(err) } +}) + +// 绩效签字确认 +router.post('/performance/:id/sign', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const record = await prisma.performanceRecord.findFirst({ + where: { id: req.params.id, employeeId, orgId, employeeAck: false }, + }) + if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签字' } }) + + const updated = await prisma.performanceRecord.update({ + where: { id: record.id }, + data: { employeeAck: true, ackDate: new Date() }, + }) + + await createEvidence({ + orgId, + category: 'PERFORMANCE', + refId: record.id, + employeeId, + events: [{ action: `绩效签字确认:${record.period}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], + createdBy: employeeId, + }).catch(() => {}) + + res.json({ success: true, data: updated, message: '签字成功' }) + } catch (err) { next(err) } +}) + +// ========== 员工端:违纪签字 ========== +// 查看自己的违纪记录 +router.get('/disciplinary', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const records = await prisma.disciplinaryRecord.findMany({ + where: { employeeId, orgId }, + orderBy: { violationDate: 'desc' }, + }) + res.json({ success: true, data: records }) + } catch (err) { next(err) } +}) + +// 违纪签字确认 +router.post('/disciplinary/:id/sign', portalAuth, async (req: any, res, next) => { + try { + const { id: employeeId, orgId } = req.employee + const record = await prisma.disciplinaryRecord.findFirst({ + where: { id: req.params.id, employeeId, orgId, employeeAck: false }, + }) + if (!record) return res.status(404).json({ success: false, error: { message: '记录不存在或已签字' } }) + + const updated = await prisma.disciplinaryRecord.update({ + where: { id: record.id }, + data: { employeeAck: true, ackDate: new Date(), ackMethod: 'SIGN' }, + }) + + await createEvidence({ + orgId, + category: 'DISCIPLINARY', + refId: record.id, + employeeId, + events: [{ action: `违纪签字确认:${record.violationType}`, timestamp: new Date().toISOString(), ip: req.ip, userAgent: req.headers['user-agent'] }], + createdBy: employeeId, + }).catch(() => {}) + + res.json({ success: true, data: updated, message: '签字成功' }) + } catch (err) { next(err) } +}) + export default router diff --git a/backend/src/services/evidence.service.ts b/backend/src/services/evidence.service.ts index adea4b9..b4299fa 100644 --- a/backend/src/services/evidence.service.ts +++ b/backend/src/services/evidence.service.ts @@ -12,6 +12,8 @@ export type EvidenceCategory = | 'DISCIPLINARY' | 'ATTENDANCE' | 'TERMINATION' + | 'TRAINING' + | 'PERFORMANCE' /** * 创建证据链记录 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a0f156..45a63ed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -54,6 +54,7 @@ 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 MyRecords = lazy(() => import('./pages/portal/MyRecords')) const RiskCenter = lazy(() => import('./pages/compliance/RiskCenter')) const SalaryDashboard = lazy(() => import('./pages/SalaryDashboard')) const CommercialInsurance = lazy(() => import('./pages/CommercialInsurance')) @@ -232,6 +233,7 @@ export default function App() { } /> } /> } /> + } /> {/* 兜底 */} } /> diff --git a/frontend/src/lib/api-services.ts b/frontend/src/lib/api-services.ts index 2169f55..bc863a4 100644 --- a/frontend/src/lib/api-services.ts +++ b/frontend/src/lib/api-services.ts @@ -1029,4 +1029,25 @@ export const portalApi = { /** 签署操作 */ signEsign: (id: string) => portalPost(`/esign/${id}/sign`).then(unwrap()), + /** 我的培训记录 */ + myTraining: () => + portalGet('/training').then(unwrap()), + /** 培训签收 */ + signTraining: (id: string) => + portalPost(`/training/${id}/sign`).then(unwrap()), + /** 培训拒绝签收 */ + refuseTraining: (id: string) => + portalPost(`/training/${id}/refuse`).then(unwrap()), + /** 我的绩效记录 */ + myPerformance: () => + portalGet('/performance').then(unwrap()), + /** 绩效签字 */ + signPerformance: (id: string) => + portalPost(`/performance/${id}/sign`).then(unwrap()), + /** 我的违纪记录 */ + myDisciplinary: () => + portalGet('/disciplinary').then(unwrap()), + /** 违纪签字 */ + signDisciplinary: (id: string) => + portalPost(`/disciplinary/${id}/sign`).then(unwrap()), } diff --git a/frontend/src/pages/portal/MyRecords.tsx b/frontend/src/pages/portal/MyRecords.tsx new file mode 100644 index 0000000..714c18f --- /dev/null +++ b/frontend/src/pages/portal/MyRecords.tsx @@ -0,0 +1,227 @@ +/** + * 员工端 — 培训/绩效/违纪记录查看与签收 + */ + +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { GraduationCap, TrendingUp, AlertTriangle, CheckCircle, Clock, XCircle, ArrowLeft } from 'lucide-react' +import { portalApi } from '../../lib/api-services' +import Button from '../../components/ui/Button' + +type Tab = 'training' | 'performance' | 'disciplinary' + +const TABS: { key: Tab; label: string; icon: any }[] = [ + { key: 'training', label: '培训记录', icon: GraduationCap }, + { key: 'performance', label: '绩效考核', icon: TrendingUp }, + { key: 'disciplinary', label: '违纪记录', icon: AlertTriangle }, +] + +const ACK_LABELS: Record = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' } +const ACK_COLORS: Record = { PENDING: 'text-amber-600', SIGNED: 'text-green-600', REFUSED: 'text-red-600' } + +const RESULT_LABELS: Record = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' } +const TYPE_LABELS: Record = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' } +const SEVERITY_LABELS: Record = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' } +const ACTION_LABELS: Record = { ORAL_WARNING: '口头警告', WRITTEN_WARNING: '书面警告', DEDUCTION: '扣款', DEMOTION: '降职', TERMINATION: '解除劳动合同' } + +function fmtDate(d: string | Date | null): string { + if (!d) return '-' + return new Date(d).toLocaleDateString('zh-CN') +} + +export default function MyRecords() { + const [tab, setTab] = useState('training') + + return ( +
+
+ {TABS.map(t => { + const Icon = t.icon + const active = tab === t.key + return ( + + ) + })} +
+ + {tab === 'training' && } + {tab === 'performance' && } + {tab === 'disciplinary' && } +
+ ) +} + +function TrainingTab() { + const queryClient = useQueryClient() + const { data: records, isLoading } = useQuery({ + queryKey: ['portal-training'], + queryFn: () => portalApi.myTraining(), + }) + + const signMut = useMutation({ + mutationFn: (id: string) => portalApi.signTraining(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portal-training'] }) + }, + }) + + const refuseMut = useMutation({ + mutationFn: (id: string) => portalApi.refuseTraining(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portal-training'] }) + }, + }) + + if (isLoading) return
加载中...
+ + return ( +
+ {(records || []).length === 0 ? ( +
暂无培训记录
+ ) : (records || []).map((r: any) => ( +
+
+
+ + {r.topic} +
+ + {ACK_LABELS[r.ackStatus] || r.ackStatus} + +
+
+
培训日期:{fmtDate(r.trainingDate)}
+
讲师:{r.trainer || '-'}
+
时长:{r.duration}小时
+
+ {r.content &&
{r.content}
} + {r.remark &&
备注:{r.remark}
} + {r.ackStatus === 'PENDING' && ( +
+ + +
+ )} + {r.ackDate &&
签收时间:{fmtDate(r.ackDate)}
} +
+ ))} +
+ ) +} + +function PerformanceTab() { + const queryClient = useQueryClient() + const { data: records, isLoading } = useQuery({ + queryKey: ['portal-performance'], + queryFn: () => portalApi.myPerformance(), + }) + + const signMut = useMutation({ + mutationFn: (id: string) => portalApi.signPerformance(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portal-performance'] }) + }, + }) + + if (isLoading) return
加载中...
+ + return ( +
+ {(records || []).length === 0 ? ( +
暂无绩效记录
+ ) : (records || []).map((r: any) => ( +
+
+
+ + {r.period} 绩效考核 +
+ + {r.employeeAck ? '已签字' : '待签字'} + +
+
+
得分:{r.score}
+
等级:{r.grade}
+
结果:{RESULT_LABELS[r.result] || r.result}
+
+ {r.summary &&
评语:{r.summary}
} + {r.improvementPlan &&
改进计划:{r.improvementPlan}
} + {r.reviewer &&
考评人:{r.reviewer}
} + {!r.employeeAck && ( +
+ +
+ )} + {r.ackDate &&
签字时间:{fmtDate(r.ackDate)}
} +
+ ))} +
+ ) +} + +function DisciplinaryTab() { + const queryClient = useQueryClient() + const { data: records, isLoading } = useQuery({ + queryKey: ['portal-disciplinary'], + queryFn: () => portalApi.myDisciplinary(), + }) + + const signMut = useMutation({ + mutationFn: (id: string) => portalApi.signDisciplinary(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['portal-disciplinary'] }) + }, + }) + + if (isLoading) return
加载中...
+ + return ( +
+ {(records || []).length === 0 ? ( +
暂无违纪记录
+ ) : (records || []).map((r: any) => ( +
+
+
+ + {TYPE_LABELS[r.violationType] || r.violationType} +
+ + {r.employeeAck ? '已签字' : '待签字'} + +
+
+
违纪日期:{fmtDate(r.violationDate)}
+
严重程度:{SEVERITY_LABELS[r.severity] || r.severity}
+
处理:{ACTION_LABELS[r.action] || r.action}
+
+
{r.description}
+ {r.actionDetail &&
处理详情:{r.actionDetail}
} + {r.witness &&
见证人:{r.witness}
} + {!r.employeeAck && ( +
+ +
+ )} + {r.ackDate &&
签字时间:{fmtDate(r.ackDate)}
} +
+ ))} +
+ ) +} diff --git a/frontend/src/pages/portal/PortalNav.tsx b/frontend/src/pages/portal/PortalNav.tsx index b2dee6b..02e58ed 100644 --- a/frontend/src/pages/portal/PortalNav.tsx +++ b/frontend/src/pages/portal/PortalNav.tsx @@ -3,13 +3,14 @@ */ import { Link, useLocation, useNavigate } from 'react-router-dom' -import { DollarSign, FileText, ScrollText, LogOut, PenTool } from 'lucide-react' +import { DollarSign, FileText, ScrollText, LogOut, PenTool, ClipboardList } 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 }, + { path: '/portal/records', label: '我的记录', icon: ClipboardList }, ] export default function PortalNav() { diff --git a/frontend/src/pages/roster/DisciplinaryRecords.tsx b/frontend/src/pages/roster/DisciplinaryRecords.tsx index bc5322e..122d5e6 100644 --- a/frontend/src/pages/roster/DisciplinaryRecords.tsx +++ b/frontend/src/pages/roster/DisciplinaryRecords.tsx @@ -193,7 +193,6 @@ function DisciplinaryForm({ employees, record, onSubmit, onClose }: { severity: record?.severity || 'WARNING', action: record?.action || 'ORAL_WARNING', actionDetail: record?.actionDetail || '', - employeeAck: record?.employeeAck || false, witness: record?.witness || '', }) @@ -263,10 +262,18 @@ function DisciplinaryForm({ employees, record, onSubmit, onClose }: { setForm({ ...form, witness: e.target.value })} placeholder="见证人(选填)" /> - + {record && ( +
+ +
+ {record.employeeAck ? ( + 已签字({record.ackDate ? new Date(record.ackDate).toLocaleDateString('zh-CN') : ''}) + ) : ( + 待签字 由员工在员工端签字确认 + )} +
+
+ )}
diff --git a/frontend/src/pages/roster/PerformanceRecords.tsx b/frontend/src/pages/roster/PerformanceRecords.tsx index ea9f947..1529a9d 100644 --- a/frontend/src/pages/roster/PerformanceRecords.tsx +++ b/frontend/src/pages/roster/PerformanceRecords.tsx @@ -99,14 +99,15 @@ export default function PerformanceRecords() { 等级 结果 考评人 + 签字 操作 {isLoading ? ( - 加载中... + 加载中... ) : records.length === 0 ? ( - 暂无绩效记录 + 暂无绩效记录 ) : records.map((r: any) => ( @@ -122,6 +123,13 @@ export default function PerformanceRecords() { {r.reviewer || '-'} + + {r.employeeAck ? ( + 已签字 + ) : ( + 待签字 + )} +
+ {record && ( +
+ +
+ {record.employeeAck ? ( + 已签字({record.ackDate ? new Date(record.ackDate).toLocaleDateString('zh-CN') : ''}) + ) : ( + 待签字 由员工在员工端签字确认 + )} +
+
+ )}
diff --git a/frontend/src/pages/roster/TrainingRecords.tsx b/frontend/src/pages/roster/TrainingRecords.tsx index e9523da..6606d1e 100644 --- a/frontend/src/pages/roster/TrainingRecords.tsx +++ b/frontend/src/pages/roster/TrainingRecords.tsx @@ -197,7 +197,6 @@ function TrainingForm({ employees, record, onSubmit, onClose }: { content: record?.content || '', trainer: record?.trainer || '', duration: record?.duration || 0, - ackStatus: record?.ackStatus || 'PENDING', remark: record?.remark || '', }) @@ -242,14 +241,17 @@ function TrainingForm({ employees, record, onSubmit, onClose }: { setForm({ ...form, duration: Number(e.target.value) })} />
-
- - -
+ {record && ( +
+ +
+ + {ACK_LABELS[record.ackStatus] || record.ackStatus} + + 由员工在员工端签收 +
+
+ )}
setForm({ ...form, remark: e.target.value })} placeholder="备注" />