376 lines
18 KiB
TypeScript
376 lines
18 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { ChevronRight, Download, RefreshCw } from 'lucide-react';
|
|
import { AnimatePresence, motion } from 'motion/react';
|
|
import * as XLSX from 'xlsx';
|
|
import TrendBadge from '../../TrendBadge';
|
|
import type { HydrogenDailyDetailCustomer, HydrogenDailyDetailStation, HydrogenDailyRow } from '../../types';
|
|
import type { DailyDetailState } from '../../HydrogenDaily';
|
|
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
|
|
|
|
type DailySortKey = 'date' | 'price' | 'kg' | 'fee' | 'chainPct';
|
|
|
|
interface DailyDetailTableProps {
|
|
rows: HydrogenDailyRow[];
|
|
totalKg: number;
|
|
totalFee: number;
|
|
expanded: Set<string>;
|
|
highlightedDate?: string | null;
|
|
details: Record<string, DailyDetailState>;
|
|
onToggle: (date: string) => void;
|
|
onRetryDetail: (date: string) => void;
|
|
}
|
|
|
|
export function DailyDetailTable({
|
|
rows,
|
|
totalKg,
|
|
totalFee,
|
|
expanded,
|
|
highlightedDate,
|
|
details,
|
|
onToggle,
|
|
onRetryDetail,
|
|
}: DailyDetailTableProps) {
|
|
const [sortKey, setSortKey] = useState<DailySortKey>('date');
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
|
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => {
|
|
if (key === 'price') return row.totalKg > 0 ? row.totalFee / row.totalKg : 0;
|
|
return row[key === 'kg' ? 'totalKg' : key === 'fee' ? 'totalFee' : key];
|
|
}), [rows, sortDirection, sortKey]);
|
|
const changeSort = (nextKey: DailySortKey) => {
|
|
const next = toggleSort(sortKey, sortDirection, nextKey);
|
|
setSortKey(next.key);
|
|
setSortDirection(next.direction);
|
|
};
|
|
return (
|
|
<section className="overflow-hidden rounded-[14px] border border-slate-200/70 bg-white shadow-sm">
|
|
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
|
<h2 className="text-[14px] font-bold text-slate-800">
|
|
每日加氢数据明细
|
|
<span className="ml-1 text-[11px] font-normal text-slate-400">(日期 → 加氢站 → 客户 → 车辆/来源)</span>
|
|
</h2>
|
|
<div className="flex shrink-0 items-center gap-2">
|
|
<span className="hidden text-[11px] font-medium text-slate-400 sm:inline">{rows.length} 天</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => exportDailyRows(rows, details)}
|
|
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-slate-200 bg-white px-2.5 text-[11px] font-semibold text-slate-600 hover:bg-slate-50"
|
|
>
|
|
<Download size={13} />导出 Excel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<div className="min-w-[780px]">
|
|
<div className="grid grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] gap-3 bg-slate-50 px-3 py-2 text-[11px] font-medium text-slate-500">
|
|
<span><SortableColumnHeader label="日期 / 加氢站" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></span>
|
|
<span className="text-right"><SortableColumnHeader label="单价 (元/Kg)" sortKey="price" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></span>
|
|
<span className="text-right"><SortableColumnHeader label="加氢量 (Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></span>
|
|
<span className="text-right"><SortableColumnHeader label="成本 / 环比" sortKey="fee" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></span>
|
|
<span className="text-right">站点余额</span>
|
|
</div>
|
|
<div className="grid grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] gap-3 bg-blue-50/60 px-3 py-2 text-[12px] font-semibold text-blue-700">
|
|
<span>合计</span>
|
|
<span />
|
|
<span className="text-right font-mono tabular-nums">{formatNumber(totalKg, 2)}</span>
|
|
<span className="text-right font-mono tabular-nums">¥{formatNumber(totalFee, 0)}</span>
|
|
<span className="text-right text-[10px] font-medium text-slate-400">以源表为准</span>
|
|
</div>
|
|
{sortedRows.map(row => {
|
|
const open = expanded.has(row.date);
|
|
const highlighted = highlightedDate === row.date;
|
|
const abnormal = Math.abs(row.chainPct) >= 0.3;
|
|
const background = highlighted
|
|
? 'bg-sky-50 ring-1 ring-inset ring-sky-200'
|
|
: abnormal
|
|
? row.chainPct > 0 ? 'bg-emerald-50/35' : 'bg-red-50/35'
|
|
: '';
|
|
return (
|
|
<div key={row.date} id={`hydrogen-daily-row-${row.date}`} className={`border-t border-slate-100 ${background}`}>
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggle(row.date)}
|
|
className="grid w-full grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] gap-3 px-3 py-2.5 text-left transition-colors hover:bg-slate-50/60"
|
|
>
|
|
<span className="flex items-center gap-1.5 text-[12px] font-semibold text-slate-700">
|
|
<ChevronRight size={14} className={`text-sky-600 transition-transform ${open ? 'rotate-90' : ''}`} />
|
|
<span className="font-mono tabular-nums">{row.date}</span>
|
|
<span className="text-[10px] font-normal text-slate-400">({row.stations.length} 个加氢站)</span>
|
|
</span>
|
|
<span className="text-right text-[12px] text-slate-300">—</span>
|
|
<span className="text-right font-mono text-[12px] font-semibold tabular-nums text-slate-800">{formatNumber(row.totalKg, 2)}</span>
|
|
<span className="text-right"><TrendBadge value={row.chainPct} /></span>
|
|
<span className="text-right text-[10px] text-slate-400">展开查看</span>
|
|
</button>
|
|
<AnimatePresence initial={false}>
|
|
{open ? (
|
|
<StationRows
|
|
date={row.date}
|
|
fallbackStations={row.stations}
|
|
detail={details[row.date]}
|
|
onRetry={() => onRetryDetail(row.date)}
|
|
sortKey={sortKey}
|
|
sortDirection={sortDirection}
|
|
/>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
interface StationRowsProps {
|
|
date: string;
|
|
fallbackStations: HydrogenDailyRow['stations'];
|
|
detail?: DailyDetailState;
|
|
onRetry: () => void;
|
|
sortKey: DailySortKey;
|
|
sortDirection: SortDirection;
|
|
}
|
|
|
|
function StationRows({ date, fallbackStations, detail, onRetry, sortKey, sortDirection }: StationRowsProps) {
|
|
const [openStations, setOpenStations] = useState<Set<string>>(new Set());
|
|
const [openCustomers, setOpenCustomers] = useState<Set<string>>(new Set());
|
|
const stations = detail?.data?.stations;
|
|
const sortedStations = useMemo(() => sortBy(stations ?? [], sortKey, sortDirection, (station, key) => dailyDetailValue(station, key)), [sortDirection, sortKey, stations]);
|
|
|
|
const toggleStation = (station: HydrogenDailyDetailStation) => {
|
|
const key = `${date}:${station.id}`;
|
|
setOpenStations(previous => toggleSet(previous, key));
|
|
};
|
|
|
|
const toggleCustomer = (station: HydrogenDailyDetailStation, customer: HydrogenDailyDetailCustomer) => {
|
|
const key = `${date}:${station.id}:${customer.id}:${customer.name}`;
|
|
setOpenCustomers(previous => toggleSet(previous, key));
|
|
};
|
|
|
|
return (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="overflow-hidden bg-slate-50/60"
|
|
>
|
|
{detail?.loading ? <DetailStatus label="正在读取客户和车辆明细" /> : null}
|
|
{detail?.error ? (
|
|
<div className="flex items-center justify-between px-9 py-3 text-[11px] text-rose-600">
|
|
<span>明细读取失败,请重试。</span>
|
|
<button type="button" onClick={onRetry} className="inline-flex items-center gap-1 font-semibold"><RefreshCw size={12} />重试</button>
|
|
</div>
|
|
) : null}
|
|
{!detail ? <DetailStatus label={`正在准备 ${fallbackStations.length} 个站点明细`} /> : null}
|
|
{stations?.length === 0 ? (
|
|
<div className="px-9 py-3 text-[11px] text-slate-400">当日无站点明细</div>
|
|
) : sortedStations.map((station, index) => {
|
|
const stationKey = `${date}:${station.id}`;
|
|
const stationOpen = openStations.has(stationKey);
|
|
return (
|
|
<div key={`${station.id}-${station.name}-${index}`} className="border-t border-slate-100 first:border-t-0">
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleStation(station)}
|
|
className="grid w-full grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] items-center gap-3 px-3 py-2 pl-9 text-left hover:bg-slate-100/80"
|
|
>
|
|
<div className="flex min-w-0 items-center gap-1.5">
|
|
<ChevronRight size={13} className={`shrink-0 text-sky-600 transition-transform ${stationOpen ? 'rotate-90' : ''}`} />
|
|
<div className="min-w-0">
|
|
<div className="truncate text-[12px] font-medium text-slate-700" title={station.name}>{station.name}</div>
|
|
<div className="mt-0.5 text-[10px] text-slate-400">{station.customers.length} 个客户 · {formatStationType(station.stationType)}</div>
|
|
</div>
|
|
</div>
|
|
<span className="text-right font-mono text-[12px] font-semibold tabular-nums text-slate-600">{station.kg > 0 ? formatNumber(station.fee / station.kg, 2) : '—'}</span>
|
|
<span className="text-right font-mono text-[12px] font-semibold tabular-nums text-slate-800">{formatNumber(station.kg, 2)}</span>
|
|
<span className="text-right font-mono text-[11px] font-semibold tabular-nums text-emerald-700">¥{formatNumber(station.fee, 0)}</span>
|
|
<span
|
|
className={`text-right font-mono text-[10px] tabular-nums ${station.balance === null ? 'text-slate-400' : 'font-semibold text-slate-700'}`}
|
|
title={station.balanceEffectiveTime ? `余额记录时间:${station.balanceEffectiveTime}` : '数据源暂无该站点余额记录'}
|
|
>
|
|
{station.balance === null ? '暂无记录' : `¥${formatNumber(station.balance, 2)}`}
|
|
</span>
|
|
</button>
|
|
<AnimatePresence initial={false}>
|
|
{stationOpen ? (
|
|
<CustomerRows
|
|
date={date}
|
|
station={station}
|
|
openCustomers={openCustomers}
|
|
onToggle={customer => toggleCustomer(station, customer)}
|
|
sortKey={sortKey}
|
|
sortDirection={sortDirection}
|
|
/>
|
|
) : null}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function CustomerRows({
|
|
date,
|
|
station,
|
|
openCustomers,
|
|
onToggle,
|
|
sortKey,
|
|
sortDirection,
|
|
}: {
|
|
date: string;
|
|
station: HydrogenDailyDetailStation;
|
|
openCustomers: Set<string>;
|
|
onToggle: (customer: HydrogenDailyDetailCustomer) => void;
|
|
sortKey: DailySortKey;
|
|
sortDirection: SortDirection;
|
|
}) {
|
|
const sortedCustomers = useMemo(() => sortBy(station.customers, sortKey, sortDirection, (customer, key) => dailyDetailValue(customer, key)), [sortDirection, sortKey, station.customers]);
|
|
return (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="overflow-hidden bg-white"
|
|
>
|
|
{sortedCustomers.map(customer => {
|
|
const customerKey = `${date}:${station.id}:${customer.id}:${customer.name}`;
|
|
const customerOpen = openCustomers.has(customerKey);
|
|
return (
|
|
<div key={customerKey} className="border-t border-slate-100">
|
|
<button
|
|
type="button"
|
|
onClick={() => onToggle(customer)}
|
|
className="grid w-full grid-cols-[minmax(250px,1fr)_110px_120px_110px_100px] items-center gap-3 px-3 py-2 pl-14 text-left hover:bg-sky-50/50"
|
|
>
|
|
<span className="flex min-w-0 items-center gap-1.5">
|
|
<ChevronRight size={12} className={`shrink-0 text-slate-400 transition-transform ${customerOpen ? 'rotate-90' : ''}`} />
|
|
<span className="truncate text-[11px] font-medium text-slate-700" title={customer.name}>{customer.name}</span>
|
|
<span className="shrink-0 text-[10px] text-slate-400">{customer.vehicles.length} 条</span>
|
|
</span>
|
|
<span className="text-right text-[11px] text-slate-400">客户</span>
|
|
<span className="text-right font-mono text-[11px] tabular-nums text-slate-700">{formatNumber(customer.kg, 2)}</span>
|
|
<span className="text-right font-mono text-[11px] tabular-nums text-emerald-700">¥{formatNumber(customer.fee, 0)}</span>
|
|
<span className="text-right text-[11px] text-slate-300">—</span>
|
|
</button>
|
|
<AnimatePresence initial={false}>
|
|
{customerOpen ? <VehicleRows customer={customer} sortKey={sortKey} sortDirection={sortDirection} /> : null}
|
|
</AnimatePresence>
|
|
</div>
|
|
);
|
|
})}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function VehicleRows({ customer, sortKey, sortDirection }: { customer: HydrogenDailyDetailCustomer; sortKey: DailySortKey; sortDirection: SortDirection }) {
|
|
const sortedVehicles = useMemo(() => sortBy(customer.vehicles, sortKey, sortDirection, (vehicle, key) => dailyDetailValue(vehicle, key)), [customer.vehicles, sortDirection, sortKey]);
|
|
return (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.15 }}
|
|
className="overflow-hidden bg-slate-50/80 px-3 py-1.5 pl-[74px]"
|
|
>
|
|
{sortedVehicles.map(vehicle => (
|
|
<div key={vehicle.id} className="grid grid-cols-[minmax(176px,1fr)_110px_120px_110px_100px] items-center gap-3 border-t border-slate-100 py-1.5 first:border-t-0">
|
|
<div className="flex min-w-0 items-center gap-2 text-[10px]">
|
|
<span className="font-mono tabular-nums text-slate-400">{vehicle.time}</span>
|
|
<span className="truncate font-semibold text-slate-700">{vehicle.plateNo}</span>
|
|
<span className={`rounded px-1 py-0.5 font-medium ${vehicle.vehicleScope === 'lingniu' ? 'bg-blue-50 text-blue-600' : 'bg-amber-50 text-amber-700'}`}>
|
|
{vehicle.vehicleScope === 'lingniu' ? '羚牛' : '外部'}
|
|
</span>
|
|
</div>
|
|
<div className="flex min-w-0 justify-end gap-1 text-[9px]">
|
|
<span className="max-w-[72px] truncate rounded bg-white px-1 py-0.5 text-slate-500" title={vehicle.source}>{vehicle.source}</span>
|
|
<span className="rounded bg-white px-1 py-0.5 text-slate-500">{formatVerifyStatus(vehicle.verifyStatus)}</span>
|
|
</div>
|
|
<span className="text-right font-mono text-[10px] font-semibold tabular-nums text-slate-700">{formatNumber(vehicle.kg, 3)}</span>
|
|
<span className="text-right font-mono text-[10px] tabular-nums text-emerald-700">¥{formatNumber(vehicle.fee, 2)}</span>
|
|
<span className="text-right text-[10px] text-slate-300">—</span>
|
|
</div>
|
|
))}
|
|
</motion.div>
|
|
);
|
|
}
|
|
|
|
function dailyDetailValue(
|
|
row: HydrogenDailyDetailStation | HydrogenDailyDetailCustomer | HydrogenDailyDetailCustomer['vehicles'][number],
|
|
key: DailySortKey,
|
|
) {
|
|
if (key === 'date') return 'time' in row ? `${row.time} ${row.plateNo}` : 'name' in row ? row.name : '';
|
|
if (key === 'price') return row.kg > 0 ? row.fee / row.kg : 0;
|
|
if (key === 'chainPct') return row.kg;
|
|
return row[key];
|
|
}
|
|
|
|
function DetailStatus({ label }: { label: string }) {
|
|
return <div className="px-9 py-3 text-[11px] text-slate-400">{label}</div>;
|
|
}
|
|
|
|
function toggleSet(previous: Set<string>, key: string) {
|
|
const next = new Set(previous);
|
|
next.has(key) ? next.delete(key) : next.add(key);
|
|
return next;
|
|
}
|
|
|
|
function formatVerifyStatus(value: string) {
|
|
const normalized = value.toUpperCase();
|
|
if (normalized === 'VERIFIED' || normalized === 'PASS') return '已核验';
|
|
if (normalized === 'FAILED' || normalized === 'REJECT') return '异常';
|
|
return '待核验';
|
|
}
|
|
|
|
function formatStationType(value: string) {
|
|
const normalized = value.toLowerCase();
|
|
if (normalized === 'self' || normalized === 'internal') return '自营站';
|
|
if (normalized === 'external' || normalized === 'partner') return '合作站';
|
|
return '加氢站';
|
|
}
|
|
|
|
function exportDailyRows(rows: HydrogenDailyRow[], details: Record<string, DailyDetailState>) {
|
|
const workbook = XLSX.utils.book_new();
|
|
const summaryRows = rows.flatMap(row => row.stations.length > 0
|
|
? row.stations.map(station => ({
|
|
日期: row.date,
|
|
加氢站: station.name,
|
|
单价元每Kg: station.pricePerKg,
|
|
加氢量Kg: station.kg,
|
|
成本元: station.fee,
|
|
日环比: row.chainPct,
|
|
}))
|
|
: [{ 日期: row.date, 加氢站: '', 单价元每Kg: 0, 加氢量Kg: row.totalKg, 成本元: row.totalFee, 日环比: row.chainPct }]);
|
|
XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(summaryRows), '日报汇总');
|
|
|
|
const recordRows = Object.values(details).flatMap(state => {
|
|
if (!state.data) return [];
|
|
return state.data.stations.flatMap(station => station.customers.flatMap(customer => (
|
|
customer.vehicles.map(vehicle => ({
|
|
日期: state.data?.date,
|
|
时间: vehicle.time,
|
|
加氢站: station.name,
|
|
客户: customer.name,
|
|
车牌: vehicle.plateNo,
|
|
车辆归属: vehicle.vehicleScope === 'lingniu' ? '羚牛' : '外部',
|
|
来源: vehicle.source,
|
|
核验状态: formatVerifyStatus(vehicle.verifyStatus),
|
|
单价元每Kg: vehicle.unitPrice,
|
|
加氢量Kg: vehicle.kg,
|
|
成本元: vehicle.fee,
|
|
}))
|
|
)));
|
|
});
|
|
if (recordRows.length > 0) XLSX.utils.book_append_sheet(workbook, XLSX.utils.json_to_sheet(recordRows), '已下钻流水');
|
|
const start = rows.at(-1)?.date ?? '开始';
|
|
const end = rows[0]?.date ?? '结束';
|
|
XLSX.writeFile(workbook, `氢能按日_${start}_${end}.xlsx`);
|
|
}
|
|
|
|
function formatNumber(value: number, digits: number): string {
|
|
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
|
}
|