feat: 培训/绩效/违纪员工端签收+管理端签字状态只读化
- 后端: portal新增培训/绩效/违纪列表查看+签收接口,签收时创建证据链 - 管理端: 弹窗去掉HR手动签字勾选,改为只读显示签字状态 - 绩效列表新增签字状态列 - 员工端: 新增MyRecords页面,支持培训签收/拒绝、绩效签字、违纪签字 - 员工端导航新增'我的记录'入口 - EvidenceCategory新增TRAINING/PERFORMANCE类型
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -12,6 +12,8 @@ export type EvidenceCategory =
|
||||
| 'DISCIPLINARY'
|
||||
| 'ATTENDANCE'
|
||||
| 'TERMINATION'
|
||||
| 'TRAINING'
|
||||
| 'PERFORMANCE'
|
||||
|
||||
/**
|
||||
* 创建证据链记录
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>()),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
{record && (
|
||||
<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 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="备注" />
|
||||
|
||||
Reference in New Issue
Block a user