feat(assets): add dimension analysis and authorized user previews
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
Refactor asset drilldowns with combined dimension filters, parent summaries and progressive loading. Add the hidden /who read-only permission preview with scoped authorization, identity mapping and audit logging. Refine inventory counts and AI问数 presentation.
This commit is contained in:
@@ -24,16 +24,14 @@ import {
|
||||
Cell,
|
||||
LabelList,
|
||||
} from 'recharts';
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api';
|
||||
import type { FlowDetailItem, FlowStatsResponse, FlowType, WeeklyDetailItem } from './api';
|
||||
import type { SummaryData, TypeSummary, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart, fetchSubjects, fetchFlowStats, type SubjectOption } from './api';
|
||||
import type { FlowDetailItem, FlowStatsResponse, FlowType } from './api';
|
||||
import {
|
||||
buildCustomerPieData,
|
||||
buildVehicleModalRequest,
|
||||
deriveCustomerView,
|
||||
deriveDepartmentView,
|
||||
deriveInventoryView,
|
||||
deriveModalVehicleView,
|
||||
formatLocalDateTime,
|
||||
getRecentFlowRange,
|
||||
selectFlowDetails,
|
||||
@@ -45,8 +43,8 @@ import { MultiSearchSelect } from '../../components/MultiSearchSelect';
|
||||
import Blur from '../../components/Blur';
|
||||
import { ErrorState, LoadingState, PageFrame, SkeletonBlock, SurfaceCard } from '../../components/ui/surface';
|
||||
import { AssetsHeader } from './components/AssetsHeader';
|
||||
import { FlowDetailModal } from './components/FlowDetailModal';
|
||||
import { VehicleDetailModal } from './components/VehicleDetailModal';
|
||||
import { FlowAnalysisModal } from './components/FlowAnalysisModal';
|
||||
import { AssetAnalysisModal } from './components/analysis/AssetAnalysisModal';
|
||||
import { OverviewView } from './components/OverviewView';
|
||||
|
||||
|
||||
@@ -68,7 +66,7 @@ export default function AssetsModule() {
|
||||
useEffect(() => {
|
||||
if (!assistantOpen) return;
|
||||
const previousTitle = document.title;
|
||||
document.title = 'AI 问数 - 资产BI';
|
||||
document.title = 'AI问数 - 资产BI';
|
||||
return () => { document.title = previousTitle; };
|
||||
}, [assistantOpen]);
|
||||
const openAssistant = () => {
|
||||
@@ -110,16 +108,14 @@ export default function AssetsModule() {
|
||||
const [expandedModels, setExpandedModels] = useState<Set<string>>(new Set());
|
||||
const [expandedAssetTypes, setExpandedAssetTypes] = useState<Set<string>>(new Set());
|
||||
const [showPlateNumbers, setShowPlateNumbers] = useState<VehicleModalSelection | null>(null);
|
||||
const closeAssetAnalysis = useCallback(() => setShowPlateNumbers(null), []);
|
||||
|
||||
// Data state
|
||||
const [summary, setSummary] = useState<SummaryData | null>(null);
|
||||
const [processedData, setProcessedData] = useState<TypeSummary[]>([]);
|
||||
const [modalVehicles, setModalVehicles] = useState<VehicleListItem[]>([]);
|
||||
const [modalWeeklyDetail, setModalWeeklyDetail] = useState<WeeklyDetailItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdate, setLastUpdate] = useState<string>(() => formatLocalDateTime(new Date()));
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [flowRange, setFlowRange] = useState(() => getRecentFlowRange());
|
||||
const [flowStats, setFlowStats] = useState<FlowStatsResponse | null>(null);
|
||||
const [flowLoading, setFlowLoading] = useState(false);
|
||||
@@ -164,17 +160,6 @@ export default function AssetsModule() {
|
||||
const [regionChartView, setRegionChartView] = useState<'region' | 'province'>('region');
|
||||
const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]);
|
||||
|
||||
// Modal filter state
|
||||
const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' });
|
||||
const [isModalFilterExpanded, setIsModalFilterExpanded] = useState(false);
|
||||
|
||||
// Reset modal filters when modal opens
|
||||
useEffect(() => {
|
||||
if (showPlateNumbers) {
|
||||
setModalFilters({ plateNumber: '', model: '', brand: '', location: '' });
|
||||
}
|
||||
}, [showPlateNumbers]);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -263,30 +248,7 @@ export default function AssetsModule() {
|
||||
.catch(() => setRegionChartData([]));
|
||||
}, [regionChartView, selectedSubject]);
|
||||
|
||||
// Load modal vehicles
|
||||
useEffect(() => {
|
||||
if (!showPlateNumbers) {
|
||||
setModalVehicles([]);
|
||||
setModalWeeklyDetail([]);
|
||||
return;
|
||||
}
|
||||
setModalLoading(true);
|
||||
const request = buildVehicleModalRequest(showPlateNumbers, selectedSubject);
|
||||
if (request.kind === 'weekly') {
|
||||
setModalVehicles([]);
|
||||
fetchWeeklyDetail(request.type, { ...request.filters, subject: selectedSubject })
|
||||
.then(setModalWeeklyDetail)
|
||||
.catch(() => setModalWeeklyDetail([]))
|
||||
.finally(() => setModalLoading(false));
|
||||
return;
|
||||
}
|
||||
|
||||
setModalWeeklyDetail([]);
|
||||
fetchVehicleList(request.params)
|
||||
.then(setModalVehicles)
|
||||
.catch(() => setModalVehicles([]))
|
||||
.finally(() => setModalLoading(false));
|
||||
}, [showPlateNumbers, selectedSubject]);
|
||||
// Vehicle records are requested only at the final level of AssetAnalysisModal.
|
||||
|
||||
const allTypesExpanded = processedData.length > 0 && processedData.every((t) => expandedAssetTypes.has(t.type));
|
||||
|
||||
@@ -404,18 +366,6 @@ export default function AssetsModule() {
|
||||
[customerData, deptData, customerFilters],
|
||||
);
|
||||
|
||||
const {
|
||||
plates: uniqueModalPlates,
|
||||
models: uniqueModalModels,
|
||||
brands: uniqueModalBrands,
|
||||
locations: uniqueModalLocations,
|
||||
filteredVehicles: filteredModalVehicles,
|
||||
filteredWeeklyDetails: filteredModalWeeklyDetail,
|
||||
} = useMemo(
|
||||
() => deriveModalVehicleView(modalVehicles, modalWeeklyDetail, modalFilters),
|
||||
[modalVehicles, modalWeeklyDetail, modalFilters],
|
||||
);
|
||||
|
||||
const selectedFlowDetails = useMemo(
|
||||
() => selectFlowDetails(flowStats, selectedFlow),
|
||||
[flowStats, selectedFlow],
|
||||
@@ -464,7 +414,7 @@ export default function AssetsModule() {
|
||||
|
||||
if (assistantOpen) {
|
||||
return (
|
||||
<Suspense fallback={<div role="status" className="min-h-screen bg-[var(--app-bg)] p-6 text-slate-700">正在加载 AI 问数页面…</div>}>
|
||||
<Suspense fallback={<div role="status" className="min-h-screen bg-[var(--app-bg)] p-6 text-slate-700">正在加载AI问数页面…</div>}>
|
||||
<AssetAssistant key={selectedSubject || 'all'} subject={selectedSubject} appearance="light" close={closeAssistant} />
|
||||
</Suspense>
|
||||
);
|
||||
@@ -1448,7 +1398,7 @@ export default function AssetsModule() {
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">业务负责人</label>
|
||||
<select className="w-full bg-white border border-gray-200 rounded-lg py-2 px-2 text-xs focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 outline-none transition-all cursor-pointer shadow-sm" value={draftCustomerFilters.manager} onChange={(e) => setDraftCustomerFilters(prev => ({ ...prev, manager: e.target.value }))}>
|
||||
<option value="">所有负责人</option>
|
||||
<option value="">所有业务负责人</option>
|
||||
{customerManagersGroupedByDept
|
||||
.filter(g => g.department !== '公务车')
|
||||
.filter(g => !draftCustomerFilters.department || g.department === draftCustomerFilters.department)
|
||||
@@ -1502,7 +1452,7 @@ export default function AssetsModule() {
|
||||
)}
|
||||
{customerFilters.manager && (
|
||||
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded text-[10px] flex items-center gap-1">
|
||||
负责人: {customerFilters.manager}
|
||||
业务负责人: {customerFilters.manager}
|
||||
<button onClick={() => setCustomerFilters(prev => ({...prev, manager: ''}))} className="hover:text-red-500 ml-0.5">×</button>
|
||||
</span>
|
||||
)}
|
||||
@@ -1720,28 +1670,17 @@ export default function AssetsModule() {
|
||||
)}
|
||||
|
||||
|
||||
<FlowDetailModal
|
||||
<FlowAnalysisModal
|
||||
selectedFlow={selectedFlow}
|
||||
selectedFlowDetails={selectedFlowDetails}
|
||||
setSelectedFlow={setSelectedFlow}
|
||||
exportFlowDetails={exportFlowDetails}
|
||||
/>
|
||||
|
||||
<VehicleDetailModal
|
||||
<AssetAnalysisModal
|
||||
selection={showPlateNumbers}
|
||||
setSelection={setShowPlateNumbers}
|
||||
isFilterExpanded={isModalFilterExpanded}
|
||||
setIsFilterExpanded={setIsModalFilterExpanded}
|
||||
filters={modalFilters}
|
||||
setFilters={setModalFilters}
|
||||
plates={uniqueModalPlates}
|
||||
models={uniqueModalModels}
|
||||
brands={uniqueModalBrands}
|
||||
locations={uniqueModalLocations}
|
||||
loading={modalLoading}
|
||||
weeklyDetails={modalWeeklyDetail}
|
||||
filteredWeeklyDetails={filteredModalWeeklyDetail}
|
||||
filteredVehicles={filteredModalVehicles}
|
||||
subject={selectedSubject}
|
||||
close={closeAssetAnalysis}
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -1750,11 +1689,11 @@ export default function AssetsModule() {
|
||||
ref={assistantTrigger}
|
||||
type="button"
|
||||
onClick={openAssistant}
|
||||
aria-label="进入 AI 问数页面"
|
||||
aria-label="进入AI问数页面"
|
||||
className="fixed bottom-[calc(5rem+env(safe-area-inset-bottom))] right-4 z-[60] inline-flex min-h-12 items-center gap-2 rounded-full bg-gradient-to-br from-blue-600 to-cyan-500 px-4 text-sm font-semibold text-white shadow-lg shadow-blue-200 transition hover:from-blue-700 hover:to-cyan-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 md:bottom-6 md:right-6"
|
||||
>
|
||||
<Sparkles size={20} aria-hidden="true" />
|
||||
AI 问数
|
||||
AI问数
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AssetAnalysisCell } from '../../shared/assets/analytics';
|
||||
import {
|
||||
aggregateAnalysisRows,
|
||||
filterAnalysisCells,
|
||||
filterCustomCells,
|
||||
getAnalysisDimensions,
|
||||
getAnalysisSlice,
|
||||
getAvailableDimensions,
|
||||
getCustomAnalysis,
|
||||
getDefaultDimensions,
|
||||
summarizeAnalyticsCells,
|
||||
} from './analytics-model';
|
||||
|
||||
function cell(overrides: Partial<AssetAnalysisCell>): AssetAnalysisCell {
|
||||
return {
|
||||
type: '18T', model: '车型甲', region: '华东', city: '嘉兴市',
|
||||
assetStatus: 'inventory', assetDisposition: '在库', operationKind: '可运营',
|
||||
inventoryState: 'available', abnormalType: '不适用', abnormalAge: 'unknown',
|
||||
department: '业务一部', manager: '负责人甲', customer: '客户甲', quantity: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const cells = [
|
||||
cell({ quantity: 5 }),
|
||||
cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: '30d+', quantity: 3 }),
|
||||
cell({ region: '华南', city: '广州市', inventoryState: 'pending', quantity: 2 }),
|
||||
cell({ assetStatus: 'operating', assetDisposition: '租赁', operationKind: '租赁', inventoryState: 'not-applicable', quantity: 10 }),
|
||||
cell({ type: '49T', region: '华南', city: '深圳市', assetStatus: 'operating', assetDisposition: '自营', operationKind: '自营', inventoryState: 'not-applicable', quantity: 4 }),
|
||||
cell({ assetStatus: 'other', assetDisposition: '其他', operationKind: '未分类', inventoryState: 'not-applicable', quantity: 1 }),
|
||||
];
|
||||
|
||||
test('inventory subset counts reconcile at group and filtered-total levels without adding to inventory', () => {
|
||||
assert.ok(!getAvailableDimensions('inventory').includes('manager'));
|
||||
assert.ok(!getAvailableDimensions('inventory').includes('customer'));
|
||||
assert.ok(getAvailableDimensions('operating').includes('manager'));
|
||||
assert.ok(getAvailableDimensions('operating').includes('customer'));
|
||||
const result = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'] });
|
||||
assert.equal(result.total, 10);
|
||||
assert.equal(result.summary.pending, 2);
|
||||
assert.equal(result.summary.abnormal, 3);
|
||||
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.pending, 0), 2);
|
||||
assert.equal(result.rows.reduce((sum, row) => sum + row.totals.abnormal, 0), 3);
|
||||
const filtered = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region', 'type'], filterValues: { region: ['华东'] } });
|
||||
assert.equal(filtered.total, 8);
|
||||
assert.equal(filtered.summary.pending, 0);
|
||||
assert.equal(filtered.summary.abnormal, 3);
|
||||
assert.equal(filtered.rows[0].totals.pending, 0);
|
||||
assert.equal(filtered.rows[0].totals.abnormal, 3);
|
||||
});
|
||||
|
||||
test('management totals reconcile without double-counting inventory subsets', () => {
|
||||
const summary = summarizeAnalyticsCells(cells);
|
||||
assert.equal(summary.assets, 25);
|
||||
assert.equal(summary.operating, 14);
|
||||
assert.equal(summary.inventory, 10);
|
||||
assert.equal(summary.other, 1);
|
||||
assert.equal(summary.abnormal, 3);
|
||||
assert.equal(summary.pending, 2);
|
||||
assert.equal(summary.abnormalRate, 30);
|
||||
assert.equal(summary.pendingRate, 20);
|
||||
assert.ok(Math.abs(summary.operatingRate - 56) < 1e-10);
|
||||
});
|
||||
|
||||
test('operating drill keeps region, city, type and operation kind as aggregate levels', () => {
|
||||
let slice = getAnalysisSlice(cells, { view: 'operating' });
|
||||
assert.equal(slice.dimension, 'region');
|
||||
assert.equal(slice.total, 14);
|
||||
assert.deepEqual(slice.rows.map(row => row.count), [10, 4]);
|
||||
assert.equal(slice.rows.reduce((sum, row) => sum + row.share, 0), 100);
|
||||
for (const expectedDimension of ['city', 'type', 'operationKind']) {
|
||||
assert.equal(slice.isLeaf, false);
|
||||
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
|
||||
assert.equal(slice.dimension, expectedDimension);
|
||||
assert.equal(slice.total, 10);
|
||||
}
|
||||
slice = getAnalysisSlice(cells, { view: 'operating', path: slice.rows[0].nextPath });
|
||||
assert.equal(slice.isLeaf, true);
|
||||
assert.equal(slice.dimension, null);
|
||||
assert.deepEqual(slice.rows, []);
|
||||
assert.equal(summarizeAnalyticsCells(filterAnalysisCells(cells, slice.filters, slice.metric)).assets, slice.total);
|
||||
});
|
||||
|
||||
test('asset uses mutually exclusive stock, lease and self-operated dispositions', () => {
|
||||
const slice = getAnalysisSlice(cells, { view: 'assets', filters: { type: '18T' } });
|
||||
assert.equal(slice.dimension, 'assetDisposition');
|
||||
assert.equal(slice.rows.reduce((sum, row) => sum + row.count, 0), slice.total);
|
||||
assert.deepEqual(slice.rows.map(row => row.value).sort(), ['其他', '在库', '租赁'].sort());
|
||||
});
|
||||
|
||||
test('fixed global dimensions are skipped and conflicting stale paths cannot widen results', () => {
|
||||
const slice = getAnalysisSlice(cells, {
|
||||
view: 'operating', filters: { region: '华南', type: '49T' },
|
||||
path: [{ dimension: 'region', value: '华南' }, { dimension: 'city', value: '深圳市' }],
|
||||
});
|
||||
assert.equal(slice.dimension, 'operationKind');
|
||||
assert.equal(slice.total, 4);
|
||||
assert.deepEqual(slice.path, [{ dimension: 'city', value: '深圳市' }]);
|
||||
const conflict = getAnalysisSlice(cells, {
|
||||
view: 'operating', filters: { region: '华南' }, path: [{ dimension: 'region', value: '华东' }],
|
||||
});
|
||||
assert.equal(conflict.total, 0);
|
||||
assert.deepEqual(conflict.rows, []);
|
||||
});
|
||||
|
||||
test('abnormal pivot retains all three dimensions once and ends at vehicle type', () => {
|
||||
for (const start of ['abnormalType', 'region', 'abnormalAge'] as const) {
|
||||
const dimensions = getAnalysisDimensions('abnormal', start);
|
||||
assert.equal(dimensions[0], start);
|
||||
assert.equal(dimensions.at(-1), 'type');
|
||||
assert.equal(new Set(dimensions).size, dimensions.length);
|
||||
}
|
||||
const missingTime = cell({ inventoryState: 'abnormal', abnormalType: '维修', abnormalAge: 'unknown', quantity: 2 });
|
||||
const slice = getAnalysisSlice([...cells, missingTime], { view: 'abnormal', abnormalStart: 'abnormalAge' });
|
||||
assert.equal(slice.total, 5);
|
||||
assert.equal(slice.rows.find(row => row.value === 'unknown')?.label, '审批时间缺失');
|
||||
assert.equal(slice.rows.find(row => row.value === 'unknown')?.count, 2);
|
||||
});
|
||||
|
||||
test('chronological label sort preserves all duration buckets without rounding totals', () => {
|
||||
const durationCells = ['unknown', '30d+', '0-3d', '15-30d', '7-15d', '3-7d'].map(abnormalAge => cell({
|
||||
inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'],
|
||||
}));
|
||||
const rows = aggregateAnalysisRows(durationCells, 'abnormalAge', 'abnormal', 'label');
|
||||
assert.deepEqual(rows.map(row => row.value), ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown']);
|
||||
assert.equal(rows.reduce((sum, row) => sum + row.count, 0), 6);
|
||||
assert.equal(summarizeAnalyticsCells([]).operatingRate, 0);
|
||||
});
|
||||
|
||||
test('custom analysis combines independent dimensions and conserves the selected metric', () => {
|
||||
const analysis = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'] });
|
||||
assert.deepEqual(analysis.dimensions, ['region', 'type']);
|
||||
assert.equal(analysis.total, 25);
|
||||
assert.equal(analysis.rows.length, 3);
|
||||
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
|
||||
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.count, 19);
|
||||
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '18T')?.count, 2);
|
||||
assert.equal(analysis.rows.find(row => row.values.region === '华南' && row.values.type === '49T')?.count, 4);
|
||||
assert.ok(Math.abs(analysis.rows.reduce((sum, row) => sum + row.share, 0) - 100) < 1e-10);
|
||||
});
|
||||
|
||||
test('custom composite keys cannot collide when dimension values contain delimiters', () => {
|
||||
const collisionCells = [
|
||||
cell({ region: 'A · B', city: 'C', quantity: 2 }),
|
||||
cell({ region: 'A', city: 'B · C', quantity: 3 }),
|
||||
cell({ region: '["A","B"]', city: '"C"', quantity: 4 }),
|
||||
];
|
||||
const analysis = getCustomAnalysis(collisionCells, { view: 'assets', dimensions: ['region', 'city'] });
|
||||
assert.equal(analysis.rows.length, 3);
|
||||
assert.equal(new Set(analysis.rows.map(row => row.key)).size, 3);
|
||||
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 9);
|
||||
});
|
||||
|
||||
test('custom filters OR values within a dimension and AND across dimensions', () => {
|
||||
const filterValues = { region: ['华东', '华南'], type: ['18T'], inventoryState: ['available', 'pending'] };
|
||||
const filtered = filterCustomCells(cells, filterValues, 'inventory');
|
||||
assert.equal(summarizeAnalyticsCells(filtered).inventory, 7);
|
||||
const analysis = getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues });
|
||||
assert.equal(analysis.total, 7);
|
||||
assert.deepEqual(analysis.rows.map(row => row.count), [5, 2]);
|
||||
assert.equal(analysis.rows[0].share, 5 / 7 * 100);
|
||||
assert.equal(getCustomAnalysis(cells, { view: 'inventory', dimensions: ['region'], filterValues: { region: ['不存在区域'] } }).total, 0);
|
||||
});
|
||||
|
||||
test('empty filter selections impose no restriction and filter-only analysis gives one total', () => {
|
||||
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: [], filterValues: { region: [] } });
|
||||
assert.equal(analysis.total, 14);
|
||||
assert.equal(analysis.rows.length, 1);
|
||||
assert.equal(analysis.rows[0].label, '合计');
|
||||
assert.equal(analysis.rows[0].count, 14);
|
||||
assert.equal(analysis.rows[0].share, 100);
|
||||
assert.deepEqual(analysis.rows[0].values, {});
|
||||
assert.equal(analysis.summary.assets, 25);
|
||||
assert.equal(getCustomAnalysis([], { view: 'operating', dimensions: [] }).rows.length, 0);
|
||||
assert.equal(getCustomAnalysis([cell({})], { view: 'operating', dimensions: [] }).rows.length, 0);
|
||||
});
|
||||
|
||||
test('custom shares use the selected metric rather than all assets in the scope', () => {
|
||||
const analysis = getCustomAnalysis(cells, { view: 'operating', dimensions: ['region'] });
|
||||
assert.equal(analysis.total, 14);
|
||||
assert.equal(analysis.summary.assets, 25);
|
||||
assert.equal(analysis.rows.find(row => row.values.region === '华东')?.share, 10 / 14 * 100);
|
||||
assert.equal(analysis.rows.find(row => row.values.region === '华南')?.share, 4 / 14 * 100);
|
||||
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), analysis.total);
|
||||
});
|
||||
|
||||
test('custom dimensions remove unknown, duplicate and inapplicable values without losing counts', () => {
|
||||
const analysis = getCustomAnalysis(cells, {
|
||||
view: 'operating',
|
||||
dimensions: ['region', 'bogus', 'region', 'abnormalAge', 'type'] as Parameters<typeof getCustomAnalysis>[1]['dimensions'],
|
||||
});
|
||||
assert.deepEqual(analysis.dimensions, ['region', 'type']);
|
||||
assert.equal(analysis.rows.reduce((sum, row) => sum + row.count, 0), 14);
|
||||
for (const view of ['assets', 'operating', 'inventory', 'abnormal', 'pending', 'department', 'customer'] as const) {
|
||||
assert.equal(getDefaultDimensions(view).length, 1);
|
||||
assert.ok(getAvailableDimensions(view).includes(getDefaultDimensions(view)[0]));
|
||||
}
|
||||
});
|
||||
|
||||
test('custom sorting supports count order and semantic duration order', () => {
|
||||
const ascending = getCustomAnalysis(cells, { view: 'assets', dimensions: ['region', 'type'], sort: 'count-asc' });
|
||||
assert.deepEqual(ascending.rows.map(row => row.count), [2, 4, 19]);
|
||||
const durationCells = ['unknown', '30d+', '0-3d'].map(abnormalAge => cell({ inventoryState: 'abnormal', abnormalAge: abnormalAge as AssetAnalysisCell['abnormalAge'] }));
|
||||
const byLabel = getCustomAnalysis(durationCells, { view: 'abnormal', dimensions: ['abnormalAge'], sort: 'label' });
|
||||
assert.deepEqual(byLabel.rows.map(row => row.values.abnormalAge), ['0-3d', '30d+', 'unknown']);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
import type {
|
||||
AssetAnalysisCell,
|
||||
AssetAnalysisDimension,
|
||||
AssetAnalysisFilters,
|
||||
AssetAnalysisMetric,
|
||||
} from '../../shared/assets/analytics';
|
||||
import { ASSET_ANALYSIS_DIMENSIONS } from '../../shared/assets/analytics';
|
||||
|
||||
export type AnalysisView = AssetAnalysisMetric | 'department' | 'customer';
|
||||
export type AnalysisSort = 'count-desc' | 'count-asc' | 'label';
|
||||
export type AbnormalStartDimension = 'abnormalType' | 'region' | 'abnormalAge';
|
||||
export interface AnalysisPathStep {
|
||||
dimension: AssetAnalysisDimension;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AnalysisTotals {
|
||||
assets: number;
|
||||
operating: number;
|
||||
inventory: number;
|
||||
abnormal: number;
|
||||
pending: number;
|
||||
other: number;
|
||||
/** All rates are percentages, not fractions. */
|
||||
operatingRate: number;
|
||||
inventoryRate: number;
|
||||
/** Abnormal and pending are inventory subsets; their denominator is inventory. */
|
||||
abnormalRate: number;
|
||||
pendingRate: number;
|
||||
}
|
||||
|
||||
export interface AnalysisAggregateRow {
|
||||
value: string;
|
||||
label: string;
|
||||
count: number;
|
||||
/** Share of the current metric total, as a percentage. */
|
||||
share: number;
|
||||
totals: AnalysisTotals;
|
||||
}
|
||||
|
||||
export interface AnalysisRow extends AnalysisAggregateRow {
|
||||
nextPath: AnalysisPathStep[];
|
||||
isLeaf: boolean;
|
||||
}
|
||||
|
||||
export interface AnalysisOptions {
|
||||
view: AnalysisView;
|
||||
filters?: AssetAnalysisFilters;
|
||||
path?: readonly AnalysisPathStep[];
|
||||
abnormalStart?: AbnormalStartDimension;
|
||||
sort?: AnalysisSort;
|
||||
}
|
||||
|
||||
export interface AnalysisSlice {
|
||||
metric: AssetAnalysisMetric;
|
||||
dimension: AssetAnalysisDimension | null;
|
||||
dimensions: AssetAnalysisDimension[];
|
||||
path: AnalysisPathStep[];
|
||||
filters: AssetAnalysisFilters;
|
||||
rows: AnalysisRow[];
|
||||
total: number;
|
||||
summary: AnalysisTotals;
|
||||
isLeaf: boolean;
|
||||
breadcrumbs: Array<AnalysisPathStep & { label: string }>;
|
||||
}
|
||||
|
||||
export const ANALYSIS_META: Record<AnalysisView, {
|
||||
title: string;
|
||||
description: string;
|
||||
metric: AssetAnalysisMetric;
|
||||
unit: '台';
|
||||
}> = {
|
||||
assets: { title: '资产结构', description: '从车型看在库、租赁与自营结构,再定位区域和城市。', metric: 'assets', unit: '台' },
|
||||
operating: { title: '运营分析', description: '按区域、城市、车型和运营状态逐层比较运营分布。', metric: 'operating', unit: '台' },
|
||||
inventory: { title: '库存分析', description: '按区域、城市和车型定位库存。', metric: 'inventory', unit: '台' },
|
||||
abnormal: { title: '异动分析', description: '交叉分析异动类型、区域与已异动时长,识别集中与积压。', metric: 'abnormal', unit: '台' },
|
||||
pending: { title: '待交车分析', description: '按大区、城市和车型查看待交车分布。', metric: 'pending', unit: '台' },
|
||||
department: { title: '部门分析', description: '从部门到业务负责人和车型,查看运营资产配置。', metric: 'operating', unit: '台' },
|
||||
customer: { title: '客户分析', description: '从客户到车型和区域,查看运营资产集中度。', metric: 'operating', unit: '台' },
|
||||
};
|
||||
|
||||
export const DIMENSION_LABELS: Record<AssetAnalysisDimension, string> = {
|
||||
type: '车型', model: '型号', region: '区域', city: '城市',
|
||||
assetStatus: '资产状态', assetDisposition: '资产用途', operationKind: '运营状态',
|
||||
inventoryState: '库存状态', abnormalType: '异动类型', abnormalAge: '已异动时长',
|
||||
department: '部门', manager: '业务负责人', customer: '客户',
|
||||
};
|
||||
|
||||
const VALUE_LABELS: Partial<Record<AssetAnalysisDimension, Record<string, string>>> = {
|
||||
assetStatus: { operating: '运营中', inventory: '在库', other: '其他状态' },
|
||||
inventoryState: {
|
||||
abnormal: '异动', pending: '待交车', available: '可运营', awaiting: '待运营',
|
||||
unclassified: '未分类库存', 'not-applicable': '不适用',
|
||||
},
|
||||
abnormalAge: {
|
||||
'0-3d': '不足 3 天', '3-7d': '3–7 天', '7-15d': '7–15 天',
|
||||
'15-30d': '15–30 天', '30d+': '30 天及以上', unknown: '审批时间缺失',
|
||||
},
|
||||
};
|
||||
|
||||
const DIMENSION_ORDER: Partial<Record<AssetAnalysisDimension, readonly string[]>> = {
|
||||
region: ['华东', '华南', '华北', '华中', '西南', '西北', '其他'],
|
||||
abnormalAge: ['0-3d', '3-7d', '7-15d', '15-30d', '30d+', 'unknown'],
|
||||
assetStatus: ['inventory', 'operating', 'other'],
|
||||
assetDisposition: ['在库', '租赁', '自营', '其他运营', '其他'],
|
||||
inventoryState: ['available', 'awaiting', 'pending', 'abnormal', 'unclassified', 'not-applicable'],
|
||||
};
|
||||
|
||||
const PATHS: Record<Exclude<AnalysisView, 'abnormal'>, readonly AssetAnalysisDimension[]> = {
|
||||
assets: ['type', 'assetDisposition', 'region', 'city'],
|
||||
operating: ['region', 'city', 'type', 'operationKind'],
|
||||
inventory: ['region', 'city', 'type', 'inventoryState'],
|
||||
pending: ['region', 'city', 'type'],
|
||||
department: ['department', 'manager', 'type'],
|
||||
customer: ['customer', 'type', 'region'],
|
||||
};
|
||||
|
||||
export function getAnalysisDimensions(
|
||||
view: AnalysisView,
|
||||
abnormalStart: AbnormalStartDimension = 'abnormalType',
|
||||
): AssetAnalysisDimension[] {
|
||||
if (view !== 'abnormal') return [...PATHS[view]];
|
||||
const comparisons: AbnormalStartDimension[] = ['abnormalType', 'region', 'abnormalAge'];
|
||||
return [abnormalStart, ...comparisons.filter(dimension => dimension !== abnormalStart), 'type'];
|
||||
}
|
||||
|
||||
export function getDimensionValueLabel(dimension: AssetAnalysisDimension, value: string): string {
|
||||
return VALUE_LABELS[dimension]?.[value] ?? (value || '未标注');
|
||||
}
|
||||
|
||||
/** Counts derive from the same mutually exclusive state dimensions as the server. */
|
||||
export function cellMatchesMetric(cell: AssetAnalysisCell, metric: AssetAnalysisMetric): boolean {
|
||||
switch (metric) {
|
||||
case 'assets': return true;
|
||||
case 'operating': return cell.assetStatus === 'operating';
|
||||
case 'inventory': return cell.assetStatus === 'inventory';
|
||||
case 'abnormal': return cell.assetStatus === 'inventory' && cell.inventoryState === 'abnormal';
|
||||
case 'pending': return cell.assetStatus === 'inventory' && cell.inventoryState === 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
export function filterAnalysisCells(
|
||||
cells: readonly AssetAnalysisCell[],
|
||||
filters: AssetAnalysisFilters = {},
|
||||
metric: AssetAnalysisMetric = 'assets',
|
||||
): AssetAnalysisCell[] {
|
||||
const entries = Object.entries(filters).filter(([, value]) => value !== undefined && value !== '') as Array<[AssetAnalysisDimension, string]>;
|
||||
return cells.filter(cell => cellMatchesMetric(cell, metric) && entries.every(([dimension, value]) => cell[dimension] === value));
|
||||
}
|
||||
|
||||
const percent = (count: number, total: number): number => total > 0 ? count / total * 100 : 0;
|
||||
|
||||
export function summarizeAnalyticsCells(cells: readonly AssetAnalysisCell[]): AnalysisTotals {
|
||||
const totals: AnalysisTotals = {
|
||||
assets: 0, operating: 0, inventory: 0, abnormal: 0, pending: 0, other: 0,
|
||||
operatingRate: 0, inventoryRate: 0, abnormalRate: 0, pendingRate: 0,
|
||||
};
|
||||
for (const cell of cells) {
|
||||
totals.assets += cell.quantity;
|
||||
if (cell.assetStatus === 'operating') totals.operating += cell.quantity;
|
||||
else if (cell.assetStatus === 'inventory') {
|
||||
totals.inventory += cell.quantity;
|
||||
if (cell.inventoryState === 'abnormal') totals.abnormal += cell.quantity;
|
||||
if (cell.inventoryState === 'pending') totals.pending += cell.quantity;
|
||||
} else totals.other += cell.quantity;
|
||||
}
|
||||
totals.operatingRate = percent(totals.operating, totals.assets);
|
||||
totals.inventoryRate = percent(totals.inventory, totals.assets);
|
||||
totals.abnormalRate = percent(totals.abnormal, totals.inventory);
|
||||
totals.pendingRate = percent(totals.pending, totals.inventory);
|
||||
return totals;
|
||||
}
|
||||
|
||||
export function aggregateAnalysisRows(
|
||||
cells: readonly AssetAnalysisCell[],
|
||||
dimension: AssetAnalysisDimension,
|
||||
metric: AssetAnalysisMetric = 'assets',
|
||||
sort: AnalysisSort = 'count-desc',
|
||||
): AnalysisAggregateRow[] {
|
||||
const groups = new Map<string, AssetAnalysisCell[]>();
|
||||
for (const cell of cells) {
|
||||
const value = cell[dimension];
|
||||
const group = groups.get(value);
|
||||
if (group) group.push(cell);
|
||||
else groups.set(value, [cell]);
|
||||
}
|
||||
const total = summarizeAnalyticsCells(cells)[metric];
|
||||
const rows: AnalysisAggregateRow[] = [];
|
||||
for (const [value, group] of groups) {
|
||||
const totals = summarizeAnalyticsCells(group);
|
||||
const count = totals[metric];
|
||||
if (count > 0) rows.push({ value, label: getDimensionValueLabel(dimension, value), count, share: percent(count, total), totals });
|
||||
}
|
||||
const compareLabels = (a: AnalysisAggregateRow, b: AnalysisAggregateRow) => {
|
||||
const order = DIMENSION_ORDER[dimension];
|
||||
if (order) {
|
||||
const aIndex = order.indexOf(a.value);
|
||||
const bIndex = order.indexOf(b.value);
|
||||
const result = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex);
|
||||
if (result !== 0) return result;
|
||||
}
|
||||
return a.label.localeCompare(b.label, 'zh-CN', { numeric: true });
|
||||
};
|
||||
return rows.sort((a, b) => {
|
||||
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
|
||||
return difference || compareLabels(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep global filters and drill selections intersected; a stale path cannot widen scope. */
|
||||
export function getAnalysisSlice(cells: readonly AssetAnalysisCell[], options: AnalysisOptions): AnalysisSlice {
|
||||
const { view, abnormalStart = 'abnormalType', sort = 'count-desc' } = options;
|
||||
const metric = ANALYSIS_META[view].metric;
|
||||
const filters: AssetAnalysisFilters = {};
|
||||
for (const [key, value] of Object.entries(options.filters ?? {})) {
|
||||
if (value !== undefined && value !== '') filters[key as AssetAnalysisDimension] = value;
|
||||
}
|
||||
const dimensions = getAnalysisDimensions(view, abnormalStart).filter(dimension => filters[dimension] === undefined);
|
||||
const path: AnalysisPathStep[] = [];
|
||||
let conflictingPath = false;
|
||||
for (const step of options.path ?? []) {
|
||||
const globalValue = filters[step.dimension];
|
||||
if (globalValue !== undefined) {
|
||||
if (globalValue !== step.value) conflictingPath = true;
|
||||
continue;
|
||||
}
|
||||
// Only a prefix of this analysis schema is valid. Unrelated old paths are discarded.
|
||||
if (dimensions[path.length] !== step.dimension) break;
|
||||
path.push({ dimension: step.dimension, value: step.value });
|
||||
}
|
||||
for (const step of path) filters[step.dimension] = step.value;
|
||||
const scopedCells = conflictingPath ? [] : filterAnalysisCells(cells, filters);
|
||||
const summary = summarizeAnalyticsCells(scopedCells);
|
||||
const dimension = dimensions[path.length] ?? null;
|
||||
const isLeaf = dimension === null;
|
||||
const rows: AnalysisRow[] = dimension === null ? [] : aggregateAnalysisRows(scopedCells, dimension, metric, sort).map(row => ({
|
||||
...row,
|
||||
nextPath: [...path, { dimension, value: row.value }],
|
||||
isLeaf: path.length + 1 === dimensions.length,
|
||||
}));
|
||||
return {
|
||||
metric, dimension, dimensions, path, filters, rows, total: summary[metric], summary, isLeaf,
|
||||
breadcrumbs: path.map(step => ({ ...step, label: getDimensionValueLabel(step.dimension, step.value) })),
|
||||
};
|
||||
}
|
||||
|
||||
export type AnalysisFilterValues = Partial<Record<AssetAnalysisDimension, string[]>>;
|
||||
|
||||
export interface CustomAnalysisRow {
|
||||
key: string;
|
||||
values: Partial<Record<AssetAnalysisDimension, string>>;
|
||||
label: string;
|
||||
count: number;
|
||||
share: number;
|
||||
totals: AnalysisTotals;
|
||||
}
|
||||
|
||||
export interface CustomAnalysisOptions {
|
||||
view: AnalysisView;
|
||||
dimensions: readonly AssetAnalysisDimension[];
|
||||
filterValues?: AnalysisFilterValues;
|
||||
sort?: AnalysisSort;
|
||||
}
|
||||
|
||||
export interface CustomAnalysis {
|
||||
rows: CustomAnalysisRow[];
|
||||
total: number;
|
||||
summary: AnalysisTotals;
|
||||
dimensions: AssetAnalysisDimension[];
|
||||
metric: AssetAnalysisMetric;
|
||||
}
|
||||
|
||||
const COMMON_DIMENSIONS: readonly AssetAnalysisDimension[] = [
|
||||
'type', 'model', 'region', 'city', 'department', 'manager', 'customer',
|
||||
];
|
||||
const BUSINESS_DIMENSIONS: Record<AnalysisView, readonly AssetAnalysisDimension[]> = {
|
||||
assets: ['assetDisposition', 'operationKind'],
|
||||
operating: ['operationKind'],
|
||||
inventory: ['operationKind'],
|
||||
abnormal: ['abnormalType', 'abnormalAge'],
|
||||
pending: [],
|
||||
department: ['operationKind'],
|
||||
customer: ['operationKind'],
|
||||
};
|
||||
|
||||
/** Dimensions are choices, not a required drill sequence. */
|
||||
export function getAvailableDimensions(view: AnalysisView): AssetAnalysisDimension[] {
|
||||
const common = view === 'inventory'
|
||||
? COMMON_DIMENSIONS.filter(dimension => dimension !== 'manager' && dimension !== 'customer')
|
||||
: COMMON_DIMENSIONS;
|
||||
return [...common, ...BUSINESS_DIMENSIONS[view]];
|
||||
}
|
||||
|
||||
export function getDefaultDimensions(view: AnalysisView): AssetAnalysisDimension[] {
|
||||
if (view === 'assets') return ['type'];
|
||||
if (view === 'abnormal') return ['abnormalType'];
|
||||
if (view === 'department') return ['department'];
|
||||
if (view === 'customer') return ['customer'];
|
||||
return ['region'];
|
||||
}
|
||||
|
||||
/** Multiple values of one dimension are OR; selections across dimensions are AND. */
|
||||
export function filterCustomCells(
|
||||
cells: readonly AssetAnalysisCell[],
|
||||
filterValues: AnalysisFilterValues = {},
|
||||
metric: AssetAnalysisMetric = 'assets',
|
||||
): AssetAnalysisCell[] {
|
||||
const constraints: Array<[AssetAnalysisDimension, Set<string>]> = [];
|
||||
for (const dimension of ASSET_ANALYSIS_DIMENSIONS) {
|
||||
const selected = filterValues[dimension];
|
||||
if (!Array.isArray(selected) || selected.length === 0) continue;
|
||||
constraints.push([dimension, new Set(selected)]);
|
||||
}
|
||||
return cells.filter(cell => cellMatchesMetric(cell, metric)
|
||||
&& constraints.every(([dimension, selected]) => selected.has(cell[dimension])));
|
||||
}
|
||||
|
||||
export function compareCustomLabels(a: CustomAnalysisRow, b: CustomAnalysisRow, dimensions: readonly AssetAnalysisDimension[]): number {
|
||||
for (const dimension of dimensions) {
|
||||
const aValue = a.values[dimension]!;
|
||||
const bValue = b.values[dimension]!;
|
||||
const order = DIMENSION_ORDER[dimension];
|
||||
if (order) {
|
||||
const aIndex = order.indexOf(aValue);
|
||||
const bIndex = order.indexOf(bValue);
|
||||
const difference = (aIndex < 0 ? order.length : aIndex) - (bIndex < 0 ? order.length : bIndex);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
const difference = getDimensionValueLabel(dimension, aValue).localeCompare(getDimensionValueLabel(dimension, bValue), 'zh-CN', { numeric: true });
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return a.key.localeCompare(b.key);
|
||||
}
|
||||
|
||||
export function getCustomAnalysis(cells: readonly AssetAnalysisCell[], options: CustomAnalysisOptions): CustomAnalysis {
|
||||
const metric = ANALYSIS_META[options.view].metric;
|
||||
const available = new Set(getAvailableDimensions(options.view));
|
||||
const dimensions = [...new Set(options.dimensions.filter(dimension => available.has(dimension)))];
|
||||
const scopedCells = filterCustomCells(cells, options.filterValues);
|
||||
const summary = summarizeAnalyticsCells(scopedCells);
|
||||
const total = summary[metric];
|
||||
const groups = new Map<string, { values: CustomAnalysisRow['values']; cells: AssetAnalysisCell[] }>();
|
||||
for (const cell of scopedCells) {
|
||||
// Structured JSON preserves values containing separators or quotes.
|
||||
const entries = dimensions.map(dimension => [dimension, cell[dimension]] as const);
|
||||
const key = JSON.stringify(entries);
|
||||
const group = groups.get(key);
|
||||
if (group) group.cells.push(cell);
|
||||
else groups.set(key, { values: Object.fromEntries(entries), cells: [cell] });
|
||||
}
|
||||
const rows: CustomAnalysisRow[] = [];
|
||||
for (const [key, group] of groups) {
|
||||
const totals = summarizeAnalyticsCells(group.cells);
|
||||
const count = totals[metric];
|
||||
if (count <= 0) continue;
|
||||
const label = dimensions.length === 0 ? '合计' : dimensions.map(dimension => getDimensionValueLabel(dimension, group.values[dimension]!)).join(' · ');
|
||||
rows.push({ key, values: group.values, label, count, share: percent(count, total), totals });
|
||||
}
|
||||
const sort = options.sort ?? 'count-desc';
|
||||
rows.sort((a, b) => {
|
||||
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
|
||||
return difference || compareCustomLabels(a, b, dimensions);
|
||||
});
|
||||
return { rows, total, summary, dimensions, metric };
|
||||
}
|
||||
@@ -398,7 +398,7 @@ export default function AssetAssistant({
|
||||
<section
|
||||
ref={page}
|
||||
className={`aq-shell aq-page aq-${theme}`}
|
||||
aria-label="资产 AI 问数"
|
||||
aria-label="AI问数"
|
||||
>
|
||||
<aside className="aq-sidebar">
|
||||
<div className="aq-brand">
|
||||
@@ -406,8 +406,7 @@ export default function AssetAssistant({
|
||||
<Sparkles size={19} />
|
||||
</div>
|
||||
<div>
|
||||
<strong>羚牛 · 资产智能</strong>
|
||||
<span>ASSET INTELLIGENCE</span>
|
||||
<strong>AI问数</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button className="aq-new" onClick={reset}>
|
||||
@@ -423,7 +422,7 @@ export default function AssetAssistant({
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
AI 问数<span className="aq-beta">BETA</span>
|
||||
AI问数<span className="aq-beta">BETA</span>
|
||||
</button>
|
||||
<button className="aq-nav" onClick={close}>
|
||||
<BarChart3 size={16} />
|
||||
@@ -471,9 +470,9 @@ export default function AssetAssistant({
|
||||
<header className="aq-header">
|
||||
<div>
|
||||
<Sparkles size={17} />
|
||||
<h1>AI 问数</h1>
|
||||
<h1>AI问数</h1>
|
||||
<span className="aq-header-divider" />
|
||||
<span className="aq-header-subtitle">你的资产分析助手</span>
|
||||
<span className="aq-header-subtitle">用对话分析资产数据</span>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
@@ -562,10 +561,6 @@ export default function AssetAssistant({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="aq-welcome-note">
|
||||
<Database size={14} />
|
||||
只读查询 · 真实数据 · 可追溯明细
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aq-turns">
|
||||
|
||||
@@ -396,15 +396,6 @@
|
||||
font-size: 11px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.aq-welcome-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--aq-muted);
|
||||
margin-top: 24px;
|
||||
}
|
||||
.aq-turns {
|
||||
max-width: 820px;
|
||||
margin: 0 auto;
|
||||
@@ -1134,10 +1125,6 @@
|
||||
padding: 14px;
|
||||
min-height: 110px;
|
||||
}
|
||||
.aq-welcome-note {
|
||||
font-size: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.aq-turns {
|
||||
padding: 19px 16px 24px;
|
||||
}
|
||||
@@ -1298,9 +1285,6 @@
|
||||
.aq-prompt-grid > button > div {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.aq-welcome-note {
|
||||
margin-top: 14px;
|
||||
}
|
||||
}
|
||||
/* Keep the assistant above the global feedback launcher, below the account watermark. */
|
||||
.aq-shell {
|
||||
@@ -2090,8 +2074,6 @@
|
||||
.aq-prompt-grid > button > div > svg:last-child { display: none; }
|
||||
.aq-prompt-grid > button strong { font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.aq-prompt-grid > button > span { display: none; }
|
||||
.aq-welcome-note { margin-top: 12px; font-size: 12px; line-height: 1.5; flex-wrap: nowrap; }
|
||||
.aq-welcome-note svg { flex-shrink: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aq-page .aq-spin, .aq-page .aq-shimmer, .aq-page .aq-trace li { animation: none; }
|
||||
|
||||
@@ -138,7 +138,7 @@ test("copy includes regional facts and daily flow without duplicating groups or
|
||||
};
|
||||
const regionalText = copyResultText(regional, null);
|
||||
assert.match(regionalText, /华东:99台,占比73.3%/);
|
||||
assert.match(regionalText, /其中异动1台,其中待交车0台/);
|
||||
assert.match(regionalText, /异动1台,待交车0台/);
|
||||
assert.match(regionalText, /分析发现:[\s\S]*华东占当前可运营库存73.3%/);
|
||||
assert.ok(!regionalText.includes("测试车牌不应复制"));
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ export function copyResultText(
|
||||
"分组统计:",
|
||||
...result.groups.map(
|
||||
(group) =>
|
||||
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占比${group.share}%`},运营率${group.rate}%${group.abnormal === undefined ? "" : `,其中异动${group.abnormal}台`}${group.pending === undefined ? "" : `,其中待交车${group.pending}台`}`,
|
||||
`${group.name}:${group.count}台${group.share === undefined ? "" : `,占比${group.share}%`},运营率${group.rate}%${group.abnormal === undefined ? "" : `,异动${group.abnormal}台`}${group.pending === undefined ? "" : `,待交车${group.pending}台`}`,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -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)} 台`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { dimensionChildCounts, dimensionCountUnit, dimensionParentTotals, dimensionRowSpans, orderDimensionGroups, visibleDimensionRowSpans } from './dimension-layout';
|
||||
|
||||
type Row = { values: string[]; count: number };
|
||||
const values = (row: Row) => row.values;
|
||||
const count = (row: Row) => row.count;
|
||||
|
||||
test('child counts deduplicate immediate children rather than counting leaf rows or vehicles', () => {
|
||||
const rows: Row[] = [
|
||||
{ values: ['负责人甲', '客户一', '18T'], count: 29 },
|
||||
{ values: ['负责人甲', '客户一', '49T'], count: 17 },
|
||||
{ values: ['负责人甲', '客户二', '18T'], count: 80 },
|
||||
{ values: ['负责人乙', '客户一', '18T'], count: 9 },
|
||||
];
|
||||
const counts = dimensionChildCounts(rows, 3, values);
|
||||
assert.deepEqual(counts, [[2, 2], [2, 2], [2, 1], [1, 1]]);
|
||||
assert.deepEqual(counts.slice(2, 3), [[2, 1]], 'continuation pages keep the full child count');
|
||||
assert.deepEqual(dimensionChildCounts(rows.filter(row => row.values[1] === '客户一'), 3, values), [[1, 2], [1, 2], [1, 1]]);
|
||||
assert.deepEqual(dimensionChildCounts(rows, 1, values), [[], [], [], []]);
|
||||
assert.deepEqual(['customer', 'city', 'type', 'manager', 'date'].map(dimensionCountUnit), ['家', '个', '种', '位', '天']);
|
||||
});
|
||||
|
||||
test('parent subtotals include all descendants and separate identical child labels under different parents', () => {
|
||||
const rows: Row[] = [
|
||||
{ values: ['负责人甲', '客户一', '18T'], count: 29 },
|
||||
{ values: ['负责人甲', '客户一', '49T'], count: 17 },
|
||||
{ values: ['负责人甲', '客户二', '18T'], count: 80 },
|
||||
{ values: ['负责人乙', '客户一', '18T'], count: 9 },
|
||||
];
|
||||
const totals = dimensionParentTotals(rows, 3, values, count);
|
||||
assert.deepEqual(totals, [[126, 46], [126, 46], [126, 80], [9, 9]]);
|
||||
// A continuation page still displays 126, not the 80 vehicles on that page.
|
||||
assert.deepEqual(totals.slice(2, 3), [[126, 80]]);
|
||||
assert.deepEqual(dimensionParentTotals(rows.filter(row => row.values[1] === '客户一'), 3, values, count), [[46, 46], [46, 46], [9, 9]]);
|
||||
assert.deepEqual(dimensionParentTotals(rows, 1, values, count), [[], [], [], []]);
|
||||
assert.deepEqual(dimensionParentTotals([], 3, values, count), []);
|
||||
});
|
||||
|
||||
test('merged layout ranks parent totals and keeps their children contiguous without changing leaf counts', () => {
|
||||
const rows: Row[] = [
|
||||
{ values: ['华东', '租赁'], count: 6 },
|
||||
{ values: ['华南', '租赁'], count: 10 },
|
||||
{ values: ['华东', '在库'], count: 5 },
|
||||
];
|
||||
const ordered = orderDimensionGroups(rows, 2, values, count);
|
||||
assert.deepEqual(ordered.map(values), [['华东', '租赁'], ['华东', '在库'], ['华南', '租赁']]);
|
||||
assert.equal(ordered.reduce((sum, row) => sum + row.count, 0), 21);
|
||||
assert.deepEqual(dimensionRowSpans(ordered, 2, values), [[2, 1], [0, 1], [1, 1]]);
|
||||
assert.deepEqual(orderDimensionGroups(rows, 2, values, count, 'count-asc').map(count), [10, 5, 6]);
|
||||
assert.deepEqual(rows.map(count), [6, 10, 5]);
|
||||
});
|
||||
|
||||
test('equal child names under different parents never merge, and the final dimension stays visible', () => {
|
||||
const rows = [
|
||||
['华东', '未标注', '18T'], ['华东', '未标注', '49T'],
|
||||
['华南', '未标注', '18T'], ['华南', '未标注', '49T'],
|
||||
].map(values => ({ values, count: 1 }));
|
||||
assert.deepEqual(dimensionRowSpans(rows, 3, values), [[2, 2, 1], [0, 0, 1], [2, 2, 1], [0, 0, 1]]);
|
||||
});
|
||||
|
||||
test('a parent split across pages is rendered again at the start of each page', () => {
|
||||
const rows = ['A', 'B', 'C', 'D'].map(type => ({ values: ['华东', type], count: 1 }));
|
||||
assert.deepEqual(dimensionRowSpans(rows.slice(0, 3), 2, values), [[3, 1], [0, 1], [0, 1]]);
|
||||
assert.deepEqual(dimensionRowSpans(rows.slice(3), 2, values), [[1, 1]]);
|
||||
assert.deepEqual(dimensionRowSpans(rows, 1, values), [[1], [1], [1], [1]]);
|
||||
assert.deepEqual(dimensionRowSpans([], 3, values), []);
|
||||
});
|
||||
|
||||
test('continuous loading grows the original parent spans across batches without repeating parent cells', () => {
|
||||
const rows = [
|
||||
['华东', '嘉兴', 'A'], ['华东', '嘉兴', 'B'], ['华东', '嘉兴', 'C'],
|
||||
['华东', '杭州', 'D'], ['华南', '广州', 'E'],
|
||||
].map(parts => ({ values: parts, count: 2 }));
|
||||
const complete = dimensionRowSpans(rows, 3, values);
|
||||
assert.deepEqual(visibleDimensionRowSpans(complete, 2), [[2, 2, 1], [0, 0, 1]]);
|
||||
assert.deepEqual(visibleDimensionRowSpans(complete, 4), [[4, 3, 1], [0, 0, 1], [0, 0, 1], [0, 1, 1]]);
|
||||
assert.deepEqual(visibleDimensionRowSpans(complete, 40), complete);
|
||||
assert.deepEqual(visibleDimensionRowSpans(complete, 0), []);
|
||||
assert.deepEqual(complete, [[4, 3, 1], [0, 0, 1], [0, 0, 1], [0, 1, 1], [1, 1, 1]]);
|
||||
assert.equal(dimensionParentTotals(rows, 3, values, count)[0][0], 8);
|
||||
assert.equal(dimensionChildCounts(rows, 3, values)[0][0], 2);
|
||||
});
|
||||
|
||||
test('hierarchical label order supports semantic ordering and separator-containing values', () => {
|
||||
const rows = [
|
||||
{ values: ['A|B', '30+'], count: 2 }, { values: ['A', 'B|30+'], count: 1 },
|
||||
{ values: ['A|B', '3-7'], count: 1 },
|
||||
];
|
||||
const semantic = ['3-7', '30+'];
|
||||
const result = orderDimensionGroups(rows, 2, values, count, 'label', (a, b, index) => index === 0
|
||||
? a.values[0].localeCompare(b.values[0]) : semantic.indexOf(a.values[1]) - semantic.indexOf(b.values[1]));
|
||||
assert.deepEqual(result.map(values), [['A', 'B|30+'], ['A|B', '3-7'], ['A|B', '30+']]);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
type GroupSort = 'count-desc' | 'count-asc' | 'label';
|
||||
|
||||
export function dimensionCountUnit(dimension: string): string {
|
||||
if (dimension === 'customer') return '家';
|
||||
if (dimension === 'manager') return '位';
|
||||
if (dimension === 'date') return '天';
|
||||
if (['type', 'model', 'assetStatus', 'assetDisposition', 'operationKind', 'inventoryState', 'abnormalType', 'contract_type'].includes(dimension)) return '种';
|
||||
if (dimension === 'abnormalAge') return '组';
|
||||
return '个';
|
||||
}
|
||||
|
||||
/** Distinct immediate children within each full parent prefix, across all filtered results. */
|
||||
export function dimensionChildCounts<T>(rows: readonly T[], dimensionCount: number, values: (row: T) => readonly string[]): number[][] {
|
||||
const children = new Map<string, Set<string>>();
|
||||
const keys = rows.map(row => {
|
||||
const parts = values(row);
|
||||
return Array.from({ length: Math.max(0, dimensionCount - 1) }, (_, depth) => {
|
||||
const key = JSON.stringify(parts.slice(0, depth + 1));
|
||||
const set = children.get(key) ?? new Set<string>();
|
||||
set.add(parts[depth + 1]);
|
||||
children.set(key, set);
|
||||
return key;
|
||||
});
|
||||
});
|
||||
return keys.map(row => row.map(key => children.get(key)!.size));
|
||||
}
|
||||
|
||||
/** Use all filtered groups before rendering a prefix so each parent includes every descendant. */
|
||||
export function dimensionParentTotals<T>(
|
||||
rows: readonly T[],
|
||||
dimensionCount: number,
|
||||
values: (row: T) => readonly string[],
|
||||
count: (row: T) => number,
|
||||
): number[][] {
|
||||
const keys = rows.map(row => Array.from({ length: Math.max(0, dimensionCount - 1) }, (_, depth) => JSON.stringify(values(row).slice(0, depth + 1))));
|
||||
const totals = new Map<string, number>();
|
||||
rows.forEach((row, index) => {
|
||||
for (const key of keys[index]) totals.set(key, (totals.get(key) || 0) + count(row));
|
||||
});
|
||||
return keys.map(row => row.map(key => totals.get(key) || 0));
|
||||
}
|
||||
|
||||
/** Sort siblings by their combined count, keeping every parent subtree contiguous. */
|
||||
export function orderDimensionGroups<T>(
|
||||
rows: readonly T[],
|
||||
dimensionCount: number,
|
||||
values: (row: T) => readonly string[],
|
||||
count: (row: T) => number,
|
||||
sort: GroupSort = 'count-desc',
|
||||
compareAt?: (a: T, b: T, index: number) => number,
|
||||
): T[] {
|
||||
const visit = (siblings: readonly T[], depth: number): T[] => {
|
||||
if (depth >= dimensionCount || siblings.length < 2) return [...siblings];
|
||||
const groups = new Map<string, { rows: T[]; count: number }>();
|
||||
for (const row of siblings) {
|
||||
const key = values(row)[depth];
|
||||
const group = groups.get(key) ?? { rows: [], count: 0 };
|
||||
group.rows.push(row);
|
||||
group.count += count(row);
|
||||
groups.set(key, group);
|
||||
}
|
||||
return [...groups.values()].sort((a, b) => {
|
||||
const difference = sort === 'count-desc' ? b.count - a.count : sort === 'count-asc' ? a.count - b.count : 0;
|
||||
return difference || (compareAt
|
||||
? compareAt(a.rows[0], b.rows[0], depth)
|
||||
: values(a.rows[0])[depth].localeCompare(values(b.rows[0])[depth], 'zh-CN', { numeric: true }));
|
||||
}).flatMap(group => visit(group.rows, depth + 1));
|
||||
};
|
||||
return visit(rows, 0);
|
||||
}
|
||||
|
||||
/** A child is merged only within its full parent prefix. */
|
||||
export function dimensionRowSpans<T>(
|
||||
rows: readonly T[],
|
||||
dimensionCount: number,
|
||||
values: (row: T) => readonly string[],
|
||||
): number[][] {
|
||||
const keys = rows.map(values);
|
||||
const spans = rows.map(() => Array<number>(dimensionCount).fill(1));
|
||||
for (let dimension = 0; dimension < dimensionCount - 1; dimension += 1) {
|
||||
let start = 0;
|
||||
while (start < rows.length) {
|
||||
let end = start + 1;
|
||||
while (end < rows.length && keys[start].slice(0, dimension + 1).every((value, index) => value === keys[end][index])) end += 1;
|
||||
spans[start][dimension] = end - start;
|
||||
for (let row = start + 1; row < end; row += 1) spans[row][dimension] = 0;
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** Grow parent cells with the rendered prefix while reusing the complete span calculation. */
|
||||
export function visibleDimensionRowSpans(spans: readonly (readonly number[])[], visibleCount: number): number[][] {
|
||||
const count = Math.min(spans.length, Math.max(0, Math.floor(visibleCount)));
|
||||
return spans.slice(0, count).map((row, index) => row.map(span => Math.min(span, count - index)));
|
||||
}
|
||||
Reference in New Issue
Block a user