feat(assets): add dimension analysis and authorized user previews
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:
shishengliang
2026-09-28 12:29:51 +08:00
parent 6de77adb47
commit f1b3f8d3da
69 changed files with 3871 additions and 536 deletions
@@ -5,11 +5,13 @@ type DetailsProps = { vehicles: VehicleListItem[] };
function InventoryVehicleDetails({ vehicles, kind }: DetailsProps & { kind: 'abnormal' | 'pending' }) {
const pending = kind === 'pending';
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '具体工作', '申请人', '目标完成时间', '实际完成时间'];
const labels = pending ? ['合同编号', '客户名称(已脱敏)'] : ['异动类型', '审批通过时间', '已异动时长', '具体工作', '申请人', '预计完成时间', '实际完成时间'];
const details = (vehicle: VehicleListItem) => pending
? [vehicle.pendingDelivery?.contractNo || '未提供', vehicle.pendingDelivery?.customerName || '未提供']
: [
vehicle.abnormalMove?.type || '未提供',
vehicle.abnormalMove?.approvalPassedAt ? new Date(vehicle.abnormalMove.approvalPassedAt).toLocaleString('zh-CN', { hour12: false }) : '审批时间缺失',
vehicle.abnormalMove?.elapsedDays != null ? `${vehicle.abnormalMove.elapsedDays.toFixed(1)} 天` : '无法计算',
vehicle.abnormalMove?.work || '未提供',
vehicle.abnormalMove?.applicant || '未提供',
vehicle.abnormalMove?.targetCompletionTime || '未提供',
@@ -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>;
}
@@ -46,7 +46,7 @@ export function FlowDetailModal({
{dateLabel} · {FLOW_META[selectedFlow.type].label}明细
</h3>
<div className="mt-1 text-[12px] font-bold text-slate-300">
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、负责人和客户
共 {selectedFlowDetails.length} 车次,包含车牌、运维签章时间、建单时间、部门、业务负责人和客户
</div>
</div>
<button
@@ -120,7 +120,7 @@ export function FlowDetailModal({
<div className="mt-1 font-bold text-slate-700">{item.eventTime || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
<div className="font-black text-slate-400">负责人</div>
<div className="font-black text-slate-400">业务负责人</div>
<div className="mt-1 font-bold text-slate-700">{item.manager || '-'}</div>
</div>
<div className="rounded-xl bg-slate-50 p-2">
@@ -0,0 +1,89 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Check, ChevronDown, Filter, Plus, RotateCcw, Search, X } from 'lucide-react';
import type { AssetAnalysisCell, AssetAnalysisDimension as Dimension } from '../../../../shared/assets/analytics';
import { ANALYSIS_META, DIMENSION_LABELS, filterCustomCells, getDimensionValueLabel, type AnalysisFilterValues, type AnalysisView } from '../../analytics-model';
type Props = {
view: AnalysisView;
cells: AssetAnalysisCell[];
available: Dimension[];
dimensions: Dimension[];
setDimensions: (value: Dimension[]) => void;
filters: AnalysisFilterValues;
setFilters: (value: AnalysisFilterValues) => void;
reset: () => void;
};
export function AnalysisControls({ view, cells, available, dimensions, setDimensions, filters, setFilters, reset }: Props) {
const root = useRef<HTMLElement>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const [editor, setEditor] = useState<Dimension | null>(null);
const [draft, setDraft] = useState<string[]>([]);
const [query, setQuery] = useState('');
const activeCount = Object.values(filters).filter(values => values?.length).length;
useEffect(() => {
if (!editor) return;
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setEditor(null); };
document.addEventListener('pointerdown', outside);
return () => document.removeEventListener('pointerdown', outside);
}, [editor]);
const metric = ANALYSIS_META[view].metric;
const options = useMemo(() => {
if (!editor) return [];
const otherFilters = { ...filters };
delete otherFilters[editor];
const eligible = filterCustomCells(cells, otherFilters, metric);
const counts = new Map<string, number>();
eligible.forEach(cell => counts.set(cell[editor], (counts.get(cell[editor]) || 0) + cell.quantity));
for (const value of filters[editor] || []) if (!counts.has(value)) counts.set(value, 0);
return [...counts].map(([value, count]) => ({ value, count, label: getDimensionValueLabel(editor, value) }))
.sort((a,b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))
.filter(option => option.label.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
}, [cells, editor, filters, metric, query]);
const closeEditor = () => { setEditor(null); triggerRef.current?.focus(); };
const open = (dimension: Dimension, trigger: HTMLButtonElement) => {
triggerRef.current = trigger;
if (editor === dimension) { setEditor(null); return; }
const bounds = root.current!.getBoundingClientRect();
const button = trigger.getBoundingClientRect();
setPosition({ top: button.bottom - bounds.top + 5, left: Math.max(8, Math.min(button.left - bounds.left, bounds.width - 338)) });
setEditor(dimension); setDraft(filters[dimension] || []); setQuery('');
};
const apply = () => {
if (!editor) return;
const next = { ...filters };
if (draft.length) next[editor] = draft; else delete next[editor];
setFilters(next); closeEditor();
};
return <section ref={root} className="analysis-builder" aria-label="自定义分析设置">
<div className="analysis-builder-heading"><h3>分析维度</h3><span>点名称组合统计 · 点箭头搜索筛选</span><button className="analysis-reset" onClick={() => { reset(); setEditor(null); }}><RotateCcw size={12} />重置分析</button></div>
<div className="analysis-dimension-options" role="group" aria-label="选择分析维度">
<button aria-pressed={dimensions.length === 0} onClick={() => { setDimensions([]); setEditor(null); }}><Filter size={12} />仅筛选</button>
{available.map(dimension => {
const index = dimensions.indexOf(dimension);
const selected = filters[dimension] || [];
const filterLabel = selected.map(value => getDimensionValueLabel(dimension, value)).join('、');
return <div className={`analysis-dimension-column${selected.length ? ' has-filter' : ''}`} key={dimension}>
<button className="analysis-dimension-name" title={dimension === 'region' ? '区域划分与库存统计一致' : undefined} aria-pressed={index >= 0} onClick={() => { setDimensions(index < 0 ? [...dimensions, dimension] : dimensions.filter(item => item !== dimension)); setEditor(null); }}>
{index >= 0 ? <span className="analysis-dimension-order">{index + 1}</span> : <Plus size={12} />}{DIMENSION_LABELS[dimension]}
</button>
<button className="analysis-dimension-dropdown" title={selected.length ? filterLabel : `搜索、筛选${DIMENSION_LABELS[dimension]}`} aria-label={`筛选${DIMENSION_LABELS[dimension]}${selected.length ? `,已选${selected.length}项` : ''}`} aria-expanded={editor === dimension} aria-controls={editor === dimension ? 'analysis-column-filter' : undefined} onClick={event => open(dimension, event.currentTarget)}>
{selected.length > 0 && <><Filter size={11} /><span className="analysis-inline-filter">{selected.length === 1 ? filterLabel : `${selected.length}项`}</span></>}<ChevronDown size={12} />
</button>
</div>;
})}
</div>
{activeCount > 0 && <div className="analysis-active-note"><span>{activeCount} 个维度已筛选 · 同维度多选为“或”,不同维度为“且”</span><button className="analysis-text-button" onClick={() => { setFilters({}); setEditor(null); }}>清空全部筛选</button></div>}
{editor && <div id="analysis-column-filter" className="analysis-filter-editor analysis-column-popover" style={position} role="group" aria-label={`${DIMENSION_LABELS[editor]}筛选菜单`} onKeyDown={event => { if (event.key === 'Escape') { event.stopPropagation(); closeEditor(); } }}>
<div className="analysis-filter-editor-top"><strong>{DIMENSION_LABELS[editor]}</strong><span>搜索与筛选</span><button className="analysis-icon-button" aria-label="关闭筛选编辑" onClick={closeEditor}><X size={15} /></button></div>
<label className="analysis-filter-search"><Search size={13} /><input autoFocus aria-label="搜索筛选值" placeholder={`搜索${DIMENSION_LABELS[editor]}`} value={query} onChange={event => setQuery(event.target.value)} /></label>
<div className="analysis-filter-selection"><button onClick={() => setDraft([...new Set([...draft, ...options.map(option => option.value)])])}>全选{query ? '搜索结果' : ''}</button><button onClick={() => setDraft([])}>清除选择</button><span>已选 {draft.length} 项</span></div>
<div className="analysis-filter-options" role="group" aria-label={`${DIMENSION_LABELS[editor]}选项`}>
{options.map(option => <label key={option.value}><input type="checkbox" checked={draft.includes(option.value)} onChange={event => setDraft(event.target.checked ? [...draft,option.value] : draft.filter(value => value !== option.value))} /><span>{option.label}</span><small>{option.count} 台</small></label>)}
{!options.length && <p>没有匹配选项,可调整搜索或其他筛选。</p>}
</div>
<div className="analysis-filter-editor-bottom"><span>未选择时不限制此维度</span><button className="analysis-button" onClick={closeEditor}>取消</button><button className="analysis-primary" onClick={apply}><Check size={13} />应用</button></div>
</div>}
</section>;
}
@@ -0,0 +1,270 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowLeft, BarChart3, Filter, Loader2, Search, X } from 'lucide-react';
import { fetchJson } from '../../../../auth/api-client';
import Blur from '../../../../components/Blur';
import type { AssetAnalysisResponse, AssetAnalysisDimension } from '../../../../shared/assets/analytics';
import { ANALYSIS_META, DIMENSION_LABELS, compareCustomLabels, getCustomAnalysis, getAvailableDimensions, getDefaultDimensions, getDimensionValueLabel, type AnalysisFilterValues, type CustomAnalysisRow, type AnalysisSort, type AnalysisView } from '../../analytics-model';
import { dimensionChildCounts, dimensionCountUnit, dimensionParentTotals, dimensionRowSpans, orderDimensionGroups, visibleDimensionRowSpans } from '../../dimension-layout';
import { AnalysisControls } from './AnalysisControls';
import { DimensionPicker } from './DimensionPicker';
import { ProgressiveRowsStatus } from './ProgressiveRowsStatus';
import { useProgressiveRows } from './useProgressiveRows';
import { buildVehicleModalRequest, formatLocalDateTime, type VehicleModalSelection } from '../../model';
import { fetchWeeklyDetail, type WeeklyDetailItem } from '../../api';
import type { VehicleListItem } from '../../types';
import { AbnormalVehicleDetails, PendingVehicleDetails } from '../AbnormalVehicleDetails';
import { useDialogFocus } from '../useDialogFocus';
import './asset-analysis.css';
type Props = { selection: VehicleModalSelection | null; subject: string | null; close: () => void };
const number = (value: number) => value.toLocaleString('zh-CN');
function initialView(selection: VehicleModalSelection): AnalysisView {
if (selection.category === 'Abnormal') return 'abnormal';
if (selection.category === 'Inventory') return 'inventory';
if (selection.category === 'Pending') return 'pending';
if (selection.source === 'department') return 'department';
if (selection.source === 'customer') return 'customer';
if (selection.category === 'Operating') return 'operating';
return 'assets';
}
export function AssetAnalysisModal({ selection, subject, close }: Props) {
if (!selection) return null;
return <AnalysisDialog key={JSON.stringify([selection, subject])} selection={selection} subject={subject} close={close} />;
}
function AnalysisDialog({ selection, subject, close }: Props & { selection: VehicleModalSelection }) {
const dialog = useRef<HTMLDivElement>(null);
const scrollRoot = useRef<HTMLDivElement>(null);
useDialogFocus(true, dialog, close);
const [data, setData] = useState<AssetAnalysisResponse | null>(null);
const [weekly, setWeekly] = useState<WeeklyDetailItem[]>([]);
const [busy, setBusy] = useState(true);
const [error, setError] = useState('');
const [retry, setRetry] = useState(0);
const view = initialView(selection);
const [dimensions, setDimensions] = useState<AssetAnalysisDimension[]>(() => getDefaultDimensions(view));
const [filterValues, setFilterValues] = useState<AnalysisFilterValues>({});
const [sort, setSort] = useState<AnalysisSort>('count-desc');
const [mergeParents, setMergeParents] = useState(true);
const [detailGroup, setDetailGroup] = useState<CustomAnalysisRow | null>(null);
const showDetails = detailGroup !== null;
const [vehicles, setVehicles] = useState<VehicleListItem[]>([]);
const [detailBusy, setDetailBusy] = useState(false);
const [detailError, setDetailError] = useState('');
const [detailRetry, setDetailRetry] = useState(0);
const [search, setSearch] = useState('');
const request = useMemo(() => buildVehicleModalRequest(selection, subject), [selection, subject]);
const endpoint = `/api/vehicles/analysis${subject ? `?subject=${encodeURIComponent(subject)}` : ''}`;
const available = getAvailableDimensions(view);
const analysis = useMemo(() => getCustomAnalysis(data?.cells ?? [], { view, dimensions, filterValues, sort }), [data, view, dimensions, filterValues, sort]);
const filterValuesKey = JSON.stringify(filterValues);
const detailFiltersKey = JSON.stringify(detailGroup?.values ?? {});
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setBusy(true); setError('');
const load = request.kind === 'weekly'
? fetchWeeklyDetail(request.type, { ...request.filters, subject }).then(value => { if (!cancelled) setWeekly(value); })
: fetchJson<AssetAnalysisResponse>(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: request.params }), signal: controller.signal }).then(value => { if (!cancelled) setData(value); });
load.catch(e => { if (!cancelled) setError(e instanceof Error ? e.message : '统计加载失败'); })
.finally(() => { if (!cancelled) setBusy(false); });
return () => { cancelled = true; controller.abort(); };
}, [request, subject, endpoint, retry]);
useEffect(() => {
if (!showDetails || request.kind !== 'vehicles' || !data) return;
const controller = new AbortController();
let cancelled = false;
setDetailBusy(true); setDetailError(''); setVehicles([]); setSearch('');
fetchJson<VehicleListItem[]>(endpoint.replace('/analysis', '/analysis/details'), {
method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: controller.signal,
body: JSON.stringify({ metric: analysis.metric, filters: JSON.parse(detailFiltersKey), filterValues: JSON.parse(filterValuesKey), generatedAt: data.generatedAt, scope: request.params }),
}).then(value => { if (!cancelled) setVehicles(value); })
.catch(e => { if (!cancelled) setDetailError(e instanceof Error ? e.message : '车辆明细加载失败'); })
.finally(() => { if (!cancelled) setDetailBusy(false); });
return () => { cancelled = true; controller.abort(); };
}, [showDetails, analysis.metric, detailFiltersKey, filterValuesKey, request, data, endpoint, detailRetry]);
const visibleVehicles = useMemo(() => {
const query = search.toLocaleLowerCase();
return vehicles.filter(vehicle => !query || [vehicle.plateNumber, vehicle.vin, vehicle.model, vehicle.city].some(value => value?.toLocaleLowerCase().includes(query)));
}, [vehicles, search]);
const detailProgress = useProgressiveRows({ total: visibleVehicles.length, rootRef: scrollRoot, resetKey: visibleVehicles, enabled: showDetails && !detailBusy && !busy, batchSize: 40 });
const shownVehicles = useMemo(() => visibleVehicles.slice(0, detailProgress.visibleCount), [visibleVehicles, detailProgress.visibleCount]);
const updateDimensions = (next: AssetAnalysisDimension[]) => { setDimensions(next); };
const updateFilters = (next: AnalysisFilterValues) => { setFilterValues(next); };
const reset = () => { updateDimensions(getDefaultDimensions(view)); updateFilters({}); setMergeParents(true); };
const narrowToGroup = (row: CustomAnalysisRow) => {
updateFilters({ ...filterValues, ...Object.fromEntries(Object.entries(row.values).map(([dimension, value]) => [dimension, [value]])) });
};
const merged = mergeParents && analysis.dimensions.length > 1;
const orderedGroups = useMemo(() => merged
? orderDimensionGroups(analysis.rows, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count, sort, (a, b, index) => compareCustomLabels(a, b, [analysis.dimensions[index]]))
: analysis.rows, [analysis, merged, sort]);
const parentTotals = useMemo(() => merged ? dimensionParentTotals(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || ''), row => row.count) : [], [orderedGroups, analysis.dimensions, merged]);
const childCounts = useMemo(() => merged ? dimensionChildCounts(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [orderedGroups, analysis.dimensions, merged]);
const groupProgress = useProgressiveRows({ total: orderedGroups.length, rootRef: scrollRoot, resetKey: orderedGroups, enabled: !showDetails && !busy, batchSize: 40 });
const shownGroups = useMemo(() => orderedGroups.slice(0, groupProgress.visibleCount), [orderedGroups, groupProgress.visibleCount]);
const completeSpans = useMemo(() => merged ? dimensionRowSpans(orderedGroups, analysis.dimensions.length, row => analysis.dimensions.map(dimension => row.values[dimension] || '')) : [], [merged, orderedGroups, analysis.dimensions]);
const spans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
const title = request.kind === 'weekly' ? '交还车统计分析' : ANALYSIS_META[view].title;
const scopeLabel = selection.scopeLabel || [subject || '全部公司', selection.title].filter(Boolean).join(' · ');
return <div className="asset-analysis-overlay">
<div ref={dialog} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} className={`asset-analysis-dialog ${view === 'abnormal' ? 'analysis-amber' : ''}`}>
<header className="analysis-header"><div><h2><BarChart3 size={16} />{title}</h2><p>{scopeLabel}</p></div><button type="button" aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
{busy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在汇总统计数据…</div> : error ? <div className="analysis-message" role="alert"><p>统计数据加载失败:{error}</p><button className="analysis-button" onClick={() => setRetry(retry + 1)}>重新加载</button></div> : request.kind === 'weekly' ? <WeeklyAnalysis rows={weekly} /> : <>
{showDetails && <div className="analysis-navigation"><button className="analysis-back" onClick={() => setDetailGroup(null)}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup.label}</span></div>}
<div ref={scrollRoot} className="analysis-scroll">
{!showDetails && <>
<AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(analysis.total)}</strong> 台</span><span>{analysis.rows.length} 个分组</span><span className="analysis-result-dimensions">{analysis.dimensions.length ? analysis.dimensions.map(dimension => DIMENSION_LABELS[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
{view === 'inventory' && <div className="analysis-inventory-subsets" aria-label="库存子集统计"><span className="analysis-pending-count">待交车 <strong>{number(analysis.summary.pending)}</strong> 台</span><span className="analysis-abnormal-count">异动 <strong>{number(analysis.summary.abnormal)}</strong> 台</span><small>已包含在当前库存数量中</small></div>}
{view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
<div className="analysis-table-tools"><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options">{analysis.dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}<label>排序<select aria-label="统计分组排序" value={sort} onChange={event => { setSort(event.target.value as AnalysisSort); }}><option value="count-desc">数量从多到少</option><option value="count-asc">数量从少到多</option><option value="label">维度顺序</option></select></label></div></div>
{analysis.total === 0 ? <div className="analysis-message"><BarChart3 size={22} /><h3>没有符合条件的数据</h3><p>移除部分筛选条件,或恢复当前入口的全部数据。</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
<div className={`analysis-group-table${merged ? ' is-merged' : ''}`} role="region" aria-label="组合统计表" tabIndex={0}><table>
<thead><tr>{analysis.dimensions.length ? analysis.dimensions.map(dimension => <th key={dimension}>{DIMENSION_LABELS[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(台)</th>{view === 'inventory' && <><th className="analysis-numeric">待交车</th><th className="analysis-numeric">异动</th></>}<th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead>
<tbody>{shownGroups.map((row, rowIndex) => <tr key={row.key} className={merged && spans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
{analysis.dimensions.length ? analysis.dimensions.map((dimension, columnIndex) => {
const span = merged ? spans[rowIndex][columnIndex] : 1;
return span === 0 ? null : <td key={dimension} rowSpan={span} className={span > 1 ? 'analysis-parent-cell' : undefined} data-label={DIMENSION_LABELS[dimension]}><Blur>{getDimensionValueLabel(dimension, row.values[dimension] || '')}</Blur>{merged && columnIndex < analysis.dimensions.length - 1 && childCounts[rowIndex][columnIndex] > 1 && <><small className="analysis-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{number(parentTotals[rowIndex][columnIndex])}</strong> 台</small><small className="analysis-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{DIMENSION_LABELS[analysis.dimensions[columnIndex + 1]]}:<strong>{number(childCounts[rowIndex][columnIndex])}</strong> {dimensionCountUnit(analysis.dimensions[columnIndex + 1])}</small></>}</td>;
}) : <td data-label="统计范围">当前筛选结果</td>}
<td className="analysis-numeric" data-label="数量(台)"><strong>{number(row.count)}</strong></td>
{view === 'inventory' && <><td className={`analysis-numeric ${row.totals.pending ? 'analysis-pending-count' : 'analysis-zero-count'}`} data-label="待交车(台)">{number(row.totals.pending)}</td><td className={`analysis-numeric ${row.totals.abnormal ? 'analysis-abnormal-count' : 'analysis-zero-count'}`} data-label="异动(台)">{number(row.totals.abnormal)}</td></>}
<td className="analysis-share-cell" data-label="占比"><span>{row.share.toFixed(1)}%</span><span className="analysis-bar-track" aria-hidden="true"><span style={{ width: `${row.share}%` }} /></span></td>
<td className="analysis-group-actions">{analysis.dimensions.length > 0 && <button aria-label={`筛选分组 ${row.label}`} onClick={() => narrowToGroup(row)}><Filter size={12} />筛选此组</button>}<button aria-label={`查看明细 ${row.label}`} onClick={() => setDetailGroup(row)}>查看明细</button></td>
</tr>)}</tbody>
</table></div>
<div className="analysis-table-total"><span>合计 {number(analysis.total)} 台{view === 'inventory' && ` · 待交车 ${number(analysis.summary.pending)} 台 · 异动 ${number(analysis.summary.abnormal)} 台`} · 占比基于当前筛选结果</span><span>100%</span></div>
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</>}
</>}
{showDetails && <div className="analysis-details">
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><label className="analysis-search"><Search size={15} /><input aria-label="搜索车辆明细" placeholder="搜索车牌、VIN、型号、城市" value={search} onChange={event => { setSearch(event.target.value); }} /></label></div>
{detailBusy ? <div className="analysis-message" role="status"><Loader2 className="animate-spin" />正在加载车辆明细…</div> : detailError ? <div className="analysis-message" role="alert"><p>{detailError}</p><button className="analysis-button" onClick={() => setDetailRetry(detailRetry + 1)}>重新加载明细</button></div> : <>
{vehicles.length !== detailGroup.count && <p className="analysis-definition">车辆状态可能已更新:统计快照 {detailGroup.count} 台,当前明细 {vehicles.length} 台。</p>}
{(view === 'abnormal' || detailGroup.values.inventoryState === 'abnormal' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'abnormal')) ? <AbnormalVehicleDetails vehicles={shownVehicles} /> : (view === 'pending' || detailGroup.values.inventoryState === 'pending' || (filterValues.inventoryState?.length === 1 && filterValues.inventoryState[0] === 'pending')) ? <PendingVehicleDetails vehicles={shownVehicles} /> : <VehicleRows vehicles={shownVehicles} />}
<ProgressiveRowsStatus {...detailProgress} unit="台" />
</>}
</div>}
</div>
<footer className="analysis-footer"><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></footer>
</>}
</div>
</div>;
}
function VehicleRows({ vehicles }: { vehicles: VehicleListItem[] }) {
if (!vehicles.length) return <div className="analysis-message">暂无匹配车辆</div>;
const stateLabel = (vehicle: VehicleListItem) => {
const operation = ({ '1': '租赁', '2': '自营', '3': '可运营', '4': '待运营' } as Record<string, string>)[vehicle.operationStatus || ''] || vehicle.operationStatus;
const business = ({ Abnormal: '异动', Pending: '待交车', Inventory: '在库', Operating: '运营中' } as Record<string, string>)[vehicle.status] || vehicle.status;
return [business, operation].filter(Boolean).join(' · ');
};
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', '客户 / 签约公司', '部门 / 业务负责人', '所属公司'].map(label => <th key={label}>{label}</th>)}</tr></thead><tbody>{vehicles.map(vehicle => <tr key={vehicle.id}><td><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></td><td>{vehicle.brandLabel || '—'} · {vehicle.type}<small>{vehicle.model}</small></td><td>{vehicle.province || '未标注'} / {vehicle.city || vehicle.location || '未标注'}</td><td>{stateLabel(vehicle)}</td><td><Blur>{vehicle.customerName || '—'}</Blur><small><Blur>{vehicle.orgName || '—'}</Blur></small></td><td><Blur>{vehicle.departmentName || '—'}</Blur><small><Blur>{vehicle.customerManager || '—'}</Blur></small></td><td>{vehicle.subjectOrg || '—'}</td></tr>)}</tbody></table></div>;
}
type WeeklyDimension = 'date' | 'contract_type';
const WEEKLY_DIMENSIONS: WeeklyDimension[] = ['date', 'contract_type'];
const EMPTY_WEEKLY_ROWS: WeeklyDetailItem[] = [];
const weeklyDimensionValue = (row: WeeklyDetailItem, dimension: WeeklyDimension) => dimension === 'date'
? row.handover_date?.slice(0, 10) || '' : row.contract_type?.trim() || '';
function WeeklyAnalysis({ rows }: { rows: WeeklyDetailItem[] }) {
type Dimension = WeeklyDimension;
const dimensionsAvailable = WEEKLY_DIMENSIONS;
const scrollRoot = useRef<HTMLDivElement>(null);
const labels: Record<Dimension, string> = { date: '交还日期', contract_type: '合同类型' };
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 value = weeklyDimensionValue;
const label = (dimension: Dimension, entry: string) => entry || (dimension === 'date' ? '日期缺失' : '未标注');
const filtered = useMemo(() => rows.filter(row => dimensionsAvailable.every(dimension => !filters[dimension]?.length || filters[dimension]!.includes(value(row, dimension)))), [rows, filters]);
const grouped = useMemo(() => {
const result = new Map<string, { key: string; values: string[]; rows: WeeklyDetailItem[] }>();
for (const row of filtered) {
const values = dimensions.map(dimension => value(row, dimension));
const key = JSON.stringify(values);
const group = result.get(key) ?? { key, values, rows: [] };
group.rows.push(row);
result.set(key, group);
}
return result;
}, [filtered, dimensions]);
const groups = useMemo(() => {
const flatGroups = [...grouped.values()].sort((a, b) => b.rows.length - a.rows.length || a.key.localeCompare(b.key, 'zh-CN'));
return mergeActive ? orderDimensionGroups(flatGroups, dimensions.length, group => group.values, group => group.rows.length) : flatGroups;
}, [grouped, mergeActive, dimensions.length]);
const detailGroup = detailKey === null ? null : grouped.get(detailKey);
const detailRows = detailGroup?.rows ?? EMPTY_WEEKLY_ROWS;
const showDetails = detailKey !== null;
const groupLabel = (group: { values: string[] }) => dimensions.length
? dimensions.map((dimension, index) => `${labels[dimension]}:${label(dimension, group.values[index])}`).join(' · ')
: '当前筛选结果';
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 updateFilters = (next: Partial<Record<Dimension, string[]>>) => { setFilters(next); setDetailKey(null); };
const setFilter = (dimension: Dimension, selected: string[] | undefined) => {
const next = { ...filters };
if (selected === undefined) delete next[dimension]; else next[dimension] = selected;
updateFilters(next);
};
const updateDimensions = (next: Dimension[]) => { setDimensions(next); setDetailKey(null); };
const activeFilters = dimensionsAvailable.filter(dimension => filters[dimension]?.length);
const filterOptions = useMemo(() => Object.fromEntries(dimensionsAvailable.map(dimension => {
const eligible = rows.filter(row => dimensionsAvailable.every(other => other === dimension || !filters[other]?.length || filters[other]!.includes(value(row, other))));
const counts = new Map<string, number>();
for (const row of eligible) { const entry = value(row, dimension); counts.set(entry, (counts.get(entry) || 0) + 1); }
for (const entry of filters[dimension] || []) if (!counts.has(entry)) counts.set(entry, 0);
return [dimension, [...counts].map(([entry, count]) => ({ value: entry, label: label(dimension, entry), count })).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label, 'zh-CN'))];
})), [rows, filters]);
return <div ref={scrollRoot} className="analysis-scroll">
{!showDetails ? <>
<div className="analysis-builder">
<div className="analysis-builder-heading"><h3>分析维度</h3><span>点击名称分组,点击箭头筛选</span><button type="button" className="analysis-reset" onClick={() => { updateDimensions(['date']); updateFilters({}); setMergeParents(true); }}>恢复默认</button></div>
<div className="adp-row" role="group" aria-label="交还车统计维度">
{dimensionsAvailable.map(dimension => {
const options = filterOptions[dimension];
return <DimensionPicker key={dimension} label={labels[dimension]} grouped={dimensions.includes(dimension)} order={dimensions.indexOf(dimension) + 1} options={options} selectedValues={filters[dimension]} onToggleGroup={() => updateDimensions(dimensions.includes(dimension) ? dimensions.filter(item => item !== dimension) : [...dimensions, dimension])} onApplyFilter={selected => setFilter(dimension, selected)} />;
})}
<button type="button" className="analysis-button" aria-pressed={dimensions.length === 0} onClick={() => updateDimensions([])}>仅筛选</button>
{activeFilters.length > 0 && <button type="button" className="analysis-text-button" onClick={() => updateFilters({})}>清空筛选</button>}
</div>
</div>
<div className="analysis-result-bar" role="status" aria-live="polite"><span>当前结果 <strong>{number(filtered.length)}</strong> 车次</span><span>{groups.length} 个分组</span><span className="analysis-result-dimensions">{dimensions.length ? dimensions.map(dimension => labels[dimension]).join(' × ') : '仅汇总,不分组'}</span></div>
<div className="analysis-table-tools"><p>筛选分组可继续分析,查看明细可核对业务记录。</p>{dimensions.length > 1 && <label className="analysis-merge-toggle"><input type="checkbox" checked={mergeParents} onChange={event => { setMergeParents(event.target.checked); }} />合并上级维度</label>}</div>
{groups.length ? <>
<div className={`analysis-group-table${mergeActive ? ' is-merged' : ''}`} role="region" aria-label="交还车组合统计表" tabIndex={0}><table><thead><tr>{dimensions.length ? dimensions.map(dimension => <th key={dimension}>{labels[dimension]}</th>) : <th>统计范围</th>}<th className="analysis-numeric">数量(车次)</th><th className="analysis-numeric">占比</th><th>分析操作</th></tr></thead><tbody>
{shownGroups.map((group, rowIndex) => <tr key={group.key} className={mergeActive && groupSpans[rowIndex][0] > 0 ? 'analysis-group-start' : undefined}>
{dimensions.length ? dimensions.map((dimension, index) => {
const span = mergeActive ? groupSpans[rowIndex][index] : 1;
return span === 0 ? null : <td data-label={labels[dimension]} key={dimension} rowSpan={span > 1 ? span : undefined} className={span > 1 ? 'analysis-parent-cell' : undefined}>{label(dimension, group.values[index])}{mergeActive && index < dimensions.length - 1 && childCounts[rowIndex][index] > 1 && <><small className="analysis-parent-total" title="当前筛选范围内该分组的完整合计(全部筛选结果)">合计 <strong>{number(parentTotals[rowIndex][index])}</strong> 车次</small><small className="analysis-parent-children" title="当前筛选范围内下一级维度的去重分组数(全部筛选结果)">{labels[dimensions[index + 1]]}:<strong>{number(childCounts[rowIndex][index])}</strong> {dimensionCountUnit(dimensions[index + 1])}</small></>}</td>;
}) : <td data-label="统计范围">当前筛选结果</td>}
<td className="analysis-numeric" data-label="数量(车次)"><strong>{number(group.rows.length)}</strong></td><td className="analysis-share-cell" data-label="占比"><span>{(group.rows.length / filtered.length * 100).toFixed(1)}%</span><span className="analysis-bar-track" aria-hidden="true"><span style={{ width: `${group.rows.length / filtered.length * 100}%` }} /></span></td>
<td className="analysis-group-actions">{dimensions.length > 0 && <button type="button" aria-label={`筛选分组 ${groupLabel(group)}`} onClick={() => updateFilters({ ...filters, ...Object.fromEntries(dimensions.map((dimension, index) => [dimension, [group.values[index]]])) })}><Filter size={12} />筛选此组</button>}<button type="button" aria-label={`查看明细 ${groupLabel(group)}`} onClick={() => { setDetailKey(group.key); }}>查看明细</button></td>
</tr>)}
</tbody></table></div>
<div className="analysis-table-total"><span>合计 {number(filtered.length)} 车次 · 占比基于当前筛选结果</span><span>100%</span></div>
<ProgressiveRowsStatus {...groupProgress} unit="组" />
</> : <div className="analysis-message"><p>没有符合条件的数据,请调整筛选。</p>{activeFilters.length > 0 && <button type="button" className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
</> : <>
<div className="analysis-navigation"><button type="button" className="analysis-back" onClick={() => { setDetailKey(null); }}><ArrowLeft size={14} />返回统计分析</button><span className="analysis-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</span></div>
<div className="analysis-table-tools"><h3>交还车业务明细 <small>{number(detailRows.length)} 车次</small></h3></div>
{detailRows.length ? <><div className="analysis-detail-table" role="region" aria-label="交还车业务明细表" tabIndex={0}><table><thead><tr><th>车牌</th><th>日期</th><th>合同类型</th><th>客户</th></tr></thead><tbody>{shownDetails.map((row, index) => <tr key={`${row.truck_id}-${index}`}><td><Blur>{row.plate_number || '—'}</Blur></td><td>{row.handover_date || '—'}</td><td>{row.contract_type || '—'}</td><td><Blur>{row.customer_name || '—'}</Blur></td></tr>)}</tbody></table></div>
<ProgressiveRowsStatus {...detailProgress} unit="条" />
</> : <p className="analysis-message">当前分组暂无数据,请返回统计分析。</p>}
</>}
</div>;
}
@@ -0,0 +1,77 @@
import { useEffect, useId, useRef, useState } from 'react';
import { ChevronDown, Filter, Search, X } from 'lucide-react';
import './dimension-picker.css';
export interface DimensionPickerOption { value: string; label: string; count?: number }
export interface DimensionPickerProps {
label: string;
grouped: boolean;
order?: number;
options: DimensionPickerOption[];
selectedValues?: string[];
onToggleGroup: () => void;
onApplyFilter: (values: string[] | undefined) => void;
}
/** A grouping switch and an independently applied, searchable multi-value filter. */
export function DimensionPicker({ label, grouped, order, options, selectedValues, onToggleGroup, onApplyFilter }: DimensionPickerProps) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<string[]>([]);
const [query, setQuery] = useState('');
const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 420 });
const root = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const search = useRef<HTMLInputElement>(null);
const menuId = useId();
const selected = selectedValues ?? [];
const labels = new Map(options.map(option => [option.value, option.label]));
const selectedLabel = selected.map(value => labels.get(value) || value || '未标注').join('、');
const visible = options.filter(option => option.label.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()));
const close = (restoreFocus = true) => { setOpen(false); if (restoreFocus) trigger.current?.focus(); };
const show = () => {
if (open) { close(); return; }
setDraft([...selected]); setQuery(''); setOpen(true);
};
useEffect(() => {
if (!open) return;
const place = () => {
const rect = trigger.current?.getBoundingClientRect();
if (!rect) return;
const width = Math.min(320, window.innerWidth - 16);
const roomBelow = window.innerHeight - rect.bottom - 12;
const useAbove = roomBelow < 220 && rect.top > roomBelow;
const height = Math.min(420, Math.max(160, useAbove ? rect.top - 12 : roomBelow));
setPosition({ left: Math.max(8, Math.min(rect.left, window.innerWidth - width - 8)), top: useAbove ? Math.max(8, rect.top - height - 5) : rect.bottom + 5, maxHeight: height });
};
place();
const frame = requestAnimationFrame(() => search.current?.focus());
const outside = (event: PointerEvent) => { if (!root.current?.contains(event.target as Node)) setOpen(false); };
document.addEventListener('pointerdown', outside);
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => { cancelAnimationFrame(frame); document.removeEventListener('pointerdown', outside); window.removeEventListener('resize', place); window.removeEventListener('scroll', place, true); };
}, [open]);
return <div ref={root} className={`analysis-dimension-picker${grouped ? ' adp-grouped' : ''}${selected.length ? ' adp-filtered' : ''}`}>
<div className="adp-split">
<button type="button" className="adp-name" aria-pressed={grouped} onClick={() => { setOpen(false); onToggleGroup(); }} title={`点击${grouped ? '取消' : '加入'}${label}分组`}>
{grouped && order !== undefined ? <span className="adp-order">{order}</span> : null}{label}
</button>
<button ref={trigger} type="button" className="adp-arrow" aria-label={`筛选${label}`} aria-expanded={open} aria-controls={open ? menuId : undefined} aria-haspopup="dialog" onClick={show} title={selected.length ? `${label}已筛选 ${selected.length} 项:${selectedLabel}` : `筛选${label}`}>
{selected.length ? <><Filter size={11} /><span>{selected.length}</span></> : null}<ChevronDown size={12} />
</button>
</div>
{selected.length ? <button type="button" className="adp-selected" onClick={show} title={selectedLabel}>{selected.length === 1 ? selectedLabel : `${selectedLabel}(${selected.length} 项)`}</button> : null}
{open ? <div id={menuId} role="dialog" aria-label={`${label}筛选菜单`} className="adp-menu" style={{ left: position.left, top: position.top, maxHeight: position.maxHeight }} onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); } }}>
<div className="adp-menu-heading"><strong>{label}</strong><button type="button" aria-label={`关闭${label}筛选`} onClick={() => close()}><X size={14} /></button></div>
<label className="adp-search"><Search size={13} /><input ref={search} aria-label={`搜索${label}筛选值`} value={query} onChange={event => setQuery(event.target.value)} placeholder="搜索选项" /></label>
<div className="adp-select-tools"><button type="button" disabled={!visible.length} onClick={() => setDraft(current => [...new Set([...current, ...visible.map(option => option.value)])])}>全选搜索结果</button><button type="button" onClick={() => setDraft([])}>清除选择</button><span>{draft.length} 项已选</span></div>
<div className="adp-options" role="group" aria-label={`${label}可选值`}>
{visible.map(option => <label key={option.value}><input type="checkbox" checked={draft.includes(option.value)} onChange={event => setDraft(current => event.target.checked ? [...current, option.value] : current.filter(value => value !== option.value))} /><span>{option.label}</span>{option.count !== undefined ? <small>{option.count.toLocaleString('zh-CN')}</small> : null}</label>)}
{!visible.length ? <p>没有匹配选项</p> : null}
</div>
<div className="adp-menu-footer"><span>同维度多选为“或”;未选表示不限</span><div><button type="button" onClick={() => close()}>取消</button><button type="button" className="adp-apply" onClick={() => { onApplyFilter(draft.length ? draft : undefined); close(); }}>应用筛选</button></div></div>
</div> : null}
</div>;
}
@@ -0,0 +1,18 @@
import type { RefCallback } from 'react';
import './progressive-rows.css';
interface Props {
visibleCount: number;
total: number;
hasMore: boolean;
loadMore: () => void;
sentinelRef: RefCallback<HTMLDivElement>;
unit?: string;
}
export function ProgressiveRowsStatus({ visibleCount, total, hasMore, loadMore, sentinelRef, unit = '条' }: Props) {
return <div ref={sentinelRef} className="analysis-progressive-status">
<span role="status" aria-live="polite">已加载 {visibleCount.toLocaleString('zh-CN')} / {total.toLocaleString('zh-CN')} {unit}</span>
{hasMore ? <><span className="analysis-progressive-hint">下滚继续加载</span><button type="button" onClick={loadMore}>加载更多</button></> : <span className="analysis-progressive-hint">已显示全部</span>}
</div>;
}
@@ -0,0 +1,172 @@
.asset-analysis-overlay { position:fixed; inset:0; z-index:70; background:rgb(0 0 0 / .4); backdrop-filter:blur(4px); display:flex; align-items:center; justify-content:center; padding:24px; }
.asset-analysis-dialog { --analysis-line:#d5deea; --analysis-boundary:#b8c6d8; --analysis-accent:#2563eb; --analysis-tint:#eff6ff; display:flex; flex-direction:column; width:min(1040px,100%); max-height:90dvh; min-height:430px; background:white; border-radius:12px; box-shadow:0 24px 60px rgb(15 23 42 / .2); overflow:hidden; color:#334155; font-size:12px; outline:none; }
.asset-analysis-dialog.analysis-amber { --analysis-accent:#b45309; --analysis-tint:#fffbeb; }
.asset-analysis-dialog button { cursor:pointer; font:inherit; transition:background-color .15s,color .15s; }
.asset-analysis-dialog button:disabled { cursor:default; opacity:.45; }
.asset-analysis-dialog button:focus-visible,.asset-analysis-dialog input:focus-visible,.asset-analysis-dialog select:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
.analysis-header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:14px 18px; background:#1e293b; color:white; flex-shrink:0; }
.analysis-header h2 { display:flex; align-items:center; gap:8px; font-size:14px; font-weight:650; }
.analysis-header h2 svg { color:#60a5fa; }
.analysis-header p { font-size:11px; color:#cbd5e1; margin-top:4px; line-height:1.5; overflow-wrap:anywhere; }
.analysis-header button { padding:9px; border-radius:6px; flex-shrink:0; }
.analysis-header button:hover { background:#334155; }
.analysis-scroll { overflow-y:auto; overscroll-behavior:contain; min-height:0; padding:16px 18px; flex:1; }
.analysis-builder { border:1px solid #e2e8f0; border-radius:8px; padding:12px; background:#fff; }
.analysis-builder-heading { display:flex; align-items:center; flex-wrap:wrap; gap:8px; margin-bottom:10px; }
.analysis-builder-heading h3 { font-size:12px; font-weight:650; color:#1e293b; }
.analysis-builder-heading>span { color:#64748b; font-size:11px; }
.analysis-reset { display:inline-flex; align-items:center; gap:4px; color:#64748b; margin-left:auto; padding:3px 0; font-size:11px!important; }
.analysis-reset:hover { color:#2563eb; }
.analysis-dimension-options { display:flex; flex-wrap:wrap; gap:6px; }
.analysis-dimension-options button { display:inline-flex; align-items:center; gap:5px; min-height:30px; border:1px solid #e2e8f0; background:#f8fafc; color:#475569; padding:5px 9px; border-radius:5px; font-size:12px; }
.analysis-dimension-options button:hover { border-color:#93c5fd; }
.analysis-dimension-options button[aria-pressed=true] { background:#eff6ff; color:#1d4ed8; border-color:#93c5fd; }
.analysis-dimension-order { display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; font-size:10px; border-radius:3px; background:#2563eb; color:white; }
.analysis-filter-row { display:flex; align-items:center; flex-wrap:wrap; gap:6px; border-top:1px solid #edf2f7; margin-top:12px; padding-top:10px; }
.analysis-filter-label { display:inline-flex; align-items:center; gap:4px; color:#475569; font-weight:600; margin-right:3px; }
.analysis-filter-chip { display:inline-flex; align-items:center; max-width:100%; background:#f1f5f9; border:1px solid #e2e8f0; border-radius:5px; overflow:hidden; }
.analysis-filter-chip button { padding:5px 7px; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.analysis-filter-chip button:hover { background:#e2e8f0; }
.analysis-filter-chip button:last-child { flex-shrink:0; padding:6px; }
.analysis-add-filter { display:inline-flex; align-items:center; gap:3px; border:1px dashed #94a3b8; border-radius:5px; padding:5px 7px; color:#475569; background:white; }
.analysis-add-filter:hover { color:#2563eb; border-color:#2563eb; }
.analysis-filter-empty { color:#64748b; font-size:11px; }
.analysis-text-button { color:#2563eb; padding:5px; }
.analysis-filter-editor { margin-top:12px; border:1px solid #bfdbfe; border-radius:7px; box-shadow:0 4px 16px rgb(15 23 42 / .05); background:#fff; }
.analysis-filter-editor-top { display:flex; align-items:center; gap:12px; padding:10px 12px; border-bottom:1px solid #e2e8f0; }
.analysis-filter-editor-top>label:first-child { display:flex; align-items:center; gap:6px; white-space:nowrap; }
.analysis-filter-editor select { padding:5px 8px; border:1px solid #cbd5e1; background:white; border-radius:5px; }
.analysis-filter-search { display:flex; align-items:center; gap:6px; min-width:0; margin-left:auto; border:1px solid #e2e8f0; padding:6px 8px; border-radius:5px; color:#64748b; }
.analysis-filter-search input { min-width:0; width:130px; outline:none; color:#334155; }
.analysis-icon-button { padding:5px; }
.analysis-filter-options { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:3px 12px; padding:8px 12px; max-height:190px; overflow:auto; }
.analysis-filter-options label { display:flex; align-items:center; gap:7px; min-width:0; padding:7px 4px; cursor:pointer; border-radius:4px; }
.analysis-filter-options label:hover { background:#f8fafc; }
.analysis-filter-options input { accent-color:#2563eb; flex-shrink:0; }
.analysis-filter-options label span { overflow-wrap:anywhere; }
.analysis-filter-options small { margin-left:auto; color:#64748b; font-size:11px; white-space:nowrap; }
.analysis-filter-options>p { grid-column:1/-1; padding:12px 0; color:#64748b; }
.analysis-filter-editor-bottom { display:flex; align-items:center; flex-wrap:wrap; gap:8px; border-top:1px solid #e2e8f0; padding:8px 12px; }
.analysis-filter-editor-bottom>span { color:#64748b; font-size:11px; margin-right:auto; }
.analysis-primary,.analysis-button { display:inline-flex; align-items:center; justify-content:center; gap:5px; min-height:30px; padding:6px 10px; font-size:12px; border-radius:5px; background:#2563eb; color:#fff; }
.analysis-primary:hover { background:#1d4ed8; }
.analysis-button { background:#f1f5f9; color:#475569; }
.analysis-result-bar { display:flex; align-items:center; gap:18px; flex-wrap:wrap; padding:13px 0 8px; color:#64748b; }
.analysis-result-bar strong { font-size:22px; font-weight:650; color:var(--analysis-accent); font-variant-numeric:tabular-nums; margin:0 5px; }
.analysis-result-dimensions { margin-left:auto; font-size:11px; max-width:65%; line-height:1.7; }
.analysis-inventory-subsets { display:flex; align-items:center; flex-wrap:wrap; gap:6px 16px; margin:2px 0 12px; font-size:12px; }
.analysis-inventory-subsets strong { font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; }
.analysis-inventory-subsets small { font-size:11px; color:#64748b; }
.analysis-pending-count { color:#2563eb; font-variant-numeric:tabular-nums; }
.analysis-abnormal-count { color:#b45309; font-variant-numeric:tabular-nums; }
.analysis-zero-count { color:#94a3b8; font-variant-numeric:tabular-nums; }
.analysis-definition { font-size:11px; line-height:1.7; padding:8px 10px; background:#fffbeb; color:#92400e; border-radius:5px; margin:8px 0; }
.analysis-table-tools { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; margin:10px 0; }
.analysis-table-tools h3 { font-size:13px; font-weight:600; color:#1e293b; }
.analysis-table-tools h3 small { font-size:11px; color:#64748b; font-weight:400; margin-left:6px; }
.analysis-table-tools>p { font-size:11px; color:#64748b; }
.analysis-table-tools label { display:flex; align-items:center; gap:6px; font-size:11px; color:#64748b; }
.analysis-table-tools select { border:1px solid #e2e8f0; border-radius:5px; padding:5px 7px; background:white; color:#475569; font-size:11px; }
.analysis-group-table { overflow:auto; border:1px solid var(--analysis-boundary); border-radius:6px 6px 0 0; }
.analysis-group-table table { border-collapse:collapse; width:100%; font-size:12px; }
.analysis-group-table th { padding:10px 12px; color:#64748b; font-weight:500; text-align:left; background:#f8fafc; white-space:nowrap; }
.analysis-group-table td { padding:10px 12px; border-top:1px solid var(--analysis-line); min-width:84px; max-width:230px; overflow-wrap:anywhere; }
.analysis-group-table tbody tr:hover { background:#f8fafc; }
.analysis-group-table .analysis-numeric { text-align:right; white-space:nowrap; }
.analysis-group-table .analysis-numeric strong { font-size:14px; font-weight:650; color:var(--analysis-accent); font-variant-numeric:tabular-nums; }
.analysis-share-cell { min-width:90px!important; text-align:right; font-variant-numeric:tabular-nums; color:#64748b; }
.analysis-bar-track { display:block; height:4px; background:#f1f5f9; border-radius:2px; overflow:hidden; margin-top:5px; }
.analysis-bar-track>span { display:block; height:100%; background:var(--analysis-accent); border-radius:2px; }
.analysis-group-actions { white-space:nowrap; min-width:170px!important; }
.analysis-group-actions button { display:inline-flex; align-items:center; gap:4px; padding:5px 6px; color:#2563eb; font-size:11px; border-radius:4px; }
.analysis-group-actions button:hover { background:#eff6ff; }
.analysis-group-actions button+button { margin-left:5px; }
.analysis-table-total { display:flex; justify-content:space-between; gap:12px; padding:10px 12px; color:#64748b; font-size:11px; background:#f8fafc; border:1px solid var(--analysis-boundary); border-top:0; border-radius:0 0 6px 6px; }
.analysis-navigation { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 18px; border-bottom:1px solid #e2e8f0; flex-shrink:0; }
.analysis-navigation nav { display:flex; flex-wrap:wrap; gap:6px; }
.analysis-back { display:flex; gap:5px; align-items:center; font-size:12px; color:#2563eb; white-space:nowrap; padding:5px 0; }
.analysis-detail-scope { color:#64748b; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.analysis-search { border:1px solid #e2e8f0; padding:6px 8px; border-radius:5px; }
.analysis-search input { width:220px; color:#334155; outline:none; min-width:0; font-size:12px; }
.analysis-detail-table { overflow-x:auto; }
.analysis-detail-table table { width:100%; min-width:600px; border-collapse:collapse; font-size:12px; }
.analysis-detail-table th { font-weight:600; text-align:left; background:#f8fafc; padding:10px; color:#64748b; }
.analysis-detail-table td { padding:10px; border-bottom:1px solid #e2e8f0; }
.analysis-detail-table td small { display:block; font-size:11px; color:#64748b; margin-top:3px; }
.analysis-details .text-base { font-size:13px; }.analysis-details .text-sm { font-size:12px; }
.analysis-message { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:12px; padding:40px 16px; text-align:center; color:#64748b; flex:1; font-size:12px; }
.analysis-message h3 { font-size:13px; font-weight:600; color:#334155; }
.analysis-footer { display:flex; justify-content:space-between; gap:8px; flex-wrap:wrap; font-size:10px; color:#64748b; padding:9px 18px; background:#f8fafc; border-top:1px solid #e2e8f0; flex-shrink:0; }
/* Legacy weekly entries share the compact analysis vocabulary. */
.analysis-summary { display:flex; align-items:center; justify-content:space-between; gap:15px; background:var(--analysis-tint); padding:12px; border-radius:6px; margin:10px 0; }
.analysis-summary strong { display:block; font-size:22px; color:var(--analysis-accent); }.analysis-summary small { font-size:11px; margin-left:4px; }
.analysis-row { display:grid; grid-template-columns:1fr 1fr auto 16px; gap:12px; width:100%; text-align:left; align-items:center; padding:12px; border-bottom:1px solid #e2e8f0; }
@media(max-width:640px) {
.asset-analysis-overlay { padding:8px; }.asset-analysis-dialog { max-height:94dvh; min-height:58dvh; border-radius:10px; }
.analysis-header { padding:12px; }.analysis-scroll { padding:12px; }.analysis-builder { padding:10px; }
.analysis-builder-heading>span { font-size:10px; }.analysis-dimension-options { gap:5px; }.analysis-dimension-options button { min-height:34px; padding:6px 8px; }
.analysis-filter-row { gap:6px; }.analysis-filter-empty { width:100%; margin-top:1px; font-size:10px; }
.analysis-filter-editor-top { flex-wrap:wrap; gap:8px; padding:8px; }.analysis-filter-search { order:3; width:100%; }.analysis-filter-search input { width:100%; }.analysis-filter-editor-top .analysis-icon-button { margin-left:auto; }
.analysis-filter-options { grid-template-columns:repeat(2,minmax(0,1fr)); gap:2px 8px; padding:8px; }.analysis-filter-options label { padding:9px 2px; }.analysis-filter-options small { font-size:10px; }
.analysis-filter-editor-bottom { gap:6px; padding:8px; }.analysis-filter-editor-bottom>span { width:100%; }.analysis-filter-editor-bottom .analysis-text-button { margin-right:auto; }
.analysis-result-bar { gap:10px; }.analysis-result-dimensions { max-width:100%; width:100%; margin:0; font-size:10px; }
.analysis-table-tools>p { font-size:10px; }.analysis-definition { font-size:10px; }
.analysis-group-table { border:0; overflow:visible; }.analysis-group-table table,.analysis-group-table tbody { display:block; }.analysis-group-table thead { display:none; }
.analysis-group-table tr { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); border:1px solid #e2e8f0; border-radius:6px; margin-bottom:8px; padding:7px; gap:4px 8px; }
.analysis-group-table td { display:flex; flex-direction:column; gap:3px; min-width:0!important; max-width:none; padding:4px; border:0; font-size:12px; }
.analysis-group-table td[data-label]::before { content:attr(data-label); font-size:10px; color:#64748b; }
.analysis-group-table .analysis-numeric,.analysis-group-table .analysis-share-cell { text-align:left; }.analysis-group-table .analysis-share-cell .analysis-bar-track { max-width:100px; }
.analysis-group-table .analysis-group-actions { grid-column:1/-1; display:flex; flex-direction:row; justify-content:flex-end; border-top:1px solid #edf2f7; padding-top:5px; }
.analysis-group-actions button { min-height:32px; }.analysis-table-total { border-radius:6px; border-top:1px solid #e2e8f0; font-size:10px; }
.analysis-navigation { padding:8px 12px; }.analysis-search { width:100%; }.analysis-search input { width:100%; }.analysis-footer { padding:8px 12px; font-size:10px; }
}
@media(prefers-reduced-motion:reduce) { .asset-analysis-dialog * { transition:none!important; animation:none!important; } }
/* Dimension labels and their Excel-style filter dropdowns form one control. */
.analysis-builder { position:relative; }
.analysis-dimension-column { display:inline-flex; min-width:0; max-width:100%; }
.analysis-dimension-options .analysis-dimension-name { border-radius:5px 0 0 5px; white-space:nowrap; }
.analysis-dimension-options .analysis-dimension-dropdown { border-radius:0 5px 5px 0; border-left:0; padding:5px 7px; min-width:28px; justify-content:center; }
.analysis-dimension-dropdown svg { flex-shrink:0; }
.analysis-inline-filter { max-width:105px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:10px; }
.analysis-dimension-column.has-filter .analysis-dimension-dropdown { background:#eff6ff; color:#2563eb; border-color:#93c5fd; }
.analysis-active-note { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:4px; margin-top:9px; font-size:10px; color:#64748b; }
.analysis-active-note .analysis-text-button { font-size:10px; }
.analysis-column-popover { position:absolute; z-index:5; width:330px; max-width:calc(100% - 16px); margin-top:0; box-shadow:0 8px 28px rgb(15 23 42 / .18); }
.analysis-column-popover .analysis-filter-editor-top { gap:7px; padding:9px 10px; }
.analysis-column-popover .analysis-filter-editor-top strong { font-size:12px; }
.analysis-column-popover .analysis-filter-editor-top>span { font-size:10px; color:#64748b; }
.analysis-column-popover .analysis-icon-button { margin-left:auto; }
.analysis-column-popover .analysis-filter-search { margin:8px 10px 0; width:auto; }
.analysis-column-popover .analysis-filter-search input { width:100%; font-size:12px; }
.analysis-filter-selection { display:flex; align-items:center; gap:12px; padding:8px 12px 2px; font-size:11px; }
.analysis-filter-selection button { color:#2563eb; padding:3px 0; }
.analysis-filter-selection>span { margin-left:auto; color:#64748b; }
.analysis-column-popover .analysis-filter-options { grid-template-columns:1fr; max-height:180px; padding:4px 10px 8px; }
.analysis-column-popover .analysis-filter-options label { padding:7px 3px; }
.analysis-column-popover .analysis-filter-editor-bottom { gap:6px; padding:8px 10px; }
.analysis-column-popover .analysis-filter-editor-bottom>span { width:auto; margin-right:auto; font-size:10px; }
@media(max-width:640px) { .analysis-column-popover .analysis-filter-options { max-height:160px; }.analysis-inline-filter { max-width:72px; } }
.analysis-table-options { display:flex; flex-wrap:wrap; align-items:center; gap:14px; }
.analysis-parent-total { display:block; margin-top:7px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; }
.analysis-parent-total strong { color:var(--analysis-accent); font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; }
.analysis-parent-children { display:block; margin-top:3px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; }
.analysis-parent-children strong { font-weight:600; color:#475569; font-variant-numeric:tabular-nums; }
.analysis-merge-toggle { cursor:pointer; white-space:nowrap; }
.analysis-merge-toggle input { accent-color:#2563eb; width:13px; height:13px; }
.analysis-group-table.is-merged td { vertical-align:top; }
.analysis-group-table.is-merged td[rowspan]:not([rowspan="1"]) { background:#f8fafc; border-right:1px solid var(--analysis-boundary); border-bottom:1px solid var(--analysis-boundary); font-weight:500; }
@media(max-width:640px) {
.analysis-group-table.is-merged { overflow:auto; border:1px solid var(--analysis-boundary); border-radius:6px; }
.analysis-group-table.is-merged table { display:table; min-width:560px; }
.analysis-group-table.is-merged thead { display:table-header-group; }
.analysis-group-table.is-merged tbody { display:table-row-group; }
.analysis-group-table.is-merged tr { display:table-row; padding:0; border:0; }
.analysis-group-table.is-merged td { display:table-cell; padding:10px 12px; border-top:1px solid var(--analysis-line); min-width:80px!important; }
.analysis-group-table.is-merged td[data-label]::before { display:none; }
.analysis-group-table.is-merged .analysis-numeric,.analysis-group-table.is-merged .analysis-share-cell { text-align:right; }
.analysis-group-table.is-merged .analysis-group-actions { display:table-cell; min-width:170px!important; }
}
/* Keep a one-pixel grid; darker boundaries separate parent groups. */
.analysis-group-table tbody tr:first-child > td,
.analysis-group-table.is-merged .analysis-group-start > td { border-top-color:var(--analysis-boundary); }
@@ -0,0 +1,34 @@
.adp-row { display:flex; flex-wrap:wrap; align-items:flex-start; gap:6px; }
.analysis-dimension-picker { display:inline-flex; flex-direction:column; align-items:stretch; min-width:0; max-width:190px; vertical-align:top; font-size:12px; }
.analysis-dimension-picker .adp-split { display:flex; min-height:30px; border:1px solid #e2e8f0; border-radius:5px; background:#f8fafc; overflow:hidden; }
.analysis-dimension-picker .adp-split button { display:inline-flex; align-items:center; justify-content:center; gap:4px; min-height:28px; padding:5px 8px; border:0; border-radius:0; background:transparent; color:#475569; font-size:12px; line-height:1.3; }
.analysis-dimension-picker .adp-split .adp-arrow { border-left:1px solid #e2e8f0; padding:5px 6px; font-size:10px; }
.analysis-dimension-picker.adp-grouped .adp-split { background:#eff6ff; border-color:#93c5fd; }
.analysis-dimension-picker.adp-grouped .adp-name { color:#1d4ed8; }
.analysis-dimension-picker.adp-filtered .adp-arrow { color:#1d4ed8; background:#dbeafe; }
.analysis-dimension-picker .adp-split button:hover { background:#e2e8f0; }
.analysis-dimension-picker .adp-order { display:inline-flex; align-items:center; justify-content:center; width:15px; height:15px; background:#2563eb; border-radius:3px; font-size:10px; color:white; }
.analysis-dimension-picker .adp-selected { text-align:left; padding:3px 2px 0; border:0; background:none; color:#2563eb; font-size:10px; max-width:190px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.analysis-dimension-picker .adp-menu { position:fixed; z-index:1100; display:flex; flex-direction:column; width:min(320px,calc(100vw - 16px)); border:1px solid #cbd5e1; border-radius:7px; background:#fff; color:#334155; box-shadow:0 8px 30px rgb(15 23 42 / .17); overflow:hidden; font-size:12px; }
.analysis-dimension-picker .adp-menu button { padding:5px 8px; border:0; border-radius:4px; background:transparent; color:#475569; font-size:11px; min-height:26px; line-height:1.4; }
.analysis-dimension-picker .adp-menu button:hover { background:#f1f5f9; }
.analysis-dimension-picker .adp-menu-heading { display:flex; align-items:center; justify-content:space-between; padding:7px 10px; border-bottom:1px solid #e2e8f0; flex-shrink:0; }
.analysis-dimension-picker .adp-menu-heading strong { font-size:12px; font-weight:600; }
.analysis-dimension-picker .adp-menu-heading button { padding:4px; min-height:22px; }
.analysis-dimension-picker .adp-search { display:flex; align-items:center; gap:6px; margin:9px 10px 5px; padding:6px 8px; border:1px solid #e2e8f0; border-radius:5px; flex-shrink:0; color:#64748b; }
.analysis-dimension-picker .adp-search input { width:100%; min-width:0; border:0; outline:none; background:white; color:#334155; font-size:12px; }
.analysis-dimension-picker .adp-select-tools { display:flex; align-items:center; gap:2px; padding:0 6px 4px; flex-shrink:0; }
.analysis-dimension-picker .adp-select-tools button { color:#2563eb; font-size:10px; padding:4px; }
.analysis-dimension-picker .adp-select-tools>span { margin-left:auto; padding-right:4px; font-size:10px; color:#64748b; }
.analysis-dimension-picker .adp-options { min-height:0; flex:1; overflow-y:auto; overscroll-behavior:contain; padding:0 8px 6px; }
.analysis-dimension-picker .adp-options label { display:flex; align-items:center; gap:7px; min-height:30px; padding:6px 4px; border-radius:4px; cursor:pointer; font-size:12px; }
.analysis-dimension-picker .adp-options label:hover { background:#f8fafc; }
.analysis-dimension-picker .adp-options input { accent-color:#2563eb; flex-shrink:0; width:13px; height:13px; }
.analysis-dimension-picker .adp-options label>span { overflow-wrap:anywhere; }
.analysis-dimension-picker .adp-options small { margin-left:auto; padding-left:5px; color:#64748b; font-size:10px; font-variant-numeric:tabular-nums; }
.analysis-dimension-picker .adp-options p { padding:16px; text-align:center; color:#64748b; font-size:11px; }
.analysis-dimension-picker .adp-menu-footer { border-top:1px solid #e2e8f0; padding:8px 10px; flex-shrink:0; }
.analysis-dimension-picker .adp-menu-footer>span { display:block; font-size:10px; color:#64748b; margin-bottom:7px; }
.analysis-dimension-picker .adp-menu-footer>div { display:flex; justify-content:flex-end; gap:6px; }
.analysis-dimension-picker .adp-menu-footer .adp-apply { background:#2563eb; color:white; }
.analysis-dimension-picker .adp-menu-footer .adp-apply:hover { background:#1d4ed8; }
@@ -0,0 +1,5 @@
.analysis-progressive-status { display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:8px 14px; min-height:44px; padding:10px 12px; color:#64748b; font-size:11px; font-variant-numeric:tabular-nums; }
.analysis-progressive-hint { color:#94a3b8; }
.analysis-progressive-status button { padding:5px 10px; border:1px solid #e2e8f0; border-radius:5px; background:#fff; color:#2563eb; cursor:pointer; font:inherit; }
.analysis-progressive-status button:hover { background:#eff6ff; }
.analysis-progressive-status button:focus-visible { outline:2px solid #2563eb; outline-offset:2px; }
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useMemo, useState, type RefObject } from 'react';
interface ProgressiveRowsOptions {
total: number;
rootRef: RefObject<HTMLElement | null>;
resetKey: unknown;
enabled?: boolean;
batchSize?: number;
}
/** Render a cumulative prefix, loading another batch near the actual scroll body's bottom. */
export function useProgressiveRows({ total, rootRef, resetKey, enabled = true, batchSize = 40 }: ProgressiveRowsOptions) {
const context = useMemo(() => ({ resetKey, enabled, batchSize }), [resetKey, enabled, batchSize]);
const [progress, setProgress] = useState(() => ({ context, limit: batchSize }));
const [sentinel, setSentinel] = useState<HTMLDivElement | null>(null);
const visibleCount = Math.min(total, progress.context === context ? progress.limit : batchSize);
const hasMore = visibleCount < total;
const loadMore = useCallback(() => {
if (!enabled) return;
setProgress(previous => ({ context, limit: Math.min(total, (previous.context === context ? previous.limit : batchSize) + batchSize) }));
}, [enabled, context, total, batchSize]);
useEffect(() => {
if (enabled && rootRef.current) rootRef.current.scrollTop = 0;
}, [context, enabled, rootRef]);
useEffect(() => {
const root = rootRef.current;
if (!enabled || !hasMore || !root || !sentinel || typeof IntersectionObserver === 'undefined') return;
let active = true;
let queued = false;
let frame: number | undefined;
const observer = new IntersectionObserver(entries => {
if (!active || queued || !entries.some(entry => entry.isIntersecting)) return;
queued = true;
// One append per observer: let layout settle before deciding whether more rows fit.
frame = requestAnimationFrame(loadMore);
}, { root, rootMargin: '0px 0px 240px 0px', threshold: 0 });
observer.observe(sentinel);
return () => {
active = false;
observer.disconnect();
if (frame !== undefined) cancelAnimationFrame(frame);
};
}, [enabled, hasMore, rootRef, sentinel, visibleCount, loadMore]);
return { visibleCount, total, hasMore, loadMore, sentinelRef: setSentinel };
}
@@ -0,0 +1,86 @@
.bi-flow-modal-backdrop { position: fixed; inset: 0; z-index: 1000; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgb(2 6 23 / 45%); backdrop-filter: blur(4px); }
.bi-flow-modal-parent-total { display:block; margin-top:7px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; }
.bi-flow-modal-parent-total strong { color:#2563eb; font-size:13px; font-weight:600; font-variant-numeric:tabular-nums; }
.bi-flow-modal-parent-children { display:block; margin-top:3px; color:#64748b; font-size:11px; font-weight:400; line-height:1.6; white-space:nowrap; }
.bi-flow-modal-parent-children strong { font-weight:600; color:#475569; font-variant-numeric:tabular-nums; }
.bi-flow-modal { --flow-line:#d5deea; --flow-boundary:#b8c6d8; display: flex; flex-direction: column; width: 100%; max-width: 960px; max-height: 88dvh; min-width: 0; overflow: hidden; background: white; border-radius: 12px; color: #334155; font-size: 12px; box-shadow: 0 24px 64px rgb(15 23 42 / 20%); }
.bi-flow-modal button, .bi-flow-modal select { font: inherit; cursor: pointer; }
.bi-flow-modal button:disabled { cursor: not-allowed; opacity: .4; }
.bi-flow-modal button:focus-visible, .bi-flow-modal select:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
.bi-flow-modal-header { flex-shrink: 0; padding: 14px 18px; background: #1e293b; color: white; }
.bi-flow-modal-header-row { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; }
.bi-flow-modal-header h3 { margin: 0 0 5px; font-size: 14px; line-height: 1.5; font-weight: 700; }
.bi-flow-modal-header p { margin: 0; font-size: 11px; color: #cbd5e1; }
.bi-flow-modal-close { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex-shrink: 0; border: 0; border-radius: 50%; background: rgb(255 255 255 / 10%); color: white; }
.bi-flow-modal-close:hover { background: rgb(255 255 255 / 20%); }
.bi-flow-modal-header .bi-flow-modal-definition { margin-top: 8px; color: #94a3b8; font-size: 10px; }
.bi-flow-modal-body { overflow-y: auto; overscroll-behavior: contain; min-height:0; flex:1; padding: 16px 18px; }
.bi-flow-modal-controls { padding-bottom: 14px; border-bottom: 1px solid #e2e8f0; }
.bi-flow-modal-control-row { display: flex; align-items: center; gap: 12px; }
.bi-flow-modal-control-label { flex: 0 0 50px; color: #64748b; font-size: 11px; }
.bi-flow-modal-chip { display: inline-flex; align-items: center; gap: 4px; min-height: 28px; padding: 4px 9px; border: 1px solid #e2e8f0; border-radius: 6px; background: white; color: #64748b; font-size: 12px; }
.bi-flow-modal-chip:hover { border-color: #93c5fd; }
.bi-flow-modal-chip-active { border-color: #bfdbfe; background: #eff6ff; color: #2563eb; }
.bi-flow-modal-control-hint { margin: 7px 0 12px 62px; font-size: 10px; color: #94a3b8; line-height: 1.6; }
.bi-flow-modal-clear { color: #64748b; background: none; border: 0; font-size: 10px !important; }
.bi-flow-modal-detail-nav { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.bi-flow-modal-detail-nav > span { font-size: 11px; color: #64748b; overflow-wrap: anywhere; }
.bi-flow-modal-back { display: inline-flex; align-items: center; gap: 5px; border: 0; background: #f1f5f9; color: #475569; padding: 6px 8px; border-radius: 6px; font-size: 11px !important; }
.bi-flow-modal-back:hover { background: #e2e8f0; }
.bi-flow-modal-summary { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; margin: 14px 0 16px; }
.bi-flow-modal-summary > div { padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.bi-flow-modal-summary > div + div { border-left: 1px solid #e2e8f0; }
.bi-flow-modal-summary span { font-size: 11px; color: #64748b; }
.bi-flow-modal-summary strong { color: #2563eb; font-size: 22px; font-weight: 700; line-height: 1.2; font-variant-numeric: tabular-nums; }
.bi-flow-modal strong small { margin-left: 4px; font-size: 10px; font-weight: 400; color: #64748b; }
.bi-flow-modal-purple { color: #7c3aed !important; }
.bi-flow-modal-section-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.bi-flow-modal-section-heading h4 { margin: 0; font-size: 12px; font-weight: 600; color: #0f172a; }
.bi-flow-modal-section-heading p { margin: 4px 0 0; font-size: 10px; line-height: 1.6; color: #64748b; }
.bi-flow-modal-export { display: inline-flex; gap: 5px; align-items: center; background: #eff6ff; border: 1px solid #dbeafe; color: #2563eb; border-radius: 6px; padding: 6px 8px; font-size: 11px !important; }
.bi-flow-modal-export:hover { background: #dbeafe; }
.bi-flow-modal-table-wrap { max-width: 100%; overflow-x: auto; border: 1px solid var(--flow-boundary); border-radius: 8px; }
.bi-flow-modal-table { width: 100%; border-collapse: collapse; text-align: left; font-size: 12px; }
.bi-flow-modal-table th { padding: 10px 12px; background: #f8fafc; color: #64748b; font-weight: 500; white-space: nowrap; }
.bi-flow-modal-table td { height: 40px; padding: 7px 12px; border-top: 1px solid var(--flow-line); line-height: 1.5; max-width: 200px; overflow-wrap: anywhere; }
.bi-flow-modal-table tr:hover td { background: #f8fafc; }
.bi-flow-modal-number { text-align: right; white-space: nowrap; width: 65px; }
.bi-flow-modal-number strong { font-size: 14px; font-weight: 600; color: #0f172a; font-variant-numeric: tabular-nums; }
.bi-flow-modal-share { width: 126px; white-space: nowrap; }
.bi-flow-modal-share > div { display: flex; align-items: center; justify-content: flex-end; gap: 7px; font-size: 11px; font-variant-numeric: tabular-nums; }
.bi-flow-modal-share > div > span:last-child { min-width: 42px; text-align: right; }
.bi-flow-modal-track { display: block; width: 48px; flex-shrink: 0; height: 5px; background: #f1f5f9; overflow: hidden; border-radius: 3px; }
.bi-flow-modal-fill { display: block; height: 100%; border-radius: 3px; }
.bi-flow-modal-fill-delivered { background: #2563eb; }
.bi-flow-modal-fill-returned { background: #ea580c; }
.bi-flow-modal-fill-replaced { background: #7c3aed; }
.bi-flow-modal-action { width: 78px; white-space: nowrap; text-align: right; }
.bi-flow-modal-action button { padding: 3px 0; color: #2563eb; font-size: 11px; background: none; border: 0; }
.bi-flow-modal-action button:hover { text-decoration: underline; }
.bi-flow-modal-detail-table { min-width: 800px; }
.bi-flow-modal-detail-table td { font-size: 12px; min-width: 80px; }
.bi-flow-modal-detail-table td:nth-child(3), .bi-flow-modal-detail-table td:nth-child(4) { min-width: 133px; font-size: 11px; }
.bi-flow-modal-empty { padding: 30px 12px; text-align: center; color: #94a3b8; font-size: 12px; background: #f8fafc; border-radius: 8px; }
@media (max-width: 640px) {
.bi-flow-modal-backdrop { padding: 0; align-items: flex-end; }
.bi-flow-modal { border-radius: 12px 12px 0 0; max-height: 90dvh; }
.bi-flow-modal-header, .bi-flow-modal-body { padding: 12px; }
.bi-flow-modal-control-row { align-items: flex-start; gap: 8px; }
.bi-flow-modal-control-label { flex-basis: 44px; padding-top: 6px; font-size: 10px; }
.bi-flow-modal-control-hint { margin-left: 52px; }
.bi-flow-modal-summary > div { padding: 10px 8px; }
.bi-flow-modal-summary span { font-size: 10px; }
.bi-flow-modal-summary strong { font-size: 22px; }
.bi-flow-modal-table th, .bi-flow-modal-table td { padding-left: 9px; padding-right: 9px; }
.bi-flow-modal-table th:not(.bi-flow-modal-number):not(.bi-flow-modal-share):not(.bi-flow-modal-action) { min-width: 72px; }
.bi-flow-modal-share { width: 65px; }
.bi-flow-modal-track { display: none; }
}
.bi-flow-modal-merge-toggle { display:inline-flex; align-items:center; gap:6px; color:#64748b; font-size:11px; cursor:pointer; white-space:nowrap; }
.bi-flow-modal-merge-toggle input { width:13px; height:13px; accent-color:#2563eb; }
.bi-flow-modal-table td.bi-flow-modal-parent-cell { vertical-align:top; padding-top:11px; background:#f8fafc; border-right:1px solid var(--flow-boundary); border-bottom:1px solid var(--flow-boundary); font-weight:500; }
/* Parent boundaries are stronger in color, never thicker. */
.bi-flow-modal-table tbody tr:first-child > td,
.bi-flow-modal-table .bi-flow-modal-group-start > td { border-top-color:var(--flow-boundary); }
@@ -1,100 +1,176 @@
import { Fragment, type Dispatch, type SetStateAction } from 'react';
import { ArrowUpRight, ChevronRight } from 'lucide-react';
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { InventoryModelTags } from './InventoryModelTags';
import { inventoryBrandGroups, inventoryRegionRows, inventorySelection, inventorySubtotalSelection, inventoryTotal, type InventoryDisplayGroup } from './inventory-presentation';
import { inventoryTypeLabel, type InventoryFilters, type VehicleModalSelection } from '../../model';
import { inventorySelection, type InventoryDisplayGroup } from './inventory-presentation';
import type { InventoryFilters } from '../../model';
import type { RegionalInventoryStats } from '../../types';
import { inventorySubtotalSelection } from './inventory-presentation';
import React from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import type { InventoryStatisticsSectionProps } from './types';
export interface InventoryListProps {
groups: InventoryDisplayGroup[];
tab: 'region' | 'model';
expanded: Set<string>;
toggle: (name: string) => void;
total: number;
filters: InventoryFilters;
select: Dispatch<SetStateAction<VehicleModalSelection | null>>;
type InventoryDesktopTableProps = Pick<
InventoryStatisticsSectionProps,
| 'inventoryTab'
| 'inventoryByRegion'
| 'expandedInventoryRegions'
| 'toggleInventoryRegion'
| 'inventoryByModel'
| 'expandedInventoryTypes'
| 'toggleInventoryType'
| 'setVehicleSelection'
>;
export function InventoryDesktopTable({
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryDesktopTableProps) {
return (
<div className="hidden lg:block overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 text-[11px] text-slate-500 uppercase tracking-wider border-b border-slate-100">
<th className="p-3 font-semibold w-64">{inventoryTab === 'region' ? '区域 / 城市' : '车型分类 / 型号'}</th>
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '品牌' : '品牌'}</th>
<th className="p-3 font-semibold">{inventoryTab === 'region' ? '车型' : '所在区域/城市'}</th>
<th className="p-3 font-semibold text-center w-32">库存数量</th>
</tr>
</thead>
<tbody className="text-xs">
{inventoryTab === 'region' ? (
Object.entries(inventoryByRegion).map(([region, cities]) => (
<React.Fragment key={region}>
<tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryRegion(region)}
>
<td colSpan={4} className="p-3 font-bold text-slate-700">
<div className="flex flex-wrap items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{region}</span>
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection(Object.values(cities).flat(), `库存统计 - ${region}`));
}}>
(共 {Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(cities).flat()} />
</div>
</td>
</tr>
<AnimatePresence>
{expandedInventoryRegions.has(region) && Object.entries(cities).map(([city, stats]) => (
<React.Fragment key={city}>
{stats.map((stat, idx) => (
<motion.tr
key={`${city}-${stat.model}-${idx}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
>
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
{idx === 0 ? <span className="font-medium text-slate-600">{city}</span> : ''}
</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.model}</td>
<td className="p-3 text-center font-bold text-blue-600">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`));
}}
className="text-blue-600 hover:underline font-bold"
>
{stat.quantity}
</button>
{((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && (
<div className="mt-1"><InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} /></div>
)}
</td>
</motion.tr>
))}
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))
) : (
Object.entries(inventoryByModel).map(([type, models]) => (
<React.Fragment key={type}>
<tr
className="bg-slate-50/30 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors"
onClick={() => toggleInventoryType(type)}
>
<td colSpan={4} className="p-3 font-bold text-slate-700">
<div className="flex flex-wrap items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span>{type}</span>
<span className="text-[10px] font-normal text-slate-400 ml-2 cursor-pointer hover:text-blue-500 transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection(Object.values(models).flat(), `库存统计 - ${type}`));
}}>
(共 {Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台)
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(models).flat()} />
</div>
</td>
</tr>
<AnimatePresence>
{expandedInventoryTypes.has(type) && Object.entries(models).map(([model, stats]) => (
<React.Fragment key={model}>
{stats.map((stat, idx) => (
<motion.tr
key={`${model}-${stat.region}-${stat.city}-${idx}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="border-b border-slate-50 hover:bg-slate-50/20 transition-colors"
>
<td className="p-3 pl-8 text-slate-500 border-r border-slate-50">
{idx === 0 ? <span className="font-medium text-slate-600">{model}</span> : ''}
</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.brand}</td>
<td className="p-3 text-slate-600 border-r border-slate-50">{stat.region} / {stat.city}</td>
<td className="p-3 text-center font-bold text-blue-600">
<button
onClick={(e) => {
e.stopPropagation();
setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`));
}}
className="text-blue-600 hover:underline font-bold"
>
{stat.quantity}
</button>
{((stat.pending ?? 0) > 0 || (stat.abnormal ?? 0) > 0) && (
<div className="mt-1"><InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} /></div>
)}
</td>
</motion.tr>
))}
</React.Fragment>
))}
</AnimatePresence>
</React.Fragment>
))
)}
</tbody>
</table>
</div>
);
}
export function InventoryQuantity({ quantity, label, onClick }: { quantity: number; label: string; onClick: () => void }) {
return <button className="inventory-quantity" disabled={!quantity} onClick={onClick} aria-label={`${label},库存 ${quantity} 台,查看车辆明细`}>
{quantity.toLocaleString()}<ArrowUpRight size={14} aria-hidden="true" />
</button>;
}
export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryListProps['select']) {
export function selectInventoryRow(row: RegionalInventoryStats, select: InventoryStatisticsSectionProps['setVehicleSelection']) {
select(inventorySelection(row, `库存统计 · ${row.city} · ${row.model}`, { vehicleType: row.type }));
}
export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: Pick<InventoryListProps, 'tab' | 'filters' | 'select'>) {
export function selectInventoryGroup(group: InventoryDisplayGroup, { tab, filters, select }: { tab: 'region' | 'model'; filters: InventoryFilters; select: InventoryStatisticsSectionProps['setVehicleSelection'] }) {
select(inventorySelection(tab === 'model' ? { ...filters, type: group.name } : filters, `库存统计 · ${group.name}`, tab === 'region'
? { location: filters.city || group.name }
: {}));
}
export function InventoryShare({ quantity, total }: { quantity: number; total: number }) {
const percent = total ? quantity / total * 100 : 0;
return <div className="inventory-share"><span className="inventory-share-track" aria-hidden="true"><span style={{ width: `${percent}%` }} /></span><span>{percent.toFixed(1)}%</span></div>;
}
function InventorySubtotal({ rows, label, select }: { rows: RegionalInventoryStats[]; label: string; select: InventoryListProps['select'] }) {
return <div className={`inventory-subtotal${rows.length === 1 ? ' inventory-subtotal-inline' : ''}`} aria-label={`${label}合计 ${inventoryTotal(rows)} 台`}>
<button className="inventory-subtotal-button" disabled={!inventoryTotal(rows)} aria-label={`${label}合计 ${inventoryTotal(rows)} 台,查看车辆明细`} onClick={() => select(inventorySubtotalSelection(rows, label))}>合计 <strong>{inventoryTotal(rows).toLocaleString()}</strong> 台<ArrowUpRight size={12} aria-hidden="true" /></button>
<InventoryStatusCounts rows={rows} />
</div>;
}
function InventoryDetailRows({ rows, city, tab, total, select }: Pick<InventoryListProps, 'tab' | 'total' | 'select'> & { rows: RegionalInventoryStats[]; city?: string }) {
const brands = inventoryBrandGroups(rows, tab === 'region' ? 'type' : 'model');
return brands.map((brand, brandIndex) => brand.variants.map((variants, variantIndex) =>
(tab === 'model' ? inventoryRegionRows(variants) : variants.map(row => ({ row, regionSpan: 0 }))).map(({ row, regionSpan }, index) =>
<tr className="inventory-detail-row" key={`${brandIndex}-${variantIndex}-${index}`}>
{tab === 'region' && brandIndex === 0 && variantIndex === 0 && index === 0 && <th rowSpan={rows.length} scope="rowgroup" className="inventory-merged-cell inventory-city-cell">{city || '未分配'}<InventorySubtotal select={select} rows={rows} label={city || '未分配'} /></th>}
{variantIndex === 0 && index === 0 && <td rowSpan={brand.rows.length} className="inventory-merged-cell">{brand.brand || '—'}<InventorySubtotal select={select} rows={brand.rows} label={brand.brand || '未分配品牌'} /></td>}
{index === 0 && <td rowSpan={variants.length} className="inventory-merged-cell">
{tab === 'region' ? <span className="inventory-tag inventory-tag-type">{inventoryTypeLabel(row.type) || '未分类'}</span> : <InventoryModelTags row={row} hideType />}
<InventorySubtotal select={select} rows={variants} label={tab === 'region' ? inventoryTypeLabel(row.type) : row.model} />
</td>}
{tab === 'model' && regionSpan > 0 && <td rowSpan={regionSpan} className="inventory-merged-cell inventory-region-cell">{row.region || '未分配'}<InventorySubtotal select={select} rows={variants.filter(item => item.region === row.region)} label={row.region || '未分配'} /></td>}
<td>{tab === 'region' ? <InventoryModelTags row={row} hideType /> : row.city || '未分配'}</td>
<td className="inventory-number-column"><InventoryQuantity quantity={row.quantity} label={`${row.city} · ${row.brand} · ${row.model}`} onClick={() => selectInventoryRow(row, select)} /><InventoryStatusCounts rows={[row]} /></td>
<td><InventoryShare quantity={row.quantity} total={total} /></td>
</tr>
)));
}
export function InventoryDesktopTable(props: InventoryListProps) {
const { groups, tab, expanded, toggle, total, select } = props;
return <div className="inventory-desktop">
<table className="inventory-table" data-group-by={tab}>
<caption className="sr-only">按{tab === 'region' ? '区域' : '车型'}分组的库存统计,占比基于当前筛选库存总数</caption>
<thead><tr>
{tab === 'region' && <th scope="col">区域 / 城市</th>}
<th scope="col">品牌</th>
{tab === 'region' && <th scope="col">车型</th>}
<th scope="col">类别</th>
{tab === 'model' && <><th scope="col">区域</th><th scope="col">城市</th></>}
<th scope="col" className="inventory-number-column">库存数量</th>
<th scope="col" className="inventory-share-column">库存占比</th>
</tr></thead>
{groups.map(group => <Fragment key={group.name}><tbody>
<tr className="inventory-group-row" data-expanded={expanded.has(group.name)}>
<th scope="row" colSpan={4}>
<div className="inventory-group-heading">
<button className="inventory-expand" aria-expanded={expanded.has(group.name)} onClick={() => toggle(group.name)}>
<ChevronRight size={16} aria-hidden="true" /><strong>{group.name || '未分配'}</strong><span>{group.sections.length} 个{tab === 'region' ? '城市' : '型号'}</span>
</button>
<InventoryStatusCounts rows={group.rows} />
</div>
</th>
<td className="inventory-number-column"><InventoryQuantity quantity={group.quantity} label={group.name} onClick={() => selectInventoryGroup(group, props)} /></td>
<td><InventoryShare quantity={group.quantity} total={total} /></td>
</tr>
</tbody>
{expanded.has(group.name) && (tab === 'region' ? group.sections : [[group.name, group.rows] as const]).map(([name, rows]) => <tbody key={name} className="inventory-detail-group">
<InventoryDetailRows rows={rows} city={name} tab={tab} total={total} select={select} />
</tbody>)}
</Fragment>)}
</table>
</div>;
}
@@ -1,77 +1,131 @@
import { Fragment, useId, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, MapPin, Truck } from 'lucide-react';
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { inventoryMobileDrilldown, inventoryMobileGroups, inventoryPage, inventorySubtotalSelection, inventoryTotal, type InventoryDisplayGroup } from './inventory-presentation';
import { InventoryQuantity, InventoryShare, selectInventoryGroup, type InventoryListProps } from './InventoryDesktopTable';
import { inventorySubtotalSelection } from './inventory-presentation';
import { ChevronDown, ChevronRight } from 'lucide-react';
import type { InventoryStatisticsSectionProps } from './types';
function MobileGroupDetails({ group, tab, select }: Pick<InventoryListProps, 'tab' | 'select'> & { group: InventoryDisplayGroup }) {
const [path, setPath] = useState<string[]>([]);
const [requestedPage, setPage] = useState(0);
const heading = useRef<HTMLHeadingElement>(null);
const view = inventoryMobileDrilldown(group.rows, tab, path);
const labels = { region: '区域', city: '城市', type: '车型', variant: '品牌 / 型号' };
const { page, pageCount, start, rows: pageGroups } = inventoryPage(view.groups, requestedPage);
const activePath = view.parents.map(parent => parent.key);
const navigate = (nextPath: string[]) => {
setPath(nextPath);
setPage(0);
heading.current?.focus({ preventScroll: true });
};
const openVehicles = (rows: typeof group.rows, label: string) => select(inventorySubtotalSelection(rows, label));
type InventoryMobileListProps = Pick<
InventoryStatisticsSectionProps,
| 'inventoryTab'
| 'inventoryByRegion'
| 'expandedInventoryRegions'
| 'toggleInventoryRegion'
| 'inventoryByModel'
| 'expandedInventoryTypes'
| 'toggleInventoryType'
| 'setVehicleSelection'
>;
return <div className="inventory-mobile-details">
{view.parents.length > 0 && <nav className="inventory-mobile-breadcrumb" aria-label="库存下钻路径">
<button onClick={() => navigate([])}>{group.name || '未分配'}</button>
{view.parents.map((parent, index) => <Fragment key={parent.key}>
<ChevronRight size={12} aria-hidden="true" />
{index === view.parents.length - 1 ? <span aria-current="location">{parent.label}</span> :
<button onClick={() => navigate(activePath.slice(0, index + 1))}>{parent.label}</button>}
</Fragment>)}
</nav>}
<div className="inventory-mobile-detail-toolbar">
<h3 ref={heading} tabIndex={-1}>{labels[view.dimension]}库存 <span>· {view.groups.length} 项</span></h3>
{view.parents.length > 0 && <button className="inventory-text-button" onClick={() => navigate(activePath.slice(0, -1))}><ChevronLeft size={14} aria-hidden="true" />返回上一级</button>}
export function InventoryMobileList({
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryMobileListProps) {
return (
<div className="lg:hidden p-3 space-y-3">
{inventoryTab === 'region' ? (
Object.entries(inventoryByRegion).map(([region, cities]) => (
<div key={region} className="border border-slate-100 rounded overflow-hidden">
<div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
onClick={() => toggleInventoryRegion(region)}
>
<div className="flex items-center gap-2">
{expandedInventoryRegions.has(region) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span className="text-xs font-bold text-slate-700">{region}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<span className="text-[10px] font-bold text-blue-600">
{Object.values(cities).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(cities).flat()} />
</div>
</div>
{expandedInventoryRegions.has(region) && (
<div className="p-2 space-y-2 bg-white">
{Object.entries(cities).map(([city, stats]) => (
<div key={city} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="text-[10px] font-bold text-slate-500 mb-2">{city}</div>
<div className="space-y-2">
{stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.model}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<button
onClick={() => setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`))}
className="font-bold text-blue-600 hover:underline"
>
{stat.quantity} 台
</button>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} />
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
))
) : (
Object.entries(inventoryByModel).map(([type, models]) => (
<div key={type} className="border border-slate-100 rounded overflow-hidden">
<div
className="bg-slate-50 p-3 flex justify-between items-center cursor-pointer"
onClick={() => toggleInventoryType(type)}
>
<div className="flex items-center gap-2">
{expandedInventoryTypes.has(type) ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<span className="text-xs font-bold text-slate-700">{type}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<span className="text-[10px] font-bold text-blue-600">
{Object.values(models).flat().reduce((acc, s) => acc + s.quantity, 0)} 台
</span>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={Object.values(models).flat()} />
</div>
</div>
{expandedInventoryTypes.has(type) && (
<div className="p-2 space-y-2 bg-white">
{Object.entries(models).map(([model, stats]) => (
<div key={model} className="border-l-2 border-slate-200 pl-3 py-1">
<div className="text-[10px] font-bold text-slate-500 mb-2">{model}</div>
<div className="space-y-2">
{stats.map((stat, idx) => (
<div key={idx} className="flex justify-between items-center text-[11px] bg-slate-50/50 p-2 rounded">
<div className="flex flex-col">
<span className="text-slate-400 text-[9px]">{stat.brand}</span>
<span className="text-slate-700 font-medium">{stat.region} / {stat.city}</span>
</div>
<div className="flex flex-col items-end gap-1 text-right">
<button
onClick={() => setShowPlateNumbers(inventorySubtotalSelection([stat], `库存统计 - ${stat.model} - ${stat.city}`))}
className="font-bold text-blue-600 hover:underline"
>
{stat.quantity} 台
</button>
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={[stat]} />
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
))
)}
</div>
<div className="inventory-mobile-list-heading"><span>{labels[view.dimension]}</span><span>库存(台)</span></div>
<ul className="inventory-mobile-records" aria-label={`${labels[view.dimension]}库存列表`}>
{pageGroups.map(item => <li key={item.key} className="inventory-mobile-drill-row">
<button className="inventory-mobile-drill-label"
aria-label={view.dimension === 'variant' ? `${item.label},查看车辆明细` : `查看${item.label}的${view.dimension === 'region' ? '城市' : view.dimension === 'city' && tab === 'region' ? '车型' : '品牌型号'}库存`}
onClick={() => view.dimension === 'variant' ? openVehicles(item.rows, item.label) : navigate([...activePath, item.key])}>
<span>{item.label}</span>
{view.dimension !== 'variant' && <ChevronRight size={14} aria-hidden="true" />}
</button>
<InventoryQuantity quantity={item.quantity} label={item.label} onClick={() => openVehicles(item.rows, item.label)} />
{item.rows.some(row => row.abnormal || row.pending) && <div className="inventory-mobile-status"><InventoryStatusCounts rows={item.rows} /></div>}
</li>)}
</ul>
<div className="inventory-mobile-scope-total" role="status"><span>当前范围合计</span><strong>{inventoryTotal(view.rows).toLocaleString()} 台</strong></div>
{pageCount > 1 && <nav className="inventory-mobile-pagination" aria-label={`${group.name}明细分页`}>
<button disabled={page === 0} onClick={() => setPage(page - 1)}><ChevronLeft size={16} aria-hidden="true" />上一页</button>
<span aria-live="polite">{start + 1}–{start + pageGroups.length} / {view.groups.length}</span>
<button disabled={page === pageCount - 1} onClick={() => setPage(page + 1)}>下一页<ChevronRight size={16} aria-hidden="true" /></button>
</nav>}
</div>;
}
export function InventoryMobileList(props: InventoryListProps) {
const { groups, tab, total } = props;
const [activeGroup, setActiveGroup] = useState<string | null>(null);
const id = useId();
return <div className="inventory-mobile">
<p className="inventory-mobile-hint">点击名称逐层查看,点击数量查看车辆</p>
{groups.map((group, index) => <article key={group.name} className="inventory-mobile-group" data-expanded={activeGroup === group.name}>
<div className="inventory-mobile-group-heading">
<button className="inventory-expand" aria-expanded={activeGroup === group.name} aria-controls={`${id}-${index}`} onClick={() => setActiveGroup(activeGroup === group.name ? null : group.name)}>
<span className="inventory-mobile-group-icon">{tab === 'region' ? <MapPin size={16} aria-hidden="true" /> : <Truck size={16} aria-hidden="true" />}</span><strong>{group.name || '未分配'}</strong><span>{inventoryMobileGroups(group.rows, tab === 'region' ? 'city' : 'region').length} 个{tab === 'region' ? '城市' : '区域'}</span><ChevronRight size={14} aria-hidden="true" />
</button>
<InventoryQuantity quantity={group.quantity} label={group.name} onClick={() => selectInventoryGroup(group, props)} />
</div>
<div className="inventory-mobile-group-meta"><span>库存占比</span><InventoryShare quantity={group.quantity} total={total} /></div>
{(group.rows.some(row => row.abnormal || row.pending)) && <div className="inventory-mobile-status"><InventoryStatusCounts rows={group.rows} /></div>}
<div id={`${id}-${index}`} hidden={activeGroup !== group.name}>
{activeGroup === group.name && <MobileGroupDetails group={group} tab={tab} select={props.select} />}
</div>
</article>)}
</div>;
);
}
@@ -1,121 +1,213 @@
import { useId, useRef, useState } from 'react';
import { ArrowUpRight, Boxes, ChevronDown, ChevronsDownUp, ChevronsUpDown, Filter, MapPin, SearchX, Truck, X } from 'lucide-react';
import { useAuth } from '../../../../auth/useAuth';
import { InventoryStatusCounts } from './InventoryStatusCounts';
import { inventorySubtotalSelection } from './inventory-presentation';
import { Filter } from 'lucide-react';
import { AnimatePresence, motion } from 'motion/react';
import { InventoryDesktopTable } from './InventoryDesktopTable';
import { InventoryMobileList } from './InventoryMobileList';
import { inventoryGroups, inventorySelection, inventoryTotal } from './inventory-presentation';
import type { InventoryFilters } from '../../model';
import type { InventoryStatisticsSectionProps } from './types';
import './inventory-statistics.css';
const emptyFilters: InventoryFilters = { region: '', city: '', brand: '', type: '', model: '' };
const filterLabels: Record<keyof InventoryFilters, string> = { region: '区域', city: '城市', brand: '品牌', type: '车型', model: '型号 / 批次' };
export function InventoryStatisticsSection(props: InventoryStatisticsSectionProps) {
const {
inventoryFilters: filters, setInventoryFilters: setFilters,
draftInventoryFilters: draft, setDraftInventoryFilters: setDraft,
isInventoryFilterOpen: filterOpen, setIsInventoryFilterOpen: setFilterOpen,
filteredInventoryStats: rows, inventoryTab: tab, setInventoryTab: setTab,
setVehicleSelection: select,
} = props;
const id = useId();
const filterTrigger = useRef<HTMLButtonElement>(null);
const [sort, setSort] = useState<'default' | 'quantity'>('default');
const total = inventoryTotal(rows);
const abnormal = rows.reduce((sum, row) => sum + (row.abnormal || 0), 0);
const pending = rows.reduce((sum, row) => sum + (row.pending || 0), 0);
const activeFilters = (Object.keys(filters) as (keyof InventoryFilters)[]).filter(key => filters[key]);
const groups = inventoryGroups(tab === 'region' ? props.inventoryByRegion : props.inventoryByModel, sort, tab);
const expanded = tab === 'region' ? props.expandedInventoryRegions : props.expandedInventoryTypes;
const toggle = tab === 'region' ? props.toggleInventoryRegion : props.toggleInventoryType;
const allExpanded = groups.length > 0 && groups.every(group => expanded.has(group.name));
const closeFilters = () => { setFilterOpen(false); filterTrigger.current?.focus(); };
const resetFilters = () => { setFilters({ ...emptyFilters }); setDraft({ ...emptyFilters }); };
const options = {
region: props.uniqueInventoryRegions, city: props.uniqueInventoryCities,
brand: props.uniqueInventoryBrands, type: props.uniqueInventoryTypes,
model: props.uniqueInventoryModelsForType,
export function InventoryStatisticsSection({
inventoryFilters,
setInventoryFilters,
draftInventoryFilters,
setDraftInventoryFilters,
isInventoryFilterOpen,
setIsInventoryFilterOpen,
uniqueInventoryRegions,
uniqueInventoryCities,
uniqueInventoryBrands,
uniqueInventoryTypes,
uniqueInventoryModelsForType,
filteredInventoryStats,
inventoryTab,
setInventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
}: InventoryStatisticsSectionProps) {
const { preview } = useAuth();
const listProps = {
inventoryTab,
inventoryByRegion,
expandedInventoryRegions,
toggleInventoryRegion,
inventoryByModel,
expandedInventoryTypes,
toggleInventoryType,
setVehicleSelection: setShowPlateNumbers,
};
const listProps = { groups, tab, expanded, toggle, total, filters, select };
return (
<section data-testid="inventory-statistics" className="inventory-statistics" aria-labelledby={`${id}-heading`}>
<header className="inventory-heading">
<div className="inventory-title">
<span className="inventory-title-icon"><Boxes size={20} aria-hidden="true" /></span>
<div><h2 id={`${id}-heading`}>库存统计</h2><p>按区域、车型核对库存分布</p></div>
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm mb-6 overflow-hidden min-h-[420px]">
<div className="p-3 sm:p-4 border-b border-gray-50 bg-white flex items-center justify-between relative">
<div className="flex items-center gap-3">
<div className="w-1.5 h-6 bg-blue-600 rounded-full"></div>
<div className="flex flex-col">
<h2 className="text-lg font-bold text-gray-800 leading-tight">库存统计</h2>
<span className="text-[10px] text-gray-400 font-medium">{preview ? '公共库存汇总 · 明细按当前用户权限查看' : '实时更新库存数据'}</span>
</div>
</div>
<button ref={filterTrigger} className="inventory-control" aria-expanded={filterOpen} aria-controls={`${id}-filters`}
onClick={() => { if (!filterOpen) setDraft({ ...filters }); setFilterOpen(!filterOpen); }}>
<Filter size={16} aria-hidden="true" /> 筛选{activeFilters.length > 0 && <span className="inventory-filter-count">{activeFilters.length}</span>}
<ChevronDown size={14} aria-hidden="true" />
</button>
</header>
<div className="inventory-metrics" data-testid="inventory-status-summary">
<button className="inventory-metric inventory-metric-primary" disabled={!total}
onClick={() => select(inventorySelection(filters, activeFilters.length ? '库存统计 · 筛选结果' : '库存统计 · 全部库存'))}>
<span>{activeFilters.length ? '筛选后库存' : '库存总数'}<ArrowUpRight size={16} aria-hidden="true" /></span>
<strong>{total.toLocaleString()}<small>台</small></strong>
</button>
<button className="inventory-metric inventory-metric-pending" disabled={!pending}
onClick={() => select(inventorySelection(filters, '库存统计 · 待交车', { category: 'Pending' }))}>
<span>其中待交车<ArrowUpRight size={16} aria-hidden="true" /></span>
<strong>{pending.toLocaleString()}<small>台</small></strong>
</button>
<button className="inventory-metric inventory-metric-warning" disabled={!abnormal}
onClick={() => select(inventorySelection(filters, '库存统计 · 异动', { category: 'Abnormal' }))}>
<span>其中异动<ArrowUpRight size={16} aria-hidden="true" /></span>
<strong>{abnormal.toLocaleString()}<small>台</small></strong>
</button>
<div className="flex items-center gap-6 ml-auto pr-2">
<div className="flex flex-col items-end cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => setShowPlateNumbers(inventorySubtotalSelection(filteredInventoryStats, '库存总数'))}>
<span className="text-[10px] text-gray-400 font-bold tracking-wider uppercase mb-0.5">总库存数据</span>
<span className="text-2xl font-black text-gray-900 leading-none">
{filteredInventoryStats.reduce((acc, s) => acc + s.quantity, 0)}
<span className="text-xs font-bold text-gray-400 ml-1">台</span>
</span>
</div>
<div className="relative">
<button
onClick={() => { if (!isInventoryFilterOpen) setDraftInventoryFilters({...inventoryFilters}); setIsInventoryFilterOpen(!isInventoryFilterOpen); }}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
isInventoryFilterOpen || (inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model)
? 'bg-blue-600 text-white shadow-md'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Filter size={14} />
<span>筛选</span>
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
<span className="w-2 h-2 bg-white rounded-full animate-pulse"></span>
)}
</button>
<AnimatePresence>
{isInventoryFilterOpen && (
<>
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="fixed inset-x-4 top-20 max-h-[80vh] overflow-auto sm:inset-auto sm:top-20 sm:right-4 sm:w-72 bg-white rounded-xl shadow-2xl border border-slate-100 z-50 p-4"
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xs font-bold text-slate-800">库存统计 - 数据筛选</h3>
<button onClick={() => setDraftInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[10px] text-blue-500 hover:underline">重置所有</button>
</div>
<div className="space-y-3 text-left">
<div>
<label className="text-[10px] text-slate-400 block mb-1">区域</label>
<select value={draftInventoryFilters.region} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, region: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部区域</option>
{uniqueInventoryRegions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">城市</label>
<select value={draftInventoryFilters.city} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, city: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部城市</option>
{uniqueInventoryCities.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">品牌</label>
<select value={draftInventoryFilters.brand} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, brand: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部品牌</option>
{uniqueInventoryBrands.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">车型</label>
<select value={draftInventoryFilters.type} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, type: e.target.value, model: ''})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部车型</option>
{uniqueInventoryTypes.map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 block mb-1">批次</label>
<select value={draftInventoryFilters.model} onChange={(e) => setDraftInventoryFilters({...draftInventoryFilters, model: e.target.value})} className="w-full text-xs bg-white border border-slate-200 rounded-lg px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm cursor-pointer">
<option value="">全部批次</option>
{uniqueInventoryModelsForType.map(m => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
<button onClick={() => { setInventoryFilters({...draftInventoryFilters}); setIsInventoryFilterOpen(false); }} className="w-full mt-4 py-2 bg-blue-600 text-white rounded-lg text-xs font-bold hover:bg-blue-700 transition-colors">确认</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
</div>
</div>
<div className="inventory-summary-note"><span>{new Set(rows.map(row => row.region)).size} 个区域 · {new Set(rows.map(row => row.city)).size} 个城市</span><span>待交车、异动已包含在库存总数中</span></div>
{filterOpen && <form id={`${id}-filters`} className="inventory-filters" aria-label="库存筛选"
onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); closeFilters(); } }}
onSubmit={event => { event.preventDefault(); setFilters({ ...draft }); closeFilters(); }}>
<div className="inventory-filter-heading"><strong>筛选库存</strong><button type="button" className="inventory-icon-button" aria-label="关闭库存筛选" onClick={closeFilters}><X size={18} aria-hidden="true" /></button></div>
<div className="inventory-filter-fields">
{(Object.keys(filterLabels) as (keyof InventoryFilters)[]).map(key => <label key={key}>
{filterLabels[key]}
<select value={draft[key]} onChange={event => setDraft({ ...draft, [key]: event.target.value, ...(key === 'type' ? { model: '' } : {}), ...(key === 'region' ? { city: '' } : {}) })}>
<option value="">全部{filterLabels[key]}</option>
{options[key].filter(Boolean).map(value => <option key={value} value={value}>{value}</option>)}
</select>
</label>)}
{filteredInventoryStats.some(row => (row.pending ?? 0) > 0 || (row.abnormal ?? 0) > 0) && (
<div className="border-b border-gray-100 px-4 py-2" data-testid="inventory-status-summary">
<InventoryStatusCounts onSelect={setShowPlateNumbers} rows={filteredInventoryStats} />
</div>
<div className="inventory-filter-actions">
<button type="button" className="inventory-text-button" onClick={() => setDraft({ ...emptyFilters })}>重置条件</button>
<button type="button" className="inventory-control" onClick={closeFilters}>取消</button>
<button type="submit" className="inventory-control inventory-control-primary">应用筛选</button>
</div>
</form>}
)}
{activeFilters.length > 0 && <div className="inventory-active-filters" aria-label="已应用的库存筛选">
<span>已筛选</span>
{activeFilters.map(key => <button key={key} onClick={() => setFilters({ ...filters, [key]: '', ...(key === 'type' ? { model: '' } : {}), ...(key === 'region' ? { city: '' } : {}) })}
aria-label={`移除${filterLabels[key]}筛选:${filters[key]}`}>
{filterLabels[key]}:{filters[key]}<X size={13} aria-hidden="true" />
</button>)}
<button className="inventory-clear" onClick={resetFilters}>清除全部</button>
</div>}
<div className="inventory-toolbar">
<div className="inventory-view-switch" role="group" aria-label="库存分组方式">
<button aria-pressed={tab === 'region'} onClick={() => setTab('region')}><MapPin size={15} aria-hidden="true" />按区域</button>
<button aria-pressed={tab === 'model'} onClick={() => setTab('model')}><Truck size={15} aria-hidden="true" />按车型</button>
</div>
<div className="inventory-toolbar-actions">
<label className="inventory-sort"><span className="sr-only">库存分组排序</span><select value={sort} onChange={event => setSort(event.target.value as typeof sort)}><option value="default">默认排序</option><option value="quantity">库存从多到少</option></select></label>
<button className="inventory-text-button inventory-expand-all" disabled={!groups.length} onClick={() => groups.forEach(group => { if (expanded.has(group.name) === allExpanded) toggle(group.name); })}>
{allExpanded ? <ChevronsDownUp size={16} aria-hidden="true" /> : <ChevronsUpDown size={16} aria-hidden="true" />}{allExpanded ? '收起全部' : '展开全部'}
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
<div className="flex bg-gray-200/50 p-1 rounded-lg w-fit shadow-inner">
<button
onClick={() => setInventoryTab('region')}
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
inventoryTab === 'region' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
按区域
</button>
<button
onClick={() => setInventoryTab('model')}
className={`px-6 py-1.5 rounded-md text-xs font-bold transition-all ${
inventoryTab === 'model' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'
}`}
>
按车型
</button>
</div>
</div>
{groups.length ? <><InventoryDesktopTable {...listProps} /><InventoryMobileList key={JSON.stringify([tab, filters])} {...listProps} /></> :
<div className="inventory-empty"><SearchX size={32} aria-hidden="true" /><h3>{activeFilters.length ? '没有符合条件的库存' : '暂无库存数据'}</h3><p>{activeFilters.length ? '试试减少筛选条件,或清除筛选查看全部库存。' : '当前所属公司范围内暂无库存记录。'}</p>{activeFilters.length > 0 && <button className="inventory-control" onClick={resetFilters}>清除筛选</button>}</div>}
<footer className="inventory-footer"><span role="status" aria-live="polite">共 {groups.length} 个{tab === 'region' ? '区域' : '车型'} · {rows.length} 条记录 · {total.toLocaleString()} 台库存</span><span>占比基于当前筛选结果 · 单位:台</span></footer>
</section>
{/* Active Filters Bar */}
{(inventoryFilters.region || inventoryFilters.city || inventoryFilters.brand || inventoryFilters.type || inventoryFilters.model) && (
<div className="px-4 py-2 border-b border-gray-100 flex flex-wrap gap-2 items-center">
{inventoryFilters.region && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
区域: {inventoryFilters.region}
<button onClick={() => setInventoryFilters({...inventoryFilters, region: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.city && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
城市: {inventoryFilters.city}
<button onClick={() => setInventoryFilters({...inventoryFilters, city: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.brand && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
品牌: {inventoryFilters.brand}
<button onClick={() => setInventoryFilters({...inventoryFilters, brand: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.type && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
车型: {inventoryFilters.type}
<button onClick={() => setInventoryFilters({...inventoryFilters, type: '', model: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
{inventoryFilters.model && (
<span className="px-2 py-0.5 bg-blue-50 text-blue-700 rounded text-[10px] flex items-center gap-1">
批次: {inventoryFilters.model}
<button onClick={() => setInventoryFilters({...inventoryFilters, model: ''})} className="hover:text-red-500 ml-0.5">×</button>
</span>
)}
<button onClick={() => setInventoryFilters({ region: '', city: '', brand: '', type: '', model: '' })} className="text-[11px] text-red-500 font-bold ml-auto hover:text-red-600">清除</button>
</div>
)}
{/* Desktop View Table */}
<InventoryDesktopTable {...listProps} />
{/* Mobile View */}
<InventoryMobileList {...listProps} />
</div>
);
}
@@ -1,12 +1,17 @@
import type { RegionalInventoryStats } from '../../types';
import type { VehicleModalSelection } from '../../model';
import { inventorySubtotalSelection } from './inventory-presentation';
/** Status counts are subsets of these filtered inventory rows, never extra stock. */
export function InventoryStatusCounts({ rows }: { rows: RegionalInventoryStats[] }) {
export function InventoryStatusCounts({ rows, onSelect }: { rows: RegionalInventoryStats[]; onSelect: (selection: VehicleModalSelection) => void }) {
const abnormal = rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0);
const pending = rows.reduce((sum, row) => sum + (row.pending ?? 0), 0);
if (abnormal === 0 && pending === 0) return null;
return <span className="inline-flex flex-wrap gap-x-3 gap-y-1 text-[11px] font-normal" data-testid="inventory-status-counts">
{abnormal > 0 && <span className="inventory-status-abnormal text-amber-700">其中异动 <strong>{abnormal}</strong> 台</span>}
{pending > 0 && <span className="inventory-status-pending text-blue-700">其中待交车 <strong>{pending}</strong> 台</span>}
{abnormal > 0 && <button type="button" className="inventory-status-abnormal text-amber-700 hover:underline focus-visible:outline-2 focus-visible:outline-amber-500 rounded-sm" title="分析当前范围内的异动车辆" onClick={event => {
event.stopPropagation();
onSelect(inventorySubtotalSelection(rows, rows.length === 1 ? rows[0].model : '异动', 'Abnormal'));
}}>异动 <strong>{abnormal}</strong> 台</button>}
{pending > 0 && <span className="inventory-status-pending text-blue-700">待交车 <strong>{pending}</strong> 台</span>}
</span>;
}
@@ -52,11 +52,11 @@ export function SummaryMetrics({
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]">
<button type="button" data-testid="asset-abnormal-card" className="inline-flex items-center gap-1 text-amber-600 hover:underline"
onClick={(event) => { event.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Abnormal', source: 'asset', title: '库存中的异动车辆' }); }}>
<TriangleAlert size={12} />其中异动 <strong>{SUMMARY.inventory.abnormal}</strong>
<TriangleAlert size={12} />异动 <strong>{SUMMARY.inventory.abnormal}</strong>
</button>
<button type="button" className="inline-flex items-center gap-1 text-blue-600 hover:underline"
onClick={(event) => { event.stopPropagation(); setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset', title: '库存中的待交车' }); }}>
<PlusCircle size={12} />其中待交车 <strong>{SUMMARY.pendingDelivery}</strong>
<PlusCircle size={12} />待交车 <strong>{SUMMARY.pendingDelivery}</strong>
</button>
</div>
@@ -12,6 +12,19 @@ import { fetchVehicleList } from '../../api';
const filters = { region: '华东', city: '上海', brand: '品牌甲', type: '18T', model: '批次一' };
test('abnormal inventory drill preserves exact row keys and intersects the abnormal category and company', () => {
const rows = [{ ...filters, batch: '', quantity: 10, abnormal: 1 }];
const selection = inventorySubtotalSelection(rows, rows[0].model, 'Abnormal');
const request = buildVehicleModalRequest(selection, '公司甲');
assert.equal(request.kind, 'vehicles');
if (request.kind !== 'vehicles') throw new Error('wrong endpoint');
assert.deepEqual(request.params.inventoryKeys, rows.map(inventoryKey));
assert.equal(request.params.category, 'Abnormal');
assert.equal(request.params.subject, '公司甲');
assert.match(selection.scopeLabel!, /异动 1 台$/);
assert.ok(selection.scopeLabel!.includes('批次一'));
});
test('mobile region navigation shows only cities then types, preserving totals and exact leaf scope', () => {
const rows = [
{ ...filters, batch: '', quantity: 9 },
@@ -115,10 +128,10 @@ test('mobile pages cover every record once and clamp after a filter reduces the
test('inventory status hints show nonzero subset counts and hide zeros', () => {
const row = { ...filters, batch: '', quantity: 9, pending: 2, abnormal: 1 };
const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [row] }));
assert.match(html, /其中待交车 <strong>2<\/strong> 台/);
assert.match(html, /其中异动 <strong>1<\/strong> 台/);
assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { rows: [{ ...row, pending: 0, abnormal: 0 }] })), '');
const html = renderToStaticMarkup(createElement(InventoryStatusCounts, { onSelect: () => {}, rows: [row] }));
assert.match(html, /待交车 <strong>2<\/strong> 台/);
assert.match(html, /异动 <strong>1<\/strong> 台/);
assert.equal(renderToStaticMarkup(createElement(InventoryStatusCounts, { onSelect: () => {}, rows: [{ ...row, pending: 0, abnormal: 0 }] })), '');
});
test('filtered inventory totals retain all filters in the vehicle request', () => {
@@ -53,13 +53,15 @@ export function inventorySelection(filters: InventoryFilters, title: string, ove
};
}
export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string): VehicleModalSelection {
export function inventorySubtotalSelection(rows: RegionalInventoryStats[], label: string, category: 'Inventory' | 'Abnormal' = 'Inventory'): VehicleModalSelection {
const common = (key: 'region' | 'city' | 'brand' | 'type') => rows.length && rows.every(row => row[key] === rows[0][key]) ? rows[0][key] : '';
const scope = [...new Set([common('region'), common('city'), common('brand'), common('type'), label].filter(Boolean))].join(' · ');
return {
batch: 'All', model: 'All', location: 'All', source: 'asset', category: 'Inventory',
batch: 'All', model: 'All', location: 'All', source: 'asset', category,
inventoryKeys: [...new Set(rows.map(inventoryKey))],
title: `库存统计 · ${scope}`, scopeLabel: `${scope} · 合计 ${inventoryTotal(rows)} 台`,
title: `库存统计 · ${scope}`, scopeLabel: category === 'Abnormal'
? `${scope} · 异动 ${rows.reduce((sum, row) => sum + (row.abnormal ?? 0), 0)} 台`
: `${scope} · 合计 ${inventoryTotal(rows)} 台`,
};
}