453 lines
17 KiB
TypeScript
453 lines
17 KiB
TypeScript
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, PerformanceItemDetail } 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: PerformanceItemDetail) => {
|
||
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;
|