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
@@ -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 };
}