651 lines
20 KiB
TypeScript
651 lines
20 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Empty,
|
||
Input,
|
||
message,
|
||
Modal,
|
||
Space,
|
||
Spin,
|
||
Table,
|
||
} from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import { UserOutlined } from '@ant-design/icons';
|
||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||
import { getTaskScoreDetail, saveTaskUserScore } from '@/api/appraisal';
|
||
import PageBackButton from '@/components/PageBackButton';
|
||
import './appraisal-detail.css';
|
||
|
||
interface DetailItem {
|
||
_rowKey?: string;
|
||
id?: string | number;
|
||
reviewCategory?: string;
|
||
reviewItem?: string;
|
||
remarks?: string;
|
||
score?: number;
|
||
weight?: number;
|
||
remark?: string;
|
||
[key: string]: unknown;
|
||
}
|
||
|
||
interface GroupRow {
|
||
category: string;
|
||
items: DetailItem[];
|
||
remarkCate: string;
|
||
}
|
||
|
||
interface ExamineTask {
|
||
taskName?: string;
|
||
templateId?: string | number;
|
||
templateType?: string | number;
|
||
}
|
||
|
||
interface ExamineUser {
|
||
id?: string | number;
|
||
userName?: string;
|
||
manageScore?: string | number;
|
||
judgeContent?: string;
|
||
selfJudgeContent?: string;
|
||
examineStatus?: string | number;
|
||
examineStatusSelf?: string | number;
|
||
}
|
||
|
||
interface RemarkRow {
|
||
remark?: string;
|
||
}
|
||
|
||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||
typeof value === 'object' && value !== null;
|
||
|
||
const toNumber = (value: unknown, fallback = 0) => {
|
||
const num = Number(value);
|
||
return Number.isFinite(num) ? num : fallback;
|
||
};
|
||
|
||
const clampScore = (value: number) => Math.min(10, Math.max(0, Math.round(value)));
|
||
|
||
const toBoolean = (value: unknown) => {
|
||
if (typeof value === 'boolean') {
|
||
return value;
|
||
}
|
||
if (typeof value === 'number') {
|
||
return value === 1;
|
||
}
|
||
const raw = String(value ?? '').toLowerCase().trim();
|
||
return raw === '1' || raw === 'true' || raw === 'yes';
|
||
};
|
||
|
||
const normalizePayload = (response: unknown) =>
|
||
isObject(response) && response.data !== undefined ? response.data : response;
|
||
|
||
const groupDetailRows = (items: DetailItem[], remarks: RemarkRow[] = []) => {
|
||
const map = new Map<string, DetailItem[]>();
|
||
items.forEach((item) => {
|
||
const key = String(item.reviewCategory ?? '未分类');
|
||
const list = map.get(key) ?? [];
|
||
const fallbackKey = `${key}_${list.length}`;
|
||
list.push({
|
||
...item,
|
||
_rowKey: String(item.id ?? fallbackKey),
|
||
score: toNumber(item.score, 0),
|
||
weight: toNumber(item.weight, 0),
|
||
remark: String(item.remark ?? ''),
|
||
});
|
||
map.set(key, list);
|
||
});
|
||
|
||
const groups: GroupRow[] = [];
|
||
Array.from(map.entries()).forEach(([category, list], index) => {
|
||
groups.push({
|
||
category,
|
||
items: list,
|
||
remarkCate: String(remarks[index]?.remark ?? ''),
|
||
});
|
||
});
|
||
return groups;
|
||
};
|
||
|
||
interface ScoreBarProps {
|
||
value: number;
|
||
editable: boolean;
|
||
onChange: (value: number) => void;
|
||
}
|
||
|
||
const ScoreBar = ({ value, editable, onChange }: ScoreBarProps) => {
|
||
const normalized = clampScore(value);
|
||
|
||
const updateByPercent = (percent: number) => {
|
||
onChange(clampScore(percent * 10));
|
||
};
|
||
|
||
const handleTrackClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||
if (!editable) {
|
||
return;
|
||
}
|
||
const rect = event.currentTarget.getBoundingClientRect();
|
||
if (rect.width <= 0) {
|
||
return;
|
||
}
|
||
const percent = (event.clientX - rect.left) / rect.width;
|
||
updateByPercent(percent);
|
||
};
|
||
|
||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||
if (!editable) {
|
||
return;
|
||
}
|
||
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
|
||
event.preventDefault();
|
||
onChange(clampScore(normalized - 1));
|
||
return;
|
||
}
|
||
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
|
||
event.preventDefault();
|
||
onChange(clampScore(normalized + 1));
|
||
}
|
||
};
|
||
|
||
const bubblePosition = normalized * 10;
|
||
const bubbleClass =
|
||
normalized === 0 ? 'detail-score-bubble is-start' : normalized === 10 ? 'detail-score-bubble is-end' : 'detail-score-bubble';
|
||
|
||
return (
|
||
<div className="detail-score-wrap">
|
||
<div className="detail-score-top">
|
||
<span>0</span>
|
||
</div>
|
||
<div
|
||
className={`detail-score-track ${editable ? 'is-editable' : 'is-readonly'}`}
|
||
onClick={handleTrackClick}
|
||
role={editable ? 'slider' : undefined}
|
||
aria-valuemin={0}
|
||
aria-valuemax={10}
|
||
aria-valuenow={normalized}
|
||
tabIndex={editable ? 0 : -1}
|
||
onKeyDown={handleKeyDown}
|
||
>
|
||
<div className="detail-score-fill" style={{ width: `${bubblePosition}%` }} />
|
||
<div className={bubbleClass} style={{ left: `${bubblePosition}%` }}>
|
||
{normalized}
|
||
</div>
|
||
</div>
|
||
{normalized === 0 && <div className="statusText">暂未打分</div>}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const AppraisalDetailPage = () => {
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
|
||
const isNormal = toBoolean(searchParams.get('isNormal'));
|
||
const [isEdit, setIsEdit] = useState(toBoolean(searchParams.get('edit')));
|
||
const examineTaskId = searchParams.get('examineTaskId') ?? searchParams.get('taskId') ?? '';
|
||
const reviewType = searchParams.get('reviewType') ?? (isNormal ? '1' : '0');
|
||
const routeExamineId = searchParams.get('examineId') ?? '';
|
||
const routeUserId = searchParams.get('userId') ?? '';
|
||
|
||
const [loading, setLoading] = useState(false);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [examineTask, setExamineTask] = useState<ExamineTask>({});
|
||
const [examineUser, setExamineUser] = useState<ExamineUser>({});
|
||
const [groups, setGroups] = useState<GroupRow[]>([]);
|
||
const [manageScore, setManageScore] = useState<number>(0);
|
||
const [judgeContent, setJudgeContent] = useState('');
|
||
|
||
const [remarkModalOpen, setRemarkModalOpen] = useState(false);
|
||
const [editingCell, setEditingCell] = useState<{ groupIndex: number; rowIndex: number } | null>(null);
|
||
const [remarkDraft, setRemarkDraft] = useState('');
|
||
|
||
const templateType = useMemo(() => String(examineTask.templateType ?? ''), [examineTask.templateType]);
|
||
const isTemplateZero = templateType === '0';
|
||
|
||
const recalcManageScore = useCallback((nextGroups: GroupRow[]) => {
|
||
const score = nextGroups
|
||
.flatMap((group) => group.items)
|
||
.reduce((sum, row) => sum + toNumber(row.score) * toNumber(row.weight), 0) / 10;
|
||
setManageScore(Number(score.toFixed(2)));
|
||
}, []);
|
||
|
||
const loadDetail = useCallback(async () => {
|
||
if (!examineTaskId) {
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
try {
|
||
const params: Record<string, unknown> = {
|
||
examineTaskId,
|
||
reviewType,
|
||
};
|
||
if (routeExamineId) {
|
||
params.examineId = routeExamineId;
|
||
}
|
||
if (routeUserId) {
|
||
params.userId = routeUserId;
|
||
}
|
||
|
||
const response = await getTaskScoreDetail(params);
|
||
const payload = normalizePayload(response);
|
||
|
||
if (!isObject(payload)) {
|
||
message.error('评分详情返回格式异常');
|
||
setGroups([]);
|
||
return;
|
||
}
|
||
|
||
const detailRows = Array.isArray(payload.examineConfigDetailVoList)
|
||
? (payload.examineConfigDetailVoList as DetailItem[])
|
||
: [];
|
||
const remarks = Array.isArray(payload.remark) ? (payload.remark as RemarkRow[]) : [];
|
||
const nextGroups = groupDetailRows(detailRows, remarks);
|
||
setGroups(nextGroups);
|
||
|
||
const task = isObject(payload.examineTask) ? (payload.examineTask as ExamineTask) : {};
|
||
const user = isObject(payload.examineUser) ? (payload.examineUser as ExamineUser) : {};
|
||
setExamineTask(task);
|
||
setExamineUser(user);
|
||
|
||
if (isNormal && String(task.templateType ?? '') === '0') {
|
||
setJudgeContent(String(user.selfJudgeContent ?? user.judgeContent ?? ''));
|
||
} else {
|
||
setJudgeContent(String(user.judgeContent ?? ''));
|
||
}
|
||
|
||
const currentManageScore = toNumber(user.manageScore);
|
||
if (currentManageScore > 0) {
|
||
setManageScore(currentManageScore);
|
||
} else if (!isNormal) {
|
||
recalcManageScore(nextGroups);
|
||
}
|
||
|
||
if (String(user.examineStatusSelf ?? '') === '1' && String(user.examineStatus ?? '') === '1') {
|
||
setIsEdit(false);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch score detail:', error);
|
||
message.error('获取评分详情失败');
|
||
setGroups([]);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [examineTaskId, isNormal, recalcManageScore, reviewType, routeExamineId, routeUserId]);
|
||
|
||
useEffect(() => {
|
||
void loadDetail();
|
||
}, [loadDetail]);
|
||
|
||
const updateItemScore = (groupIndex: number, rowIndex: number, value: number) => {
|
||
setGroups((prev) => {
|
||
const next = prev.map((group, gIndex) => {
|
||
if (gIndex !== groupIndex) {
|
||
return group;
|
||
}
|
||
return {
|
||
...group,
|
||
items: group.items.map((row, rIndex) => (rIndex === rowIndex ? { ...row, score: value } : row)),
|
||
};
|
||
});
|
||
if (!isNormal) {
|
||
recalcManageScore(next);
|
||
}
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const updateGroupRemark = (groupIndex: number, value: string) => {
|
||
setGroups((prev) =>
|
||
prev.map((group, idx) =>
|
||
idx === groupIndex
|
||
? {
|
||
...group,
|
||
remarkCate: value,
|
||
}
|
||
: group,
|
||
),
|
||
);
|
||
};
|
||
|
||
const openRemarkModal = (groupIndex: number, rowIndex: number) => {
|
||
const row = groups[groupIndex]?.items[rowIndex];
|
||
setEditingCell({ groupIndex, rowIndex });
|
||
setRemarkDraft(String(row?.remark ?? ''));
|
||
setRemarkModalOpen(true);
|
||
};
|
||
|
||
const saveRemarkModal = () => {
|
||
if (!editingCell) {
|
||
setRemarkModalOpen(false);
|
||
return;
|
||
}
|
||
if (remarkDraft.length > 200) {
|
||
message.warning('自评总结限制200个字符');
|
||
return;
|
||
}
|
||
setGroups((prev) =>
|
||
prev.map((group, gIndex) => {
|
||
if (gIndex !== editingCell.groupIndex) {
|
||
return group;
|
||
}
|
||
return {
|
||
...group,
|
||
items: group.items.map((row, rIndex) =>
|
||
rIndex === editingCell.rowIndex
|
||
? {
|
||
...row,
|
||
remark: remarkDraft,
|
||
}
|
||
: row,
|
||
),
|
||
};
|
||
}),
|
||
);
|
||
setRemarkModalOpen(false);
|
||
};
|
||
|
||
const buildPayload = (submitStatus: 0 | 1) => {
|
||
const allItems = groups.flatMap((group) => group.items);
|
||
const detailList = allItems.map((item) => ({
|
||
score: toNumber(item.score, 0),
|
||
configId: item.id,
|
||
remark: String(item.remark ?? ''),
|
||
reviewCategory: item.reviewCategory,
|
||
}));
|
||
|
||
const payload: Record<string, unknown> = {
|
||
examineId: examineUser.id ?? routeExamineId ?? '',
|
||
taskId: examineTaskId,
|
||
reviewType,
|
||
examineDetailList: detailList,
|
||
examineRemarkList: [],
|
||
manageScore: manageScore,
|
||
judgeContent,
|
||
};
|
||
|
||
if (isNormal) {
|
||
payload.examineStatusSelf = submitStatus;
|
||
} else {
|
||
payload.examineStatus = submitStatus;
|
||
}
|
||
|
||
if (isNormal && !isTemplateZero) {
|
||
payload.examineRemarkList = groups
|
||
.filter((group) => group.category !== '发展与协作')
|
||
.map((group) => ({
|
||
reviewCategory: group.category,
|
||
remark: group.remarkCate,
|
||
}));
|
||
}
|
||
|
||
if (isNormal && isTemplateZero) {
|
||
payload.selfJudgeContent = judgeContent;
|
||
payload.judgeContent = '';
|
||
}
|
||
|
||
return payload;
|
||
};
|
||
|
||
const validateSubmit = (submitStatus: 0 | 1) => {
|
||
if (submitStatus === 0) {
|
||
return true;
|
||
}
|
||
|
||
const allItems = groups.flatMap((group) => group.items);
|
||
const hasUnscored = allItems.some(
|
||
(item) => !toNumber(item.score, 0) && (!isNormal || item.reviewCategory !== '发展与协作'),
|
||
);
|
||
if (hasUnscored) {
|
||
message.warning('存在未评分绩效项,请完善后再试');
|
||
return false;
|
||
}
|
||
|
||
if (isNormal) {
|
||
const devRows = allItems.filter((item) => item.reviewCategory === '发展与协作');
|
||
const hasEmptyDevRemark = devRows.some((item) => !String(item.remark ?? '').trim());
|
||
if (hasEmptyDevRemark) {
|
||
message.warning('发展与协作下的自评总结为必填,请完善后再试');
|
||
return false;
|
||
}
|
||
const hasShortDevRemark = devRows.some(
|
||
(item) => String(item.remark ?? '').trim().length > 0 && String(item.remark ?? '').trim().length < 100,
|
||
);
|
||
if (hasShortDevRemark) {
|
||
message.warning('发展与协作下的自评总结最少100个字符,请完善后再试');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
if (!isNormal && isTemplateZero && judgeContent.length > 300) {
|
||
message.warning('总体评价限制300个字符');
|
||
return false;
|
||
}
|
||
|
||
if (!isNormal && !judgeContent.trim()) {
|
||
message.warning('总体评价为必填');
|
||
return false;
|
||
}
|
||
|
||
if (isNormal && isTemplateZero && !judgeContent.trim()) {
|
||
message.warning('个人总体评价为必填');
|
||
return false;
|
||
}
|
||
|
||
if (isNormal && !isTemplateZero) {
|
||
const cateRemarks = groups.filter((group) => group.category !== '发展与协作').map((group) => group.remarkCate);
|
||
if (cateRemarks.some((remark) => !String(remark).trim())) {
|
||
message.warning('存在未填写大类评价,请完善后再试');
|
||
return false;
|
||
}
|
||
if (cateRemarks.some((remark) => String(remark).trim().length < 100)) {
|
||
message.warning('大类评价最少100个字符,请完善后再试');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
const submitScore = async (submitStatus: 0 | 1) => {
|
||
if (!validateSubmit(submitStatus)) {
|
||
return;
|
||
}
|
||
|
||
const doSubmit = async () => {
|
||
setSubmitting(true);
|
||
try {
|
||
const payload = buildPayload(submitStatus);
|
||
await saveTaskUserScore(payload);
|
||
message.success('操作成功');
|
||
navigate(-1);
|
||
} catch (error) {
|
||
console.error('Failed to save score:', error);
|
||
message.error('保存失败');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
if (submitStatus === 1) {
|
||
Modal.confirm({
|
||
title: '确认提交绩效评分',
|
||
content: '提交后将无法修改,该操作不可逆,请确认后再试',
|
||
okText: '确定',
|
||
cancelText: '取消',
|
||
onOk: doSubmit,
|
||
});
|
||
return;
|
||
}
|
||
|
||
await doSubmit();
|
||
};
|
||
|
||
const buildColumns = (group: GroupRow, groupIndex: number): ColumnsType<DetailItem> => {
|
||
const cols: ColumnsType<DetailItem> = [
|
||
{
|
||
title: '考核项',
|
||
dataIndex: 'reviewItem',
|
||
width: 200,
|
||
},
|
||
{
|
||
title: '评分标准',
|
||
dataIndex: 'remarks',
|
||
},
|
||
];
|
||
|
||
if (!isNormal && examineTask.templateId && group.category === '发展与协作' && !isTemplateZero) {
|
||
cols.push({
|
||
title: '员工自评',
|
||
dataIndex: 'remark',
|
||
render: (_, row) => <Input.TextArea value={String(row.remark ?? '')} autoSize={{ minRows: 3 }} readOnly />,
|
||
});
|
||
}
|
||
|
||
if ((isNormal && group.category !== '发展与协作') || !isNormal) {
|
||
cols.push({
|
||
title: '评分',
|
||
dataIndex: 'score',
|
||
width: 380,
|
||
render: (_, row, rowIndex) => (
|
||
<ScoreBar
|
||
value={toNumber(row.score, 0)}
|
||
editable={isEdit}
|
||
onChange={(nextScore) => updateItemScore(groupIndex, rowIndex, nextScore)}
|
||
/>
|
||
),
|
||
});
|
||
}
|
||
|
||
if (isNormal && ((examineTask.templateId && group.category === '发展与协作') || !examineTask.templateId)) {
|
||
cols.push({
|
||
title: '自评总结',
|
||
dataIndex: 'remark',
|
||
width: 140,
|
||
render: (_, row, rowIndex) => (
|
||
<Button type="link" onClick={() => openRemarkModal(groupIndex, rowIndex)}>
|
||
{String(row.remark ?? '').trim() ? '查看' : '暂未评价'}
|
||
</Button>
|
||
),
|
||
});
|
||
}
|
||
|
||
return cols;
|
||
};
|
||
|
||
return (
|
||
<div className="app-container appraisal-detail-page">
|
||
{!examineTaskId && <Alert type="warning" showIcon message="缺少 examineTaskId 参数,无法加载评分详情" />}
|
||
|
||
<Spin spinning={loading}>
|
||
<div className="conetentBox">
|
||
<div style={{ marginBottom: 12 }}>
|
||
<PageBackButton
|
||
fallbackPath={isNormal ? '/workAppraisal/normalWorker' : '/workAppraisal/manager'}
|
||
/>
|
||
</div>
|
||
<div className="titleBox">
|
||
<div className="titleMain">
|
||
<span className="block" />
|
||
<span>{examineTask.taskName ?? '绩效考核详情'}</span>
|
||
</div>
|
||
{isEdit && (
|
||
<Space size={20}>
|
||
<Button style={{ width: 90 }} onClick={() => submitScore(0)} loading={submitting}>
|
||
保存
|
||
</Button>
|
||
<Button style={{ width: 90 }} type="primary" onClick={() => submitScore(1)} loading={submitting}>
|
||
提交
|
||
</Button>
|
||
</Space>
|
||
)}
|
||
</div>
|
||
|
||
<div className="headerBox">
|
||
<div className="userInfo">
|
||
<UserOutlined />
|
||
<span>{examineUser.userName ?? '-'}</span>
|
||
</div>
|
||
{!isNormal && (
|
||
<div className="totalBox">
|
||
<span>考核评分:</span>
|
||
<span className="scoreTotal">{manageScore}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="tableBox">
|
||
{groups.length === 0 ? (
|
||
<Empty description="暂无评分项" />
|
||
) : (
|
||
groups.map((group, groupIndex) => (
|
||
<div className="tableRow detail-group" key={`${group.category}_${groupIndex}`}>
|
||
<div className="userBox detail-group-title">{group.category}</div>
|
||
<Table<DetailItem>
|
||
rowKey="_rowKey"
|
||
columns={buildColumns(group, groupIndex)}
|
||
dataSource={group.items}
|
||
pagination={false}
|
||
size="middle"
|
||
/>
|
||
|
||
{isNormal && !isTemplateZero && group.category !== '发展与协作' && (
|
||
<div className="detail-group-remark">
|
||
<div className="detail-subtitle">评价</div>
|
||
<Input.TextArea
|
||
autoSize={{ minRows: 4 }}
|
||
maxLength={300}
|
||
showCount
|
||
value={group.remarkCate}
|
||
onChange={(event) => updateGroupRemark(groupIndex, event.target.value)}
|
||
readOnly={!isEdit}
|
||
placeholder="0/300"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
|
||
{((isNormal && isTemplateZero) || !isNormal) && (
|
||
<div className="detail-overall">
|
||
<div className="userBox detail-group-title">总体评价</div>
|
||
<Input.TextArea
|
||
autoSize={{ minRows: 4 }}
|
||
maxLength={300}
|
||
showCount
|
||
value={judgeContent}
|
||
onChange={(event) => setJudgeContent(event.target.value)}
|
||
readOnly={!isEdit}
|
||
placeholder="0/300"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Spin>
|
||
|
||
<Modal
|
||
title="自评总结"
|
||
open={remarkModalOpen}
|
||
onOk={isEdit ? saveRemarkModal : () => setRemarkModalOpen(false)}
|
||
onCancel={() => setRemarkModalOpen(false)}
|
||
okButtonProps={{ style: { display: isEdit ? 'inline-flex' : 'none' } }}
|
||
okText="确定"
|
||
cancelText={isEdit ? '取消' : '关闭'}
|
||
>
|
||
<Input.TextArea
|
||
autoSize={{ minRows: 4 }}
|
||
maxLength={200}
|
||
showCount
|
||
value={remarkDraft}
|
||
onChange={(event) => setRemarkDraft(event.target.value)}
|
||
readOnly={!isEdit}
|
||
placeholder="0/200"
|
||
/>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default AppraisalDetailPage;
|