379 lines
25 KiB
TypeScript
379 lines
25 KiB
TypeScript
import { ChevronDown, ChevronRight, Database, Download, Search, Truck, X } from 'lucide-react';
|
|
import { Fragment, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import * as XLSX from 'xlsx';
|
|
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
|
|
import type {
|
|
HydrogenOverviewDetailGroup,
|
|
HydrogenOverviewDetailGroupBy,
|
|
HydrogenOverviewDetailRecord,
|
|
HydrogenOverviewDetailResponse,
|
|
} from '../../types';
|
|
import type { HydrogenVehicleScope } from '../../api';
|
|
|
|
type Selection = {
|
|
stationId?: number | null;
|
|
customerId?: number | null;
|
|
customerName?: string | null;
|
|
plateNo?: string | null;
|
|
};
|
|
|
|
type TreeSortKey = 'name' | 'ownership' | 'source' | 'verify' | 'recordCount' | 'kg' | 'cost' | 'revenue';
|
|
|
|
interface OverviewDrillTreeDialogProps {
|
|
title: string;
|
|
initialVehicleScope: HydrogenVehicleScope;
|
|
initialSelection?: Selection;
|
|
load: (
|
|
groupBy: HydrogenOverviewDetailGroupBy | null,
|
|
selection: Selection,
|
|
vehicleScope: HydrogenVehicleScope,
|
|
includeAll?: boolean,
|
|
) => Promise<HydrogenOverviewDetailResponse>;
|
|
onClose: () => void;
|
|
}
|
|
|
|
const number = (value: number, digits = 2) => value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
|
const customerKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup) => `${stationId}:${customer.id}:${customer.name}`;
|
|
const vehicleKey = (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => `${customerKey(stationId, customer)}:${vehicle.name}`;
|
|
|
|
/** 原型定义的四层账本树:加氢站 -> 客户 -> 车辆 -> 单笔订单与核对明细。 */
|
|
export function OverviewDrillTreeDialog({ title, initialVehicleScope, initialSelection = {}, load, onClose }: OverviewDrillTreeDialogProps) {
|
|
const [root, setRoot] = useState<HydrogenOverviewDetailResponse | null>(null);
|
|
const [customers, setCustomers] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
|
|
const [vehicles, setVehicles] = useState<Record<string, HydrogenOverviewDetailGroup[]>>({});
|
|
const [orders, setOrders] = useState<Record<string, HydrogenOverviewDetailRecord[]>>({});
|
|
const [openStations, setOpenStations] = useState<Record<string, boolean>>({});
|
|
const [openCustomers, setOpenCustomers] = useState<Record<string, boolean>>({});
|
|
const [openVehicles, setOpenVehicles] = useState<Record<string, boolean>>({});
|
|
const [loading, setLoading] = useState<string | null>('root');
|
|
const [stationFilter, setStationFilter] = useState('all');
|
|
const [customerFilter, setCustomerFilter] = useState('all');
|
|
const [plateFilter, setPlateFilter] = useState('all');
|
|
const [vehicleScope, setVehicleScope] = useState<HydrogenVehicleScope>(initialVehicleScope);
|
|
const [exporting, setExporting] = useState(false);
|
|
const [sortKey, setSortKey] = useState<TreeSortKey>('kg');
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
|
|
|
const loadRoot = useCallback(async (scope: HydrogenVehicleScope) => {
|
|
setLoading('root');
|
|
setCustomers({});
|
|
setVehicles({});
|
|
setOrders({});
|
|
setOpenStations({});
|
|
setOpenCustomers({});
|
|
setOpenVehicles({});
|
|
try {
|
|
setRoot(await load('station', initialSelection, scope));
|
|
} catch {
|
|
setRoot(null);
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
}, [initialSelection, load]);
|
|
|
|
useEffect(() => { void loadRoot(vehicleScope); }, [loadRoot, vehicleScope]);
|
|
|
|
const toggleStation = async (station: HydrogenOverviewDetailGroup) => {
|
|
const key = String(station.id);
|
|
if (openStations[key]) {
|
|
setOpenStations(value => ({ ...value, [key]: false }));
|
|
return;
|
|
}
|
|
setOpenStations(value => ({ ...value, [key]: true }));
|
|
if (customers[key]) return;
|
|
setLoading(`station:${key}`);
|
|
try {
|
|
const data = await load('customer', { ...initialSelection, stationId: Number(station.id) || null }, vehicleScope);
|
|
setCustomers(value => ({ ...value, [key]: data.groups }));
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
};
|
|
|
|
const toggleCustomer = async (stationId: number | string, customer: HydrogenOverviewDetailGroup) => {
|
|
const key = customerKey(stationId, customer);
|
|
if (openCustomers[key]) {
|
|
setOpenCustomers(value => ({ ...value, [key]: false }));
|
|
return;
|
|
}
|
|
setOpenCustomers(value => ({ ...value, [key]: true }));
|
|
if (vehicles[key]) return;
|
|
setLoading(`customer:${key}`);
|
|
try {
|
|
const data = await load('vehicle', {
|
|
...initialSelection,
|
|
stationId: Number(stationId) || null,
|
|
customerId: Number(customer.id) || 0,
|
|
customerName: customer.name,
|
|
}, vehicleScope);
|
|
setVehicles(value => ({ ...value, [key]: data.groups }));
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
};
|
|
|
|
const toggleVehicle = async (stationId: number | string, customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => {
|
|
const key = vehicleKey(stationId, customer, vehicle);
|
|
if (openVehicles[key]) {
|
|
setOpenVehicles(value => ({ ...value, [key]: false }));
|
|
return;
|
|
}
|
|
setOpenVehicles(value => ({ ...value, [key]: true }));
|
|
if (orders[key]) return;
|
|
setLoading(`vehicle:${key}`);
|
|
try {
|
|
const data = await load(null, {
|
|
...initialSelection,
|
|
stationId: Number(stationId) || null,
|
|
customerId: Number(customer.id) || 0,
|
|
customerName: customer.name,
|
|
plateNo: vehicle.name,
|
|
}, vehicleScope);
|
|
setOrders(value => ({ ...value, [key]: data.records }));
|
|
} finally {
|
|
setLoading(null);
|
|
}
|
|
};
|
|
|
|
const stationOptions = root?.groups ?? [];
|
|
const selectedStation = stationFilter === 'all' ? null : stationOptions.find(station => String(station.id) === stationFilter) ?? null;
|
|
const customerOptions = selectedStation ? customers[String(selectedStation.id)] ?? [] : [];
|
|
const selectedCustomer = customerFilter === 'all' ? null : customerOptions.find(customer => customerKey(selectedStation?.id ?? '', customer) === customerFilter) ?? null;
|
|
const plateOptions = selectedStation && selectedCustomer ? vehicles[customerKey(selectedStation.id, selectedCustomer)] ?? [] : [];
|
|
const visibleStations = useMemo(() => {
|
|
const filtered = stationFilter === 'all' ? stationOptions : stationOptions.filter(station => String(station.id) === stationFilter);
|
|
return sortTreeGroups(filtered, sortKey, sortDirection);
|
|
}, [sortDirection, sortKey, stationFilter, stationOptions]);
|
|
|
|
const selectStation = (value: string) => {
|
|
setStationFilter(value);
|
|
setCustomerFilter('all');
|
|
setPlateFilter('all');
|
|
const station = stationOptions.find(item => String(item.id) === value);
|
|
if (station && !openStations[String(station.id)]) void toggleStation(station);
|
|
};
|
|
|
|
const selectCustomer = (value: string) => {
|
|
setCustomerFilter(value);
|
|
setPlateFilter('all');
|
|
const customer = customerOptions.find(item => customerKey(selectedStation?.id ?? '', item) === value);
|
|
if (selectedStation && customer && !openCustomers[value]) void toggleCustomer(selectedStation.id, customer);
|
|
};
|
|
|
|
const changeVehicleScope = (scope: HydrogenVehicleScope) => {
|
|
if (scope === vehicleScope) return;
|
|
setVehicleScope(scope);
|
|
setStationFilter('all');
|
|
setCustomerFilter('all');
|
|
setPlateFilter('all');
|
|
};
|
|
|
|
const changeSort = (nextKey: TreeSortKey) => {
|
|
const next = toggleSort(sortKey, sortDirection, nextKey);
|
|
setSortKey(next.key);
|
|
setSortDirection(next.direction);
|
|
};
|
|
|
|
const exportAll = async () => {
|
|
setExporting(true);
|
|
try {
|
|
const detail = await load(null, {
|
|
...initialSelection,
|
|
stationId: selectedStation ? Number(selectedStation.id) || null : null,
|
|
customerId: selectedCustomer ? Number(selectedCustomer.id) || 0 : null,
|
|
customerName: selectedCustomer?.name ?? null,
|
|
plateNo: plateFilter === 'all' ? null : plateFilter,
|
|
}, vehicleScope, true);
|
|
const rows = detail.records.map(record => ({
|
|
加氢时间: record.refuelTime,
|
|
加氢站: record.stationName,
|
|
客户: record.customerName,
|
|
车牌: record.plateNo,
|
|
车辆归属: record.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆',
|
|
数据来源: record.source,
|
|
核对状态: verifyText(record.verifyStatus),
|
|
订单编号: record.orderNo,
|
|
加氢量Kg: record.kg,
|
|
成本元: record.cost,
|
|
客户收入元: record.revenue,
|
|
}));
|
|
const workbook = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(rows), '穿透账单');
|
|
XLSX.writeFile(workbook, `${title.replaceAll(/[\\/:*?"<>|]/g, '_')}_穿透账单.xlsx`);
|
|
if (detail.truncated) window.alert('当前筛选范围超过 20,000 笔,导出已截取前 20,000 笔。请收窄筛选范围后再次导出。');
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
if (typeof document === 'undefined') return null;
|
|
const summary = root?.summary;
|
|
return createPortal(
|
|
<div className="ehb-modal-overlay" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
|
|
<section className="ehb-modal-card" role="dialog" aria-modal="true" aria-label={`${title}穿透明细`}>
|
|
<header className="ehb-modal-head">
|
|
<div className="ehb-modal-head__title-group">
|
|
<div>
|
|
<h2 className="ehb-modal-head__title">{title}</h2>
|
|
<p className="ehb-modal-head__sub">加氢站 → 客户 → 车辆 → 单笔订单与核对明细</p>
|
|
</div>
|
|
</div>
|
|
<button type="button" onClick={onClose} className="ehb-modal-close-btn" aria-label="关闭下钻弹层"><X size={17} /></button>
|
|
</header>
|
|
<div className="ehb-modal-body">
|
|
<div className="ehb-modal-meta-bar">
|
|
<Metric label="统计范围" value={title} />
|
|
<Metric label="加氢总量" value={`${number(summary?.kg ?? 0, 2)} Kg`} tone="text-sky-600" />
|
|
<Metric label="涉及加氢站" value={`${summary?.stationCount ?? 0} 站`} />
|
|
<Metric label="账本流水" value={`${summary?.recordCount ?? 0} 笔`} />
|
|
</div>
|
|
|
|
<div className="ehb-modal-filter-row">
|
|
<div className="ehb-modal-filter-group">
|
|
<SearchSelect allLabel="全部加氢站" value={stationFilter} onChange={selectStation} options={stationOptions.map(item => ({ value: String(item.id), label: item.name }))} />
|
|
<SearchSelect allLabel="全部客户" value={customerFilter} onChange={selectCustomer} disabled={!selectedStation} options={customerOptions.map(item => ({ value: customerKey(selectedStation?.id ?? '', item), label: item.name }))} />
|
|
<SearchSelect allLabel="全部车辆" value={plateFilter} onChange={setPlateFilter} disabled={!selectedCustomer} options={plateOptions.map(item => ({ value: item.name, label: item.name }))} />
|
|
<div className="flex h-8 overflow-hidden rounded-md border border-slate-300 bg-white text-[11px]">
|
|
{([{ key: 'all', label: '全部车辆' }, { key: 'lingniu', label: '仅羚牛车辆' }, { key: 'external', label: '仅外部车辆' }] as const).map(item => (
|
|
<button key={item.key} type="button" onClick={() => changeVehicleScope(item.key)} className={`inline-flex items-center gap-1 px-2.5 font-medium transition-colors ${vehicleScope === item.key ? 'bg-sky-50 text-sky-700 shadow-sm' : 'text-slate-500 hover:bg-slate-50'}`}>
|
|
{item.key !== 'all' ? <Truck size={13} /> : null}{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="ehb-modal-hint-text">提示:点击表格行可四级层层展开</p>
|
|
</div>
|
|
<button type="button" onClick={() => void exportAll()} disabled={exporting} className="inline-flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-slate-300 bg-white px-2.5 text-[11px] font-semibold text-slate-600 transition-colors hover:border-sky-300 hover:bg-sky-50 hover:text-sky-700 disabled:opacity-60">
|
|
<Download size={14} />{exporting ? '正在导出…' : '导出 Excel 穿透账单'}
|
|
</button>
|
|
</div>
|
|
|
|
<p className="mb-2 text-center text-[11px] text-slate-400 md:hidden">左右滑动查看完整数据与凭证列</p>
|
|
<div className="ehb-modal-table-wrap is-v-scroll">
|
|
<table className="ehb-modal-table min-w-[1080px]">
|
|
<thead className="sticky top-0 z-10">
|
|
<tr>
|
|
<th className="min-w-[240px] border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="加氢站 / 客户 / 车辆与凭证链路" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
|
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="类型 / 归属" sortKey="ownership" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
|
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="数据来源及凭证号" sortKey="source" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
|
<th className="border-b border-slate-200 px-3 py-2.5"><SortableColumnHeader label="核对状态" sortKey="verify" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
|
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢笔数" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
|
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="加氢总量 (Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
|
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="成本 (元)" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
|
<th className="border-b border-slate-200 px-3 py-2.5 text-right"><SortableColumnHeader label="客户收入 (元)" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{visibleStations.map(station => {
|
|
const stationKey = String(station.id);
|
|
return <StationBranch key={`${stationKey}:${station.name}`} station={station} expanded={Boolean(openStations[stationKey])} loadingKey={loading} customers={customers[stationKey] ?? []} customerFilter={customerFilter} plateFilter={plateFilter} openCustomers={openCustomers} vehicles={vehicles} orders={orders} openVehicles={openVehicles} sortKey={sortKey} sortDirection={sortDirection} onStation={() => void toggleStation(station)} onCustomer={customer => void toggleCustomer(station.id, customer)} onVehicle={(customer, vehicle) => void toggleVehicle(station.id, customer, vehicle)} />;
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{!root && loading === null ? <div className="mt-4 flex items-center gap-2 rounded-lg border border-dashed border-slate-300 bg-white p-5 text-sm text-slate-500"><Database size={18} />真实账本读取失败,请关闭后重试。</div> : null}
|
|
</div>
|
|
</section>
|
|
</div>, document.body,
|
|
);
|
|
}
|
|
|
|
function Metric({ label, value, tone = 'text-slate-900' }: { label: string; value: string; tone?: string }) {
|
|
return <div className="ehb-modal-meta-item"><div className="ehb-modal-meta-label">{label}</div><div className={`ehb-modal-meta-val ${tone}`} title={value}>{value}</div></div>;
|
|
}
|
|
|
|
function SearchSelect({ allLabel, value, onChange, options, disabled = false }: { allLabel: string; value: string; onChange: (value: string) => void; options: { value: string; label: string }[]; disabled?: boolean }) {
|
|
const [open, setOpen] = useState(false);
|
|
const [keyword, setKeyword] = useState('');
|
|
const selected = options.find(option => option.value === value);
|
|
const filtered = options.filter(option => option.label.toLowerCase().includes(keyword.trim().toLowerCase()));
|
|
const pick = (next: string) => {
|
|
onChange(next);
|
|
setOpen(false);
|
|
setKeyword('');
|
|
};
|
|
return <div className={`ehb-bi-search-select w-[170px] ${disabled ? 'is-disabled' : ''}`}>
|
|
<button type="button" aria-label={allLabel} aria-expanded={open} disabled={disabled} onClick={() => setOpen(value => !value)} className={`ehb-bi-search-select__trigger ${open ? 'is-open' : ''} ${selected ? 'has-value' : ''}`}>
|
|
<span className="ehb-bi-search-select__label">{selected?.label ?? allLabel}</span><ChevronDown size={14} className="ehb-bi-search-select__chevron" />
|
|
</button>
|
|
{open ? <div className="ehb-bi-search-select__dropdown">
|
|
<label className="ehb-bi-search-select__search"><Search size={13} /><input autoFocus value={keyword} onChange={event => setKeyword(event.target.value)} placeholder={`搜索${allLabel.replace('全部', '')}…`} /></label>
|
|
<div className="ehb-bi-search-select__list">
|
|
<button type="button" onClick={() => pick('all')} className={`ehb-bi-search-select__item ${value === 'all' ? 'is-selected' : ''}`}>{allLabel}</button>
|
|
{filtered.map(option => <button key={option.value} type="button" onClick={() => pick(option.value)} className={`ehb-bi-search-select__item ${value === option.value ? 'is-selected' : ''}`}>{option.label}</button>)}
|
|
{filtered.length === 0 ? <p className="ehb-bi-search-select__empty">暂无匹配结果</p> : null}
|
|
</div>
|
|
</div> : null}
|
|
</div>;
|
|
}
|
|
|
|
function ExpandMark({ open }: { open: boolean }) { return open ? <ChevronDown size={15} className="shrink-0 text-sky-600" /> : <ChevronRight size={15} className="shrink-0 text-sky-600" />; }
|
|
|
|
function StationBranch({ station, expanded, loadingKey, customers, customerFilter, plateFilter, openCustomers, vehicles, orders, openVehicles, sortKey, sortDirection, onStation, onCustomer, onVehicle }: {
|
|
station: HydrogenOverviewDetailGroup; expanded: boolean; loadingKey: string | null; customers: HydrogenOverviewDetailGroup[]; customerFilter: string; plateFilter: string; openCustomers: Record<string, boolean>; vehicles: Record<string, HydrogenOverviewDetailGroup[]>; orders: Record<string, HydrogenOverviewDetailRecord[]>; openVehicles: Record<string, boolean>; sortKey: TreeSortKey; sortDirection: SortDirection; onStation: () => void; onCustomer: (customer: HydrogenOverviewDetailGroup) => void; onVehicle: (customer: HydrogenOverviewDetailGroup, vehicle: HydrogenOverviewDetailGroup) => void;
|
|
}) {
|
|
const stationKey = String(station.id);
|
|
const visibleCustomers = sortTreeGroups(customerFilter === 'all' ? customers : customers.filter(customer => customerKey(station.id, customer) === customerFilter), sortKey, sortDirection);
|
|
return <>
|
|
<TreeRow className={expanded ? 'bg-sky-50 font-bold' : 'bg-slate-50 font-bold'} onClick={onStation} label={<><ExpandMark open={expanded} />{station.name}<span className="ml-2 text-[11px] font-medium text-slate-400">({station.customerCount} 家客户)</span></>} ownership="-" source="全量自动归集" verify="-" group={station} />
|
|
{expanded && visibleCustomers.map(customer => {
|
|
const key = customerKey(stationKey, customer);
|
|
const childVehicles = vehicles[key] ?? [];
|
|
const visibleVehicles = sortTreeGroups(plateFilter === 'all' ? childVehicles : childVehicles.filter(vehicle => vehicle.name === plateFilter), sortKey, sortDirection);
|
|
return <Fragment key={key}>
|
|
<TreeRow className={openCustomers[key] ? 'bg-slate-100' : 'bg-white'} onClick={() => onCustomer(customer)} indent={1} label={<><ExpandMark open={Boolean(openCustomers[key])} />└─ 客户:{customer.name}</>} ownership="客户" source={`${childVehicles.length || '待'} 辆车挂载`} verify="-" group={customer} />
|
|
{openCustomers[key] && visibleVehicles.map(vehicle => {
|
|
const key = vehicleKey(stationKey, customer, vehicle);
|
|
return <Fragment key={key}>
|
|
<TreeRow className={openVehicles[key] ? 'bg-slate-100 text-[11px]' : 'bg-white text-[11px]'} onClick={() => onVehicle(customer, vehicle)} indent={2} label={<><ExpandMark open={Boolean(openVehicles[key])} /><strong>{vehicle.name}</strong><span className="ml-1 text-[10px] font-normal text-slate-400">({vehicle.recordCount} 笔订单)</span></>} ownership={vehicle.vehicleScope === 'lingniu' ? '羚牛车辆' : '外部车辆'} source={vehicle.source ?? '未知来源'} verify={verifyText(vehicle.verifyStatus)} group={vehicle} />
|
|
{openVehicles[key] && <OrderRows orders={orders[key] ?? []} sortKey={sortKey} sortDirection={sortDirection} />}
|
|
</Fragment>;
|
|
})}
|
|
{openCustomers[key] && loadingKey === `customer:${key}` ? <LoadingRow text="正在读取车辆汇总…" /> : null}
|
|
</Fragment>;
|
|
})}
|
|
{expanded && loadingKey === `station:${stationKey}` ? <LoadingRow text="正在读取客户汇总…" /> : null}
|
|
</>;
|
|
}
|
|
|
|
function TreeRow({ label, ownership, source, verify, group, indent = 0, className, onClick }: { label: ReactNode; ownership: string; source: string; verify: string; group: HydrogenOverviewDetailGroup; indent?: number; className: string; onClick: () => void }) {
|
|
return <tr className={`${className} cursor-pointer border-b border-slate-100 transition-colors hover:bg-slate-50`} onClick={onClick}><td className="px-3 py-2.5" style={{ paddingLeft: `${12 + indent * 16}px` }}><span className="inline-flex items-center gap-1.5">{label}</span></td><td className="px-3 py-2.5 text-slate-500">{ownership}</td><td className="px-3 py-2.5 text-[11px] text-slate-500">{source}</td><td className="px-3 py-2.5">{verify === '-' ? <span className="text-slate-400">-</span> : <VerifyTag value={verify} />}</td><td className="px-3 py-2.5 text-right font-mono text-slate-600">{group.recordCount} 笔</td><td className="px-3 py-2.5 text-right font-mono font-semibold text-sky-600">{number(group.kg, 3)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.cost)}</td><td className="px-3 py-2.5 text-right font-mono">{number(group.revenue)}</td></tr>;
|
|
}
|
|
|
|
function OrderRows({ orders, sortKey, sortDirection }: { orders: HydrogenOverviewDetailRecord[]; sortKey: TreeSortKey; sortDirection: SortDirection }) {
|
|
return <>{sortTreeOrders(orders, sortKey, sortDirection).map(order => <tr key={order.id} className="border-b border-slate-100 bg-slate-50 text-[11px]"><td className="px-3 py-2" style={{ paddingLeft: '76px' }}><span className="mr-1.5 text-slate-300">└──</span><span className="mr-1 text-[10px] text-slate-500">订单编号</span><span className="font-mono font-semibold text-sky-600">{order.orderNo || order.id}</span><span className="ml-1 text-[10px] text-slate-500">({order.refuelTime})</span></td><td className="px-3 py-2 text-[10px] text-slate-500">单价 ¥{order.costPrice.toFixed(2)}/Kg</td><td className="px-3 py-2">{order.source}</td><td className="px-3 py-2"><VerifyTag value={verifyText(order.verifyStatus)} /></td><td className="px-3 py-2 text-right font-mono text-slate-400">1 笔</td><td className="px-3 py-2 text-right font-mono text-sky-600">{number(order.kg, 3)}</td><td className="px-3 py-2 text-right font-mono">{number(order.cost)}</td><td className="px-3 py-2 text-right font-mono">{number(order.revenue)}</td></tr>)}</>;
|
|
}
|
|
|
|
function sortTreeGroups(rows: HydrogenOverviewDetailGroup[], sortKey: TreeSortKey, sortDirection: SortDirection) {
|
|
return sortBy(rows, sortKey, sortDirection, (row, key) => {
|
|
if (key === 'ownership') return row.vehicleScope ?? '';
|
|
if (key === 'verify') return row.verifyStatus ?? '';
|
|
return row[key === 'name' ? 'name' : key] ?? '';
|
|
});
|
|
}
|
|
|
|
function sortTreeOrders(rows: HydrogenOverviewDetailRecord[], sortKey: TreeSortKey, sortDirection: SortDirection) {
|
|
return sortBy(rows, sortKey, sortDirection, (row, key) => {
|
|
if (key === 'name') return row.orderNo || row.id;
|
|
if (key === 'ownership') return row.vehicleScope;
|
|
if (key === 'verify') return row.verifyStatus;
|
|
if (key === 'recordCount') return 1;
|
|
return row[key] ?? '';
|
|
});
|
|
}
|
|
|
|
function LoadingRow({ text }: { text: string }) { return <tr><td className="px-8 py-3 text-xs text-slate-400" colSpan={8}>{text}</td></tr>; }
|
|
|
|
function verifyText(value?: string) {
|
|
const normalized = (value ?? '').toUpperCase();
|
|
if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核对';
|
|
if (normalized === 'PARTIAL') return '部分核对';
|
|
if (normalized === 'FAILED' || normalized === 'REJECT') return '异常';
|
|
return '未核对';
|
|
}
|
|
|
|
function VerifyTag({ value }: { value: string }) {
|
|
const color = value === '已核对' ? 'border-emerald-100 bg-emerald-50 text-emerald-700' : value === '部分核对' ? 'border-amber-100 bg-amber-50 text-amber-700' : value === '异常' ? 'border-rose-100 bg-rose-50 text-rose-700' : 'border-slate-200 bg-slate-50 text-slate-500';
|
|
return <span className={`inline-flex rounded border px-1.5 py-0.5 text-[10px] font-medium ${color}`}>{value}</span>;
|
|
}
|