Files
performance-evaluation-system/frontend/src/pages/manager/PerformanceReview.tsx
2026-04-11 11:51:54 +08:00

453 lines
17 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 React, { useEffect, useState } from 'react';
import {
Card,
Descriptions,
Tag,
Button,
Space,
Typography,
message,
Spin,
Row,
Col,
Divider,
Form,
InputNumber,
Input,
Modal,
Alert,
List,
} from 'antd';
import {
CheckOutlined,
CloseOutlined,
ArrowLeftOutlined,
} from '@ant-design/icons';
import { useParams, useNavigate } from 'react-router-dom';
import { PerformanceRecord } from '../../api/performance';
import http from '../../api/http';
import { useIsMobile } from '../../hooks/useBreakpoint';
import dayjs from 'dayjs';
const { Title, Text } = Typography;
const { TextArea } = Input;
const STATUS_MAP: Record<string, { label: string; color: string }> = {
draft: { label: '草稿', color: 'default' },
submitted: { label: '已提交', color: 'processing' },
under_review: { label: '审核中', color: 'warning' },
completed: { label: '已完成', color: 'success' },
rejected: { label: '已驳回', color: 'error' },
};
const LEVEL_MAP: Record<string, { label: string; color: string }> = {
excellent: { label: '优秀', color: 'gold' },
qualified: { label: '合格', color: 'green' },
need_motivation: { label: '需激励', color: 'orange' },
unqualified: { label: '不合格', color: 'red' },
};
interface ItemScoreForm {
managerScore: number;
scoreExplanation: string;
}
interface ReviewFormData {
reviewOpinion: string;
itemScores: Record<string, ItemScoreForm>;
}
const PerformanceReview: React.FC = () => {
const { perfId } = useParams<{ perfId: string }>();
const navigate = useNavigate();
const isMobile = useIsMobile();
const [form] = Form.useForm<ReviewFormData>();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [record, setRecord] = useState<PerformanceRecord | null>(null);
const [rejectModalOpen, setRejectModalOpen] = useState(false);
const [rejectReason, setRejectReason] = useState('');
useEffect(() => {
loadDetail();
}, [perfId]);
const loadDetail = async () => {
if (!perfId) return;
setLoading(true);
try {
const { data } = await http.get(
`/api/performance/manager/detail/${perfId}`
);
const record = data.data || data;
setRecord(record);
// Initialize form with existing manager scores if available
const itemScores: Record<string, ItemScoreForm> = {};
data.performanceItems?.forEach((item) => {
itemScores[item.itemName] = {
managerScore: item.managerScore ?? item.aiScore ?? item.selfScore ?? 0,
scoreExplanation: item.managerExplanation ?? '',
};
});
form.setFieldsValue({
reviewOpinion: data.reviewOpinion ?? '',
itemScores,
});
} catch (err: any) {
message.error(err?.response?.data?.message || '加载绩效详情失败');
navigate('/manager/subordinates');
} finally {
setLoading(false);
}
};
const handleApprove = async () => {
try {
const values = await form.validateFields();
setSubmitting(true);
const itemScores = Object.entries(values.itemScores).map(
([itemName, score]) => ({
itemName,
managerScore: score.managerScore,
managerExplanation: score.scoreExplanation || '',
})
);
await http.post('/api/performance/manager/review', {
perfId: Number(perfId),
reviewOpinion: values.reviewOpinion,
itemScores,
});
message.success('审核通过,绩效已归档');
navigate('/manager/subordinates');
} catch (err: any) {
if (err?.errorFields) {
message.error('请完成所有必填项');
} else {
message.error(err?.response?.data?.message || '审核失败,请重试');
}
} finally {
setSubmitting(false);
}
};
const handleReject = async () => {
if (!rejectReason.trim()) {
message.error('请填写驳回原因');
return;
}
setSubmitting(true);
try {
await http.post('/api/performance/manager/reject', {
perfId: Number(perfId),
reason: rejectReason,
});
message.success('绩效已驳回,员工将收到通知');
navigate('/manager/subordinates');
} catch (err: any) {
message.error(err?.response?.data?.message || '驳回失败,请重试');
} finally {
setSubmitting(false);
setRejectModalOpen(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 100 }}>
<Spin size="large" />
</div>
);
}
if (!record) {
return null;
}
const status = STATUS_MAP[record.status] || {
label: record.status,
color: 'default',
};
const level = record.level ? LEVEL_MAP[record.level] : null;
const isCompleted = record.status === 'completed';
return (
<div style={{ padding: isMobile ? 12 : 24, maxWidth: 1200, margin: '0 auto' }}>
<Space style={{ marginBottom: 12 }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/manager/subordinates')} size={isMobile ? 'small' : 'middle'}>
</Button>
</Space>
<Title level={isMobile ? 4 : 3}>
{record.month}
{isCompleted && <Tag color="success" style={{ marginLeft: 8 }}></Tag>}
</Title>
{/* 基础信息 */}
<Card style={{ marginBottom: 12 }} bodyStyle={{ padding: isMobile ? 12 : 24 }}>
{isMobile ? (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 16px' }}>
{[
{ label: '员工姓名', value: (record as any).userName || '-' },
{ label: '部门', value: (record as any).userDepartment || '-' },
{ label: '岗位', value: (record as any).userPosition || '-' },
{ label: '考核月份', value: record.month },
{ label: '状态', value: <Tag color={status.color}>{status.label}</Tag> },
{ label: '提交时间', value: record.submitTime ? dayjs(record.submitTime).format('YYYY-MM-DD HH:mm') : '-' },
{ label: '自评总分', value: record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-' },
{ label: 'AI 评分', value: record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-' },
{ label: '最终总分', value: <Text strong>{record.totalScore != null ? Number(record.totalScore).toFixed(1) : '-'}</Text> },
].map(({ label, value }) => (
<div key={label}>
<Text type="secondary" style={{ fontSize: 12 }}>{label}</Text>
<div>{value}</div>
</div>
))}
{level && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<div><Tag color={level.color}>{level.label}</Tag></div>
</div>
)}
{isCompleted && record.reviewOpinion && (
<div style={{ gridColumn: '1 / -1' }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<div><Text>{record.reviewOpinion}</Text></div>
</div>
)}
</div>
) : (
<Descriptions bordered column={3} size="small">
<Descriptions.Item label="员工姓名">{(record as any).userName || '-'}</Descriptions.Item>
<Descriptions.Item label="部门">{(record as any).userDepartment || '-'}</Descriptions.Item>
<Descriptions.Item label="岗位">{(record as any).userPosition || '-'}</Descriptions.Item>
<Descriptions.Item label="考核月份">{record.month}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={status.color}>{status.label}</Tag></Descriptions.Item>
<Descriptions.Item label="提交时间">{record.submitTime ? dayjs(record.submitTime).format('YYYY-MM-DD HH:mm') : '-'}</Descriptions.Item>
<Descriptions.Item label="自评总分">{record.selfScore != null ? Number(record.selfScore).toFixed(1) : '-'}</Descriptions.Item>
<Descriptions.Item label="AI 评分">{record.aiScore != null ? Number(record.aiScore).toFixed(1) : '-'}</Descriptions.Item>
<Descriptions.Item label="最终总分"><Text strong>{record.totalScore != null ? Number(record.totalScore).toFixed(1) : '-'}</Text></Descriptions.Item>
{level && <Descriptions.Item label="绩效等级"><Tag color={level.color}>{level.label}</Tag></Descriptions.Item>}
{record.rewardPunish && <Descriptions.Item label="奖惩说明" span={2}>{record.rewardPunish}</Descriptions.Item>}
{isCompleted && record.reviewOpinion && (
<Descriptions.Item label="审核意见" span={3}>{record.reviewOpinion}</Descriptions.Item>
)}
</Descriptions>
)}
</Card>
{/* 工作汇总 */}
{record.workSummary && (
<Card title="工作汇总" style={{ marginBottom: 16 }}>
<Text>{record.workSummary}</Text>
</Card>
)}
{/* 考勤信息 */}
{record.attendance && (
<Card title="考勤数据" style={{ marginBottom: 12 }} bodyStyle={{ padding: isMobile ? 12 : 24 }}>
<Row gutter={[8, 8]}>
<Col xs={12} sm={6}><Text type="secondary"></Text><Text>{record.attendance.leave} </Text></Col>
<Col xs={12} sm={6}><Text type="secondary"></Text><Text>{record.attendance.late} </Text></Col>
<Col xs={12} sm={6}><Text type="secondary"></Text><Text>{record.attendance.absent} </Text></Col>
<Col xs={12} sm={6}><Text type="secondary"></Text><Text>{record.attendance.lackCard} </Text></Col>
</Row>
{record.attendance.attendanceScore != null && (
<Text type="secondary" style={{ marginTop: 8, display: 'block' }}>
<Text strong>{record.attendance.attendanceScore}</Text>
</Text>
)}
</Card>
)}
{/* AI 评分反馈 */}
{record.aiResult && (
<Card title="AI 评分反馈" style={{ marginBottom: 16 }}>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="AI 总分">
<Text strong>{record.aiResult.aiTotalScore != null ? Number(record.aiResult.aiTotalScore).toFixed(1) : '-'}</Text>
</Descriptions.Item>
</Descriptions>
{record.aiResult.aiProblems?.length > 0 && (
<>
<Divider orientation="left">AI </Divider>
<List
size="small"
dataSource={record.aiResult.aiProblems}
renderItem={(item, i) => (
<List.Item>
<Text>
{i + 1}. {item}
</Text>
</List.Item>
)}
/>
</>
)}
{record.aiResult.aiSuggestions?.length > 0 && (
<>
<Divider orientation="left">AI </Divider>
<List
size="small"
dataSource={record.aiResult.aiSuggestions}
renderItem={(item, i) => (
<List.Item>
<Text>
{i + 1}. {item}
</Text>
</List.Item>
)}
/>
</>
)}
</Card>
)}
{/* 考核项评分 */}
<Card title="考核项评分与审核" style={{ marginBottom: 16 }}>
{!isCompleted && (
<Alert
type="info"
message="请为每个考核项填写管理层评分,系统将自动计算最终总分和绩效等级"
style={{ marginBottom: 16 }}
showIcon
/>
)}
<Form form={form} layout="vertical" disabled={isCompleted}>
{record.performanceItems?.map((item) => (
<Card
key={item.itemName}
size="small"
style={{ marginBottom: 12, background: '#fafafa' }}
title={
<Space>
<Text strong>{item.itemName}</Text>
<Tag>{item.weight} </Tag>
<Tag
color={
item.itemCategory === 'business' ? 'blue' : 'green'
}
>
{item.itemCategory === 'business'
? '业务素质'
: '综合素质'}
</Tag>
</Space>
}
>
{isMobile ? (
// 移动端简化展示
<div>
<Text type="secondary" style={{ fontSize: 12 }}>{item.userContent || '-'}</Text>
<Row gutter={8} style={{ marginTop: 8 }}>
<Col span={8}><Text type="secondary" style={{ fontSize: 11 }}></Text><br /><Text>{item.selfScore ?? '-'}</Text></Col>
<Col span={8}><Text type="secondary" style={{ fontSize: 11 }}>AI评分</Text><br /><Text>{item.aiScore ?? '-'}</Text></Col>
<Col span={8}><Text type="secondary" style={{ fontSize: 11 }}></Text><br /><Text>{item.managerScore ?? '-'}</Text></Col>
</Row>
{item.aiExplanation && <Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>AI说明{item.aiExplanation}</Text>}
</div>
) : (
<Descriptions column={3} size="small" bordered>
<Descriptions.Item label="员工填报内容" span={3}>{item.userContent || '-'}</Descriptions.Item>
<Descriptions.Item label="自评分">{item.selfScore ?? '-'}</Descriptions.Item>
<Descriptions.Item label="AI 评分">{item.aiScore ?? '-'}</Descriptions.Item>
<Descriptions.Item label="管理层评分">{item.managerScore ?? '-'}</Descriptions.Item>
{item.aiExplanation && <Descriptions.Item label="AI 评分说明" span={3}>{item.aiExplanation}</Descriptions.Item>}
</Descriptions>
)}
{!isCompleted && (
<div style={{ marginTop: 12 }}>
<Form.Item
name={['itemScores', item.itemName, 'managerScore']}
label="管理层评分"
rules={[
{ required: true, message: '请填写评分' },
{ type: 'number', min: 0, max: 100, message: '评分范围 0-100' },
]}
>
<InputNumber min={0} max={100} style={{ width: 120 }} placeholder="0-100" />
</Form.Item>
</div>
)}
{isCompleted && item.managerExplanation && (
<div style={{ marginTop: 12 }}>
<Text type="secondary"></Text>
<Text>{item.managerExplanation}</Text>
</div>
)}
</Card>
))}
{/* 审核意见 */}
<Divider />
<Form.Item
name="reviewOpinion"
label="审核意见"
rules={[{ required: !isCompleted, message: '请填写审核意见' }]}
>
<TextArea
rows={4}
placeholder="请填写对员工本月绩效的整体评价和改进建议"
/>
</Form.Item>
</Form>
</Card>
{/* 操作按钮 */}
{!isCompleted && (
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', paddingBottom: 24 }}>
<Button
type="primary"
size="large"
icon={<CheckOutlined />}
onClick={handleApprove}
loading={submitting}
style={isMobile ? { flex: 1 } : {}}
>
</Button>
<Button
danger
size="large"
icon={<CloseOutlined />}
onClick={() => setRejectModalOpen(true)}
loading={submitting}
style={isMobile ? { flex: 1 } : {}}
>
</Button>
</div>
)}
{/* 驳回弹窗 */}
<Modal
title="驳回绩效"
open={rejectModalOpen}
onOk={handleReject}
onCancel={() => setRejectModalOpen(false)}
confirmLoading={submitting}
>
<TextArea
rows={4}
placeholder="请填写驳回原因,员工将收到此通知"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
</Modal>
</div>
);
};
export default PerformanceReview;