Files
TurboHR/frontend/src/pages/roster/PerformanceRecords.tsx
T
selfrelease f523f84c18 ux: 全部页面添加操作说明 PageGuide
为以下17个缺少操作说明的页面添加 PageGuide 组件:
- OrgChart: 组织架构管理
- SupportDashboard: 客服工作台
- Money: 薪酬管理
- AIAssistant: AI 智能助手
- Settings: 系统设置
- Calendar: 人事日历
- Templates: 模板管理
- AuditLog: 审计日志
- Notifications: 通知中心
- MedicalPeriodCalculator: 医疗期计算器
- HealthCheck: 用工健康检查
- AnnualValueReport: 年度价值报表
- CompanyFiles: 公司文件管理
- LeaveApproval: 请假审批
- TrainingRecords: 培训记录
- PerformanceRecords: 绩效记录
- DisciplinaryRecords: 违纪记录

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-08-15 15:26:02 +08:00

598 lines
26 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 { Link } from 'react-router-dom'
import { Search, Plus, Edit2, Trash2, X, LayoutTemplate } from 'lucide-react'
import { toast } from 'sonner'
import { rosterApi, employeeApi } from '../../lib/api-services'
import api from '../../lib/api'
import { usePageSize } from '../../hooks/usePageSize'
import { Input, Label, Select } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
import PageGuide from '../../components/ui/PageGuide'
const RESULT_LABELS: Record<string, string> = { EXCELLENT: '优秀', QUALIFIED: '合格', NEED_IMPROVE: '需改进', UNQUALIFIED: '不胜任' }
const RESULT_COLORS: Record<string, string> = { EXCELLENT: 'bg-green-50 text-green-700', QUALIFIED: 'bg-blue-50 text-blue-700', NEED_IMPROVE: 'bg-amber-50 text-amber-700', UNQUALIFIED: 'bg-red-50 text-red-700' }
export default function PerformanceRecords() {
const queryClient = useQueryClient()
const pageSize = usePageSize()
const [page, setPage] = useState(1)
const [keyword, setKeyword] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editRecord, setEditRecord] = useState<any>(null)
const [showTemplateModal, setShowTemplateModal] = useState(false)
const { data, isLoading } = useQuery({
queryKey: ['performance-list', page, pageSize, keyword],
queryFn: () => rosterApi.performanceList({ page, pageSize, keyword }),
})
const { data: employees } = useQuery({
queryKey: ['employees-active'],
queryFn: () => employeeApi.list({ status: 'ACTIVE' }),
})
const { data: templates } = useQuery({
queryKey: ['performance-templates'],
queryFn: () => rosterApi.performanceTemplates(),
})
const saveMut = useMutation({
mutationFn: (data: any) => {
const empId = data.employeeId
delete data.employeeId
const isEdit = !!data.recordId
const recordId = data.recordId
delete data.recordId
if (isEdit) {
return api.put(`/roster/${empId}/performance/${recordId}`, data)
}
return api.post(`/roster/${empId}/performance`, data)
},
onSuccess: () => {
toast.success('绩效记录已保存')
queryClient.invalidateQueries({ queryKey: ['performance-list'] })
setShowCreate(false)
setEditRecord(null)
},
onError: () => toast.error('保存失败'),
})
const deleteMut = useMutation({
mutationFn: ({ employeeId, recordId }: { employeeId: string; recordId: string }) =>
api.delete(`/roster/${employeeId}/performance/${recordId}`),
onSuccess: () => {
toast.success('记录已删除')
queryClient.invalidateQueries({ queryKey: ['performance-list'] })
},
})
const records = data?.records || []
const total = data?.total || 0
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-4">
<PageGuide> </PageGuide>
<div className="flex items-center justify-between">
<div>
<h1 className="text-base font-semibold"></h1>
<p className="mt-1 text-sm text-gray-500"></p>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={() => setShowTemplateModal(true)}>
<LayoutTemplate className="w-4 h-4 mr-1" />
</Button>
<Button size="sm" onClick={() => setShowCreate(true)}>
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<Input
value={keyword}
onChange={(e) => { setKeyword(e.target.value); setPage(1) }}
placeholder="搜索员工姓名"
className="pl-9"
/>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-xs text-gray-500">
<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>
<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={9} className="py-8 text-center text-gray-400">...</td></tr>
) : records.length === 0 ? (
<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">
<Link to={`/roster?employeeId=${r.employeeId}`} className="text-primary hover:underline">{r.employee?.name}</Link>
</td>
<td className="py-2 pr-4 text-gray-600">{r.employee?.department || '-'}</td>
<td className="py-2 pr-4">{r.period}</td>
<td className="py-2 pr-4">{r.score}</td>
<td className="py-2 pr-4">{r.grade}</td>
<td className="py-2 pr-4">
<span className={`inline-block px-2 py-0.5 rounded text-xs ${RESULT_COLORS[r.result] || 'bg-gray-50 text-gray-600'}`}>
{RESULT_LABELS[r.result] || r.result}
</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">
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
</button>
<button
onClick={() => { if (confirm('确认删除?')) deleteMut.mutate({ employeeId: r.employeeId, recordId: r.id }) }}
className="p-1 hover:bg-gray-100 rounded"
>
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500"> {total} </span>
<div className="flex gap-1">
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}></Button>
<span className="px-3 py-1 text-xs text-gray-500">{page} / {totalPages}</span>
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)}></Button>
</div>
</div>
)}
{(showCreate || editRecord) && (
<PerformanceForm
employees={employees || []}
templates={templates || []}
record={editRecord}
onSubmit={(data) => saveMut.mutate(data)}
onClose={() => { setShowCreate(false); setEditRecord(null) }}
/>
)}
{showTemplateModal && (
<TemplateModal
templates={templates || []}
onClose={() => setShowTemplateModal(false)}
/>
)}
</div>
)
}
function PerformanceForm({ employees, templates, record, onSubmit, onClose }: {
employees: any[]
templates: any[]
record: any
onSubmit: (data: any) => void
onClose: () => void
}) {
const detectPeriodType = (period: string, fallback?: string): string => {
if (/^\d{4}-Q[1-4]$/.test(period)) return 'QUARTERLY'
if (/^\d{4}$/.test(period)) return 'YEARLY'
if (/^\d{4}-\d{2}$/.test(period)) return 'MONTHLY'
return fallback || 'MONTHLY'
}
const initialPeriodType = record ? detectPeriodType(record.period, record.periodType) : 'MONTHLY'
const [form, setForm] = useState({
employeeId: record?.employeeId || '',
period: record?.period || new Date().toISOString().slice(0, 7),
periodType: initialPeriodType,
score: record?.score || 80,
grade: record?.grade || 'B',
result: record?.result || 'QUALIFIED',
summary: record?.summary || '',
improvementPlan: record?.improvementPlan || '',
reviewer: record?.reviewer || '',
templateId: record?.templateId || '',
})
const [dimensionScores, setDimensionScores] = useState<Record<string, number>>(record?.dimensionScores || {})
const selectedTemplate = templates.find((t: any) => t.id === form.templateId)
const dimensions: any[] = selectedTemplate?.dimensions || []
const scoreToGrade = (score: number): { grade: string; result: string } => {
if (score >= 90) return { grade: 'A', result: 'EXCELLENT' }
if (score >= 80) return { grade: 'B', result: 'QUALIFIED' }
if (score >= 60) return { grade: 'C', result: 'NEED_IMPROVE' }
return { grade: 'D', result: 'UNQUALIFIED' }
}
const handleScoreChange = (score: number) => {
const { grade, result } = scoreToGrade(score)
setForm({ ...form, score, grade, result })
}
const handleDimensionChange = (name: string, score: number) => {
const updated = { ...dimensionScores, [name]: score }
setDimensionScores(updated)
// 按权重计算总分
if (dimensions.length > 0) {
const totalScore = dimensions.reduce((sum: number, d: any) => {
const s = updated[d.name] ?? 0
const weight = d.weight || 0
const maxScore = d.maxScore || 100
return sum + (s / maxScore) * weight * 100
}, 0)
const { grade, result } = scoreToGrade(Math.round(totalScore))
setForm(prev => ({ ...prev, score: Math.round(totalScore), grade, result }))
}
}
const handleSubmit = () => {
const data: any = { ...form }
if (form.templateId) {
data.templateId = form.templateId
data.dimensionScores = dimensionScores
}
onSubmit(data)
}
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium">{record ? '编辑绩效记录' : '新增绩效记录'}</h3>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
</div>
<div className="space-y-3">
<div>
<Label></Label>
<Select value={form.periodType} onChange={(e) => {
const newType = e.target.value
let newPeriod = form.period
const year = form.period.slice(0, 4) || new Date().toISOString().slice(0, 4)
if (newType === 'MONTHLY') newPeriod = year + '-' + (new Date().getMonth() + 1).toString().padStart(2, '0')
else if (newType === 'QUARTERLY') newPeriod = year + '-Q1'
else if (newType === 'YEARLY') newPeriod = year
setForm({ ...form, periodType: newType, period: newPeriod })
}}>
<option value="MONTHLY"></option>
<option value="QUARTERLY"></option>
<option value="YEARLY"></option>
</Select>
</div>
{!record && (
<div>
<Label></Label>
<Select value={form.employeeId} onChange={(e) => setForm({ ...form, employeeId: e.target.value })}>
<option value=""></option>
{employees.map((emp: any) => (
<option key={emp.id} value={emp.id}>{emp.name} - {emp.department || ''}</option>
))}
</Select>
</div>
)}
<div>
<Label></Label>
<Input
type={form.periodType === 'YEARLY' ? 'number' : form.periodType === 'QUARTERLY' ? 'text' : 'month'}
value={form.period}
onChange={(e) => setForm({ ...form, period: e.target.value })}
placeholder={form.periodType === 'YEARLY' ? '如 2026' : form.periodType === 'QUARTERLY' ? '如 2026-Q1' : undefined}
/>
</div>
<div>
<Label></Label>
<Select value={form.templateId} onChange={(e) => { setForm({ ...form, templateId: e.target.value }); setDimensionScores({}) }}>
<option value="">使</option>
{templates.map((t: any) => (
<option key={t.id} value={t.id}>{t.name}{t.isDefault ? '(默认)' : ''}</option>
))}
</Select>
</div>
{dimensions.length > 0 ? (
<div className="border border-gray-200 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-gray-600"></div>
{dimensions.map((d: any) => (
<div key={d.name} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-5">
<span className="text-sm">{d.name}</span>
{d.description && <span className="text-xs text-gray-400 ml-1">({d.description})</span>}
<span className="text-xs text-gray-400 ml-1">{d.weight}%</span>
</div>
<div className="col-span-4">
<Input type="number" min={0} max={d.maxScore || 100} value={dimensionScores[d.name] ?? ''} onChange={(e) => handleDimensionChange(d.name, Number(e.target.value))} placeholder={`满分${d.maxScore || 100}`} className="text-sm" />
</div>
<div className="col-span-3 text-xs text-gray-400">/{d.maxScore || 100}</div>
</div>
))}
<div className="text-xs text-gray-500 pt-1 border-t"></div>
</div>
) : (
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" min={0} max={100} value={form.score} onChange={(e) => handleScoreChange(Number(e.target.value))} />
</div>
<div>
<Label></Label>
<Select value={form.grade} onChange={(e) => setForm({ ...form, grade: e.target.value })}>
<option value="A">A</option>
<option value="B">B</option>
<option value="C">C</option>
<option value="D">D</option>
</Select>
</div>
</div>
)}
{dimensions.length > 0 && (
<div className="grid grid-cols-2 gap-3">
<div>
<Label></Label>
<Input type="number" value={form.score} readOnly className="bg-gray-50" />
</div>
<div>
<Label></Label>
<Input value={form.grade} readOnly className="bg-gray-50" />
</div>
</div>
)}
<div>
<Label></Label>
<Select value={form.result} onChange={(e) => setForm({ ...form, result: e.target.value })}>
<option value="EXCELLENT"></option>
<option value="QUALIFIED"></option>
<option value="NEED_IMPROVE"></option>
<option value="UNQUALIFIED"></option>
</Select>
</div>
<div>
<Label> *</Label>
<Input value={form.reviewer} onChange={(e) => setForm({ ...form, reviewer: e.target.value })} placeholder="请输入考评人姓名" />
</div>
<div>
<Label></Label>
<Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} placeholder="考核评语" />
</div>
<div>
<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={handleSubmit} disabled={!form.employeeId || !form.period || !form.reviewer}></Button>
</div>
</div>
</div>
</div>
)
}
function TemplateModal({ templates, onClose }: {
templates: any[]
onClose: () => void
}) {
const queryClient = useQueryClient()
const [editing, setEditing] = useState<any>(null)
const [showForm, setShowForm] = useState(false)
const createMut = useMutation({
mutationFn: (data: any) => rosterApi.createPerformanceTemplate(data),
onSuccess: () => {
toast.success('模板已创建')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
setShowForm(false)
},
onError: () => toast.error('创建失败'),
})
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => rosterApi.updatePerformanceTemplate(id, data),
onSuccess: () => {
toast.success('模板已更新')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
setShowForm(false)
setEditing(null)
},
onError: () => toast.error('更新失败'),
})
const deleteMut = useMutation({
mutationFn: (id: string) => rosterApi.deletePerformanceTemplate(id),
onSuccess: () => {
toast.success('模板已删除')
queryClient.invalidateQueries({ queryKey: ['performance-templates'] })
},
})
return (
<div className="fixed inset-0 bg-black/30 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="font-medium"></h3>
<div className="flex gap-2">
<Button size="sm" onClick={() => { setEditing(null); setShowForm(true) }}>
<Plus className="w-4 h-4 mr-1" />
</Button>
<button onClick={onClose}><X className="w-4 h-4 text-gray-400" /></button>
</div>
</div>
{showForm ? (
<TemplateForm
template={editing}
onSubmit={(data) => {
if (editing) {
updateMut.mutate({ id: editing.id, data })
} else {
createMut.mutate(data)
}
}}
onClose={() => { setShowForm(false); setEditing(null) }}
/>
) : (
<div className="space-y-2">
{templates.length === 0 ? (
<div className="text-center py-8 text-gray-400 text-sm">
</div>
) : templates.map((t: any) => (
<div key={t.id} className="border border-gray-200 rounded-md p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{t.name}</span>
{t.isDefault && <span className="text-xs px-1.5 py-0.5 rounded bg-primary/10 text-primary"></span>}
</div>
<div className="flex gap-1">
<button onClick={() => { setEditing(t); setShowForm(true) }} className="p-1 hover:bg-gray-100 rounded">
<Edit2 className="w-3.5 h-3.5 text-gray-500" />
</button>
<button
onClick={() => { if (confirm('确认删除此模板?')) deleteMut.mutate(t.id) }}
className="p-1 hover:bg-gray-100 rounded"
>
<Trash2 className="w-3.5 h-3.5 text-red-400" />
</button>
</div>
</div>
{t.description && <p className="text-xs text-gray-500 mt-1">{t.description}</p>}
<div className="flex flex-wrap gap-1 mt-2">
{(t.dimensions as any[]).map((d: any) => (
<span key={d.name} className="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600">
{d.name}{d.weight}%
</span>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
function TemplateForm({ template, onSubmit, onClose }: {
template: any
onSubmit: (data: any) => void
onClose: () => void
}) {
const [name, setName] = useState(template?.name || '')
const [description, setDescription] = useState(template?.description || '')
const [isDefault, setIsDefault] = useState(template?.isDefault || false)
const [dimensions, setDimensions] = useState<any[]>(
template?.dimensions || [{ name: '', weight: 100, maxScore: 100, description: '' }]
)
const addDimension = () => {
setDimensions([...dimensions, { name: '', weight: 0, maxScore: 100, description: '' }])
}
const removeDimension = (idx: number) => {
setDimensions(dimensions.filter((_, i) => i !== idx))
}
const updateDimension = (idx: number, field: string, value: any) => {
setDimensions(dimensions.map((d, i) => i === idx ? { ...d, [field]: value } : d))
}
const totalWeight = dimensions.reduce((sum, d) => sum + (Number(d.weight) || 0), 0)
const canSubmit = name && dimensions.every(d => d.name) && totalWeight === 100
return (
<div className="space-y-3">
<div>
<Label> *</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:月度绩效考核表" />
</div>
<div>
<Label></Label>
<Input value={description} onChange={(e) => setDescription(e.target.value)} placeholder="模板用途说明(选填)" />
</div>
<div>
<Label> *</Label>
<div className="space-y-2">
{dimensions.map((d, idx) => (
<div key={idx} className="grid grid-cols-12 gap-2 items-center border border-gray-200 rounded p-2">
<div className="col-span-3">
<Input value={d.name} onChange={(e) => updateDimension(idx, 'name', e.target.value)} placeholder="维度名称" className="text-sm" />
</div>
<div className="col-span-2">
<Input type="number" min={0} max={100} value={d.weight} onChange={(e) => updateDimension(idx, 'weight', Number(e.target.value))} placeholder="权重%" className="text-sm" />
</div>
<div className="col-span-2">
<Input type="number" min={1} value={d.maxScore} onChange={(e) => updateDimension(idx, 'maxScore', Number(e.target.value))} placeholder="满分" className="text-sm" />
</div>
<div className="col-span-4">
<Input value={d.description || ''} onChange={(e) => updateDimension(idx, 'description', e.target.value)} placeholder="说明(选填)" className="text-sm" />
</div>
<div className="col-span-1">
{dimensions.length > 1 && (
<button onClick={() => removeDimension(idx)} className="p-1 hover:bg-gray-100 rounded">
<X className="w-3.5 h-3.5 text-red-400" />
</button>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-2">
<button onClick={addDimension} className="text-xs text-primary hover:underline">+ </button>
<span className={`text-xs ${totalWeight === 100 ? 'text-green-600' : 'text-amber-600'}`}>{totalWeight}%</span>
</div>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={isDefault} onChange={(e) => setIsDefault(e.target.checked)} />
</label>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" size="sm" onClick={onClose}></Button>
<Button size="sm" onClick={() => onSubmit({ name, description, dimensions, isDefault })} disabled={!canSubmit}>
</Button>
</div>
{!canSubmit && totalWeight !== 100 && (
<div className="text-xs text-amber-600">100%</div>
)}
</div>
)
}