340 lines
40 KiB
TypeScript
340 lines
40 KiB
TypeScript
import { useEffect, useLayoutEffect, 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 { AnalysisExportButton } from './AnalysisExportButton';
|
||
import { MobileAnalysisControls } from './MobileAnalysisControls';
|
||
import { MobileGroupedTable } from './MobileGroupedTable';
|
||
import { downloadAnalysisWorkbook, statisticsExportRows, vehicleExportRows } from '../../analysis-export';
|
||
import { useMobileLayout } from '../../useMobileLayout';
|
||
import './asset-analysis.css';
|
||
import './mobile-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 mobile = useMobileLayout();
|
||
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 overviewScroll = useRef(0);
|
||
const openDetails = (row: CustomAnalysisRow) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailGroup(row); };
|
||
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
|
||
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, preserveOnDisable: true });
|
||
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(' · ');
|
||
const exportWorkbook = async () => {
|
||
const rows = showDetails ? visibleVehicles : await fetchJson<VehicleListItem[]>(endpoint.replace('/analysis', '/analysis/details'), {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ metric: analysis.metric, filters: {}, filterValues, generatedAt: data?.generatedAt, scope: request.kind === 'vehicles' ? request.params : {} }),
|
||
});
|
||
const exportGroups = detailGroup ? [{ ...detailGroup, count: rows.length, totals: {
|
||
pending: rows.filter(v => v.status === 'Pending').length,
|
||
abnormal: rows.filter(v => v.status === 'Abnormal').length,
|
||
} }] : orderedGroups;
|
||
const statistics = statisticsExportRows(
|
||
analysis.dimensions.map(d => DIMENSION_LABELS[d]),
|
||
exportGroups.map(row => ({ values: analysis.dimensions.map(d => getDimensionValueLabel(d, row.values[d] || '')), count: row.count,
|
||
extra: view === 'inventory' ? { 待交车: row.totals.pending, 异动: row.totals.abnormal } : undefined })),
|
||
showDetails ? rows.length : analysis.total,
|
||
);
|
||
await downloadAnalysisWorkbook(statistics, vehicleExportRows(rows, view === 'inventory'), `${title}-${scopeLabel}${detailGroup ? `-${detailGroup.label}` : ''}`);
|
||
};
|
||
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' : ''}`}>
|
||
{(!mobile || request.kind !== 'weekly' || busy || error) && <header className="analysis-header">{mobile && <button type="button" aria-label={showDetails ? "返回统计分析" : "返回资产管理"} onClick={() => showDetails ? setDetailGroup(null) : close()}><ArrowLeft size={20} /></button>}<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} title={title} scopeLabel={scopeLabel} close={close} /> : <>
|
||
{showDetails && !mobile && <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 && <>
|
||
{!mobile && <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>}
|
||
{mobile && (analysis.total > 0 || Object.keys(filterValues).length > 0) && <AnalysisControls view={view} cells={data?.cells ?? []} available={available} dimensions={dimensions} setDimensions={updateDimensions} filters={filterValues} setFilters={updateFilters} reset={reset} />}
|
||
{!mobile && view === 'abnormal' && <p className="analysis-definition">已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}
|
||
<div className={`analysis-table-tools analysis-overview-tools${!analysis.total ? ' is-empty' : ''}`}><p>{merged ? '按层级汇总数量排序,相同上级合并展示。' : '筛选分组可继续分析,查看明细可核对车辆。'}</p><div className="analysis-table-options"><AnalysisExportButton disabled={!analysis.total} onExport={exportWorkbook} />{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>{Object.keys(filterValues).length ? '没有符合条件的数据' : view === 'abnormal' ? '当前无异动车辆' : view === 'pending' ? '当前无待交车辆' : '当前范围暂无车辆'}</h3><p>{Object.keys(filterValues).length ? '可移除筛选条件后重新查看。' : '当前可查看范围内没有相关记录。'}</p>{Object.keys(filterValues).length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div> : <>
|
||
{mobile ? <MobileGroupedTable dimensions={analysis.dimensions.map(d => DIMENSION_LABELS[d])} unit="台" merged={merged} setMerged={setMergeParents} spans={spans} parentTotals={parentTotals}
|
||
rows={shownGroups.map(row => ({ key: row.key, label: row.label, values: analysis.dimensions.map(d => <Blur key={d}>{getDimensionValueLabel(d, row.values[d] || '')}</Blur>), count: row.count, share: row.share, note: view === 'inventory' && (row.totals.pending || row.totals.abnormal) ? [row.totals.pending ? `待交 ${row.totals.pending}` : '', row.totals.abnormal ? `异动 ${row.totals.abnormal}` : ''].filter(Boolean).join(' · ') : undefined }))}
|
||
onDetails={index => openDetails(shownGroups[index])} onFilter={index => narrowToGroup(shownGroups[index])} /> : <>
|
||
<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={() => openDetails(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">{mobile && <p className="analysis-mobile-detail-scope">{detailGroup.label} · 当前分组</p>}
|
||
<div className="analysis-table-tools"><h3>车辆明细 <small>{number(visibleVehicles.length)} 台</small></h3><AnalysisExportButton disabled={detailBusy || !!detailError || !visibleVehicles.length} onExport={exportWorkbook} /><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} inventory={view === 'inventory'} />}
|
||
<ProgressiveRowsStatus {...detailProgress} unit="台" />
|
||
</>}
|
||
</div>}
|
||
</div>
|
||
<footer className="analysis-footer">{mobile ? <details><summary>统计口径与数据时间</summary><p>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</p><p>同维度筛选为“或”,不同维度为“且”。仅展示当前入口与筛选条件内的车辆。</p>{view === 'abnormal' && <p>已异动时长=统计时间-审批通过时间;区间左含右不含,缺失时间单列。{data && !data.approvalTimeAvailable && ' 当前未取得可用审批时间。'}</p>}</details> : <><span>统计时间 {data ? formatLocalDateTime(new Date(data.generatedAt)) : '—'} · 当前可查看范围</span><span>{showDetails ? '仅展示当前分组与筛选条件内的车辆' : '同维度筛选为“或”,不同维度筛选为“且”'}</span></>}</footer>
|
||
</>}
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
function VehicleRows({ vehicles, inventory = false }: { vehicles: VehicleListItem[]; inventory?: boolean }) {
|
||
const mobile = useMobileLayout();
|
||
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(' · ');
|
||
};
|
||
if (mobile) return <div className="analysis-vehicle-cards" aria-label="车辆明细列表">{vehicles.map(vehicle => <article className="analysis-vehicle-card" key={vehicle.id}>
|
||
<header><h4><Blur>{vehicle.plateNumber || vehicle.vin || '未提供'}</Blur></h4><span>{stateLabel(vehicle)}</span></header>
|
||
<p>{vehicle.model || `${vehicle.brandLabel || '—'} · ${vehicle.type}`} · {vehicle.city || vehicle.location || '未标注城市'}</p>
|
||
{!inventory && vehicle.customerName && <p><Blur>{vehicle.customerName}</Blur></p>}
|
||
<details><summary>车辆资料</summary><dl>{[['VIN', vehicle.vin], ['品牌 / 车型', `${vehicle.brandLabel || '—'} · ${vehicle.type}`], ['省份 / 城市', `${vehicle.province || '未标注'} / ${vehicle.city || vehicle.location || '未标注'}`], ...(inventory ? [['运维负责人', vehicle.opsManagers?.trim() || '-']] : [['签约公司', vehicle.orgName], ['部门', vehicle.departmentName], ['业务负责人', vehicle.customerManager]]), ['所属公司', vehicle.subjectOrg]].map(([label, value]) => <div key={label}><dt>{label}</dt><dd><Blur>{value || '未提供'}</Blur></dd></div>)}</dl></details>
|
||
</article>)}</div>;
|
||
return <div className="analysis-detail-table" tabIndex={0} role="region" aria-label="车辆明细表"><table><thead><tr>{['车牌 / VIN', '品牌 / 车型 / 型号', '省份 / 城市', '状态', ...(inventory ? ['运维负责人'] : ['客户 / 签约公司', '部门 / 业务负责人']), '所属公司'].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>{inventory ? <td><Blur>{vehicle.opsManagers?.trim() || '-'}</Blur></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, title, scopeLabel, close }: { rows: WeeklyDetailItem[]; title: string; scopeLabel: string; close: () => void }) {
|
||
const mobile = useMobileLayout();
|
||
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 overviewScroll = useRef(0);
|
||
const openDetails = (key: string) => { overviewScroll.current = scrollRoot.current?.scrollTop || 0; setDetailKey(key); };
|
||
useLayoutEffect(() => { if (scrollRoot.current) scrollRoot.current.scrollTop = showDetails ? 0 : overviewScroll.current; }, [showDetails]);
|
||
const 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, preserveOnDisable: true });
|
||
const detailProgress = useProgressiveRows({ total: detailRows.length, rootRef: scrollRoot, resetKey: detailRows, enabled: showDetails });
|
||
const shownGroups = useMemo(() => groups.slice(0, groupProgress.visibleCount), [groups, groupProgress.visibleCount]);
|
||
const completeSpans = useMemo(() => mergeActive ? dimensionRowSpans(groups, dimensions.length, group => group.values) : [], [groups, dimensions.length, mergeActive]);
|
||
const groupSpans = useMemo(() => visibleDimensionRowSpans(completeSpans, groupProgress.visibleCount), [completeSpans, groupProgress.visibleCount]);
|
||
const shownDetails = useMemo(() => detailRows.slice(0, detailProgress.visibleCount), [detailRows, detailProgress.visibleCount]);
|
||
const 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]);
|
||
|
||
const exportWorkbook = () => {
|
||
const selectedRows = showDetails ? detailRows : filtered;
|
||
const selectedGroups = detailGroup ? [detailGroup] : groups;
|
||
const statistics = statisticsExportRows(dimensions.map(d => labels[d]), selectedGroups.map(group => ({ values: group.values.map((v, i) => label(dimensions[i], v)), count: group.rows.length })), selectedRows.length, '车次');
|
||
const details = selectedRows.map(row => ({ 车牌: row.plate_number, 日期: row.handover_date, 合同类型: row.contract_type, 客户: row.customer_name }));
|
||
return downloadAnalysisWorkbook(statistics, details, `交还车分析-${detailGroup ? groupLabel(detailGroup) : '当前筛选结果'}`);
|
||
};
|
||
if (mobile) return <>
|
||
<header className="analysis-header"><button aria-label={showDetails ? '返回统计分析' : '返回资产管理'} onClick={() => showDetails ? setDetailKey(null) : close()}><ArrowLeft size={20} /></button><div><h2>{title}</h2><p>{scopeLabel}</p></div><button aria-label="关闭统计分析" onClick={close}><X size={18} /></button></header>
|
||
<div ref={scrollRoot} className="analysis-scroll">
|
||
<div className="analysis-result-bar" role="status"><span>当前结果 <strong>{number(showDetails ? detailRows.length : filtered.length)}</strong> 车次</span>{!showDetails && <span>{groups.length} 个分组</span>}</div>
|
||
{!showDetails ? <>
|
||
{(filtered.length > 0 || activeFilters.length > 0) && <MobileAnalysisControls available={dimensionsAvailable.map(key => ({ key, label: labels[key], options: filterOptions[key] }))} dimensions={dimensions} filters={filters} defaults={['date']} apply={(nextDimensions, nextFilters) => { updateDimensions(nextDimensions as Dimension[]); updateFilters(nextFilters); }} />}
|
||
{filtered.length > 0 && <div className="analysis-table-tools"><AnalysisExportButton onExport={exportWorkbook} /></div>}
|
||
{groups.length ? <><MobileGroupedTable dimensions={dimensions.map(d => labels[d])} unit="车次" merged={mergeActive} setMerged={setMergeParents} spans={groupSpans} parentTotals={parentTotals}
|
||
rows={shownGroups.map(group => ({ key: group.key, label: groupLabel(group), values: group.values.map((entry, i) => label(dimensions[i], entry)), count: group.rows.length, share: group.rows.length / filtered.length * 100 }))}
|
||
onDetails={index => openDetails(shownGroups[index].key)} onFilter={index => updateFilters({ ...filters, ...Object.fromEntries(dimensions.map((dimension, i) => [dimension, [shownGroups[index].values[i]]])) })} /><ProgressiveRowsStatus {...groupProgress} unit="组" /></> : <div className="analysis-message"><p>{activeFilters.length ? '没有符合条件的数据,可调整筛选。' : '当前范围暂无交还车记录。'}</p>{activeFilters.length > 0 && <button className="analysis-button" onClick={() => updateFilters({})}>清空筛选</button>}</div>}
|
||
</> : <>
|
||
<p className="analysis-mobile-detail-scope">{detailGroup ? groupLabel(detailGroup) : '当前分组'}</p>
|
||
<div className="analysis-table-tools"><h3>交还车业务明细</h3><AnalysisExportButton disabled={!detailRows.length} onExport={exportWorkbook} /></div>
|
||
<div className="analysis-vehicle-cards">{shownDetails.map((row, index) => <article className="analysis-vehicle-card" key={`${row.truck_id}-${index}`}><header><h4><Blur>{row.plate_number || '未登记车牌'}</Blur></h4><span>{row.contract_type || '未标注合同类型'}</span></header><p>{row.handover_date || '日期缺失'}</p><p><Blur>{row.customer_name || '未提供客户'}</Blur></p></article>)}</div>
|
||
<ProgressiveRowsStatus {...detailProgress} unit="条" />
|
||
</>}
|
||
</div>
|
||
<footer className="analysis-footer"><details><summary>统计口径</summary><p>按当前入口的周业务记录统计车次,同一车辆多笔记录分别计次。仅展示当前可查看范围和筛选条件内的数据。</p></details></footer>
|
||
</>;
|
||
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><div className="analysis-export-actions"><AnalysisExportButton disabled={!filtered.length} onExport={exportWorkbook} /></div>{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><AnalysisExportButton disabled={!detailRows.length} onExport={exportWorkbook} /></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>;
|
||
}
|