feat: 培训/绩效/违纪员工端签收+管理端签字状态只读化

- 后端: portal新增培训/绩效/违纪列表查看+签收接口,签收时创建证据链
- 管理端: 弹窗去掉HR手动签字勾选,改为只读显示签字状态
- 绩效列表新增签字状态列
- 员工端: 新增MyRecords页面,支持培训签收/拒绝、绩效签字、违纪签字
- 员工端导航新增'我的记录'入口
- EvidenceCategory新增TRAINING/PERFORMANCE类型
This commit is contained in:
freedakgmail
2026-08-05 08:39:04 +08:00
parent 15dde27701
commit 3afb383172
9 changed files with 437 additions and 18 deletions
+138
View File
@@ -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
+2
View File
@@ -12,6 +12,8 @@ export type EvidenceCategory =
| 'DISCIPLINARY'
| 'ATTENDANCE'
| 'TERMINATION'
| 'TRAINING'
| 'PERFORMANCE'
/**
* 创建证据链记录
+2
View File
@@ -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() {
<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="/portal/records" element={<PortalLayoutWrapper><MyRecords /></PortalLayoutWrapper>} />
{/* 兜底 */}
<Route path="*" element={<Navigate to="/" replace />} />
+21
View File
@@ -1029,4 +1029,25 @@ export const portalApi = {
/** 签署操作 */
signEsign: (id: string) =>
portalPost(`/esign/${id}/sign`).then(unwrap<any>()),
/** 我的培训记录 */
myTraining: () =>
portalGet('/training').then(unwrap<any[]>()),
/** 培训签收 */
signTraining: (id: string) =>
portalPost(`/training/${id}/sign`).then(unwrap<any>()),
/** 培训拒绝签收 */
refuseTraining: (id: string) =>
portalPost(`/training/${id}/refuse`).then(unwrap<any>()),
/** 我的绩效记录 */
myPerformance: () =>
portalGet('/performance').then(unwrap<any[]>()),
/** 绩效签字 */
signPerformance: (id: string) =>
portalPost(`/performance/${id}/sign`).then(unwrap<any>()),
/** 我的违纪记录 */
myDisciplinary: () =>
portalGet('/disciplinary').then(unwrap<any[]>()),
/** 违纪签字 */
signDisciplinary: (id: string) =>
portalPost(`/disciplinary/${id}/sign`).then(unwrap<any>()),
}
+227
View File
@@ -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<string, string> = { PENDING: '待签收', SIGNED: '已签收', REFUSED: '拒绝签收' }
const ACK_COLORS: Record<string, string> = { PENDING: 'text-amber-600', SIGNED: 'text-green-600', REFUSED: 'text-red-600' }
const RESULT_LABELS: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
const TYPE_LABELS: Record<string, string> = { LATE: '迟到', ABSENT: '旷工', INSUBORDINATION: '不服从管理', MISCONDUCT: '违纪', VIOLATE_POLICY: '违反规章制度', OTHER: '其他' }
const SEVERITY_LABELS: Record<string, string> = { WARNING: '警告', SERIOUS: '严重', SEVERE: '极其严重' }
const ACTION_LABELS: Record<string, string> = { 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<Tab>('training')
return (
<div>
<div className="flex items-center gap-3 mb-4">
{TABS.map(t => {
const Icon = t.icon
const active = tab === t.key
return (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors ${active ? 'bg-primary text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}
>
<Icon className="w-4 h-4" />
{t.label}
</button>
)
})}
</div>
{tab === 'training' && <TrainingTab />}
{tab === 'performance' && <PerformanceTab />}
{tab === 'disciplinary' && <DisciplinaryTab />}
</div>
)
}
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 <div className="py-8 text-center text-gray-400">...</div>
return (
<div className="space-y-3">
{(records || []).length === 0 ? (
<div className="py-8 text-center text-gray-400"></div>
) : (records || []).map((r: any) => (
<div key={r.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GraduationCap className="w-4 h-4 text-primary" />
<span className="font-medium">{r.topic}</span>
</div>
<span className={`text-xs ${ACK_COLORS[r.ackStatus] || 'text-gray-500'}`}>
{ACK_LABELS[r.ackStatus] || r.ackStatus}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-500">
<div>{fmtDate(r.trainingDate)}</div>
<div>{r.trainer || '-'}</div>
<div>{r.duration}</div>
</div>
{r.content && <div className="text-sm text-gray-600">{r.content}</div>}
{r.remark && <div className="text-xs text-gray-400">{r.remark}</div>}
{r.ackStatus === 'PENDING' && (
<div className="flex gap-2 pt-2">
<Button size="sm" onClick={() => signMut.mutate(r.id)} disabled={signMut.isPending}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
</Button>
<Button size="sm" variant="secondary" onClick={() => refuseMut.mutate(r.id)} disabled={refuseMut.isPending}>
<XCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
)}
{r.ackDate && <div className="text-xs text-gray-400">{fmtDate(r.ackDate)}</div>}
</div>
))}
</div>
)
}
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 <div className="py-8 text-center text-gray-400">...</div>
return (
<div className="space-y-3">
{(records || []).length === 0 ? (
<div className="py-8 text-center text-gray-400"></div>
) : (records || []).map((r: any) => (
<div key={r.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-primary" />
<span className="font-medium">{r.period} </span>
</div>
<span className={`text-xs ${r.employeeAck ? 'text-green-600' : 'text-amber-600'}`}>
{r.employeeAck ? '已签字' : '待签字'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-500">
<div>{r.score}</div>
<div>{r.grade}</div>
<div>{RESULT_LABELS[r.result] || r.result}</div>
</div>
{r.summary && <div className="text-sm text-gray-600">{r.summary}</div>}
{r.improvementPlan && <div className="text-sm text-orange-600">{r.improvementPlan}</div>}
{r.reviewer && <div className="text-xs text-gray-400">{r.reviewer}</div>}
{!r.employeeAck && (
<div className="pt-2">
<Button size="sm" onClick={() => signMut.mutate(r.id)} disabled={signMut.isPending}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
)}
{r.ackDate && <div className="text-xs text-gray-400">{fmtDate(r.ackDate)}</div>}
</div>
))}
</div>
)
}
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 <div className="py-8 text-center text-gray-400">...</div>
return (
<div className="space-y-3">
{(records || []).length === 0 ? (
<div className="py-8 text-center text-gray-400"></div>
) : (records || []).map((r: any) => (
<div key={r.id} className="border rounded-lg p-4 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-400" />
<span className="font-medium">{TYPE_LABELS[r.violationType] || r.violationType}</span>
</div>
<span className={`text-xs ${r.employeeAck ? 'text-green-600' : 'text-amber-600'}`}>
{r.employeeAck ? '已签字' : '待签字'}
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-500">
<div>{fmtDate(r.violationDate)}</div>
<div>{SEVERITY_LABELS[r.severity] || r.severity}</div>
<div>{ACTION_LABELS[r.action] || r.action}</div>
</div>
<div className="text-sm text-gray-600">{r.description}</div>
{r.actionDetail && <div className="text-xs text-gray-500">{r.actionDetail}</div>}
{r.witness && <div className="text-xs text-gray-400">{r.witness}</div>}
{!r.employeeAck && (
<div className="pt-2">
<Button size="sm" onClick={() => signMut.mutate(r.id)} disabled={signMut.isPending}>
<CheckCircle className="w-3.5 h-3.5 mr-1" />
</Button>
</div>
)}
{r.ackDate && <div className="text-xs text-gray-400">{fmtDate(r.ackDate)}</div>}
</div>
))}
</div>
)
}
+2 -1
View File
@@ -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() {
@@ -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 }: {
<Label></Label>
<Input value={form.witness} onChange={(e) => setForm({ ...form, witness: e.target.value })} placeholder="见证人(选填)" />
</div>
<label className="flex items-center gap-2">
<input type="checkbox" checked={form.employeeAck} onChange={(e) => setForm({ ...form, employeeAck: e.target.checked })} />
<span className="text-sm"></span>
</label>
{record && (
<div>
<Label></Label>
<div className="text-sm text-gray-600">
{record.employeeAck ? (
<span className="text-green-600">{record.ackDate ? new Date(record.ackDate).toLocaleDateString('zh-CN') : ''}</span>
) : (
<span className="text-amber-600"> <span className="text-xs text-gray-400 ml-1"></span></span>
)}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => onSubmit(form)} disabled={!form.employeeId || !form.description}></Button>
@@ -99,14 +99,15 @@ export default function PerformanceRecords() {
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
<th className="pb-2 pr-4 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr><td colSpan={8} className="py-8 text-center text-gray-400">...</td></tr>
<tr><td colSpan={9} className="py-8 text-center text-gray-400">...</td></tr>
) : records.length === 0 ? (
<tr><td colSpan={8} className="py-8 text-center text-gray-400"></td></tr>
<tr><td colSpan={9} className="py-8 text-center text-gray-400"></td></tr>
) : records.map((r: any) => (
<tr key={r.id} className="border-b hover:bg-gray-50">
<td className="py-2 pr-4">
@@ -122,6 +123,13 @@ export default function PerformanceRecords() {
</span>
</td>
<td className="py-2 pr-4 text-gray-600">{r.reviewer || '-'}</td>
<td className="py-2 pr-4">
{r.employeeAck ? (
<span className="text-xs text-green-600"></span>
) : (
<span className="text-xs text-amber-600"></span>
)}
</td>
<td className="py-2 pr-4">
<div className="flex gap-1">
<button onClick={() => setEditRecord(r)} className="p-1 hover:bg-gray-100 rounded">
@@ -179,7 +187,6 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
summary: record?.summary || '',
improvementPlan: record?.improvementPlan || '',
reviewer: record?.reviewer || '',
employeeAck: record?.employeeAck || false,
})
return (
@@ -241,6 +248,18 @@ function PerformanceForm({ employees, record, onSubmit, onClose }: {
<Label></Label>
<Input value={form.improvementPlan} onChange={(e) => setForm({ ...form, improvementPlan: e.target.value })} placeholder="改进计划(选填)" />
</div>
{record && (
<div>
<Label></Label>
<div className="text-sm text-gray-600">
{record.employeeAck ? (
<span className="text-green-600">{record.ackDate ? new Date(record.ackDate).toLocaleDateString('zh-CN') : ''}</span>
) : (
<span className="text-amber-600"> <span className="text-xs text-gray-400 ml-1"></span></span>
)}
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => onSubmit(form)} disabled={!form.employeeId || !form.period}></Button>
+11 -9
View File
@@ -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 }: {
<Input type="number" min={0} step={0.5} value={form.duration} onChange={(e) => setForm({ ...form, duration: Number(e.target.value) })} />
</div>
</div>
<div>
<Label></Label>
<Select value={form.ackStatus} onChange={(e) => setForm({ ...form, ackStatus: e.target.value })}>
<option value="PENDING"></option>
<option value="SIGNED"></option>
<option value="REFUSED"></option>
</Select>
</div>
{record && (
<div>
<Label></Label>
<div className="text-sm text-gray-600">
<span className={`inline-block px-2 py-0.5 rounded text-xs ${ACK_COLORS[record.ackStatus] || 'bg-gray-50 text-gray-600'}`}>
{ACK_LABELS[record.ackStatus] || record.ackStatus}
</span>
<span className="ml-2 text-xs text-gray-400"></span>
</div>
</div>
)}
<div>
<Label></Label>
<Input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} placeholder="备注" />