feat: focus ETC metric drilldowns
This commit is contained in:
@@ -89,7 +89,11 @@ export default function ETCDetails({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm">
|
<section
|
||||||
|
id="etc-details"
|
||||||
|
tabIndex={-1}
|
||||||
|
className="scroll-mt-3 overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||||
|
>
|
||||||
<header className="flex flex-col gap-3 border-b border-slate-100 px-3 py-3 md:px-4">
|
<header className="flex flex-col gap-3 border-b border-slate-100 px-3 py-3 md:px-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react';
|
import { CircleCheck, CircleX, Clock3, Database, ReceiptText, RefreshCw, Route, Truck } from 'lucide-react';
|
||||||
import { fetchEtcOverview } from './api';
|
import { fetchEtcOverview } from './api';
|
||||||
import type { EtcOverviewResponse } from './types';
|
import type { EtcOverviewResponse } from './types';
|
||||||
@@ -23,6 +24,31 @@ function syncStatusLabel(status: EtcOverviewResponse['integration']['lastSyncSta
|
|||||||
return '状态未知';
|
return '状态未知';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DrilldownMetricButton({
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-controls="etc-details"
|
||||||
|
aria-label={label}
|
||||||
|
className="min-w-0 rounded-2xl text-left outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ETCView() {
|
export default function ETCView() {
|
||||||
const [data, setData] = useState<EtcOverviewResponse | null>(null);
|
const [data, setData] = useState<EtcOverviewResponse | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -47,6 +73,18 @@ export default function ETCView() {
|
|||||||
);
|
);
|
||||||
}, [commitDetailContext, detailContext]);
|
}, [commitDetailContext, detailContext]);
|
||||||
|
|
||||||
|
const openDetailView = useCallback((next: EtcDetailView) => {
|
||||||
|
selectDetailView(next);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const details = document.getElementById('etc-details');
|
||||||
|
details?.focus({ preventScroll: true });
|
||||||
|
details?.scrollIntoView({
|
||||||
|
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||||
|
block: 'start',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [selectDetailView]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => setDetailContext(parseEtcDrillContext(window.location.search));
|
const handlePopState = () => setDetailContext(parseEtcDrillContext(window.location.search));
|
||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopState);
|
||||||
@@ -104,10 +142,18 @@ export default function ETCView() {
|
|||||||
</SurfaceCard>
|
</SurfaceCard>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<button type="button" onClick={() => selectDetailView('records')} className="text-left" title="查看通行记录"><MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} /></button>
|
<DrilldownMetricButton label={`查看 ETC 通行记录,总费用 ${fmtMoney(kpi.totalAmount)}`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||||
<button type="button" onClick={() => selectDetailView('records')} className="text-left" title="查看通行记录"><MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" /></button>
|
<MetricTile icon={ReceiptText} label="ETC 总费用" value={fmtMoney(kpi.totalAmount)} helper={`通行费 ${fmtMoney(kpi.tollAmount)} · 服务费 ${fmtMoney(kpi.serviceFee)}`} />
|
||||||
<button type="button" onClick={() => selectDetailView('records')} className="text-left" title="查看通行记录"><MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" /></button>
|
</DrilldownMetricButton>
|
||||||
<button type="button" onClick={() => selectDetailView('bills')} className="text-left" title="查看结算账单"><MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" /></button>
|
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.passageCount} 次`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||||
|
<MetricTile icon={Route} label="通行次数" value={kpi.passageCount} unit="次" helper={kpi.latestTransactionTime ? `最新 ${kpi.latestTransactionTime}` : '暂无通行记录'} tone="emerald" />
|
||||||
|
</DrilldownMetricButton>
|
||||||
|
<DrilldownMetricButton label={`查看 ETC 通行记录,共 ${kpi.vehicleCount} 辆车`} title="查看通行记录" onClick={() => openDetailView('records')}>
|
||||||
|
<MetricTile icon={Truck} label="通行车辆" value={kpi.vehicleCount} unit="辆" helper="按车牌去重" tone="amber" />
|
||||||
|
</DrilldownMetricButton>
|
||||||
|
<DrilldownMetricButton label={`查看 ETC 结算账单,应收 ${fmtMoney(kpi.receivableAmount)}`} title="查看结算账单" onClick={() => openDetailView('bills')}>
|
||||||
|
<MetricTile icon={Database} label="账单应收" value={fmtMoney(kpi.receivableAmount)} helper={`${kpi.billCount} 账单 · 已收 ${fmtMoney(kpi.paidAmount)} · ${collectionRate.toFixed(1)}%`} tone="slate" />
|
||||||
|
</DrilldownMetricButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user