f523f84c18
为以下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>
598 lines
26 KiB
TypeScript
598 lines
26 KiB
TypeScript
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>
|
||
)
|
||
}
|