Files
TurboHR/frontend/src/pages/portal/MyRecords.tsx
T
freedakgmail 3afb383172 feat: 培训/绩效/违纪员工端签收+管理端签字状态只读化
- 后端: portal新增培训/绩效/违纪列表查看+签收接口,签收时创建证据链
- 管理端: 弹窗去掉HR手动签字勾选,改为只读显示签字状态
- 绩效列表新增签字状态列
- 员工端: 新增MyRecords页面,支持培训签收/拒绝、绩效签字、违纪签字
- 员工端导航新增'我的记录'入口
- EvidenceCategory新增TRAINING/PERFORMANCE类型
2026-08-05 08:39:04 +08:00

228 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 员工端 — 培训/绩效/违纪记录查看与签收
*/
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>
)
}