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

368 lines
12 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 { 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;