Files
ln-bi/src/modules/assets/history/HistoryDetailsDialog.tsx
T

67 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}