feat(assets): add verified asset history trends and compact overview

This commit is contained in:
shishengliang
2026-10-24 13:16:48 +08:00
parent dbe0421bfe
commit 8afe13c9a3
36 changed files with 1716 additions and 9 deletions
@@ -0,0 +1,66 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { X, Search, ChevronLeft, ChevronRight } from 'lucide-react';
import { useDialogFocus } from '../components/useDialogFocus';
import { HISTORY_STATE_LABELS, historyTime, type HistoryCategory, type HistoryDetails, type HistoryPoint } from '../../../shared/assets/history';
import { fetchHistoryDetails } from './api';
import { displayHistoryCounts } from './presentation';
export function HistoryDetailsDialog({ point, subject, close }: { point: HistoryPoint; subject: string | null; close: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const [category, setCategory] = useState<HistoryCategory>('all');
const [search, setSearch] = useState('');
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [data, setData] = useState<HistoryDetails | null>(null);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [retry, setRetry] = useState(0);
useDialogFocus(true, ref, close);
useEffect(() => {
const timer = setTimeout(() => { setQuery(search.trim()); setPage(1); }, 250);
return () => clearTimeout(timer);
}, [search]);
useEffect(() => {
const abort = new AbortController();
setLoading(true); setError(''); setData(null);
fetchHistoryDetails(point.month, point.batchId, subject, category, query, page, abort.signal)
.then(result => { if (!abort.signal.aborted) setData(result); })
.catch(e => { if (!abort.signal.aborted) setError(e instanceof Error ? e.message : '明细加载失败'); })
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
return () => abort.abort();
}, [point.month, point.batchId, subject, category, query, page, retry]);
const pages = data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1;
return createPortal(<div className="asset-history-overlay" onMouseDown={e => { if (e.target === e.currentTarget) close(); }}>
<div className="asset-history-dialog" role="dialog" aria-modal="true" aria-labelledby="asset-history-detail-title" tabIndex={-1} ref={ref}>
<header><div><h2 id="asset-history-detail-title">{point.month.replace('-', '年')}月资产明细</h2>
<p>{subject || '全部公司'} · {HISTORY_STATE_LABELS[point.state]} · {point.reconstruction ? '参考时点' : point.state === 'ARCHIVED' ? '快照' : '截至'} {historyTime(point.reconstruction ? point.scheduledFor : data?.point.asOf || point.asOf)}</p></div>
<button type="button" onClick={close} aria-label="关闭月度明细"><X size={20} /></button></header>
<div className="asset-history-detail-tools">
<label>车辆分类<select aria-label="月度明细分类" value={category} onChange={e => { setCategory(e.target.value as HistoryCategory); setPage(1); }}>
<option value="all">{point.reconstruction ? '全部推算车辆' : '全部资产'}</option><option value="operating">运营车辆</option><option value="inventory">库存车辆</option></select></label>
<label className="asset-history-search"><Search size={16} /><input aria-label="搜索历史车牌或VIN" placeholder="搜索车牌 / VIN" value={search} onChange={e => setSearch(e.target.value)} /></label>
</div>
<div className="asset-history-detail-body" aria-busy={loading} aria-live="polite">
{loading ? <p className="asset-history-empty">正在加载月度明细…</p> : error ? <p className="asset-history-empty" role="alert">{error} <button type="button" onClick={() => setRetry(x => x + 1)}>重试</button></p> : data && <>
<p className="asset-history-detail-note">{data.point.reconstruction ? '以下为核对表中的交还记录推算车辆,归属未知项留空。历史总数待核实,不能将两类相加当作历史资产总数。' : data.point.state === 'ARCHIVED' ? '以下为当时归档的车辆及归属信息。' : '以下为最新实时明细,数量可能随业务更新。'} 当前范围:总数 {displayHistoryCounts(data.point)?.total ?? '待核实'} · 运营 {displayHistoryCounts(data.point)?.operating} · 库存 {displayHistoryCounts(data.point)?.inventory}</p>
{data.items.length ? <><div className="asset-history-detail-table"><table><thead><tr><th>车辆</th><th>分类</th><th>公司 / 部门</th><th>负责人 / 客户</th></tr></thead>
<tbody>{data.items.map(item => <tr key={item.id}><td><strong>{item.plateNumber || '未登记车牌'}</strong><small>{item.model}</small><small>VIN:{item.vin || '未提供'}</small>{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}</td>
<td>{item.disposition === 'operating' ? '运营' : '库存'}</td><td>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></td>
<td>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small><small>{item.contractNo || ''}</small></td></tr>)}</tbody></table></div>
<div className="asset-history-mobile-vehicles">{data.items.map(item => <article key={item.id}>
<div className="asset-history-mobile-vehicle-heading"><strong>{item.plateNumber || '未登记车牌'}</strong><span>{item.disposition === 'operating' ? '运营' : '库存'}</span></div>
<p>{item.model}</p><small>VIN:{item.vin || '未提供'}</small>
{item.reconstructionBasis && <small>推算依据:{item.reconstructionBasis}</small>}
<dl><div><dt>公司 / 部门</dt><dd>{item.subjectOrg || '未提供'}<small>{item.departmentName || (item.reconstructionBasis ? '历史未提供' : '未分配部门')}</small></dd></div>
<div><dt>负责人 / 客户</dt><dd>{item.customerManager || (item.reconstructionBasis ? '历史未提供' : '未分配')}<small>{item.customerName || (item.reconstructionBasis ? '历史未提供' : '无客户信息')}</small></dd></div></dl>
{item.contractNo && <small>合同:{item.contractNo}</small>}
</article>)}</div></> : <p className="asset-history-empty">当前条件下没有车辆</p>}
</>}
</div>
<footer><span>{data ? `${point.reconstruction ? '推算' : '筛选'}结果 ${data.total} 台 · 第 ${page} / ${pages} 页` : '每页 30 台'}</span><div>
<button type="button" aria-label="上一页" disabled={loading || !data || page <= 1} onClick={() => setPage(x => x - 1)}><ChevronLeft size={16} /></button>
<button type="button" aria-label="下一页" disabled={loading || !data || page >= pages} onClick={() => setPage(x => x + 1)}><ChevronRight size={16} /></button></div></footer>
</div>
</div>, document.body);
}