229 lines
12 KiB
TypeScript
229 lines
12 KiB
TypeScript
import type { HydrogenCustomerRow, HydrogenStationFull } from '../../types';
|
||
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,
|
||
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="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="ehb-sum-table-wrap">
|
||
<table className="ehb-sum-table">
|
||
<thead>
|
||
<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>
|
||
{sortedStations.map((s, i) => {
|
||
const kgFmt = fmtKg(s.kg);
|
||
const revFmt = fmtYuan(s.revenue);
|
||
return (
|
||
<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>
|
||
<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="ehb-ratio-text">{(s.share * 100).toFixed(1)}%</span>
|
||
</div>
|
||
</td>
|
||
<td style={{ textAlign: 'right' }} className="col-green-fee">
|
||
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
|
||
</td>
|
||
<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="ehb-ratio-text">{(s.revenueShare * 100).toFixed(1)}%</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</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,
|
||
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="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="ehb-sum-table-wrap">
|
||
<table className="ehb-sum-table">
|
||
<thead>
|
||
<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>
|
||
{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} 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="ehb-payer-tag is-own">羚牛</span>
|
||
) : c2.payer === 'mixed' ? (
|
||
<span className="ehb-payer-tag is-mix">混合</span>
|
||
) : (
|
||
<span className="ehb-payer-tag is-ext">客户</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 style={{ textAlign: 'right' }} className="col-amber-fee">
|
||
¥{costFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{costFmt.unit}</span>
|
||
</td>
|
||
<td style={{ textAlign: 'right' }} className="col-green-fee">
|
||
¥{revFmt.value} <span style={{ fontSize: 11, fontWeight: 400 }}>{revFmt.unit}</span>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<OverviewDrillDialog payload={drill} onClose={() => setDrill(null)} />
|
||
</>
|
||
);
|
||
}
|