262 lines
14 KiB
TypeScript
262 lines
14 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { ChevronLeft, ChevronRight, ReceiptText, Route, Search } from 'lucide-react';
|
|
import Blur from '../../components/Blur';
|
|
import { ErrorState, LoadingState } from '../../components/ui/surface';
|
|
import { fetchEtcBills, fetchEtcRecords } from './api';
|
|
import type { EtcBillResponse, EtcTollRecordResponse } from './types';
|
|
import type { EtcDetailView, EtcDrillContext } from './etc-drill-context';
|
|
import { DateRangeInputs } from './AnalysisDateFilters';
|
|
|
|
function fmtMoney(value: number): string {
|
|
return `¥${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
function costTypeLabel(value: number): string {
|
|
if (value === 1) return '客户承担';
|
|
if (value === 2) return '我方承担';
|
|
return '承担方待确认';
|
|
}
|
|
|
|
function paymentLabel(receivable: number, paid: number): string {
|
|
if (receivable > 0 && paid >= receivable) return '已收';
|
|
if (paid > 0) return '部分收款';
|
|
return '未收';
|
|
}
|
|
|
|
export default function ETCDetails({
|
|
context,
|
|
onContextChange,
|
|
}: {
|
|
context: EtcDrillContext;
|
|
onContextChange: (context: EtcDrillContext, mode: 'push' | 'replace') => void;
|
|
}) {
|
|
const { view, startDate, endDate, search, page } = context;
|
|
const [draftSearch, setDraftSearch] = useState(search);
|
|
const [records, setRecords] = useState<EtcTollRecordResponse | null>(null);
|
|
const [bills, setBills] = useState<EtcBillResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => setDraftSearch(search), [search]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
setError(null);
|
|
if (view === 'records') setRecords(null);
|
|
else setBills(null);
|
|
const query = {
|
|
page,
|
|
limit: 20,
|
|
startDate: startDate || undefined,
|
|
endDate: endDate || undefined,
|
|
search: search || undefined,
|
|
};
|
|
const request = view === 'records' ? fetchEtcRecords(query) : fetchEtcBills(query);
|
|
request
|
|
.then(result => {
|
|
if (cancelled) return;
|
|
if (page > result.totalPages) {
|
|
onContextChange({ ...context, page: result.totalPages }, 'replace');
|
|
return;
|
|
}
|
|
if (view === 'records') setRecords(result as EtcTollRecordResponse);
|
|
else setBills(result as EtcBillResponse);
|
|
})
|
|
.catch(loadError => {
|
|
if (!cancelled) setError(loadError instanceof Error ? loadError.message : String(loadError));
|
|
})
|
|
.finally(() => { if (!cancelled) setLoading(false); });
|
|
return () => { cancelled = true; };
|
|
}, [context, endDate, onContextChange, page, search, startDate, view]);
|
|
|
|
const activeData = view === 'records' ? records : bills;
|
|
const totalPages = activeData?.totalPages || 1;
|
|
const applySearch = () => {
|
|
onContextChange({ ...context, search: draftSearch.trim(), page: 1 }, 'replace');
|
|
};
|
|
const changeView = (next: EtcDetailView) => {
|
|
onContextChange(
|
|
{ ...context, view: next, page: 1 },
|
|
next === view ? 'replace' : 'push',
|
|
);
|
|
};
|
|
const updateDate = (field: 'start' | 'end', value: string) => {
|
|
let nextStart = field === 'start' ? value : startDate;
|
|
let nextEnd = field === 'end' ? value : endDate;
|
|
if (nextStart && nextEnd && nextStart > nextEnd) [nextStart, nextEnd] = [nextEnd, nextStart];
|
|
onContextChange({ ...context, startDate: nextStart, endDate: nextEnd, page: 1 }, 'replace');
|
|
};
|
|
|
|
return (
|
|
<section
|
|
id="etc-details"
|
|
tabIndex={-1}
|
|
className="scroll-mt-3 overflow-hidden rounded-lg border border-slate-100 bg-white shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2"
|
|
>
|
|
<header className="flex flex-col gap-3 border-b border-slate-100 px-3 py-3 md:px-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-sm font-black text-slate-900">ETC 明细下钻</h2>
|
|
<p className="mt-1 text-[10px] font-bold text-slate-400">
|
|
{view === 'records' ? '通行流水与费用承担' : '客户账期、应收与收款'}
|
|
</p>
|
|
</div>
|
|
<div className="flex rounded-lg bg-slate-100 p-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => changeView('records')}
|
|
aria-pressed={view === 'records'}
|
|
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'records' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
|
|
>
|
|
<Route size={13} />通行记录
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => changeView('bills')}
|
|
aria-pressed={view === 'bills'}
|
|
className={`inline-flex h-8 items-center gap-1.5 rounded-md px-3 text-[10px] font-black ${view === 'bills' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500'}`}
|
|
>
|
|
<ReceiptText size={13} />结算账单
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-[minmax(0,1fr)_40px] gap-2 sm:grid-cols-[minmax(280px,1fr)_minmax(0,1fr)_40px]">
|
|
<DateRangeInputs
|
|
idPrefix="etc"
|
|
ariaPrefix="ETC"
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
onChange={updateDate}
|
|
className="col-span-2 sm:col-span-1"
|
|
/>
|
|
<label className="min-w-0 rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
|
|
<span className="block text-[10px] font-black text-slate-400">搜索</span>
|
|
<input
|
|
type="search"
|
|
aria-label={view === 'records' ? '搜索车牌、客户或流水号' : '搜索客户或账单号'}
|
|
placeholder={view === 'records' ? '车牌 / 客户 / 流水号' : '客户 / 账单号'}
|
|
value={draftSearch}
|
|
maxLength={128}
|
|
onChange={event => setDraftSearch(event.target.value)}
|
|
onKeyDown={event => { if (event.key === 'Enter') applySearch(); }}
|
|
className="mt-1 h-6 w-full min-w-0 bg-transparent text-[11px] font-bold text-slate-700 outline-none placeholder:text-slate-300"
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={applySearch}
|
|
className="flex h-full min-h-[58px] w-10 items-center justify-center rounded-lg bg-slate-900 text-white hover:bg-blue-600"
|
|
aria-label="应用 ETC 明细搜索"
|
|
title="搜索"
|
|
>
|
|
<Search size={14} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{activeData ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 bg-slate-50/70 px-3 py-2 text-[10px] font-bold text-slate-500 md:px-4">
|
|
<span>{activeData.total} 条结果</span>
|
|
{view === 'records' && records ? (
|
|
<span>通行费 {fmtMoney(records.summary.tollAmount)} · 服务费 {fmtMoney(records.summary.serviceFee)} · 合计 {fmtMoney(records.summary.totalAmount)}</span>
|
|
) : view === 'bills' && bills ? (
|
|
<span>应收 {fmtMoney(bills.summary.receivableAmount)} · 已收 {fmtMoney(bills.summary.paidAmount)}</span>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{error ? (
|
|
<ErrorState message={error} variant="inline" />
|
|
) : loading && !activeData ? (
|
|
<LoadingState label="正在加载 ETC 明细" variant="inline" />
|
|
) : view === 'records' && records ? (
|
|
records.items.length === 0 ? (
|
|
<div className="py-14 text-center">
|
|
<Route size={22} className="mx-auto text-slate-300" />
|
|
<div className="mt-3 text-xs font-black text-slate-500">暂无匹配的通行记录</div>
|
|
<div className="mt-1 text-[10px] font-bold text-slate-300">首次同步后将按通行时间倒序展示</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="hidden overflow-x-auto md:block">
|
|
<table className="w-full min-w-[820px] text-left text-[11px]">
|
|
<thead className="bg-white text-[10px] font-black text-slate-400">
|
|
<tr><th className="px-4 py-2">通行时间</th><th className="px-3 py-2">车牌 / 客户</th><th className="px-3 py-2">入口 → 出口</th><th className="px-3 py-2">承担</th><th className="px-4 py-2 text-right">费用</th></tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{records.items.map(item => (
|
|
<tr key={item.id}>
|
|
<td className="px-4 py-3 font-bold text-slate-600">{item.transTime}</td>
|
|
<td className="px-3 py-3"><div className="font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur></div></td>
|
|
<td className="px-3 py-3 font-bold text-slate-600">{item.entryStation || '-'} → {item.exitStation || '-'}</td>
|
|
<td className="px-3 py-3 font-bold text-slate-600">{costTypeLabel(item.costType)}</td>
|
|
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.totalAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">通行 {fmtMoney(item.tollAmount)} · 服务 {fmtMoney(item.serviceFee)}</div></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="divide-y divide-slate-100 md:hidden">
|
|
{records.items.map(item => (
|
|
<article key={item.id} className="px-3 py-3">
|
|
<div className="flex items-start justify-between gap-3"><div><div className="font-mono text-xs font-black text-slate-800"><Blur>{item.plate}</Blur></div><div className="mt-1 text-[9px] font-bold text-slate-400">{item.transTime}</div></div><div className="text-right text-sm font-black text-slate-800">{fmtMoney(item.totalAmount)}</div></div>
|
|
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.entryStation || '-'} → {item.exitStation || '-'}</div>
|
|
<div className="mt-1 flex justify-between gap-2 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未关联客户'}</Blur><span>{costTypeLabel(item.costType)}</span></div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</>
|
|
)
|
|
) : view === 'bills' && bills ? (
|
|
bills.items.length === 0 ? (
|
|
<div className="py-14 text-center">
|
|
<ReceiptText size={22} className="mx-auto text-slate-300" />
|
|
<div className="mt-3 text-xs font-black text-slate-500">暂无匹配的结算账单</div>
|
|
<div className="mt-1 text-[10px] font-bold text-slate-300">生成账单后将按账期结束日倒序展示</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="hidden overflow-x-auto md:block">
|
|
<table className="w-full min-w-[760px] text-left text-[11px]">
|
|
<thead className="text-[10px] font-black text-slate-400"><tr><th className="px-4 py-2">账单 / 客户</th><th className="px-3 py-2">账期</th><th className="px-3 py-2">通行</th><th className="px-3 py-2">收款状态</th><th className="px-4 py-2 text-right">应收 / 已收</th></tr></thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{bills.items.map(item => (
|
|
<tr key={item.id}>
|
|
<td className="px-4 py-3"><div className="font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></td>
|
|
<td className="px-3 py-3 font-bold text-slate-600">{item.periodStart} → {item.periodEnd}</td>
|
|
<td className="px-3 py-3 font-bold text-slate-600">{item.tollCount} 次 · {fmtMoney(item.tollAmount)}</td>
|
|
<td className="px-3 py-3 font-bold text-slate-600">{paymentLabel(item.receivableAmount, item.paidAmount)}</td>
|
|
<td className="px-4 py-3 text-right"><div className="font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">已收 {fmtMoney(item.paidAmount)}</div></td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="divide-y divide-slate-100 md:hidden">
|
|
{bills.items.map(item => (
|
|
<article key={item.id} className="px-3 py-3">
|
|
<div className="flex items-start justify-between gap-3"><div><div className="text-xs font-black text-slate-800">{item.billCode}</div><div className="mt-1 text-[9px] font-bold text-slate-400"><Blur>{item.customerName || '未指定客户'}</Blur></div></div><div className="text-right"><div className="text-sm font-black text-slate-800">{fmtMoney(item.receivableAmount)}</div><div className="mt-1 text-[9px] font-bold text-slate-400">已收 {fmtMoney(item.paidAmount)}</div></div></div>
|
|
<div className="mt-2 text-[10px] font-bold text-slate-500">{item.periodStart} → {item.periodEnd}</div>
|
|
<div className="mt-1 flex justify-between text-[9px] font-bold text-slate-400"><span>{item.tollCount} 次通行</span><span>{paymentLabel(item.receivableAmount, item.paidAmount)}</span></div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</>
|
|
)
|
|
) : null}
|
|
|
|
{activeData ? (
|
|
<footer className="flex items-center justify-between border-t border-slate-100 px-3 py-3 md:px-4">
|
|
<span className="text-[10px] font-bold text-slate-400">第 {page} / {totalPages} 页</span>
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={() => onContextChange({ ...context, page: Math.max(1, page - 1) }, 'push')} disabled={page <= 1 || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="上一页" title="上一页"><ChevronLeft size={14} /></button>
|
|
<button type="button" onClick={() => onContextChange({ ...context, page: Math.min(totalPages, page + 1) }, 'push')} disabled={page >= totalPages || loading} className="flex h-8 w-8 items-center justify-center rounded-lg border border-slate-200 text-slate-500 disabled:opacity-30" aria-label="下一页" title="下一页"><ChevronRight size={14} /></button>
|
|
</div>
|
|
</footer>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|