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

666 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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