368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
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">
|
||
<div className="appraisal-module-back">
|
||
<PageBackButton fallbackPath="/workAppraisal/taskModule" />
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<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;
|