2026-03-17 07:18:07 +00:00
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
|
|
|
|
import { Alert, Collapse, Empty, Form, Input, Select, Spin, message } from 'antd';
|
|
|
|
|
|
import type { CollapseProps } from 'antd';
|
|
|
|
|
|
import { getTaskModelSet } from '@/api/appraisal';
|
|
|
|
|
|
import { useSearchParams } from 'react-router-dom';
|
|
|
|
|
|
import PageBackButton from '@/components/PageBackButton';
|
|
|
|
|
|
import './appraisal-module-detail.css';
|
|
|
|
|
|
|
|
|
|
|
|
interface ScoreConfigItem {
|
|
|
|
|
|
_itemKey: string;
|
|
|
|
|
|
id?: string | number;
|
|
|
|
|
|
reviewType?: string | number;
|
|
|
|
|
|
reviewCategory?: string;
|
|
|
|
|
|
reviewItem?: string;
|
|
|
|
|
|
remarks?: string;
|
|
|
|
|
|
weight?: number;
|
|
|
|
|
|
[key: string]: unknown;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ScoreCategory {
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
type: string;
|
|
|
|
|
|
rightArr: ScoreConfigItem[];
|
|
|
|
|
|
weight: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ScoreGroup {
|
|
|
|
|
|
type: string;
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
list: ScoreCategory[];
|
|
|
|
|
|
weight: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const REVIEW_GROUP_META = [
|
|
|
|
|
|
{ type: '0', title: '组长评估绩效指标' },
|
|
|
|
|
|
{ type: '1', title: '个人自评绩效指标' },
|
|
|
|
|
|
{ type: '2', title: '系统核算绩效指标' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const TEMPLATE_TYPE_OPTIONS = [
|
|
|
|
|
|
{ label: '年度考核', value: '0' },
|
|
|
|
|
|
{ label: '季度考核', value: '1' },
|
|
|
|
|
|
{ label: '月度考核', value: '2' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
|
|
|
|
typeof value === 'object' && value !== null;
|
|
|
|
|
|
|
|
|
|
|
|
const toNumber = (value: unknown, fallback = 0) => {
|
|
|
|
|
|
const numeric = Number(value);
|
|
|
|
|
|
return Number.isFinite(numeric) ? numeric : fallback;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const clampWeight = (value: unknown) => Math.min(20, Math.max(0, Math.round(toNumber(value, 0))));
|
|
|
|
|
|
|
|
|
|
|
|
const normalizeResponseData = (response: unknown) =>
|
|
|
|
|
|
isObject(response) && response.data !== undefined ? response.data : response;
|
|
|
|
|
|
|
|
|
|
|
|
const normalizeScoreList = (response: unknown): ScoreConfigItem[] => {
|
|
|
|
|
|
const source = normalizeResponseData(response);
|
|
|
|
|
|
if (!Array.isArray(source)) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return source
|
|
|
|
|
|
.filter((item) => isObject(item))
|
|
|
|
|
|
.map((item, index) => ({
|
|
|
|
|
|
...item,
|
|
|
|
|
|
_itemKey: String(item.id ?? `cfg_${index}`),
|
|
|
|
|
|
weight: clampWeight(item.weight),
|
|
|
|
|
|
})) as ScoreConfigItem[];
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const buildScoreGroups = (items: ScoreConfigItem[]): ScoreGroup[] => {
|
|
|
|
|
|
const typeMap = new Map<string, ScoreConfigItem[]>();
|
|
|
|
|
|
|
|
|
|
|
|
items.forEach((item) => {
|
|
|
|
|
|
const type = String(item.reviewType ?? '');
|
|
|
|
|
|
const current = typeMap.get(type) ?? [];
|
|
|
|
|
|
current.push(item);
|
|
|
|
|
|
typeMap.set(type, current);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return REVIEW_GROUP_META.map(({ type, title }) => {
|
|
|
|
|
|
const categoryMap = new Map<string, ScoreConfigItem[]>();
|
|
|
|
|
|
|
|
|
|
|
|
(typeMap.get(type) ?? []).forEach((item) => {
|
|
|
|
|
|
const categoryTitle = String(item.reviewCategory ?? '未分类');
|
|
|
|
|
|
const current = categoryMap.get(categoryTitle) ?? [];
|
|
|
|
|
|
current.push(item);
|
|
|
|
|
|
categoryMap.set(categoryTitle, current);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const list: ScoreCategory[] = Array.from(categoryMap.entries()).map(([categoryTitle, rightArr]) => ({
|
|
|
|
|
|
key: `${type}_${categoryTitle}`,
|
|
|
|
|
|
title: categoryTitle,
|
|
|
|
|
|
type,
|
|
|
|
|
|
rightArr,
|
|
|
|
|
|
weight: rightArr.reduce((sum, cfg) => sum + clampWeight(cfg.weight), 0),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
type,
|
|
|
|
|
|
title,
|
|
|
|
|
|
list,
|
|
|
|
|
|
weight: list.reduce((sum, category) => sum + category.weight, 0),
|
|
|
|
|
|
};
|
|
|
|
|
|
}).filter((group) => group.list.length > 0);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
interface WeightBarProps {
|
|
|
|
|
|
value: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const WeightBar = ({ value }: WeightBarProps) => {
|
|
|
|
|
|
const normalized = clampWeight(value);
|
|
|
|
|
|
const leftPercent = normalized * 5;
|
|
|
|
|
|
const bubbleClass =
|
|
|
|
|
|
normalized === 0
|
|
|
|
|
|
? 'module-score-text is-start'
|
|
|
|
|
|
: normalized === 20
|
|
|
|
|
|
? 'module-score-text is-end'
|
|
|
|
|
|
: 'module-score-text';
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="module-score-wrap">
|
|
|
|
|
|
<div className="module-score-scale">
|
|
|
|
|
|
<span>0</span>
|
|
|
|
|
|
{normalized !== 20 && <span>20</span>}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="module-score-track">
|
|
|
|
|
|
<div className="module-score-fill" style={{ width: `${leftPercent}%` }} />
|
|
|
|
|
|
{normalized > 0 && (
|
|
|
|
|
|
<div className={bubbleClass} style={{ left: `${leftPercent}%` }}>
|
|
|
|
|
|
{normalized}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const AppraisalModuleDetailPage = () => {
|
|
|
|
|
|
const [searchParams] = useSearchParams();
|
|
|
|
|
|
const moduleId = searchParams.get('id') ?? '';
|
|
|
|
|
|
|
|
|
|
|
|
const [moduleName, setModuleName] = useState('');
|
|
|
|
|
|
const [moduleType, setModuleType] = useState<string>();
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [scoreList, setScoreList] = useState<ScoreGroup[]>([]);
|
|
|
|
|
|
const [activeGroupTitle, setActiveGroupTitle] = useState('');
|
|
|
|
|
|
const [selectedCategoryKey, setSelectedCategoryKey] = useState('');
|
|
|
|
|
|
const [viewMode, setViewMode] = useState<'group' | 'category'>('group');
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setModuleName(searchParams.get('moduleName') ?? '');
|
|
|
|
|
|
const currentType = String(searchParams.get('moduleType') ?? '').trim();
|
|
|
|
|
|
setModuleType(currentType || undefined);
|
|
|
|
|
|
}, [searchParams]);
|
|
|
|
|
|
|
|
|
|
|
|
const loadDetail = useCallback(async () => {
|
|
|
|
|
|
if (!moduleId) {
|
|
|
|
|
|
setScoreList([]);
|
|
|
|
|
|
setActiveGroupTitle('');
|
|
|
|
|
|
setSelectedCategoryKey('');
|
|
|
|
|
|
setViewMode('group');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await getTaskModelSet(moduleId);
|
|
|
|
|
|
const groups = buildScoreGroups(normalizeScoreList(response));
|
|
|
|
|
|
setScoreList(groups);
|
|
|
|
|
|
|
|
|
|
|
|
if (groups.length > 0) {
|
|
|
|
|
|
setActiveGroupTitle(groups[0].title);
|
|
|
|
|
|
setSelectedCategoryKey(groups[0].list[0]?.key ?? '');
|
|
|
|
|
|
setViewMode('group');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setActiveGroupTitle('');
|
|
|
|
|
|
setSelectedCategoryKey('');
|
|
|
|
|
|
setViewMode('group');
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('Failed to fetch appraisal module detail:', error);
|
|
|
|
|
|
message.error('获取考核看板详情失败');
|
|
|
|
|
|
setScoreList([]);
|
|
|
|
|
|
setActiveGroupTitle('');
|
|
|
|
|
|
setSelectedCategoryKey('');
|
|
|
|
|
|
setViewMode('group');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [moduleId]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
void loadDetail();
|
|
|
|
|
|
}, [loadDetail]);
|
|
|
|
|
|
|
|
|
|
|
|
const totalWeight = useMemo(
|
|
|
|
|
|
() => scoreList.reduce((sum, group) => sum + toNumber(group.weight, 0), 0),
|
|
|
|
|
|
[scoreList],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const activeGroup = useMemo(() => {
|
|
|
|
|
|
if (scoreList.length === 0) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
return scoreList.find((group) => group.title === activeGroupTitle) ?? scoreList[0];
|
|
|
|
|
|
}, [activeGroupTitle, scoreList]);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedCategory = useMemo(() => {
|
|
|
|
|
|
if (!activeGroup) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
return activeGroup.list.find((category) => category.key === selectedCategoryKey) ?? activeGroup.list[0];
|
|
|
|
|
|
}, [activeGroup, selectedCategoryKey]);
|
|
|
|
|
|
|
|
|
|
|
|
const rightCategoryList = useMemo(() => {
|
|
|
|
|
|
if (!activeGroup) {
|
|
|
|
|
|
return [] as ScoreCategory[];
|
|
|
|
|
|
}
|
|
|
|
|
|
if (viewMode === 'group') {
|
|
|
|
|
|
return activeGroup.list;
|
|
|
|
|
|
}
|
|
|
|
|
|
return selectedCategory ? [selectedCategory] : [];
|
|
|
|
|
|
}, [activeGroup, selectedCategory, viewMode]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleCollapseChange: CollapseProps['onChange'] = (key) => {
|
|
|
|
|
|
const keyText = Array.isArray(key) ? String(key[0] ?? '') : String(key ?? '');
|
|
|
|
|
|
const nextTitle = keyText || scoreList[0]?.title || '';
|
|
|
|
|
|
setActiveGroupTitle(nextTitle);
|
|
|
|
|
|
setViewMode('group');
|
|
|
|
|
|
|
|
|
|
|
|
const group = scoreList.find((item) => item.title === nextTitle);
|
|
|
|
|
|
if (!group || group.list.length === 0) {
|
|
|
|
|
|
setSelectedCategoryKey('');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setSelectedCategoryKey((previous) =>
|
|
|
|
|
|
group.list.some((category) => category.key === previous) ? previous : group.list[0].key,
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const collapseItems: CollapseProps['items'] = scoreList.map((group) => ({
|
|
|
|
|
|
key: group.title,
|
|
|
|
|
|
label: (
|
|
|
|
|
|
<div className="module-content-title">
|
|
|
|
|
|
<span className="module-set-title">{group.title}</span>
|
|
|
|
|
|
<span className="module-status-text">{group.weight}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
children: (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
{group.list.map((category) => {
|
|
|
|
|
|
const selected = activeGroup?.title === group.title && selectedCategoryKey === category.key;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={category.key}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`module-left-sub ${selected ? 'is-selected' : ''}`}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setActiveGroupTitle(group.title);
|
|
|
|
|
|
setSelectedCategoryKey(category.key);
|
|
|
|
|
|
setViewMode('category');
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="module-left-sub-title">{category.title}</span>
|
|
|
|
|
|
<span className="module-status-text">{category.weight}%</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
),
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="app-container appraisal-module-detail-page">
|
2026-03-17 07:18:56 +00:00
|
|
|
|
<div className="appraisal-module-back">
|
2026-03-17 07:18:07 +00:00
|
|
|
|
<PageBackButton fallbackPath="/workAppraisal/taskModule" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-17 07:18:56 +00:00
|
|
|
|
<div className="appraisal-module-hero">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="appraisal-module-kicker">PERFORMANCE MODULE</div>
|
|
|
|
|
|
<div className="appraisal-module-title">绩效模块详情</div>
|
|
|
|
|
|
<div className="appraisal-module-subtitle">查看指标分组、分类结构和权重配置,保持考核模块结构清晰一致。</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="appraisal-module-summary">
|
|
|
|
|
|
<span>模块类型</span>
|
|
|
|
|
|
<strong>{TEMPLATE_TYPE_OPTIONS.find((item) => item.value === moduleType)?.label ?? '-'}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-17 07:18:07 +00:00
|
|
|
|
<div className="module-detail-header">
|
|
|
|
|
|
<Form layout="inline">
|
|
|
|
|
|
<Form.Item label="看板名称">
|
|
|
|
|
|
<Input value={moduleName} readOnly style={{ width: 300 }} />
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item label="看板类型">
|
|
|
|
|
|
<Select
|
|
|
|
|
|
value={moduleType}
|
|
|
|
|
|
disabled
|
|
|
|
|
|
style={{ width: 200 }}
|
|
|
|
|
|
placeholder="看板类型"
|
|
|
|
|
|
options={TEMPLATE_TYPE_OPTIONS}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{!moduleId && <Alert type="warning" showIcon message="缺少看板ID参数,无法加载看板详情" style={{ marginBottom: 12 }} />}
|
|
|
|
|
|
|
|
|
|
|
|
<Spin spinning={loading}>
|
|
|
|
|
|
<div className="module-detail-layout">
|
|
|
|
|
|
<div className="module-detail-left">
|
|
|
|
|
|
<div className="module-set-text">累计权重</div>
|
|
|
|
|
|
<Collapse
|
|
|
|
|
|
accordion
|
|
|
|
|
|
activeKey={activeGroup?.title}
|
|
|
|
|
|
onChange={handleCollapseChange}
|
|
|
|
|
|
items={collapseItems}
|
|
|
|
|
|
className="module-collapse"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div className="module-total-box">
|
|
|
|
|
|
<span className="module-set-title">总计</span>
|
|
|
|
|
|
<span className="module-status-text">{totalWeight}%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="module-detail-right">
|
|
|
|
|
|
{rightCategoryList.length === 0 ? (
|
|
|
|
|
|
<Empty description="暂无指标配置" />
|
|
|
|
|
|
) : (
|
|
|
|
|
|
rightCategoryList.map((category) => (
|
|
|
|
|
|
<div key={category.key} className="module-category-box">
|
|
|
|
|
|
<div className="module-category-title">{category.title}</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="module-set-header">
|
|
|
|
|
|
<div className="module-header-item is-name">考核项</div>
|
|
|
|
|
|
<div className="module-header-item is-remark">评分标准</div>
|
|
|
|
|
|
<div className="module-header-item is-weight">权重占比</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{category.rightArr.map((item) => (
|
|
|
|
|
|
<div key={item._itemKey} className="module-content-row">
|
|
|
|
|
|
<div className="module-row-item is-name">{String(item.reviewItem ?? '-')}</div>
|
|
|
|
|
|
<div className="module-row-item is-remark">{String(item.remarks ?? '-')}</div>
|
|
|
|
|
|
<div className="module-row-item is-weight">
|
|
|
|
|
|
<WeightBar value={toNumber(item.weight, 0)} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Spin>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default AppraisalModuleDetailPage;
|