545 lines
42 KiB
TypeScript
545 lines
42 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||
import {
|
||
ArrowLeft,
|
||
Calendar,
|
||
Check,
|
||
ChevronDown,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Download,
|
||
Fuel,
|
||
RefreshCw,
|
||
Wallet,
|
||
X,
|
||
} from 'lucide-react';
|
||
import * as XLSX from 'xlsx';
|
||
import { fetchHydrogenStationBoard } from './api';
|
||
import type {
|
||
HydrogenStationBoardCustomerMonth,
|
||
HydrogenStationBoardResponse,
|
||
HydrogenStationBoardStation,
|
||
HydrogenStationBoardSummaryDailyRow,
|
||
} from './types';
|
||
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from './components/SortableColumnHeader';
|
||
import './styles/energy-bi-board.css';
|
||
import './hydrogen-bi-v2/prototype-station-daily.css';
|
||
|
||
function formatYmd(date: Date): string {
|
||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||
}
|
||
|
||
function defaultRange() {
|
||
const end = new Date();
|
||
end.setHours(0, 0, 0, 0);
|
||
const start = new Date(end);
|
||
start.setDate(start.getDate() - 9);
|
||
return { start: formatYmd(start), end: formatYmd(end) };
|
||
}
|
||
|
||
function formatNumber(value: number, digits = 2): string {
|
||
return value.toLocaleString('zh-CN', { minimumFractionDigits: digits, maximumFractionDigits: digits });
|
||
}
|
||
|
||
function regionLabel(station: HydrogenStationBoardStation): string {
|
||
const parts = [station.province, station.city].filter(part => part && part !== '未归属');
|
||
return [...new Set(parts)].join(' · ') || '区域待补充';
|
||
}
|
||
|
||
function resetPageScroll() {
|
||
document.documentElement.scrollTop = 0;
|
||
document.body.scrollTop = 0;
|
||
window.scrollTo(0, 0);
|
||
}
|
||
|
||
export default function HydrogenStationBoard({ embedded = false }: { embedded?: boolean }) {
|
||
const [dateRange, setDateRange] = useState(defaultRange);
|
||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||
const [data, setData] = useState<HydrogenStationBoardResponse | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const load = useCallback(async (force = false) => {
|
||
setLoading(true);
|
||
try {
|
||
const result = await fetchHydrogenStationBoard({
|
||
startDate: dateRange.start,
|
||
endDate: dateRange.end,
|
||
stationId: selectedStationId,
|
||
force,
|
||
});
|
||
setData(result);
|
||
setError(null);
|
||
} catch (reason) {
|
||
setError(reason instanceof Error ? reason.message : String(reason));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [dateRange.end, dateRange.start, selectedStationId]);
|
||
|
||
useEffect(() => { void load(false); }, [load]);
|
||
|
||
useEffect(() => {
|
||
// 站点列表较长,切换列表/详情时统一从页面顶部开始展示。
|
||
resetPageScroll();
|
||
const frame = window.requestAnimationFrame(resetPageScroll);
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [selectedStationId]);
|
||
|
||
const selectedStation = data?.stations.find(station => station.id === selectedStationId) ?? null;
|
||
|
||
if (error && !data) {
|
||
return (
|
||
<div className="rounded-xl border border-red-200 bg-red-50 p-5 text-sm text-red-700">
|
||
<div>单站经营数据暂时不可用。</div>
|
||
<button type="button" onClick={() => void load(true)} className="mt-3 rounded-lg bg-white px-3 py-2 text-xs font-bold shadow-sm">重新加载</button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className={embedded ? 'sd-embedded' : 'relative flex flex-col gap-3'}>
|
||
{!(selectedStation && data?.selected) ? <StationBoardHeader
|
||
embedded={embedded}
|
||
selectedStation={selectedStation}
|
||
range={dateRange}
|
||
latestLedgerTime={data?.summary.latestLedgerTime ?? null}
|
||
loading={loading}
|
||
onBack={() => {
|
||
setSelectedStationId(null);
|
||
window.requestAnimationFrame(resetPageScroll);
|
||
}}
|
||
onRangeChange={setDateRange}
|
||
onRefresh={() => void load(true)}
|
||
onExport={() => data && exportStationEvidence(data, selectedStation)}
|
||
/> : null}
|
||
|
||
{data ? selectedStation && data.selected ? (
|
||
<StationDetail
|
||
station={selectedStation}
|
||
data={data}
|
||
latestLedgerTime={data.summary.latestLedgerTime}
|
||
loading={loading}
|
||
onBack={() => setSelectedStationId(null)}
|
||
onRangeChange={setDateRange}
|
||
onRefresh={() => void load(true)}
|
||
onExport={() => exportStationEvidence(data, selectedStation)}
|
||
/>
|
||
) : (
|
||
<StationOverview
|
||
data={data}
|
||
onSelectStation={stationId => {
|
||
setSelectedStationId(stationId);
|
||
window.requestAnimationFrame(resetPageScroll);
|
||
}}
|
||
/>
|
||
) : (
|
||
<StationBoardSkeleton />
|
||
)}
|
||
|
||
{loading && data ? (
|
||
<div className="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-white/45 backdrop-blur-[1px]">
|
||
<div className="flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-4 py-3 text-xs font-bold text-slate-600 shadow-xl">
|
||
<RefreshCw size={15} className="animate-spin text-blue-600" /> 正在刷新单站数据
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StationBoardHeader({
|
||
embedded,
|
||
selectedStation,
|
||
range,
|
||
latestLedgerTime,
|
||
loading,
|
||
onBack,
|
||
onRangeChange,
|
||
onRefresh,
|
||
onExport,
|
||
}: {
|
||
embedded: boolean;
|
||
selectedStation: HydrogenStationBoardStation | null;
|
||
range: { start: string; end: string };
|
||
latestLedgerTime: string | null;
|
||
loading: boolean;
|
||
onBack: () => void;
|
||
onRangeChange: (range: { start: string; end: string }) => void;
|
||
onRefresh: () => void;
|
||
onExport: () => void;
|
||
}) {
|
||
if (embedded && !selectedStation) {
|
||
return <header className="sd-topbar sd-topbar--embedded"><p className="sd-topbar__updated">最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p><div className="sd-topbar__tools"><StationRangePicker range={range} onChange={onRangeChange} /><button type="button" onClick={onRefresh} disabled={loading} className="sd-btn sd-btn--ghost"><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新</button></div></header>;
|
||
}
|
||
return (
|
||
<header className="rounded-[12px] border border-slate-200 bg-white px-4 py-3 shadow-sm">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
{selectedStation ? (
|
||
<button type="button" onClick={onBack} className="inline-flex h-9 items-center gap-1 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50">
|
||
<ArrowLeft size={15} /> 返回
|
||
</button>
|
||
) : null}
|
||
<div className="min-w-0 flex-1">
|
||
{selectedStation ? <h1 className="truncate text-[18px] font-black text-slate-950">{selectedStation.name}</h1> : null}
|
||
{selectedStation ? <p className="mt-0.5 text-[11px] font-medium text-slate-500">{regionLabel(selectedStation)} · {range.start} 至 {range.end}</p> : null}
|
||
<p className={`${selectedStation ? 'mt-1' : ''} text-[11px] font-medium text-slate-400`}>最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p>
|
||
</div>
|
||
<label className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-slate-50 px-3 text-[11px] font-bold text-slate-600">
|
||
<Calendar size={14} className="text-blue-600" />
|
||
<span className="hidden sm:inline">查询日期</span>
|
||
<input aria-label="开始日期" type="date" value={range.start} onChange={event => onRangeChange({ ...range, start: event.target.value })} className="w-[110px] bg-transparent outline-none" />
|
||
<span>至</span>
|
||
<input aria-label="结束日期" type="date" value={range.end} onChange={event => onRangeChange({ ...range, end: event.target.value })} className="w-[110px] bg-transparent outline-none" />
|
||
</label>
|
||
<button type="button" onClick={onRefresh} disabled={loading} className="inline-flex h-9 items-center gap-1.5 rounded-lg border border-slate-200 px-3 text-xs font-bold text-slate-600 hover:bg-slate-50 disabled:opacity-60">
|
||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新
|
||
</button>
|
||
{selectedStation ? (
|
||
<button type="button" onClick={onExport} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-blue-600 px-3 text-xs font-bold text-white shadow-sm hover:bg-blue-700">
|
||
<Download size={14} /> 导出取证
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function StationRangePicker({ range, onChange }: { range: { start: string; end: string }; onChange: (range: { start: string; end: string }) => void }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [activeBoundary, setActiveBoundary] = useState<'start' | 'end'>('start');
|
||
const [draft, setDraft] = useState(range);
|
||
const initial = new Date(`${range.start}T00:00:00`);
|
||
const [displayMonth, setDisplayMonth] = useState({ year: initial.getFullYear(), month: initial.getMonth() + 1 });
|
||
const rootRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
const closeWhenOutside = (event: MouseEvent) => {
|
||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) setOpen(false);
|
||
};
|
||
document.addEventListener('mousedown', closeWhenOutside);
|
||
return () => document.removeEventListener('mousedown', closeWhenOutside);
|
||
}, []);
|
||
const anchor = new Date(`${range.end}T00:00:00`);
|
||
const apply = (next: { start: string; end: string }, close = false) => {
|
||
const normalized = next.start <= next.end ? next : { start: next.end, end: next.start };
|
||
setDraft(normalized);
|
||
if (close) { onChange(normalized); setOpen(false); }
|
||
};
|
||
const today = formatYmd(anchor);
|
||
const startOfWeek = new Date(anchor);
|
||
startOfWeek.setDate(anchor.getDate() - ((anchor.getDay() + 6) % 7));
|
||
const shortcuts = {
|
||
today: { start: today, end: today },
|
||
week: { start: formatYmd(startOfWeek), end: formatYmd(new Date(startOfWeek.getFullYear(), startOfWeek.getMonth(), startOfWeek.getDate() + 6)) },
|
||
month: { start: `${anchor.getFullYear()}-${String(anchor.getMonth() + 1).padStart(2, '0')}-01`, end: formatYmd(new Date(anchor.getFullYear(), anchor.getMonth() + 1, 0)) },
|
||
};
|
||
const daysInMonth = new Date(displayMonth.year, displayMonth.month, 0).getDate();
|
||
const leadingDays = new Date(displayMonth.year, displayMonth.month - 1, 1).getDay();
|
||
const selectDay = (day: number) => {
|
||
const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||
if (activeBoundary === 'start') {
|
||
const next = { start: value, end: value > draft.end ? value : draft.end };
|
||
setDraft(next); setActiveBoundary('end'); return;
|
||
}
|
||
const next = value < draft.start ? { start: value, end: draft.start } : { start: draft.start, end: value };
|
||
setDraft(next); setActiveBoundary('start');
|
||
};
|
||
return <div className="sd-date sd-date--right" ref={rootRef}>
|
||
<button type="button" className={`sd-date__trigger ${open ? 'is-open' : ''}`} aria-label="查询日期" aria-haspopup="dialog" aria-expanded={open} onClick={() => {
|
||
if (!open) { setDraft(range); setActiveBoundary('start'); setDisplayMonth({ year: initial.getFullYear(), month: initial.getMonth() + 1 }); }
|
||
setOpen(value => !value);
|
||
}}><span className="sd-date__label">查询日期</span><span className="sd-date__value">{range.start} 至 {range.end}</span><ChevronDown size={15} aria-hidden className="sd-date__icon" /></button>
|
||
{open ? <div className="sd-date__popover sd-date__popover--range" role="dialog" aria-label="查询日期">
|
||
<div className="sd-date__shortcuts"><button type="button" onClick={() => apply(shortcuts.today, true)}>本日</button><button type="button" onClick={() => apply(shortcuts.week, true)}>本周</button><button type="button" onClick={() => apply(shortcuts.month, true)}>本月</button></div>
|
||
<div className="sd-date__range-tabs"><button type="button" className={activeBoundary === 'start' ? 'is-on' : ''} onClick={() => setActiveBoundary('start')}>开始 {draft.start}</button><button type="button" className={activeBoundary === 'end' ? 'is-on' : ''} onClick={() => setActiveBoundary('end')}>结束 {draft.end}</button></div>
|
||
<div className="sd-date__header"><button type="button" className="sd-date__nav" aria-label="上一月" onClick={() => setDisplayMonth(value => value.month === 1 ? { year: value.year - 1, month: 12 } : { ...value, month: value.month - 1 })}><ChevronLeft size={16} /></button><div className="sd-date__title">{displayMonth.year}年{String(displayMonth.month).padStart(2, '0')}月</div><button type="button" className="sd-date__nav" aria-label="下一月" onClick={() => setDisplayMonth(value => value.month === 12 ? { year: value.year + 1, month: 1 } : { ...value, month: value.month + 1 })}><ChevronRight size={16} /></button></div>
|
||
<div className="sd-date__week">{['日','一','二','三','四','五','六'].map(day => <span key={day}>{day}</span>)}</div><div className="sd-date__grid">{Array.from({ length: leadingDays }, (_, index) => <span key={`empty-${index}`} className="sd-date__day is-empty" />)}{Array.from({ length: daysInMonth }, (_, index) => { const day = index + 1; const value = `${displayMonth.year}-${String(displayMonth.month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const classes = ['sd-date__day', value === draft.start || value === draft.end ? 'is-selected' : '', value >= draft.start && value <= draft.end ? 'is-in-range' : ''].filter(Boolean).join(' '); return <button key={day} type="button" className={classes} onClick={() => selectDay(day)}>{day}</button>; })}</div>
|
||
<div className="sd-date__footer sd-date__footer--range"><button type="button" className="sd-date__today" onClick={() => apply(shortcuts.today, true)}>本日</button><button type="button" className="sd-btn sd-btn--primary sd-date__apply" onClick={() => apply(draft, true)}>确定</button></div>
|
||
</div> : null}
|
||
</div>;
|
||
}
|
||
|
||
function StationOverview({ data, onSelectStation }: { data: HydrogenStationBoardResponse; onSelectStation: (id: number) => void }) {
|
||
const [displayMode, setDisplayMode] = useState<'all' | 'single'>('all');
|
||
const [displayStationId, setDisplayStationId] = useState(data.stations[0]?.id ?? 0);
|
||
const [dailyDrill, setDailyDrill] = useState<'kg' | 'fee' | 'payment' | null>(null);
|
||
const [partnersExpanded, setPartnersExpanded] = useState(false);
|
||
const { selfManagedStations, partnerStations } = useMemo(() => {
|
||
const selfManaged = data.stations
|
||
.filter(isSelfManagedStation)
|
||
.sort((left, right) => selfManagedStationRank(left) - selfManagedStationRank(right));
|
||
const selfManagedIds = new Set(selfManaged.map(station => station.id));
|
||
return {
|
||
selfManagedStations: selfManaged,
|
||
partnerStations: data.stations
|
||
.filter(station => station.kg > 0 && !selfManagedIds.has(station.id))
|
||
.sort((left, right) => right.kg - left.kg || left.name.localeCompare(right.name, 'zh-CN')),
|
||
};
|
||
}, [data.stations]);
|
||
const visiblePartnerStations = partnersExpanded ? partnerStations : partnerStations.slice(0, 10);
|
||
const selectedStation = data.stations.filter(station => station.id === displayStationId);
|
||
// Top5 的条形、图例与钻取入口必须共用同一份按加氢量降序的结果。
|
||
const shareStations = data.stations
|
||
.filter(station => station.kg > 0)
|
||
.sort((left, right) => right.kg - left.kg)
|
||
.slice(0, 5);
|
||
const shareTrackTotal = shareStations.reduce((sum, station) => sum + station.kg, 0);
|
||
return (
|
||
<>
|
||
<section aria-label="全站核心指标" className="sd-hero-kpis">
|
||
<div className="sd-hero-kpi"><span className="sd-hero-kpi__label">加氢站</span><strong className="sd-hero-kpi__value">{data.summary.stationCount}<span>站</span></strong><span className="sd-hero-kpi__sub">统计站点数 · 活跃 {data.summary.activeStationCount} 站</span></div>
|
||
<StationSummaryMetric label="统计加氢总量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`${data.summary.recordCount} 车次 · 点按查看日明细`} onClick={() => setDailyDrill('kg')} />
|
||
<StationSummaryMetric label="统计加氢量" value={formatNumber(data.summary.totalKg)} unit="Kg" helper={`¥${formatNumber(data.summary.totalFee)} · ${data.summary.daily.length} 天`} onClick={() => setDailyDrill('fee')} />
|
||
<StationSummaryMetric label="统计现结金额" value={formatNumber(data.summary.paymentAmount)} unit="元" helper={`${data.summary.daily.filter(item => item.paymentAmount > 0).length} 天有进账`} onClick={() => setDailyDrill('payment')} />
|
||
</section>
|
||
<section className="sd-share-panel" aria-label="加氢量占比Top5">
|
||
<div className="sd-share-panel__head"><h2 className="sd-share-panel__title">加氢量占比Top5 <span className="sd-share-panel__range">{data.range.start} 至 {data.range.end}</span></h2><span className="sd-share-panel__total">合计 <strong>{formatNumber(data.summary.totalKg)} Kg</strong></span></div>
|
||
<div className="sd-share-track">{shareStations.map((station, index) => <button key={station.id} type="button" className={`sd-share-seg sd-share-seg--${index}`} style={{ width: `${shareTrackTotal > 0 ? (station.kg / shareTrackTotal) * 100 : 0}%` }} onClick={() => onSelectStation(station.id)}><span className="sd-share-seg__pct">{(station.share * 100).toFixed(1)}%</span><span className="sd-share-seg__name">{station.name}</span></button>)}</div>
|
||
<ul className="sd-share-legend">{shareStations.map((station, index) => <li key={station.id}><button type="button" className="sd-share-legend__btn" onClick={() => onSelectStation(station.id)}><span className={`sd-share-legend__swatch sd-share-seg--${index}`} /><span className="sd-share-legend__rank" aria-label={`第 ${index + 1} 名`}>{index + 1}</span><span className="sd-share-legend__name">{station.name}</span><span className="sd-share-legend__val">{formatNumber(station.kg)} Kg · {(station.share * 100).toFixed(1)}%</span></button></li>)}</ul>
|
||
</section>
|
||
|
||
<section aria-label="各站经营概况">
|
||
<div className="sd-section-head"><h2 className="sd-section-title">各站概况</h2><div className="sd-board-mode" role="tablist" aria-label="各站展示方式"><button type="button" role="tab" aria-selected={displayMode === 'all'} onClick={() => setDisplayMode('all')} className={displayMode === 'all' ? 'is-on' : ''}>全部</button><button type="button" role="tab" aria-selected={displayMode === 'single'} onClick={() => setDisplayMode('single')} className={displayMode === 'single' ? 'is-on' : ''}>单站</button>{displayMode === 'single' ? <select aria-label="选择站点" value={displayStationId} onChange={event => setDisplayStationId(Number(event.target.value))} className="sd-board-station-select">{data.stations.map(station => <option key={station.id} value={station.id}>{station.name}</option>)}</select> : null}</div></div>
|
||
{displayMode === 'all' ? <div className="sd-station-groups">
|
||
<StationGroup title="自营站" count={selfManagedStations.length} stations={selfManagedStations} onSelectStation={onSelectStation} emptyText="自营站数据待接入" />
|
||
<StationGroup title="合作站" count={partnerStations.length} stations={visiblePartnerStations} onSelectStation={onSelectStation} emptyText="本区间暂无合作站加氢量" />
|
||
{partnerStations.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setPartnersExpanded(value => !value)}>{partnersExpanded ? '收起合作站' : `更多合作站(还有 ${partnerStations.length - 10} 站)`} <ChevronDown size={14} aria-hidden className={partnersExpanded ? 'rotate-180' : ''} /></button> : null}
|
||
</div> : <div className="sd-station-single-card"><StationRows stations={selectedStation} onSelectStation={onSelectStation} /></div>}
|
||
</section>
|
||
<StationSummaryDailyDialog
|
||
mode={dailyDrill}
|
||
range={data.range}
|
||
rows={data.summary.daily}
|
||
onClose={() => setDailyDrill(null)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function isSelfManagedStation(station: HydrogenStationBoardStation): boolean {
|
||
return station.name.includes('佛山南海羚牛') || station.name.includes('东鹏大道');
|
||
}
|
||
|
||
function selfManagedStationRank(station: HydrogenStationBoardStation): number {
|
||
return station.name.includes('佛山南海羚牛') ? 0 : 1;
|
||
}
|
||
|
||
function StationGroup({ title, count, stations, onSelectStation, emptyText }: {
|
||
title: string;
|
||
count: number;
|
||
stations: HydrogenStationBoardStation[];
|
||
onSelectStation: (id: number) => void;
|
||
emptyText: string;
|
||
}) {
|
||
return <section className="sd-station-group" aria-label={title}>
|
||
<div className="sd-station-group__head"><h3>{title}</h3><span>{count} 站</span></div>
|
||
{stations.length ? <div className="sd-station-single-card"><StationRows stations={stations} onSelectStation={onSelectStation} /></div> : <p className="sd-station-group__empty">{emptyText}</p>}
|
||
</section>;
|
||
}
|
||
|
||
function StationRows({ stations, onSelectStation }: { stations: HydrogenStationBoardStation[]; onSelectStation: (id: number) => void }) {
|
||
return <>{stations.map(station => <button key={station.id} type="button" onClick={() => onSelectStation(station.id)} className="sd-station-row"><div className="sd-station-row__main"><span className="sd-station-card__icon"><Fuel size={17} /></span><div><div className="sd-station-card__name">{station.name}</div><div className="sd-station-card__region">{regionLabel(station)}</div></div>{station.kg > 0 ? <MiniSpark values={station.dailyKg.map(item => item.kg)} /> : <div className="sd-spark sd-spark--empty" />}<div className="sd-station-row__metrics"><StationRowMetric label="区间加氢" value={formatNumber(station.kg)} unit="Kg" /><StationRowMetric label="区间金额" value={formatNumber(station.fee)} unit="元" /><StationRowMetric label="现结进账" value={formatNumber(station.paymentAmount)} unit="元" /><div><span className="sd-station-card__m-label">加氢量占比</span><span className="sd-station-card__share">{(station.share * 100).toFixed(1)}%</span></div></div><span className="sd-station-card__go"><ChevronRight size={17} /></span></div></button>)}</>;
|
||
}
|
||
|
||
function StationSummaryMetric({ label, value, unit, helper, onClick }: { label: string; value: string; unit?: string; helper: string; onClick: () => void }) {
|
||
return <button type="button" onClick={onClick} className="sd-hero-kpi sd-hero-kpi--click"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}{unit ? <span>{unit}</span> : null}</strong><span className="sd-hero-kpi__sub">{helper}</span></button>;
|
||
}
|
||
|
||
function StationSummaryDailyDialog({
|
||
mode,
|
||
range,
|
||
rows,
|
||
onClose,
|
||
}: {
|
||
mode: 'kg' | 'fee' | 'payment' | null;
|
||
range: { start: string; end: string };
|
||
rows: HydrogenStationBoardSummaryDailyRow[];
|
||
onClose: () => void;
|
||
}) {
|
||
const [sortKey, setSortKey] = useState<'date' | 'recordCount' | 'kg' | 'fee' | 'paymentCount' | 'paymentAmount'>('date');
|
||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||
const sortedRows = useMemo(() => sortBy(rows, sortKey, sortDirection, (row, key) => row[key]), [rows, sortDirection, sortKey]);
|
||
const changeSort = (nextKey: typeof sortKey) => {
|
||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||
setSortKey(next.key);
|
||
setSortDirection(next.direction);
|
||
};
|
||
if (!mode) return null;
|
||
const title = mode === 'kg'
|
||
? '统计加氢总量 · 日明细'
|
||
: mode === 'fee'
|
||
? '统计加氢金额 · 单日数据'
|
||
: '统计现结金额 · 单日数据';
|
||
return (
|
||
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/60 p-3 backdrop-blur-[4px]" role="presentation" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
|
||
<section className="flex max-h-[82vh] w-full max-w-[720px] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="station-summary-drill-title">
|
||
<header className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3">
|
||
<div>
|
||
<h3 id="station-summary-drill-title" className="text-[15px] font-black text-slate-900">{title}</h3>
|
||
<p className="mt-0.5 text-[11px] font-medium text-slate-400">{range.start} 至 {range.end}</p>
|
||
</div>
|
||
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center rounded-full bg-slate-100 text-slate-500 hover:bg-slate-200" aria-label="关闭"><X size={16} /></button>
|
||
</header>
|
||
<div className="overflow-auto">
|
||
{mode === 'payment' ? (
|
||
<table className="w-full min-w-[420px] text-[12px]">
|
||
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结笔数" sortKey="paymentCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="现结金额(元)" sortKey="paymentAmount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
|
||
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.paymentCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.paymentAmount)}</td></tr>)}</tbody>
|
||
</table>
|
||
) : (
|
||
<table className="w-full min-w-[620px] text-[12px]">
|
||
<thead className="sticky top-0 bg-slate-50 text-slate-500"><tr><th className="px-4 py-2.5 text-left"><SortableColumnHeader label="日期" sortKey="date" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢车次" sortKey="recordCount" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢量(Kg)" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th><th className="px-4 py-2.5 text-right"><SortableColumnHeader label="加氢金额(元)" sortKey="fee" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th></tr></thead>
|
||
<tbody>{sortedRows.map(row => <tr key={row.date} className="border-t border-slate-100"><td className="px-4 py-2.5 font-mono font-semibold text-slate-700">{row.date}</td><td className="px-4 py-2.5 text-right tabular-nums text-slate-600">{row.recordCount}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.kg)}</td><td className="px-4 py-2.5 text-right font-semibold tabular-nums text-slate-800">{formatNumber(row.fee)}</td></tr>)}</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StationRowMetric({ label, value, unit }: { label: string; value: string; unit: string }) {
|
||
return <div><span className="sd-station-card__m-label">{label}</span><strong>{value}<span>{unit}</span></strong></div>;
|
||
}
|
||
|
||
function MiniSpark({ values }: { values: number[] }) {
|
||
const max = Math.max(...values, 0);
|
||
return <div className="sd-spark" aria-label="站点每日加氢趋势">{values.map((value, index) => <span key={index} className="sd-spark__col"><span className="sd-spark__bar" style={{ height: `${max > 0 ? Math.max(6, Math.round((value / max) * 100)) : 0}%` }} /></span>)}</div>;
|
||
}
|
||
|
||
function StationDetail({
|
||
station,
|
||
data,
|
||
latestLedgerTime,
|
||
loading,
|
||
onBack,
|
||
onRangeChange,
|
||
onRefresh,
|
||
onExport,
|
||
}: {
|
||
station: HydrogenStationBoardStation;
|
||
data: HydrogenStationBoardResponse;
|
||
latestLedgerTime: string | null;
|
||
loading: boolean;
|
||
onBack: () => void;
|
||
onRangeChange: (range: { start: string; end: string }) => void;
|
||
onRefresh: () => void;
|
||
onExport: () => void;
|
||
}) {
|
||
const detail = data.selected!;
|
||
const [hoveredDate, setHoveredDate] = useState<string | null>(null);
|
||
const [dailySummaryExpanded, setDailySummaryExpanded] = useState(false);
|
||
const lastDaily = detail.daily.at(-1);
|
||
const visibleDailySummary = dailySummaryExpanded
|
||
? detail.daily
|
||
: detail.daily.slice(-30);
|
||
const monthKey = data.range.end.slice(0, 7);
|
||
const monthRows = detail.customerMonths.filter(item => item.month === monthKey);
|
||
const monthKg = monthRows.reduce((sum, row) => sum + row.kg, 0);
|
||
const intervalPayments = detail.daily.reduce((sum, row) => sum + row.paymentAmount, 0);
|
||
const trendMax = Math.max(...detail.daily.map(row => row.kg), 0);
|
||
const hovered = detail.daily.find(row => row.date === hoveredDate) ?? null;
|
||
|
||
return (
|
||
<>
|
||
<section className="sd-detail-top">
|
||
<div className="sd-detail-top__lead"><button type="button" className="sd-btn sd-btn--ghost" onClick={onBack}><ArrowLeft size={16} /> 返回</button><div><h1 className="sd-detail-top__title">{station.name}</h1><p className="sd-detail-top__meta">{regionLabel(station)} · {data.range.start} 至 {data.range.end}</p><p className="sd-detail-top__updated">最后更新时间 {latestLedgerTime ?? '暂无账本时间'}</p></div></div>
|
||
<div className="sd-detail-top__tools"><StationRangePicker range={data.range} onChange={onRangeChange} /><button type="button" className="sd-btn sd-btn--ghost" onClick={onRefresh} disabled={loading}><RefreshCw size={14} className={loading ? 'animate-spin' : ''} /> 刷新</button><button type="button" className="sd-btn sd-btn--primary" onClick={onExport}><Download size={14} /> 导出取证</button></div>
|
||
</section>
|
||
<section className="sd-hero-kpis sd-hero-kpis--detail">
|
||
<DetailMetric label="当日加氢" value={formatNumber(lastDaily?.kg ?? 0)} unit="Kg" helper={`¥${formatNumber(lastDaily?.fee ?? 0)} · ${lastDaily?.recordCount ?? 0}车次`} />
|
||
<DetailMetric label={`近 ${detail.daily.length} 日加氢`} value={formatNumber(station.kg)} unit="Kg" helper={`¥${formatNumber(station.fee)}`} />
|
||
<DetailMetric label="本月加氢" value={formatNumber(monthKg)} unit="Kg" helper={monthKey} />
|
||
<DetailMetric label={`近 ${detail.daily.length} 日现结`} value={formatNumber(intervalPayments)} unit="元" helper={`${detail.daily.filter(row => row.paymentAmount > 0).length}天有进账`} />
|
||
</section>
|
||
<section className="sd-panel sd-panel--block">
|
||
<h2 className="sd-panel__title">加氢站每日加氢量汇总({data.range.start} 至 {data.range.end})</h2>
|
||
<div className="sd-table-scroll"><table className="sd-bi-table sd-bi-table--fill"><thead><tr><th>日期 ↓</th><th className="is-num">加氢量(Kg) ↕</th><th className="is-num">较昨日 ↕</th><th className="is-num">单价 ↕</th><th className="is-num">成本金额(元) ↕</th><th className="is-num">车次 ↕</th></tr></thead><tbody>
|
||
<tr className="is-total"><td>查询区间合计</td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.kg, 0))}</td><td className="is-num">—</td><td className="is-num">—</td><td className="is-num">{formatNumber(detail.daily.reduce((sum, row) => sum + row.fee, 0))}</td><td className="is-num">{detail.daily.reduce((sum, row) => sum + row.recordCount, 0)}</td></tr>
|
||
{[...visibleDailySummary].reverse().map(row => <tr key={row.date}><td className="is-mono">{row.date}</td><td className="is-num">{formatNumber(row.kg)}</td><td className={`is-num ${row.changeKg > 0 ? 'is-stock-up' : row.changeKg < 0 ? 'is-stock-down' : 'is-stock-flat'}`}>{row.changeKg > 0 ? '+' : ''}{formatNumber(row.changeKg)}{row.changeKg !== 0 ? <span className="sd-delta">{row.changeKg > 0 ? '▲' : '▼'}</span> : null}</td><td className="is-num">{formatNumber(row.avgPrice)}</td><td className="is-num">{formatNumber(row.fee)}</td><td className="is-num">{row.recordCount}</td></tr>)}
|
||
{detail.daily.length > 30 ? <tr><td colSpan={6} className="sd-table-more"><button type="button" onClick={() => setDailySummaryExpanded(value => !value)}>{dailySummaryExpanded ? '收起日期明细' : `更多(还有 ${detail.daily.length - 30} 天)`}</button></td></tr> : null}
|
||
</tbody></table></div>
|
||
</section>
|
||
<section className="sd-panel sd-panel--block"><div className="sd-panel__head-row"><h2 className="sd-panel__title">区间加氢量趋势</h2><span className="sd-trend-legend-chip"><span className="sd-trend-legend-dot" />{station.name}</span></div><div className="sd-trend">{hovered ? <div className="sd-trend-tip"><div className="sd-trend-tip__date">{hovered.date}</div><div className="sd-trend-tip__row"><span className="sd-trend-tip__name">加氢量</span><strong>{formatNumber(hovered.kg)} Kg</strong></div><div className="sd-trend-tip__sub">{hovered.recordCount}车次 · ¥{formatNumber(hovered.fee)}</div></div> : null}<div className="sd-trend--fill">{detail.daily.map(row => <div key={row.date} className={`sd-trend__col ${hoveredDate === row.date ? 'is-hover' : ''}`} onMouseEnter={() => setHoveredDate(row.date)} onMouseLeave={() => setHoveredDate(null)}><span className="sd-trend__val">{formatNumber(row.kg, 0)}</span><div className="sd-trend__bar-wrap"><span className="sd-trend__bar" style={{ height: `${trendMax > 0 ? Math.max(3, Math.round((row.kg / trendMax) * 100)) : 0}%` }} /></div><span className="sd-trend__date">{row.date.slice(5)}</span></div>)}</div></div></section>
|
||
|
||
<CustomerMonthMatrix title="客户月加氢量汇总(Kg)" valueKey="kg" rows={detail.customerMonths} />
|
||
<CustomerMonthMatrix title="客户月加氢费汇总(元)" valueKey="fee" rows={detail.customerMonths} />
|
||
<StationCashPanels />
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DetailMetric({ label, value, unit, helper }: { label: string; value: string; unit: string; helper: string }) {
|
||
return <div className="sd-hero-kpi sd-hero-kpi--accent"><span className="sd-hero-kpi__label">{label}</span><strong className="sd-hero-kpi__value">{value}<span className="sd-unit">{unit}</span></strong><span className="sd-hero-kpi__sub">{helper}</span></div>;
|
||
}
|
||
|
||
function CustomerMonthMatrix({ title, valueKey, rows }: { title: string; valueKey: 'kg' | 'fee'; rows: HydrogenStationBoardCustomerMonth[] }) {
|
||
const months = useMemo(() => [...new Set(rows.map(row => row.month))].sort(), [rows]);
|
||
const customers = useMemo(() => [...new Set(rows.map(row => row.customerName))].sort((left, right) => left.localeCompare(right, 'zh-CN')), [rows]);
|
||
const [selectedCustomers, setSelectedCustomers] = useState<string[]>([]);
|
||
const [expanded, setExpanded] = useState(false);
|
||
const values = useMemo(() => new Map(rows.map(row => [`${row.customerName}\u0000${row.month}`, row[valueKey]])), [rows, valueKey]);
|
||
const totals = useMemo(() => new Map(months.map(month => [month, rows.filter(row => row.month === month).reduce((sum,row)=>sum+row[valueKey],0)])), [months, rows, valueKey]);
|
||
const visibleCustomers = selectedCustomers.length === 0 ? customers : customers.filter(customer => selectedCustomers.includes(customer));
|
||
const shownCustomers = expanded ? visibleCustomers : visibleCustomers.slice(0, 10);
|
||
const trendClass = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? 'is-stock-up' : 'is-stock-down';
|
||
const trendMark = (current: number, previous: number | undefined) => previous == null || current === previous ? '' : current > previous ? '▲' : '▼';
|
||
return (
|
||
<section className="sd-panel sd-panel--block">
|
||
<div className="sd-panel__head-row"><h2 className="sd-panel__title">{title}</h2><CustomerMultiSelect options={customers} value={selectedCustomers} onChange={setSelectedCustomers} /></div>
|
||
<div className="sd-table-scroll sd-table-scroll--matrix"><table className={`sd-bi-table sd-bi-table--matrix ${valueKey === 'fee' ? 'is-amount' : ''}`}><thead><tr><th>客户</th>{months.map(month => <th key={month} className="is-num">{month.replace('-', '年')}月</th>)}</tr></thead><tbody>
|
||
<tr className="is-total"><td>合计</td>{months.map((month, index) => { const current = totals.get(month) ?? 0; const previous = index > 0 ? totals.get(months[index - 1]) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>
|
||
{shownCustomers.map(customer => <tr key={customer}><td title={customer}>{customer}</td>{months.map((month, index) => { const current = values.get(`${customer}\u0000${month}`) ?? 0; const previous = index > 0 ? values.get(`${customer}\u0000${months[index - 1]}`) : undefined; return <td key={month} className={`is-num ${trendClass(current, previous)}`}>{formatNumber(current)}{trendMark(current, previous)}</td>; })}</tr>)}
|
||
</tbody></table></div>{visibleCustomers.length > 10 ? <button type="button" className="sd-more-btn" onClick={() => setExpanded(value => !value)}>{expanded ? '收起' : `更多(还有 ${visibleCustomers.length - 10} 条)`} <ChevronDown size={14} aria-hidden className={expanded ? 'rotate-180' : ''} /></button> : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function StationCashPanels() {
|
||
return <section className="sd-dual sd-dual--cash">
|
||
<section className="sd-panel sd-panel--grow">
|
||
<div className="sd-panel__head-row"><h2 className="sd-panel__title">客户氢费收支汇总</h2></div>
|
||
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th>客户</th><th className="is-num">充值/现金结算</th><th className="is-num">扣预付</th><th className="is-num">现结</th><th className="is-num">余额</th><th>备注</th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={6}>数据写入中,敬请期待。</td></tr></tbody></table></div>
|
||
</section>
|
||
<section className="sd-panel sd-panel--grow">
|
||
<div className="sd-panel__head-row"><h2 className="sd-panel__title">氢费充值/现结进账明细</h2><span className="sd-panel__meta">数据写入中</span></div>
|
||
<div className="sd-table-scroll sd-table-scroll--cash-lines"><table className="sd-bi-table sd-bi-table--ledger"><thead><tr><th>充值日期</th><th>客户</th><th>付款方式</th><th className="is-num">金额(元)</th></tr></thead><tbody><tr className="sd-pending-row"><td colSpan={4}>数据写入中,敬请期待。</td></tr></tbody></table></div>
|
||
</section>
|
||
</section>;
|
||
}
|
||
|
||
function CustomerMultiSelect({ options, value, onChange }: { options: string[]; value: string[]; onChange: (value: string[]) => void }) {
|
||
const [open, setOpen] = useState(false);
|
||
const [search, setSearch] = useState('');
|
||
const rootRef = useRef<HTMLDivElement>(null);
|
||
const allSelected = value.length === 0 || value.length === options.length;
|
||
const selectedLabel = allSelected ? '全部客户' : value.length <= 2 ? value.join('、') : `已选 ${value.length} 家`;
|
||
const visibleOptions = search.trim() ? options.filter(option => option.includes(search.trim())) : options;
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const close = (event: MouseEvent) => { if (!rootRef.current?.contains(event.target as Node)) setOpen(false); };
|
||
document.addEventListener('mousedown', close);
|
||
return () => document.removeEventListener('mousedown', close);
|
||
}, [open]);
|
||
const toggle = (customer: string) => {
|
||
if (allSelected) { onChange([customer]); return; }
|
||
if (value.includes(customer)) { const next = value.filter(item => item !== customer); onChange(next.length === 0 ? [] : next); return; }
|
||
const next = [...value, customer];
|
||
onChange(next.length === options.length ? [] : next);
|
||
};
|
||
return <div className={`sd-msel ${open ? 'is-open' : ''}`} ref={rootRef}><button type="button" className="sd-msel__trigger" aria-haspopup="listbox" aria-expanded={open} onClick={() => setOpen(current => !current)}><span className="sd-msel__label">客户</span><span className="sd-msel__value" title={selectedLabel}>{selectedLabel}</span><ChevronDown size={14} aria-hidden className="sd-msel__chev" /></button>{open ? <div className="sd-msel__panel" role="listbox" aria-multiselectable="true"><div className="sd-msel__search"><input type="search" value={search} onChange={event => setSearch(event.target.value)} placeholder="搜索客户" aria-label="搜索客户" /></div><div className="sd-msel__actions"><button type="button" onClick={() => onChange([])}>全选</button><button type="button" onClick={() => { onChange([]); setOpen(false); }}>重置</button></div><ul className="sd-msel__list">{visibleOptions.length === 0 ? <li className="sd-msel__empty">无匹配客户</li> : visibleOptions.map(option => { const on = allSelected || value.includes(option); return <li key={option}><button type="button" className={`sd-msel__opt ${on ? 'is-on' : ''}`} role="option" aria-selected={on} onClick={() => toggle(option)}><span className="sd-msel__check" aria-hidden>{on ? <Check size={12} /> : null}</span><span className="sd-msel__name">{option}</span></button></li>; })}</ul></div> : null}</div>;
|
||
}
|
||
|
||
function exportStationEvidence(data: HydrogenStationBoardResponse, station: HydrogenStationBoardStation | null) {
|
||
if (!station || !data.selected) return;
|
||
const workbook = XLSX.utils.book_new();
|
||
const daily = [['日期','加氢量(Kg)','较昨日(Kg)','平均单价','加氢金额(元)','车次','现结金额(元)','现结笔数'], ...data.selected.daily.map(row => [row.date,row.kg,row.changeKg,row.avgPrice,row.fee,row.recordCount,row.paymentAmount,row.paymentCount])];
|
||
const customers = [['月份','客户','加氢量(Kg)','加氢费(元)','车次'], ...data.selected.customerMonths.map(row => [row.month,row.customerName,row.kg,row.fee,row.recordCount])];
|
||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(daily), '每日汇总');
|
||
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(customers), '客户月汇总');
|
||
XLSX.writeFile(workbook, `${station.name}_${data.range.start}_${data.range.end}_取证.xlsx`);
|
||
}
|
||
|
||
function StationBoardSkeleton() {
|
||
return <div className="grid gap-3"><div className="h-28 animate-pulse rounded-xl bg-slate-200/70" /><div className="h-72 animate-pulse rounded-xl bg-slate-200/70" /></div>;
|
||
}
|