pms-front-react/src/pages/workAppraisal/AppraisalDetailPage.tsx

666 lines
20 KiB
TypeScript
Raw Normal View History

2026-03-17 07:18:07 +00:00
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
2026-03-17 09:47:31 +00:00
App,
2026-03-17 07:18:07 +00:00
Alert,
Button,
Empty,
Input,
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';
2026-03-17 07:18:56 +00:00
import { usePermission } from '@/contexts/PermissionContext';
import '@/styles/permission-link.css';
2026-03-17 07:18:07 +00:00
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 = () => {
2026-03-17 09:47:31 +00:00
const { message, modal } = App.useApp();
2026-03-17 07:18:07 +00:00
const navigate = useNavigate();
const [searchParams] = useSearchParams();
2026-03-17 07:18:56 +00:00
const { canAccessPath } = usePermission();
2026-03-17 07:18:07 +00:00
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('');
2026-03-17 07:18:56 +00:00
const canEditScore = canAccessPath('/workAppraisal/detail');
const effectiveIsEdit = isEdit && canEditScore;
2026-03-17 07:18:07 +00:00
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) => {
2026-03-17 07:18:56 +00:00
if (!effectiveIsEdit) {
return;
}
2026-03-17 07:18:07 +00:00
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) {
2026-03-17 09:47:31 +00:00
modal.confirm({
2026-03-17 07:18:07 +00:00
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)}
2026-03-17 07:18:56 +00:00
editable={effectiveIsEdit}
2026-03-17 07:18:07 +00:00
onChange={(nextScore) => updateItemScore(groupIndex, rowIndex, nextScore)}
/>
),
});
}
if (isNormal && ((examineTask.templateId && group.category === '发展与协作') || !examineTask.templateId)) {
cols.push({
title: '自评总结',
dataIndex: 'remark',
width: 140,
render: (_, row, rowIndex) => (
2026-03-17 07:18:56 +00:00
effectiveIsEdit ? (
<Button type="link" onClick={() => openRemarkModal(groupIndex, rowIndex)}>
{String(row.remark ?? '').trim() ? '查看' : '暂未评价'}
</Button>
) : (
<span className="permission-link-disabled">
{String(row.remark ?? '').trim() ? '查看' : '暂未评价'}
</span>
)
2026-03-17 07:18:07 +00:00
),
});
}
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>
2026-03-17 07:18:56 +00:00
{effectiveIsEdit && (
2026-03-17 07:18:07 +00:00
<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)}
2026-03-17 07:18:56 +00:00
readOnly={!effectiveIsEdit}
2026-03-17 07:18:07 +00:00
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)}
2026-03-17 07:18:56 +00:00
readOnly={!effectiveIsEdit}
2026-03-17 07:18:07 +00:00
placeholder="0/300"
/>
</div>
)}
</div>
</div>
</Spin>
<Modal
title="自评总结"
open={remarkModalOpen}
2026-03-17 07:18:56 +00:00
onOk={effectiveIsEdit ? saveRemarkModal : () => setRemarkModalOpen(false)}
2026-03-17 07:18:07 +00:00
onCancel={() => setRemarkModalOpen(false)}
2026-03-17 07:18:56 +00:00
okButtonProps={{ style: { display: effectiveIsEdit ? 'inline-flex' : 'none' } }}
2026-03-17 07:18:07 +00:00
okText="确定"
2026-03-17 07:18:56 +00:00
cancelText={effectiveIsEdit ? '取消' : '关闭'}
2026-03-17 07:18:07 +00:00
>
<Input.TextArea
autoSize={{ minRows: 4 }}
maxLength={200}
showCount
value={remarkDraft}
onChange={(event) => setRemarkDraft(event.target.value)}
2026-03-17 07:18:56 +00:00
readOnly={!effectiveIsEdit}
2026-03-17 07:18:07 +00:00
placeholder="0/200"
/>
</Modal>
</div>
);
};
export default AppraisalDetailPage;