Files
ln-bi/src/modules/energy/hydrogen-daily/components/DailyDetailTable.tsx
T
kkfluous 62efef0ab9
ci/woodpecker/push/woodpecker Pipeline was successful
feat(energy): rebuild hydrogen BI board and drill-through
2026-08-20 13:59:03 +08:00

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 });
}