3afb383172
- 后端: portal新增培训/绩效/违纪列表查看+签收接口,签收时创建证据链 - 管理端: 弹窗去掉HR手动签字勾选,改为只读显示签字状态 - 绩效列表新增签字状态列 - 员工端: 新增MyRecords页面,支持培训签收/拒绝、绩效签字、违纪签字 - 员工端导航新增'我的记录'入口 - EvidenceCategory新增TRAINING/PERFORMANCE类型
228 lines
9.4 KiB
TypeScript
228 lines
9.4 KiB
TypeScript
/**
|
||
* 员工端 — 培训/绩效/违纪记录查看与签收
|
||
*/
|
||
|
||
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>
|
||
)
|
||
}
|