feat(energy): rebuild hydrogen BI board and drill-through
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -10,7 +10,16 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import type { HydrogenRegionShare, HydrogenStationTop } from '../../types';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { HydrogenRegionShare, HydrogenStationFull, HydrogenStationTop } from '../../types';
|
||||
import {
|
||||
buildRegionDrillPayload,
|
||||
buildStationDrillPayload,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
const REGION_COLORS = [
|
||||
'#3b82f6', '#22d3ee', '#a855f7', '#f59e0b',
|
||||
@@ -42,70 +51,123 @@ function RankYAxisTick({ x = 0, y = 0, index = 0, payload }: YAxisTickProps) {
|
||||
interface DistributionChartsProps {
|
||||
top5: HydrogenStationTop[];
|
||||
regions: HydrogenRegionShare[];
|
||||
stations: HydrogenStationFull[];
|
||||
yearKg: number;
|
||||
onSelectStation?: (stationId: number) => void;
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function DistributionCharts({ top5, regions, yearKg }: DistributionChartsProps) {
|
||||
export function DistributionCharts({ top5, regions, stations, yearKg, onSelectStation, scope = 'global', scopeLabel, onDrillRequest }: DistributionChartsProps) {
|
||||
const [regionGranularity, setRegionGranularity] = useState<'province' | 'city'>('city');
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const provinceRegions = useMemo(() => {
|
||||
const totals = new Map<string, number>();
|
||||
for (const station of stations) {
|
||||
const province = station.province?.trim() || '未归属';
|
||||
totals.set(province, (totals.get(province) ?? 0) + station.kg);
|
||||
}
|
||||
return [...totals.entries()]
|
||||
.map(([region, kg]) => ({ region, kg, share: kg / Math.max(1, yearKg) }))
|
||||
.sort((a, b) => b.kg - a.kg);
|
||||
}, [stations, yearKg]);
|
||||
const visibleRegions = regionGranularity === 'province' ? provinceRegions : regions;
|
||||
const openStation = (stationId: number) => {
|
||||
const station = stations.find(item => item.id === stationId);
|
||||
if (!station) return;
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
const openRegion = (region: HydrogenRegionShare) => {
|
||||
setSelectedStationId(null);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'region', key: `${regionGranularity}:${region.region}`, label: region.region });
|
||||
else setDrill(buildRegionDrillPayload(region, stations, regionGranularity));
|
||||
};
|
||||
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加注量 Top5</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
<>
|
||||
<div className="ehb-two-charts-row">
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">加氢站加氢量 Top5{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:Kg</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={260}>
|
||||
<BarChart data={top5} layout="vertical" margin={{ top: 4, right: 80, bottom: 4, left: 12 }}>
|
||||
<div className="ehb-chart-box-body h-[260px]">
|
||||
<ResponsiveContainer width="100%" height={260} minWidth={0} initialDimension={{ width: 1, height: 260 }}>
|
||||
<BarChart
|
||||
data={top5}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 24, left: 16, bottom: 0 }}
|
||||
barSize={10}
|
||||
className="cursor-pointer"
|
||||
onClick={(state) => {
|
||||
const stationId = (state as { activePayload?: { payload?: HydrogenStationTop }[] } | undefined)?.activePayload?.[0]?.payload?.id;
|
||||
if (stationId) openStation(stationId);
|
||||
}}
|
||||
>
|
||||
<XAxis type="number" hide />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={188}
|
||||
tick={<RankYAxisTick />}
|
||||
tickLine={false}
|
||||
type="category"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
width={180}
|
||||
tick={<RankYAxisTick />}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN')} Kg`}
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(59, 130, 246, 0.04)' }}
|
||||
contentStyle={{ borderRadius: '12px', fontSize: '12px', padding: '8px 12px', border: 'none', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}
|
||||
formatter={(val: unknown) => [`${Number(val ?? 0).toLocaleString('zh-CN')} Kg`, '加氢量']}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[6, 6, 6, 6]}>
|
||||
{top5.map((_, i) => (
|
||||
<Cell key={i} fill="url(#topBarGrad)" />
|
||||
))}
|
||||
<LabelList
|
||||
dataKey="kg"
|
||||
position="right"
|
||||
formatter={(v) => `${Number(v ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })}`}
|
||||
fill="#475569"
|
||||
fontSize={11}
|
||||
fontWeight={700}
|
||||
/>
|
||||
<Bar dataKey="kg" fill="#3b82f6" radius={[0, 4, 4, 0]}>
|
||||
<LabelList dataKey="kg" position="right" formatter={(v: unknown) => {
|
||||
const value = Number(v ?? 0);
|
||||
return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value);
|
||||
}} fontSize={11} fontWeight={700} fill="#475569" />
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="topBarGrad" x1="0" x2="1" y1="0" y2="0">
|
||||
<stop offset="0%" stopColor="#3b82f6" />
|
||||
<stop offset="100%" stopColor="#22d3ee" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<span className="text-sm font-bold text-slate-700">各区域加氢占比</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">各区域加氢占比{scopeText}</div>
|
||||
<div className="ehb-mini-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-mini-tab ${regionGranularity === 'province' ? 'is-active' : ''}`}
|
||||
onClick={() => setRegionGranularity('province')}
|
||||
>
|
||||
按省
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-mini-tab ${regionGranularity === 'city' ? 'is-active' : ''}`}
|
||||
onClick={() => setRegionGranularity('city')}
|
||||
>
|
||||
按市
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-chart-box-body h-[260px] flex items-center justify-center">
|
||||
<div className="relative w-1/2 h-[200px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={regions}
|
||||
data={visibleRegions}
|
||||
dataKey="kg"
|
||||
nameKey="region"
|
||||
innerRadius={48}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
className="cursor-pointer outline-none"
|
||||
onClick={(entry) => openRegion(entry as unknown as HydrogenRegionShare)}
|
||||
>
|
||||
{regions.map((_, i) => (
|
||||
{visibleRegions.map((_, i) => (
|
||||
<Cell key={i} fill={REGION_COLORS[i % REGION_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
@@ -118,16 +180,22 @@ export function DistributionCharts({ top5, regions, yearKg }: DistributionCharts
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 text-[11px]">
|
||||
{regions.map((r, i) => (
|
||||
<div key={r.region} className="flex items-center gap-1.5">
|
||||
{visibleRegions.map((r, i) => (
|
||||
<button key={r.region} type="button" onClick={() => openRegion(r)} className="flex min-w-0 items-center gap-1.5 rounded px-1 py-0.5 text-left hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500">
|
||||
<span className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: REGION_COLORS[i % REGION_COLORS.length] }} />
|
||||
<span className="text-slate-600 truncate">{r.region}</span>
|
||||
<span className="text-slate-400 ml-auto font-bold flex-shrink-0">{(r.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { AlertTriangle, Building2, Gauge } from 'lucide-react';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import { formatKg as fmtKg } from '../model';
|
||||
import { Activity, ChevronDown, Shield, TrendingDown } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { HydrogenMonthlyPoint, HydrogenStationFull } from '../../types';
|
||||
import {
|
||||
buildStationDrillPayload,
|
||||
formatKg as fmtKg,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
interface InsightCardsProps {
|
||||
monthAvgKg: number;
|
||||
@@ -8,13 +15,16 @@ interface InsightCardsProps {
|
||||
latestMonth: HydrogenMonthlyPoint | undefined;
|
||||
monthMomentum: number | null;
|
||||
top5Share: number;
|
||||
profitYield: number;
|
||||
customerGrossMarginPct: number;
|
||||
stationAvgKg: number;
|
||||
stationCount: number;
|
||||
yearProfitValue: string;
|
||||
yearProfitUnit: string;
|
||||
yearRevenueValue: string;
|
||||
yearRevenueUnit: string;
|
||||
stations: HydrogenStationFull[];
|
||||
onSelectStation: (stationId: number) => void;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function InsightCards({
|
||||
@@ -23,66 +33,144 @@ export function InsightCards({
|
||||
latestMonth,
|
||||
monthMomentum,
|
||||
top5Share,
|
||||
profitYield,
|
||||
customerGrossMarginPct,
|
||||
stationAvgKg,
|
||||
stationCount,
|
||||
yearProfitValue,
|
||||
yearProfitUnit,
|
||||
yearRevenueValue,
|
||||
yearRevenueUnit,
|
||||
stations,
|
||||
onSelectStation,
|
||||
onDrillRequest,
|
||||
}: InsightCardsProps) {
|
||||
const [rankingOpen, setRankingOpen] = useState(false);
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const rankingRef = useRef<HTMLDivElement>(null);
|
||||
const highestKg = stations[0]?.kg || 1;
|
||||
useEffect(() => {
|
||||
if (!rankingOpen) return;
|
||||
const closeOnOutsideClick = (event: MouseEvent) => {
|
||||
if (rankingRef.current && !rankingRef.current.contains(event.target as Node)) setRankingOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', closeOnOutsideClick);
|
||||
return () => document.removeEventListener('mousedown', closeOnOutsideClick);
|
||||
}, [rankingOpen]);
|
||||
const openStation = (station: HydrogenStationFull) => {
|
||||
setRankingOpen(false);
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 md:gap-3">
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">月度动能</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-blue-50 text-blue-600 ring-1 ring-blue-100">
|
||||
<Gauge size={18} />
|
||||
</span>
|
||||
<>
|
||||
<div className="ehb-insight" aria-label="经营洞察">
|
||||
<div className="ehb-insight__card">
|
||||
<div className="ehb-insight__icon is-down">
|
||||
<TrendingDown size={18} aria-hidden />
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
<div>
|
||||
<div className="ehb-insight__title">月度加氢异常波动</div>
|
||||
<div className="ehb-insight__value is-neg">
|
||||
{monthMomentum === null ? '暂无对比' : `${monthMomentum >= 0 ? '+' : ''}${monthMomentum.toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="ehb-insight__desc">
|
||||
{latestMonth ? `${latestMonth.month} 加氢 ${fmtKg(latestMonth.kg).value}${fmtKg(latestMonth.kg).unit}` : '暂无月度数据'}
|
||||
{bestMonth ? ` · 峰值 ${bestMonth.month}` : ''}
|
||||
{monthAvgKg > 0 ? ` · 月均 ${fmtKg(monthAvgKg).value}${fmtKg(monthAvgKg).unit}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">站点集中度</div>
|
||||
<div className="mt-1 text-lg font-black text-slate-900">Top5 {top5Share.toFixed(1)}%</div>
|
||||
|
||||
<div
|
||||
className={`ehb-insight__card ehb-insight__card--rank ${rankingOpen ? 'is-open' : ''}`}
|
||||
ref={rankingRef}
|
||||
onClick={() => setRankingOpen((v) => !v)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="点击查看加氢站加氢量排名"
|
||||
>
|
||||
<div className="ehb-insight__icon">
|
||||
<Shield size={18} aria-hidden />
|
||||
</div>
|
||||
<div className="ehb-insight__rank-body">
|
||||
<div className="ehb-insight__title">头部加氢站占比</div>
|
||||
<div className="ehb-insight__value">Top5 {top5Share.toFixed(1)}%</div>
|
||||
<div className="ehb-insight__desc">
|
||||
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit} · 点击展开加氢量排名
|
||||
</div>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-cyan-50 text-cyan-600 ring-1 ring-cyan-100">
|
||||
<Building2 size={18} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
共 {stationCount} 站 · 单站年均 {fmtKg(stationAvgKg).value}{fmtKg(stationAvgKg).unit}
|
||||
{top5Share >= 70 ? ' · 头部站点依赖偏高' : ' · 分布相对健康'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-100 bg-white p-3 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[11px] font-black text-slate-400">收支健康度</div>
|
||||
<div className={`mt-1 text-lg font-black ${profitYield >= 0 ? 'text-emerald-600' : 'text-rose-600'}`}>
|
||||
{profitYield.toFixed(1)}%
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`ehb-insight__rank-chevron ${rankingOpen ? 'is-open' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
{rankingOpen && (
|
||||
<div
|
||||
className="ehb-station-rank-dropdown"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="listbox"
|
||||
aria-label="加氢站加氢量排名"
|
||||
>
|
||||
<div className="ehb-station-rank-dropdown__head">
|
||||
<span>加氢站加氢量排名</span>
|
||||
<span className="ehb-station-rank-dropdown__meta">
|
||||
高 → 低 · 共 {stationCount} 站
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-station-rank-dropdown__list">
|
||||
{stations.map((station, index) => (
|
||||
<button
|
||||
key={`${station.id}-${station.name}-${index}`}
|
||||
type="button"
|
||||
className="ehb-station-rank-item"
|
||||
onClick={() => openStation(station)}
|
||||
title="点击钻取该站明细"
|
||||
>
|
||||
<span className={`ehb-station-rank-item__rank ${index < 3 ? 'is-top' : ''}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__main">
|
||||
<span className="ehb-station-rank-item__name">{station.name}</span>
|
||||
<span className="ehb-station-rank-item__bar">
|
||||
<span style={{ width: `${Math.max(2, station.kg / highestKg * 100)}%` }} />
|
||||
</span>
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__val">
|
||||
{fmtKg(station.kg).value} {fmtKg(station.kg).unit}
|
||||
</span>
|
||||
<span className="ehb-station-rank-item__share">{(station.share * 100).toFixed(1)}%</span>
|
||||
</button>
|
||||
))}
|
||||
{stations.length === 0 && (
|
||||
<div className="ehb-station-rank-empty">当前筛选下暂无站点数据</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`flex h-9 w-9 items-center justify-center rounded-xl ring-1 ${profitYield >= 0 ? 'bg-emerald-50 text-emerald-600 ring-emerald-100' : 'bg-rose-50 text-rose-600 ring-rose-100'}`}>
|
||||
<AlertTriangle size={18} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ehb-insight__card">
|
||||
<div className={`ehb-insight__icon ${customerGrossMarginPct >= 0 ? 'is-ok' : 'is-neg'}`}>
|
||||
<Activity size={18} aria-hidden />
|
||||
</div>
|
||||
<div className="mt-2 text-[11px] font-bold leading-relaxed text-slate-500">
|
||||
时享获利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{profitYield < 0 ? ' · 需关注亏损站点与客户价格' : ' · 当前保持正向收益'}
|
||||
<div>
|
||||
<div className="ehb-insight__title">客户单毛利率</div>
|
||||
<div className={`ehb-insight__value ${customerGrossMarginPct >= 0 ? 'is-pos' : 'is-neg'}`}>
|
||||
{customerGrossMarginPct.toFixed(1)}%
|
||||
</div>
|
||||
<div className="ehb-insight__desc">
|
||||
客户单毛利 {yearProfitValue}{yearProfitUnit} · 客户收入 {yearRevenueValue}{yearRevenueUnit}
|
||||
{customerGrossMarginPct < 0 ? ' · 需关注客户价格与站点成本' : ' · 当前客户单保持正毛利'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,76 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { CalendarDays, Fuel, Sparkles, TrendingUp, Wallet } from 'lucide-react';
|
||||
import { Activity, Fuel, Search, Truck, Wallet, Zap } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import type { HydrogenKpi } from '../../types';
|
||||
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||
import {
|
||||
buildMetricDrillPayload,
|
||||
formatKg as fmtKg,
|
||||
formatYuan as fmtYuan,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewMetricKey,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
interface KpiCardProps {
|
||||
icon: ReactNode;
|
||||
metricKey: OverviewMetricKey;
|
||||
label: string;
|
||||
hero: { value: string; unit: string };
|
||||
rows: { label: string; value: string; valueClass?: string }[];
|
||||
accentClass: string;
|
||||
iconBg: string;
|
||||
rows: { label: string; value: string }[];
|
||||
tone: 'blue' | 'green' | 'amber' | 'purple';
|
||||
valueClass?: string;
|
||||
onOpen: (key: OverviewMetricKey) => void;
|
||||
}
|
||||
|
||||
function KpiCard({ icon, label, hero, rows, accentClass, iconBg }: KpiCardProps) {
|
||||
const TONE_CLASS = {
|
||||
blue: 'bg-blue-50 text-blue-600',
|
||||
green: 'bg-emerald-50 text-emerald-600',
|
||||
amber: 'bg-amber-50 text-amber-600',
|
||||
purple: 'bg-violet-50 text-violet-600',
|
||||
} as const;
|
||||
|
||||
function KpiCard({ icon, metricKey, label, hero, rows, tone, onOpen }: KpiCardProps) {
|
||||
const isYuan = hero.value.startsWith('¥');
|
||||
const numValue = isYuan ? hero.value.replace('¥', '') : hero.value;
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-7 h-7 rounded-xl flex items-center justify-center ${iconBg}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-slate-500">{label}</span>
|
||||
<div
|
||||
className={`ehb-kpi-dual is-${tone}`}
|
||||
onClick={() => onOpen(metricKey)}
|
||||
title="点击查看真实汇总及可用下钻明细"
|
||||
>
|
||||
<div className="ehb-kpi-dual__head">
|
||||
<span className="ehb-kpi-dual__label">
|
||||
{label}
|
||||
<span className="ehb-kpi-drill-hint">
|
||||
<Search size={10} /> 钻取
|
||||
</span>
|
||||
</span>
|
||||
<span className={`ehb-kpi-dual__badge is-${tone}`}>{icon}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl md:text-2xl font-black tabular-nums leading-none ${accentClass}`}>{hero.value}</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">{hero.unit}</span>
|
||||
<div className="ehb-kpi-dual__val">
|
||||
{isYuan && <span className="ehb-kpi-dual__symbol">¥</span>}
|
||||
<span className="ehb-kpi-dual__num">{numValue}</span>
|
||||
{hero.unit && <span className="ehb-kpi-dual__unit">{hero.unit}</span>}
|
||||
</div>
|
||||
<div className="space-y-0.5 pt-1 border-t border-slate-50">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-[11px] font-bold">
|
||||
<span className="text-slate-400">{r.label}</span>
|
||||
<span className={`tabular-nums ${r.valueClass ?? 'text-slate-700'}`}>{r.value}</span>
|
||||
</div>
|
||||
<div className="ehb-kpi-dual__deck">
|
||||
{rows.map((row) => (
|
||||
<span key={row.label}>{row.label} {row.value}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
|
||||
interface KpiSectionProps {
|
||||
kpi: HydrogenKpi;
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function KpiSection({ kpi: k, scope = 'global', scopeLabel, onDrillRequest }: KpiSectionProps) {
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const yearKgFmt = fmtKg(k.yearKg);
|
||||
const yearFeeFmt = fmtYuan(k.yearFee);
|
||||
const yearProfitFmt = fmtYuan(k.yearProfit);
|
||||
@@ -50,65 +83,22 @@ export function KpiSection({ kpi: k }: { kpi: HydrogenKpi }) {
|
||||
const customerYearFee = Math.max(0, k.yearFee - k.ourYearFee);
|
||||
const customerYearFeeFmt = fmtYuan(customerYearFee);
|
||||
const yearRevenueFmt = fmtYuan(k.yearRevenue);
|
||||
const profitColor = k.yearProfit >= 0 ? 'text-emerald-600' : 'text-red-600';
|
||||
|
||||
const openDrill = (key: OverviewMetricKey) => {
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'metric', key, label: key });
|
||||
else setDrill(buildMetricDrillPayload(k, key));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2 md:gap-3">
|
||||
<KpiCard
|
||||
icon={<Fuel size={14} className="text-cyan-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-cyan-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢量"
|
||||
hero={yearKgFmt}
|
||||
rows={[
|
||||
{ label: '我司', value: `${ourYearKgFmt.value} ${ourYearKgFmt.unit}` },
|
||||
{ label: '客户', value: `${customerYearKgFmt.value} ${customerYearKgFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Wallet size={14} className="text-blue-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-blue-50"
|
||||
accentClass="text-slate-800"
|
||||
label="累计加氢费"
|
||||
hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }}
|
||||
rows={[
|
||||
{ label: '我司承担', value: `¥${fmtYuan(k.ourYearFee).value} ${fmtYuan(k.ourYearFee).unit}` },
|
||||
{ label: '客户承担', value: `¥${customerYearFeeFmt.value} ${customerYearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<TrendingUp size={14} className="text-emerald-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-emerald-50"
|
||||
accentClass={profitColor}
|
||||
label="时享加氢获利"
|
||||
hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }}
|
||||
rows={[
|
||||
{ label: '收入', value: `¥${yearRevenueFmt.value} ${yearRevenueFmt.unit}` },
|
||||
{ label: '成本', value: `¥${yearFeeFmt.value} ${yearFeeFmt.unit}` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<CalendarDays size={14} className="text-amber-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-amber-50"
|
||||
accentClass="text-amber-600"
|
||||
label="本月加氢"
|
||||
hero={monthKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${monthFeeFmt.value} ${monthFeeFmt.unit}` },
|
||||
{ label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Sparkles size={14} className="text-violet-600" strokeWidth={2.4} />}
|
||||
iconBg="bg-violet-50"
|
||||
accentClass="text-violet-600"
|
||||
label="本日加氢"
|
||||
hero={todayKgFmt}
|
||||
rows={[
|
||||
{ label: '加氢费', value: `¥${todayFeeFmt.value} ${todayFeeFmt.unit}` },
|
||||
{ label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
<div className="ehb-host-kpi" aria-label={`${scope === 'station' ? scopeLabel || '单站' : '全局'}经营指标`}>
|
||||
<KpiCard metricKey="yearKg" icon={<Fuel size={14} />} tone="blue" label="累计加氢量" hero={yearKgFmt} rows={[{ label: '我司', value: `${ourYearKgFmt.value}${ourYearKgFmt.unit}` }, { label: '客户', value: `${customerYearKgFmt.value}${customerYearKgFmt.unit}` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="yearFee" icon={<Wallet size={14} />} tone="blue" label="累计加氢费" hero={{ value: `¥${yearFeeFmt.value}`, unit: yearFeeFmt.unit }} rows={[{ label: '我司', value: `¥${fmtYuan(k.ourYearFee).value}${fmtYuan(k.ourYearFee).unit}` }, { label: '客户', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="yearProfit" icon={<Activity size={14} />} tone="green" label="客户单毛利" hero={{ value: `¥${yearProfitFmt.value}`, unit: yearProfitFmt.unit }} rows={[{ label: '收入', value: `¥${yearRevenueFmt.value}${yearRevenueFmt.unit}` }, { label: '成本', value: `¥${customerYearFeeFmt.value}${customerYearFeeFmt.unit}` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="monthKg" icon={<Truck size={14} />} tone="amber" label="本月加氢" hero={monthKgFmt} rows={[{ label: '加氢费', value: `¥${monthFeeFmt.value}${monthFeeFmt.unit}` }, { label: '占年比', value: `${k.yearKg > 0 ? (k.monthKg / k.yearKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
|
||||
<KpiCard metricKey="todayKg" icon={<Zap size={14} />} tone="purple" label="本日加氢" hero={todayKgFmt} rows={[{ label: '加氢费', value: `¥${todayFeeFmt.value}${todayFeeFmt.unit}` }, { label: '占月比', value: `${k.monthKg > 0 ? (k.todayKg / k.monthKg * 100).toFixed(1) : '0.0'}%` }]} onOpen={openDrill} />
|
||||
</div>
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
LabelList,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
import { useState } from 'react';
|
||||
import type { HydrogenMonthlyPoint } from '../../types';
|
||||
import { formatYuan as fmtYuan } from '../model';
|
||||
import {
|
||||
buildMonthDrillPayload,
|
||||
formatYuan as fmtYuan,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
type MonthlyChartPoint = HydrogenMonthlyPoint & { monthLabel: string };
|
||||
|
||||
@@ -17,19 +25,36 @@ interface MonthlyChartsProps {
|
||||
activeYear: number;
|
||||
monthly: HydrogenMonthlyPoint[];
|
||||
monthlyDual: MonthlyChartPoint[];
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}
|
||||
|
||||
export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChartsProps) {
|
||||
export function MonthlyCharts({ activeYear, monthly, monthlyDual, scope = 'global', scopeLabel, onDrillRequest }: MonthlyChartsProps) {
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const openMonthPoint = (point: MonthlyChartPoint | undefined) => {
|
||||
if (!point) return;
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'month', key: point.month, label: `${point.month} 月度经营明细` });
|
||||
else setDrill(buildMonthDrillPayload(point));
|
||||
};
|
||||
const openMonthFromChart = (state: unknown) => {
|
||||
openMonthPoint((state as { activePayload?: { payload?: MonthlyChartPoint }[] } | undefined)?.activePayload?.[0]?.payload);
|
||||
};
|
||||
const openMonthFromBar = (entry: unknown) => {
|
||||
openMonthPoint((entry as { payload?: MonthlyChartPoint } | undefined)?.payload);
|
||||
};
|
||||
const scopeText = scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : '';
|
||||
return (
|
||||
<>
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度加氢量</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 Kg</span>
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">{activeYear} 年月度加氢量{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:Kg</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<div className="ehb-chart-box-body">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
@@ -44,30 +69,29 @@ export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChart
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(34, 211, 238, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="kg" radius={[4, 4, 0, 0]}>
|
||||
{monthlyDual.map((_, i) => (
|
||||
<Cell key={i} fill="url(#monthlyBarGrad)" />
|
||||
))}
|
||||
<Legend verticalAlign="top" height={24} iconSize={8} wrapperStyle={{ fontSize: 11, paddingBottom: 4 }} />
|
||||
<Bar dataKey="lingniuKg" name="羚牛车辆" stackId="kg" fill="#4ba3df" radius={[0, 0, 0, 0]} onClick={openMonthFromBar} />
|
||||
<Bar dataKey="externalKg" name="外部车辆" stackId="kg" fill="#f4bb45" radius={[4, 4, 0, 0]} onClick={openMonthFromBar}>
|
||||
<LabelList dataKey="kg" position="top" formatter={value => {
|
||||
const total = Number(value ?? 0);
|
||||
return total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total.toFixed(0);
|
||||
}} fill="#475569" fontSize={10} fontWeight={700} />
|
||||
</Bar>
|
||||
<defs>
|
||||
<linearGradient id="monthlyBarGrad" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#22d3ee" />
|
||||
<stop offset="100%" stopColor="#3b82f6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{monthly.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">{activeYear} 年月度收支对比</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">单位 元</span>
|
||||
<div className="ehb-chart-box">
|
||||
<div className="ehb-chart-box-head">
|
||||
<div className="ehb-chart-box-title">{activeYear} 年月度收支对比{scopeText}</div>
|
||||
<div className="ehb-chart-box-meta">单位:元</div>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }}>
|
||||
<div className="ehb-chart-box-body">
|
||||
<ResponsiveContainer width="100%" height="100%" minWidth={0} initialDimension={{ width: 1, height: 200 }}>
|
||||
<BarChart data={monthlyDual} margin={{ top: 8, right: 4, bottom: 0, left: 0 }} onClick={openMonthFromChart} className="cursor-pointer">
|
||||
<XAxis
|
||||
dataKey="monthLabel"
|
||||
tick={{ fontSize: 10, fill: '#94a3b8' }}
|
||||
@@ -90,12 +114,14 @@ export function MonthlyCharts({ activeYear, monthly, monthlyDual }: MonthlyChart
|
||||
contentStyle={{ borderRadius: 12, fontSize: 12 }}
|
||||
cursor={{ fill: 'rgba(148, 163, 184, 0.06)' }}
|
||||
/>
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} />
|
||||
<Bar dataKey="fee" name="成本支出" fill="#f59e0b" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
|
||||
<Bar dataKey="revenue" name="客户收入" fill="#10b981" radius={[3, 3, 0, 0]} onClick={openMonthFromBar} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ChevronLeft, Database, ExternalLink, Search, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { OverviewDrillPayload } from '../model';
|
||||
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
|
||||
|
||||
interface OverviewDrillDialogProps {
|
||||
payload: OverviewDrillPayload | null;
|
||||
onClose: () => void;
|
||||
onPrimaryAction?: () => void;
|
||||
onGroupByChange?: (groupBy: 'station' | 'customer' | 'vehicle') => void;
|
||||
onRowSelect?: (row: Record<string, string | number>) => void;
|
||||
}
|
||||
|
||||
const TONE_CLASS = {
|
||||
default: 'text-slate-900',
|
||||
blue: 'text-sky-600',
|
||||
green: 'text-emerald-600',
|
||||
amber: 'text-amber-600',
|
||||
red: 'text-rose-600',
|
||||
} as const;
|
||||
|
||||
export function OverviewDrillDialog({ payload, onClose, onPrimaryAction, onGroupByChange, onRowSelect }: OverviewDrillDialogProps) {
|
||||
const [sortKey, setSortKey] = useState('');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
useEffect(() => {
|
||||
if (!payload) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [onClose, payload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload?.columns.length) return;
|
||||
setSortKey(payload.columns[0].key);
|
||||
setSortDirection('desc');
|
||||
}, [payload]);
|
||||
|
||||
const sortedRows = useMemo(
|
||||
() => payload ? sortBy(payload.rows, sortKey, sortDirection, (row, key) => row[key]) : [],
|
||||
[payload, sortDirection, sortKey],
|
||||
);
|
||||
const changeSort = (nextKey: string) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
|
||||
if (!payload || typeof document === 'undefined') return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex items-center justify-center bg-slate-950/70 p-3 backdrop-blur-[8px] md:p-5"
|
||||
onMouseDown={event => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
className="flex max-h-[90vh] w-full max-w-[1100px] flex-col overflow-hidden rounded-xl border border-slate-200/80 bg-white shadow-[0_25px_50px_-12px_rgba(0,0,0,0.35)]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="overview-drill-title"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-3 bg-slate-900 px-3 py-3 text-white md:px-5 md:py-4">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<button type="button" onClick={onClose} className="inline-flex h-8 shrink-0 items-center gap-1 rounded-lg border border-white/20 bg-white/10 px-2.5 text-[12px] font-bold text-slate-100 transition hover:border-sky-400 hover:bg-sky-400/15 hover:text-sky-300">
|
||||
<ChevronLeft size={16} />
|
||||
<span className="hidden sm:inline">返回</span>
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<h2 id="overview-drill-title" className="flex items-center gap-2 truncate text-[14px] font-bold text-slate-50 md:text-[16px]">
|
||||
<Search size={16} className="shrink-0 text-sky-300" />
|
||||
<span className="truncate">{payload.title}</span>
|
||||
</h2>
|
||||
<p className="mt-0.5 truncate text-[11px] font-medium text-slate-400 md:text-[12px]">{payload.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-white/10 text-slate-300 transition hover:bg-rose-500/80 hover:text-white" aria-label="关闭下钻弹层">
|
||||
<X size={17} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-slate-50 p-3 md:p-5">
|
||||
<div className="grid grid-cols-2 gap-2 rounded-lg border border-slate-200 bg-white p-3 md:grid-cols-4 md:gap-4 md:px-[18px] md:py-[14px]">
|
||||
{payload.metrics.map(metric => (
|
||||
<div key={metric.label} className="min-w-0">
|
||||
<div className="text-[11px] font-medium text-slate-500">{metric.label}</div>
|
||||
<div className={`mt-1 truncate text-[16px] font-extrabold tabular-nums ${TONE_CLASS[metric.tone ?? 'default']}`} title={metric.value}>{metric.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{payload.groupByOptions?.length && onGroupByChange ? (
|
||||
<div className="mt-4 flex items-center justify-between gap-3 rounded-lg border border-slate-200 bg-white px-3 py-2.5">
|
||||
<div className="text-[11px] font-medium text-slate-500">汇总维度</div>
|
||||
<div className="flex rounded-md bg-slate-100 p-0.5">
|
||||
{payload.groupByOptions.map(groupBy => (
|
||||
<button
|
||||
key={groupBy}
|
||||
type="button"
|
||||
onClick={() => onGroupByChange(groupBy)}
|
||||
className={`rounded px-3 py-1.5 text-[11px] font-bold transition ${payload.groupBy === groupBy ? 'bg-white text-sky-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
按{groupBy === 'customer' ? '客户' : groupBy === 'station' ? '加氢站' : '车辆'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payload.columns.length > 0 && payload.rows.length > 0 ? (
|
||||
<div className="mt-4 max-h-[440px] overflow-auto rounded-lg border border-slate-200 bg-white">
|
||||
<table className="w-full min-w-[620px] border-collapse text-left">
|
||||
<thead className="sticky top-0 z-10 bg-slate-100">
|
||||
<tr>
|
||||
{payload.columns.map(column => (
|
||||
<th key={column.key} className={`border-b border-slate-200 px-3 py-2.5 text-[11px] font-bold text-slate-600 ${column.align === 'right' ? 'text-right' : 'text-left'}`}><SortableColumnHeader label={column.label} sortKey={column.key} activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align={column.align === 'right' ? 'right' : 'left'} /></th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.map((row, rowIndex) => (
|
||||
<tr
|
||||
key={rowIndex}
|
||||
className={`border-b border-slate-100 last:border-0 hover:bg-slate-50 ${onRowSelect && payload.rowActionLabel ? 'cursor-pointer' : ''}`}
|
||||
onClick={() => onRowSelect?.(row)}
|
||||
title={onRowSelect && payload.rowActionLabel ? payload.rowActionLabel : undefined}
|
||||
>
|
||||
{payload.columns.map(column => (
|
||||
<td key={column.key} className={`whitespace-nowrap px-3 py-2.5 text-[12px] text-slate-700 ${column.align === 'right' ? 'text-right font-semibold tabular-nums' : 'text-left'}`}>{row[column.key]}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex min-h-[180px] flex-col items-center justify-center rounded-lg border border-dashed border-slate-300 bg-white px-5 text-center">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-lg bg-sky-50 text-sky-600"><Database size={19} /></span>
|
||||
<div className="mt-3 text-[13px] font-bold text-slate-700">真实明细尚未接入当前总览接口</div>
|
||||
<p className="mt-1 max-w-[620px] text-[11px] font-medium leading-5 text-slate-500">{payload.emptyMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{payload.primaryActionLabel && onPrimaryAction ? (
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="button" onClick={onPrimaryAction} className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-sky-600 px-3.5 text-[12px] font-bold text-white shadow-sm transition hover:bg-sky-700">
|
||||
{payload.primaryActionLabel}<ExternalLink size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,53 +1,174 @@
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import { ChevronDown, RefreshCw, Truck } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { HydrogenVehicleScope } from '../../api';
|
||||
import { formatRefreshTime } from '../model';
|
||||
|
||||
interface BiYearSelectProps {
|
||||
value: number;
|
||||
years: number[];
|
||||
onChange: (year: number) => void;
|
||||
}
|
||||
|
||||
function BiYearSelect({ value, years, onChange }: BiYearSelectProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className="ehb-year-select-wrapper" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-year-select-btn ${isOpen ? 'is-active' : ''}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-label="年份选择"
|
||||
>
|
||||
<span className="ehb-year-text">{value} 年</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
style={{
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: isOpen ? 'rotate(180deg)' : 'none',
|
||||
color: '#64748b',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="ehb-year-dropdown">
|
||||
<div className="ehb-year-dropdown__header">切换数据年份</div>
|
||||
<div className="ehb-year-dropdown__list">
|
||||
{years.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
className={`ehb-year-dropdown__item ${y === value ? 'is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
onChange(y);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<span>{y} 年</span>
|
||||
{y === value && <span className="ehb-year-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface OverviewHeaderProps {
|
||||
activeYear: number;
|
||||
availableYears: number[];
|
||||
lastRefreshAt: number;
|
||||
refreshing: boolean;
|
||||
vehicleScope: HydrogenVehicleScope;
|
||||
verifyScope?: 'all' | 'verified';
|
||||
onVerifyScopeChange?: (scope: 'all' | 'verified') => void;
|
||||
selectedStationId?: number | null;
|
||||
selectedStationName?: string | null;
|
||||
stations?: { id: number; name: string }[];
|
||||
latestLedgerTime?: string | null;
|
||||
lastRefreshAt?: number;
|
||||
refreshing?: boolean;
|
||||
onSelectYear: (year: number) => void;
|
||||
onVehicleScopeChange: (scope: HydrogenVehicleScope) => void;
|
||||
onStationChange?: (stationId: number | null) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function OverviewHeader({
|
||||
activeYear,
|
||||
availableYears,
|
||||
lastRefreshAt,
|
||||
refreshing,
|
||||
vehicleScope,
|
||||
verifyScope = 'all',
|
||||
onVerifyScopeChange,
|
||||
latestLedgerTime,
|
||||
lastRefreshAt = 0,
|
||||
refreshing = false,
|
||||
onSelectYear,
|
||||
onVehicleScopeChange,
|
||||
onRefresh,
|
||||
}: OverviewHeaderProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-100 px-3 py-1.5 text-[11px] text-slate-400 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{lastRefreshAt ? `更新于 ${formatRefreshTime(lastRefreshAt)}` : '数据自 2025-01-01 起'}</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 bg-slate-50 rounded-lg p-0.5">
|
||||
{availableYears.map(y => {
|
||||
const active = y === activeYear;
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
onClick={() => onSelectYear(y)}
|
||||
className={`px-2 py-0.5 text-[11px] font-bold rounded-md transition-all ${
|
||||
active ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-400 hover:text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<section className="ehb-daily-filter-card" style={{ marginBottom: 12 }} aria-label="总览筛选工具栏">
|
||||
<div className="ehb-daily-filter-row">
|
||||
<div className="ehb-daily-filter-group">
|
||||
<BiYearSelect value={activeYear} years={availableYears} onChange={onSelectYear} />
|
||||
<div className="ehb-pill-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-pill-btn ${verifyScope === 'all' ? 'is-active' : ''}`}
|
||||
onClick={() => onVerifyScopeChange?.('all')}
|
||||
>
|
||||
全量订单
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-pill-btn ${verifyScope === 'verified' ? 'is-active' : ''}`}
|
||||
onClick={() => onVerifyScopeChange?.('verified')}
|
||||
title="真实账本已返回全量有效订单"
|
||||
>
|
||||
仅已核对
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ehb-daily-filter-group">
|
||||
<div className="ehb-fleet-segmented">
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${vehicleScope === 'all' ? 'is-active' : ''}`}
|
||||
onClick={() => onVehicleScopeChange('all')}
|
||||
>
|
||||
全部车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${vehicleScope === 'lingniu' ? 'is-active' : ''}`}
|
||||
onClick={() => onVehicleScopeChange('lingniu')}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅羚牛车辆
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`ehb-fleet-btn ${vehicleScope === 'external' ? 'is-active' : ''}`}
|
||||
onClick={() => onVehicleScopeChange('external')}
|
||||
>
|
||||
<Truck size={14} />
|
||||
仅外部车辆
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="ehb-chrome__clock" style={{ fontSize: 12, color: '#64748b' }}>
|
||||
{latestLedgerTime ? `账本 ${latestLedgerTime.slice(5, 16)}` : lastRefreshAt ? formatRefreshTime(lastRefreshAt) : '已同步'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="ehb-btn ehb-btn--ghost"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
title="数据刷新"
|
||||
>
|
||||
<RefreshCw size={14} className={refreshing ? 'animate-spin' : ''} aria-hidden />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
|
||||
title="手动刷新(绕过缓存)"
|
||||
>
|
||||
<RefreshCw size={11} className={refreshing ? 'animate-spin' : ''} strokeWidth={2.6} />
|
||||
<span className="text-[11px] font-bold">刷新</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,55 +1,116 @@
|
||||
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
|
||||
import { formatKg as fmtKg, formatYuan as fmtYuan } from '../model';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronRight, Search } from 'lucide-react';
|
||||
import { SortableColumnHeader, sortBy, toggleSort, type SortDirection } from '../../components/SortableColumnHeader';
|
||||
import {
|
||||
buildCustomerDrillPayload,
|
||||
buildStationDrillPayload,
|
||||
formatKg as fmtKg,
|
||||
formatYuan as fmtYuan,
|
||||
type OverviewDrillPayload,
|
||||
type OverviewDrillRequest,
|
||||
type OverviewScope,
|
||||
} from '../model';
|
||||
import { OverviewDrillDialog } from './OverviewDrillDialog';
|
||||
|
||||
export function StationSummaryTable({ stations }: { stations: HydrogenStationFull[] }) {
|
||||
export function StationSummaryTable({
|
||||
stations,
|
||||
onSelectStation,
|
||||
scope = 'global',
|
||||
scopeLabel,
|
||||
onDrillRequest,
|
||||
}: {
|
||||
stations: HydrogenStationFull[];
|
||||
onSelectStation?: (stationId: number) => void;
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}) {
|
||||
const [province, setProvince] = useState('all');
|
||||
const [sortKey, setSortKey] = useState<'name' | 'province' | 'kg' | 'share' | 'revenue' | 'revenueShare'>('kg');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const [selectedStationId, setSelectedStationId] = useState<number | null>(null);
|
||||
const provinces = useMemo(() => [...new Set(stations.map(station => station.province?.trim()).filter(Boolean) as string[])], [stations]);
|
||||
const filteredStations = province === 'all'
|
||||
? stations
|
||||
: stations.filter(station => station.province === province);
|
||||
const sortedStations = useMemo(() => sortBy(filteredStations, sortKey, sortDirection, (station, key) => station[key]), [filteredStations, sortDirection, sortKey]);
|
||||
const changeSort = (nextKey: typeof sortKey) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
const openStation = (station: HydrogenStationFull) => {
|
||||
setSelectedStationId(station.id);
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'station', key: String(station.id), label: station.name, entityId: station.id });
|
||||
else setDrill(buildStationDrillPayload(station));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{stations.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">加氢站加氢汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">共 {stations.length} 站</span>
|
||||
<div className="ehb-sum-table-card">
|
||||
<div className="ehb-sum-table-card__head" style={{ flexWrap: 'wrap', gap: 12 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
|
||||
<div className="ehb-sum-table-card__title"><Search size={14} className="text-sky-600" />加氢站加氢汇总{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}</div>
|
||||
<div className="ehb-mini-tabs">
|
||||
<button type="button" onClick={() => setProvince('all')} className={`ehb-mini-tab ${province === 'all' ? 'is-active' : ''}`}>全国</button>
|
||||
{provinces.map(item => <button key={item} type="button" onClick={() => setProvince(item)} className={`ehb-mini-tab ${province === item ? 'is-active' : ''}`}>{item}</button>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ehb-sum-table-card__meta">
|
||||
统计范围内共 {stations.length} 站
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
|
||||
<div className="ehb-sum-table-wrap">
|
||||
<table className="ehb-sum-table">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">加氢站</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 hidden sm:table-cell">占比</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">氢费收入</th>
|
||||
<th className="text-right py-1.5 pr-1 hidden md:table-cell">收入占比</th>
|
||||
<tr>
|
||||
<th className="col-idx">#</th>
|
||||
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="加氢站" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="所属省份" sortKey="province" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="占比" sortKey="share" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="氢费收入" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="收入占比" sortKey="revenueShare" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stations.map((s, i) => {
|
||||
{sortedStations.map((s, i) => {
|
||||
const kgFmt = fmtKg(s.kg);
|
||||
const revFmt = fmtYuan(s.revenue);
|
||||
return (
|
||||
<tr key={s.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[180px]">{s.name}</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
<tr key={s.name + i} onClick={() => openStation(s)} style={{ cursor: 'pointer' }} title="查看加氢站汇总明细">
|
||||
<td className="col-idx">{i + 1}</td>
|
||||
<td style={{ fontWeight: 600, color: '#0284c7' }}>
|
||||
{s.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}>钻取 ›</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right hidden sm:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-cyan-400 to-blue-500" style={{ width: `${Math.min(100, s.share * 100)}%` }} />
|
||||
<td>
|
||||
<span style={{ fontSize: 11, color: '#0284c7', background: '#eff6ff', padding: '1px 6px', borderRadius: 4, fontWeight: 500 }}>
|
||||
{s.province ?? '未归属'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }} className="col-bold-kg">
|
||||
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="ehb-ratio-flex">
|
||||
<div className="ehb-mini-bar-track">
|
||||
<div className="ehb-mini-bar-fill is-blue" style={{ width: `${Math.min(100, s.share * 100 * 2.5)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.share * 100).toFixed(1)}%</span>
|
||||
<span className="ehb-ratio-text">{(s.share * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums font-bold text-emerald-600">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
<td style={{ textAlign: 'right' }} className="col-green-fee">
|
||||
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right hidden md:table-cell">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div className="w-12 h-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-gradient-to-r from-emerald-400 to-emerald-600" style={{ width: `${Math.min(100, s.revenueShare * 100)}%` }} />
|
||||
<td>
|
||||
<div className="ehb-ratio-flex">
|
||||
<div className="ehb-mini-bar-track">
|
||||
<div className="ehb-mini-bar-fill is-green" style={{ width: `${Math.min(100, s.revenueShare * 100 * 5)}%` }} />
|
||||
</div>
|
||||
<span className="text-slate-500 tabular-nums">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
<span className="ehb-ratio-text">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -60,55 +121,98 @@ export function StationSummaryTable({ stations }: { stations: HydrogenStationFul
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<OverviewDrillDialog
|
||||
payload={drill}
|
||||
onClose={() => { setDrill(null); setSelectedStationId(null); }}
|
||||
onPrimaryAction={selectedStationId && onSelectStation ? () => { const stationId = selectedStationId; setDrill(null); setSelectedStationId(null); onSelectStation(stationId); } : undefined}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomerSummaryTable({ customers }: { customers: HydrogenCustomerRow[] }) {
|
||||
export function CustomerSummaryTable({
|
||||
customers,
|
||||
scope = 'global',
|
||||
scopeLabel,
|
||||
onDrillRequest,
|
||||
}: {
|
||||
customers: HydrogenCustomerRow[];
|
||||
scope?: OverviewScope;
|
||||
scopeLabel?: string | null;
|
||||
onDrillRequest?: (request: OverviewDrillRequest) => void;
|
||||
}) {
|
||||
const [sortKey, setSortKey] = useState<'name' | 'payer' | 'kg' | 'cost' | 'revenue'>('kg');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
||||
const [drill, setDrill] = useState<OverviewDrillPayload | null>(null);
|
||||
const sortedCustomers = useMemo(() => sortBy(customers, sortKey, sortDirection, (customer, key) => customer[key]), [customers, sortDirection, sortKey]);
|
||||
const changeSort = (nextKey: typeof sortKey) => {
|
||||
const next = toggleSort(sortKey, sortDirection, nextKey);
|
||||
setSortKey(next.key);
|
||||
setSortDirection(next.direction);
|
||||
};
|
||||
const openCustomer = (customer: HydrogenCustomerRow, index: number) => {
|
||||
if (onDrillRequest) onDrillRequest({ kind: 'customer', key: `${customer.name}-${index}`, label: customer.name });
|
||||
else setDrill(buildCustomerDrillPayload(customer));
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{customers.length > 0 && (
|
||||
<div className="bg-white rounded-2xl border border-slate-100 shadow-sm p-3 md:p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-bold text-slate-700">客户账单汇总</span>
|
||||
<span className="text-[11px] text-slate-400 font-bold">Top {customers.length}</span>
|
||||
<div className="ehb-sum-table-card">
|
||||
<div className="ehb-sum-table-card__head">
|
||||
<div className="ehb-sum-table-card__title">
|
||||
<Search size={14} className="text-sky-600" style={{ display: 'inline-block', verticalAlign: 'middle', marginRight: 6 }} />
|
||||
客户账单汇总{scope === 'station' && scopeLabel ? ` · ${scopeLabel}` : ''}
|
||||
<span className="ehb-title-sub ehb-hide-h5">
|
||||
(已收 / 未收:等待客户能源账户和对账单打通后获取)
|
||||
</span>
|
||||
<span className="ehb-title-sub ehb-show-h5">
|
||||
(已收未收打通中)
|
||||
</span>
|
||||
</div>
|
||||
<div className="ehb-sum-table-card__meta">
|
||||
Top {customers.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto -mx-1 px-1">
|
||||
<table className="w-full text-[11px]">
|
||||
<div className="ehb-sum-table-wrap">
|
||||
<table className="ehb-sum-table">
|
||||
<thead>
|
||||
<tr className="text-slate-400 font-bold border-b border-slate-100">
|
||||
<th className="text-left py-1.5 pl-1 w-8">#</th>
|
||||
<th className="text-left py-1.5">客户</th>
|
||||
<th className="text-center py-1.5 w-14 hidden sm:table-cell">承担方</th>
|
||||
<th className="text-right py-1.5 w-20">加氢量</th>
|
||||
<th className="text-right py-1.5 pl-2 w-24">成本支出</th>
|
||||
<th className="text-right py-1.5 pr-1 w-24 hidden md:table-cell">应收</th>
|
||||
<tr>
|
||||
<th className="col-idx">#</th>
|
||||
<th style={{ textAlign: 'left' }}><SortableColumnHeader label="客户" sortKey="name" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} /></th>
|
||||
<th style={{ textAlign: 'center' }}><SortableColumnHeader label="承担方" sortKey="payer" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="center" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="加氢量" sortKey="kg" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="成本支出" sortKey="cost" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
<th style={{ textAlign: 'right' }}><SortableColumnHeader label="应收" sortKey="revenue" activeSortKey={sortKey} sortDirection={sortDirection} onSort={changeSort} align="right" /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{customers.map((c2, i) => {
|
||||
{sortedCustomers.map((c2, i) => {
|
||||
const kgFmt = fmtKg(c2.kg);
|
||||
const costFmt = fmtYuan(c2.cost);
|
||||
const revFmt = fmtYuan(c2.revenue);
|
||||
return (
|
||||
<tr key={c2.name + i} className="border-b border-slate-50 hover:bg-slate-50/60">
|
||||
<td className="py-1.5 pl-1 text-slate-400 tabular-nums">{i + 1}</td>
|
||||
<td className="py-1.5 text-slate-700 truncate max-w-[200px]">{c2.name}</td>
|
||||
<td className="py-1.5 text-center hidden sm:table-cell">
|
||||
<tr key={c2.name + i} onClick={() => openCustomer(c2, i)} style={{ cursor: 'pointer' }} title="点击查看客户账单明细">
|
||||
<td className="col-idx">{i + 1}</td>
|
||||
<td style={{ fontWeight: 600, color: '#0284c7' }}>
|
||||
{c2.name} <span style={{ fontSize: 11, fontWeight: 400, opacity: 0.8 }}>钻取 ›</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>
|
||||
{c2.payer === 'lingniu' ? (
|
||||
<span className="px-1.5 py-0.5 rounded bg-blue-50 text-blue-600 text-[10px] font-bold">羚牛</span>
|
||||
<span className="ehb-payer-tag is-own">羚牛</span>
|
||||
) : c2.payer === 'mixed' ? (
|
||||
<span className="ehb-payer-tag is-mix">混合</span>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600 text-[10px] font-bold">客户</span>
|
||||
<span className="ehb-payer-tag is-ext">客户</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-bold text-slate-700">
|
||||
{kgFmt.value}<span className="text-slate-400 font-normal ml-0.5">{kgFmt.unit}</span>
|
||||
<td style={{ textAlign: 'right' }} className="col-bold-kg">
|
||||
{kgFmt.value} <span style={{ fontSize: 11, fontWeight: 400, color: '#64748b' }}>{kgFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pl-2 text-right tabular-nums text-amber-600 font-bold">
|
||||
¥{costFmt.value}<span className="text-slate-400 font-normal ml-0.5">{costFmt.unit}</span>
|
||||
<td style={{ textAlign: 'right' }} className="col-amber-fee">
|
||||
¥{costFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{costFmt.unit}</span>
|
||||
</td>
|
||||
<td className="py-1.5 pr-1 text-right tabular-nums text-emerald-600 font-bold hidden md:table-cell">
|
||||
¥{revFmt.value}<span className="text-slate-400 font-normal ml-0.5">{revFmt.unit}</span>
|
||||
<td style={{ textAlign: 'right' }} className="col-green-fee">
|
||||
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -118,6 +222,7 @@ export function CustomerSummaryTable({ customers }: { customers: HydrogenCustome
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user