feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { useCallback, useMemo, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { ArrowLeft, Download, 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 './flow-analysis-modal.css';
|
||||
|
||||
interface FlowAnalysisModalProps {
|
||||
selectedFlow: FlowSelection | null;
|
||||
selectedFlowDetails: FlowDetailItem[];
|
||||
setSelectedFlow: Dispatch<SetStateAction<FlowSelection | null>>;
|
||||
exportFlowDetails: (rows?: FlowDetailItem[], title?: string) => void;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
return Array.from(groups.values()).sort((a, b) => 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, exportFlowDetails }: Omit<FlowAnalysisModalProps, 'selectedFlow'> & { selectedFlow: FlowSelection }) {
|
||||
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)), [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) : 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 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 });
|
||||
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();
|
||||
};
|
||||
|
||||
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"><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>
|
||||
<p className="bi-flow-modal-definition">交车包含替换交车;业务类型按实际记录拆分,同车多笔业务分别计次。</p>
|
||||
</header>
|
||||
|
||||
<div ref={scrollRoot} className="bi-flow-modal-body">
|
||||
{!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>
|
||||
|
||||
<div className="bi-flow-modal-section-heading"><div><h4>{showDetails ? '业务明细' : dimensions.length ? '组合统计' : '筛选汇总'}</h4><p>{showDetails ? `共 ${countLabel(detailRows.length)} 条业务记录` : `共 ${countLabel(groups.length)} 组 · 占比以当前筛选的 ${countLabel(filteredRows.length)} 车次为基数`}</p></div>
|
||||
{!showDetails && dimensions.length > 1 ? <label className="bi-flow-modal-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label> : null}
|
||||
{showDetails ? <button type="button" disabled={!detailRows.length} className="bi-flow-modal-export" onClick={() => exportFlowDetails(detailRows, `${FLOW_META[selectedFlow.type].label}-${detailLabel}-明细`)}><Download size={13} />导出当前明细</button> : null}
|
||||
</div>
|
||||
|
||||
{!showDetails ? groups.length ? <>
|
||||
<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={() => { setDetailKey(group.key); }}>查看明细</button></td>
|
||||
</tr>)}
|
||||
</tbody></table></div>
|
||||
<ProgressiveRowsStatus {...groupProgress} unit="组" />
|
||||
</> : <div className="bi-flow-modal-empty">当前筛选范围暂无交还车数据,请调整筛选。</div>
|
||||
: detailRows.length ? <>
|
||||
<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></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></tr>)}</tbody></table></div>
|
||||
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
||||
</> : <div className="bi-flow-modal-empty">当前分组暂无业务明细,请返回统计调整条件。</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user