190 lines
20 KiB
TypeScript
190 lines
20 KiB
TypeScript
import { useCallback, useLayoutEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||
import { ArrowLeft, X } from 'lucide-react';
|
||
import type { FlowDetailItem, FlowType } from '../api';
|
||
import type { FlowSelection } from '../model';
|
||
import { FLOW_META } from './FlowDetailModal';
|
||
import { orderDimensionGroups, dimensionRowSpans, dimensionParentTotals, dimensionChildCounts, dimensionCountUnit, visibleDimensionRowSpans } from '../dimension-layout';
|
||
import { useDialogFocus } from './useDialogFocus';
|
||
import { DimensionPicker, type DimensionPickerOption } from './analysis/DimensionPicker';
|
||
import { ProgressiveRowsStatus } from './analysis/ProgressiveRowsStatus';
|
||
import { useProgressiveRows } from './analysis/useProgressiveRows';
|
||
import { AnalysisExportButton } from './analysis/AnalysisExportButton';
|
||
import { downloadAnalysisWorkbook, statisticsExportRows } from '../analysis-export';
|
||
import './analysis/asset-analysis.css';
|
||
import './flow-analysis-modal.css';
|
||
import './analysis/mobile-analysis.css';
|
||
import { MobileAnalysisControls } from './analysis/MobileAnalysisControls';
|
||
import { MobileGroupedTable } from './analysis/MobileGroupedTable';
|
||
import { useMobileLayout } from '../useMobileLayout';
|
||
|
||
interface FlowAnalysisModalProps {
|
||
selectedFlow: FlowSelection | null;
|
||
selectedFlowDetails: FlowDetailItem[];
|
||
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
|
||
}
|
||
|
||
type Dimension = 'date' | 'department' | 'manager' | 'type';
|
||
const DIMENSIONS: { key: Dimension; label: string }[] = [
|
||
{ key: 'date', label: '日期' }, { key: 'department', label: '部门' },
|
||
{ key: 'manager', label: '业务负责人' }, { key: 'type', label: '业务类型' },
|
||
];
|
||
const DIMENSION_LABELS: Record<Dimension, string> = { date: '日期', department: '部门', manager: '业务负责人', type: '业务类型' };
|
||
const EMPTY_FLOW_ROWS: FlowDetailItem[] = [];
|
||
const countLabel = (count: number) => count.toLocaleString('zh-CN');
|
||
const dimensionValue = (row: FlowDetailItem, dimension: Dimension) => row[dimension].trim();
|
||
const dimensionLabel = (dimension: Dimension, value: string) => dimension === 'type'
|
||
? FLOW_META[value as FlowType]?.label || value
|
||
: value || `未填写${DIMENSION_LABELS[dimension]}`;
|
||
|
||
interface FlowGroup { key: string; values: string[]; rows: FlowDetailItem[] }
|
||
|
||
/** Each business event belongs to exactly one actual type; replacements also belong to the delivery scope. */
|
||
function matchesType(row: FlowDetailItem, type: FlowType): boolean {
|
||
return row.type === type || (type === 'delivered' && row.type === 'replaced');
|
||
}
|
||
|
||
function groupRows(rows: FlowDetailItem[], dimensions: Dimension[]): FlowGroup[] {
|
||
const groups = new Map<string, FlowGroup>();
|
||
for (const row of rows) {
|
||
const values = dimensions.map(dimension => dimensionValue(row, dimension));
|
||
const key = JSON.stringify(values);
|
||
const group = groups.get(key) ?? { key, values, rows: [] };
|
||
group.rows.push(row);
|
||
groups.set(key, group);
|
||
}
|
||
const dateIndex = dimensions.indexOf('date');
|
||
return Array.from(groups.values()).sort((a, b) => (dateIndex >= 0 ? b.values[dateIndex].localeCompare(a.values[dateIndex]) : 0) || b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
|
||
}
|
||
|
||
export function FlowAnalysisModal(props: FlowAnalysisModalProps) {
|
||
if (!props.selectedFlow) return null;
|
||
return <FlowAnalysisContent key={JSON.stringify(props.selectedFlow)} {...props} selectedFlow={props.selectedFlow} />;
|
||
}
|
||
|
||
function FlowAnalysisContent({ selectedFlow, selectedFlowDetails, setSelectedFlow }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
|
||
const mobile = useMobileLayout();
|
||
const [dimensions, setDimensions] = useState<Dimension[]>(['date']);
|
||
const [mergeParents, setMergeParents] = useState(true);
|
||
const mergeActive = mergeParents && dimensions.length > 1;
|
||
const [filters, setFilters] = useState<Partial<Record<Dimension, string[]>>>({});
|
||
const [detailKey, setDetailKey] = useState<string | null>(null);
|
||
const dialog = useRef<HTMLDivElement>(null);
|
||
const scrollRoot = useRef<HTMLDivElement>(null);
|
||
const close = useCallback(() => setSelectedFlow(null), [setSelectedFlow]);
|
||
useDialogFocus(true, dialog, close);
|
||
|
||
const rows = useMemo(() => selectedFlowDetails.filter(row => matchesType(row, selectedFlow.type)
|
||
&& ('date' in selectedFlow ? row.date === selectedFlow.date : row.date >= selectedFlow.start && row.date <= selectedFlow.end))
|
||
.sort((a, b) => b.date.localeCompare(a.date) || (b.eventTime || '').localeCompare(a.eventTime || '')), [selectedFlowDetails, selectedFlow]);
|
||
const filterOptions = useMemo(() => Object.fromEntries(DIMENSIONS.map(({ key }) => {
|
||
const eligible = rows.filter(row => DIMENSIONS.every(({ key: other }) => other === key || !filters[other]?.length || filters[other]!.includes(dimensionValue(row, other))));
|
||
const counts = new Map<string, number>();
|
||
for (const row of eligible) { const value = dimensionValue(row, key); counts.set(value, (counts.get(value) || 0) + 1); }
|
||
for (const value of filters[key] || []) if (!counts.has(value)) counts.set(value, 0);
|
||
return [key, [...counts].map(([value, count]) => ({ value, label: dimensionLabel(key, value), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))];
|
||
})) as Record<Dimension, DimensionPickerOption[]>, [rows, filters]);
|
||
const filteredRows = useMemo(() => rows.filter(row => DIMENSIONS.every(({ key }) => !filters[key]?.length || filters[key]!.includes(dimensionValue(row, key)))), [rows, filters]);
|
||
const groups = useMemo(() => {
|
||
const flat = groupRows(filteredRows, dimensions);
|
||
return mergeActive ? orderDimensionGroups(flat, dimensions.length, group => group.values, group => group.rows.length, dimensions.includes('date') ? 'label' : 'count-desc',
|
||
(a, b, index) => dimensions[index] === 'date' ? b.values[index].localeCompare(a.values[index]) : a.values[index].localeCompare(b.values[index], 'zh-CN')) : flat;
|
||
}, [filteredRows, dimensions, mergeActive]);
|
||
const detailGroup = useMemo(() => detailKey === null ? null : groups.find(group => group.key === detailKey), [groups, detailKey]);
|
||
const detailRows = detailGroup?.rows ?? EMPTY_FLOW_ROWS;
|
||
const showDetails = detailKey !== null;
|
||
const overviewScroll = useRef(0);
|
||
const openDetails = (key: string) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailKey(key); };
|
||
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
|
||
const detailLabel = detailGroup && dimensions.length ? detailGroup.values.map((value, i) => `${DIMENSION_LABELS[dimensions[i]]}:${dimensionLabel(dimensions[i], value)}`).join(' · ') : '当前筛选范围';
|
||
const selectedRows = showDetails ? detailRows : filteredRows;
|
||
const { replacedCount, departmentCount } = useMemo(() => ({
|
||
replacedCount: selectedRows.reduce((sum, row) => sum + Number(row.type === 'replaced'), 0),
|
||
departmentCount: new Set(selectedRows.map(row => dimensionValue(row, 'department'))).size,
|
||
}), [selectedRows]);
|
||
const rangeLabel = 'date' in selectedFlow ? selectedFlow.date : `${selectedFlow.start} 至 ${selectedFlow.end}`;
|
||
const parentTotals = useMemo(() => mergeActive ? dimensionParentTotals(groups, dimensions.length, group => group.values, group => group.rows.length) : [], [groups, dimensions.length, mergeActive]);
|
||
const childCounts = useMemo(() => mergeActive ? dimensionChildCounts(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
|
||
const groupProgress = useProgressiveRows({ total: groups.length, rootRef: scrollRoot, resetKey: groups, enabled: !showDetails, preserveOnDisable: true });
|
||
const detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails });
|
||
const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]);
|
||
const completeSpans = useMemo(() => mergeActive ? dimensionRowSpans(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
|
||
const groupSpans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
|
||
const shownDetails = useMemo(() => detailRows.slice(0, detailProgress.visibleCount), [detailRows, detailProgress.visibleCount]);
|
||
const activeFilters = DIMENSIONS.filter(({ key }) => filters[key]?.length);
|
||
const resetResult = () => { setDetailKey(null); };
|
||
const toggleDimension = (dimension: Dimension) => {
|
||
setDimensions(current => current.includes(dimension) ? current.filter(value => value !== dimension) : [...current, dimension]);
|
||
resetResult();
|
||
};
|
||
const changeFilter = (dimension: Dimension, value: string[] | undefined) => {
|
||
setFilters(current => { const next = { ...current }; if (value === undefined) delete next[dimension]; else next[dimension] = value; return next; });
|
||
resetResult();
|
||
};
|
||
|
||
const exportWorkbook = () => {
|
||
const statistics = statisticsExportRows(dimensions.map(d => DIMENSION_LABELS[d]), (detailGroup ? [detailGroup] : groups).map(group => ({ values: group.values.map((v, i) => dimensionLabel(dimensions[i], v)), count: group.rows.length })), selectedRows.length, '车次');
|
||
const details = selectedRows.map(item => ({ 业务日期: item.date, 类型: item.typeLabel, 车牌: item.plateNumber,
|
||
运维签章时间: item.eventTime, 建单时间: item.submitTime, 部门: item.department, 业务负责人: item.manager,
|
||
客户: item.customerName, 合同: item.contractNo, 项目名称: item.projectName }));
|
||
return downloadAnalysisWorkbook(statistics, details, `${FLOW_META[selectedFlow.type].label}-${rangeLabel}-${showDetails ? detailLabel : '当前筛选范围'}`);
|
||
};
|
||
|
||
return <div className="bi-flow-modal-backdrop" onClick={event => { if (event.target === event.currentTarget) close(); }}>
|
||
<div ref={dialog} role="dialog" aria-modal="true" aria-labelledby="bi-flow-modal-title" tabIndex={-1} className="bi-flow-modal" data-testid="asset-flow-analysis-modal">
|
||
<header className="bi-flow-modal-header">
|
||
<div className="bi-flow-modal-header-row">{mobile && <button className="bi-flow-mobile-back" aria-label={showDetails ? "返回交还车统计" : "返回资产管理"} onClick={() => showDetails ? setDetailKey(null) : close()}><ArrowLeft size={20} /></button>}<div><h3 id="bi-flow-modal-title">{FLOW_META[selectedFlow.type].label}分析</h3><p>{rangeLabel} · 运维签章时间 · 车次</p></div><button type="button" aria-label="关闭交还车分析" onClick={close} className="bi-flow-modal-close"><X size={16} /></button></div>
|
||
{!mobile && <p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p>}
|
||
</header>
|
||
|
||
<div ref={scrollRoot} className="bi-flow-modal-body">
|
||
{!mobile && (!showDetails ? <div className="bi-flow-modal-controls">
|
||
<div className="bi-flow-modal-control-row"><span className="bi-flow-modal-control-label">分析维度</span><div className="adp-row" role="group" aria-label="选择交还车统计维度">
|
||
{DIMENSIONS.map(({ key, label }) => <DimensionPicker key={key} label={label} grouped={dimensions.includes(key)} order={dimensions.indexOf(key) + 1} options={filterOptions[key]} selectedValues={filters[key]} onToggleGroup={() => toggleDimension(key)} onApplyFilter={values => changeFilter(key, values)} />)}
|
||
<button type="button" className={`bi-flow-modal-chip${dimensions.length === 0 ? ' bi-flow-modal-chip-active' : ''}`} aria-pressed={dimensions.length === 0} onClick={() => { setDimensions([]); resetResult(); }}>仅筛选</button>
|
||
{activeFilters.length ? <button type="button" className="bi-flow-modal-clear" onClick={() => { setFilters({}); resetResult(); }}>清空筛选</button> : null}
|
||
</div></div>
|
||
<p className="bi-flow-modal-control-hint">点击维度名切换分组,点击右侧箭头筛选;同维度多选为“或”,不同维度为“且”。</p>
|
||
</div> : <div className="bi-flow-modal-detail-nav"><button type="button" className="bi-flow-modal-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={13} />返回统计</button><span>{detailLabel}</span></div>)}
|
||
|
||
<div className="bi-flow-modal-summary">
|
||
<div><span>{showDetails ? '当前分组' : '当前筛选'}{FLOW_META[selectedFlow.type].label}</span><strong>{countLabel(selectedRows.length)}<small>车次</small></strong></div>
|
||
<div><span>涉及部门</span><strong>{departmentCount}<small>个</small></strong></div>
|
||
{selectedFlow.type === 'delivered' ? <div><span>其中:替换交车</span><strong className="bi-flow-modal-purple">{countLabel(replacedCount)}<small>车次</small></strong></div> : <div><span>{showDetails ? '占当前筛选' : '统计分组'}</span><strong>{showDetails ? `${filteredRows.length ? (selectedRows.length / filteredRows.length * 100).toFixed(1) : '0.0'}%` : groups.length}{!showDetails ? <small>组</small> : null}</strong></div>}
|
||
</div>
|
||
|
||
{mobile && !showDetails && (filteredRows.length > 0 || activeFilters.length > 0) && <MobileAnalysisControls available={DIMENSIONS.map(d => ({ ...d, options: filterOptions[d.key] }))} dimensions={dimensions} filters={filters} defaults={['date']} apply={(nextDimensions, nextFilters) => { setDimensions(nextDimensions as Dimension[]); setFilters(nextFilters); resetResult(); }} />}
|
||
{mobile && showDetails && <p className="analysis-mobile-detail-scope">{detailLabel}</p>}
|
||
<div className="bi-flow-modal-section-heading"><div>{(!mobile || showDetails) && <h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4>}<p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : mobile ? `共 ${countLabel(groups.length)} 组 · ${countLabel(filteredRows.length)} 车次` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
|
||
{!mobile && !showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
|
||
<div className="analysis-export-actions">
|
||
{(!mobile || selectedRows.length > 0) && <AnalysisExportButton disabled={!selectedRows.length} onExport={exportWorkbook} />}
|
||
</div>
|
||
</div>
|
||
|
||
{!showDetails ? groups.length ? <>
|
||
{mobile ? <MobileGroupedTable dimensions={dimensions.map(d => DIMENSION_LABELS[d])} unit="车次" merged={mergeActive} setMerged={setMergeParents} spans={groupSpans} parentTotals={parentTotals}
|
||
rows={shownGroups.map(group => ({ key: group.key, label: group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围', values: group.values.map((value, i) => dimensionLabel(dimensions[i], value)), count: group.rows.length, share: group.rows.length / filteredRows.length * 100 }))}
|
||
onDetails={index => openDetails(shownGroups[index].key)} /> :
|
||
<div className={`bi-flow-modal-table-wrap${mergeActive ? ' is-merged' : ''}`}><table className="bi-flow-modal-table" data-testid="asset-flow-group-table"><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>范围</th>}<th className="bi-flow-modal-number">车次</th><th className="bi-flow-modal-share">占比</th><th className="bi-flow-modal-action">操作</th></tr></thead><tbody>
|
||
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 0 ? 'bi-flow-modal-group-start' : undefined}>
|
||
{dimensions.length ? group.values.map((value, index) => {
|
||
const span = mergeActive ? groupSpans[rowIndex][index] : 1;
|
||
return span === 0 ? null : <td key={dimensions[index]} rowSpan={span > 1 ? span : undefined} className={span > 1 ? 'bi-flow-modal-parent-cell' : undefined}>{dimensionLabel(dimensions[index], value)}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <><small className="bi-flow-modal-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{countLabel(parentTotals[rowIndex][index])}</strong> 车次</small><small className="bi-flow-modal-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{DIMENSION_LABELS[dimensions[index + 1]]}:<strong>{countLabel(childCounts[rowIndex][index])}</strong> {dimensionCountUnit(dimensions[index + 1])}</small></>}</td>;
|
||
}) : <td>当前筛选范围</td>}
|
||
<td className="bi-flow-modal-number"><strong>{countLabel(group.rows.length)}</strong></td><td className="bi-flow-modal-share"><div><span className="bi-flow-modal-track"><span className={`bi-flow-modal-fill bi-flow-modal-fill-${selectedFlow.type}`} style={{ width: `${group.rows.length / filteredRows.length * 100}%` }} /></span><span>{(group.rows.length / filteredRows.length * 100).toFixed(1)}%</span></div></td>
|
||
<td className="bi-flow-modal-action"><button type="button" aria-label={`查看${group.values.map((value, i) => dimensionLabel(dimensions[i], value)).join('、') || '当前筛选范围'}明细`} onClick={() => openDetails(group.key)}>查看明细</button></td>
|
||
</tr>)}
|
||
</tbody></table></div>}
|
||
<ProgressiveRowsStatus {...groupProgress} unit="组" />
|
||
</> : <div className="bi-flow-modal-empty">{activeFilters.length ? '当前筛选范围暂无交还车数据,可调整筛选。' : '当前时段暂无交还车记录。'}</div>
|
||
: detailRows.length ? <>
|
||
{mobile ? <div className="analysis-vehicle-cards" data-testid="asset-flow-final-details">{shownDetails.map(item => <article className="analysis-vehicle-card" key={item.id}><header><h4>{item.plateNumber || '未登记车牌'}</h4><span>{FLOW_META[item.type].label}</span></header><p>{item.eventTime || item.date} · {item.department || '未填写部门'}</p><p>{item.customerName || '未填写客户'}</p><details><summary>业务资料</summary><dl>{[['运维签章时间', item.eventTime], ['建单时间', item.submitTime], ['业务负责人', item.manager], ['合同', item.contractNo], ['项目名称', item.projectName]].map(([label,value]) => <div key={label}><dt>{label}</dt><dd>{value || '未提供'}</dd></div>)}</dl></details></article>)}</div> :
|
||
<div className="bi-flow-modal-table-wrap" data-testid="asset-flow-final-details"><table className="bi-flow-modal-table bi-flow-modal-detail-table"><thead><tr><th>车牌</th><th>业务类型</th><th>运维签章时间</th><th>建单时间</th><th>部门</th><th>业务负责人</th><th>客户</th><th>合同</th><th>项目名称</th></tr></thead><tbody>{shownDetails.map(item => <tr key={item.id}><td>{item.plateNumber || '未登记车牌'}</td><td>{FLOW_META[item.type].label}</td><td>{item.eventTime || '—'}</td><td>{item.submitTime || '—'}</td><td>{dimensionLabel('department', dimensionValue(item, 'department'))}</td><td>{item.manager || '—'}</td><td>{item.customerName || '—'}</td><td>{item.contractNo || '—'}</td><td>{item.projectName || '—'}</td></tr>)}</tbody></table></div>}
|
||
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
||
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
|
||
</div>
|
||
{mobile && <footer className="analysis-footer"><details><summary>统计口径</summary><p>按运维签章时间统计。交车包含替换交车;同车多笔业务分别计次。仅展示当前可查看范围与筛选条件内的记录。</p></details></footer>}
|
||
</div>
|
||
</div>;
|
||
}
|