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