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:
@@ -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