|
|
"use client";
|
|
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
import { CheckCircle, Database, ClockClockwise } from "@phosphor-icons/react";
|
|
|
import type {
|
|
|
QuoteDetail,
|
|
|
QuoteItem,
|
|
|
RateOption,
|
|
|
ServiceLevel,
|
|
|
} from "@/lib/frontend/types";
|
|
|
import { formatUSD, formatPercent } from "@/lib/frontend/format";
|
|
|
import { Card } from "@/components/ui/card";
|
|
|
|
|
|
const LEVEL_LABEL: Record<ServiceLevel, string> = {
|
|
|
standard: "标准",
|
|
|
guaranteed: "保证送达",
|
|
|
};
|
|
|
|
|
|
const RATE_LABEL: Record<RateOption, string> = {
|
|
|
lowest: "最低价",
|
|
|
fastest: "最快",
|
|
|
bestValue: "最优性价比",
|
|
|
};
|
|
|
|
|
|
const RATE_ORDER: RateOption[] = ["lowest", "bestValue", "fastest"];
|
|
|
|
|
|
interface QuoteCardProps {
|
|
|
quote: QuoteDetail;
|
|
|
defaultLevel?: ServiceLevel;
|
|
|
defaultRate?: RateOption;
|
|
|
}
|
|
|
|
|
|
function findTier(
|
|
|
quotes: QuoteItem[],
|
|
|
level: ServiceLevel,
|
|
|
rate: RateOption,
|
|
|
): QuoteItem | undefined {
|
|
|
return quotes.find(
|
|
|
(q) => q.service_level === level && q.rate_option === rate,
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function SourceBadge({ quote }: { quote: QuoteDetail }) {
|
|
|
if (quote.source_type === "cache") {
|
|
|
return (
|
|
|
<span className="inline-flex items-center gap-1 rounded-sm bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
|
|
<Database size={12} weight="fill" />
|
|
|
缓存命中
|
|
|
</span>
|
|
|
);
|
|
|
}
|
|
|
if (quote.source_type === "stale") {
|
|
|
return (
|
|
|
<span className="inline-flex items-center gap-1 rounded-sm bg-amber-100 px-2 py-0.5 text-xs text-warning">
|
|
|
<ClockClockwise size={12} weight="fill" />
|
|
|
非实时
|
|
|
</span>
|
|
|
);
|
|
|
}
|
|
|
return (
|
|
|
<span className="inline-flex items-center gap-1 rounded-sm bg-green-100 px-2 py-0.5 text-xs text-success">
|
|
|
<CheckCircle size={12} weight="fill" />
|
|
|
实时报价
|
|
|
</span>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export function QuoteCard({
|
|
|
quote,
|
|
|
defaultLevel = "standard",
|
|
|
defaultRate = "lowest",
|
|
|
}: QuoteCardProps) {
|
|
|
const [level, setLevel] = useState<ServiceLevel>(defaultLevel);
|
|
|
const [rate, setRate] = useState<RateOption>(defaultRate);
|
|
|
const quotes = quote.quotes ?? [];
|
|
|
const item = findTier(quotes, level, rate) ?? quotes[0];
|
|
|
const availableRates = useMemo(
|
|
|
() =>
|
|
|
RATE_ORDER.filter((rt) =>
|
|
|
quotes.some((q) => q.service_level === level && q.rate_option === rt),
|
|
|
),
|
|
|
[quotes, level],
|
|
|
);
|
|
|
|
|
|
useEffect(() => {
|
|
|
if (availableRates.length > 0 && !availableRates.includes(rate)) {
|
|
|
setRate(availableRates[0]!);
|
|
|
}
|
|
|
}, [availableRates, rate]);
|
|
|
|
|
|
if (!item) {
|
|
|
return (
|
|
|
<Card>
|
|
|
<p className="text-sm text-text-secondary">暂无报价数据</p>
|
|
|
</Card>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
<Card className="relative">
|
|
|
{!quote.is_realtime && (
|
|
|
<span className="absolute right-4 top-4 rounded-sm bg-amber-100 px-2 py-0.5 text-xs text-warning">
|
|
|
非实时报价,仅供参考
|
|
|
</span>
|
|
|
)}
|
|
|
|
|
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
|
|
<div className="flex gap-1 rounded-md bg-bg p-1">
|
|
|
{(["standard", "guaranteed"] as ServiceLevel[]).map((lv) => (
|
|
|
<button
|
|
|
key={lv}
|
|
|
type="button"
|
|
|
onClick={() => setLevel(lv)}
|
|
|
className={`rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
|
level === lv
|
|
|
? "bg-surface text-primary shadow-card"
|
|
|
: "text-text-secondary"
|
|
|
}`}
|
|
|
>
|
|
|
{LEVEL_LABEL[lv]}
|
|
|
</button>
|
|
|
))}
|
|
|
</div>
|
|
|
<SourceBadge quote={quote} />
|
|
|
</div>
|
|
|
|
|
|
<div className="mb-3 flex gap-1 rounded-md bg-bg p-1">
|
|
|
{availableRates.map((rt) => (
|
|
|
<button
|
|
|
key={rt}
|
|
|
type="button"
|
|
|
onClick={() => setRate(rt)}
|
|
|
className={`rounded-sm px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
|
rate === rt
|
|
|
? "bg-surface text-primary shadow-card"
|
|
|
: "text-text-secondary"
|
|
|
}`}
|
|
|
>
|
|
|
{RATE_LABEL[rt]}
|
|
|
</button>
|
|
|
))}
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-end justify-between gap-4">
|
|
|
<div>
|
|
|
<p className="text-sm text-text-secondary">
|
|
|
{item.carrier} · {item.transit_description}
|
|
|
</p>
|
|
|
<p className="mt-1 font-mono text-3xl font-semibold text-text-primary num">
|
|
|
{formatUSD(item.final_total)}
|
|
|
</p>
|
|
|
</div>
|
|
|
<div className="text-right text-sm text-text-secondary">
|
|
|
<p>时效 {item.transit_days} 天</p>
|
|
|
<p className="num">原价 {formatUSD(item.raw_total)}</p>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-5 border-t border-border pt-4">
|
|
|
<p className="mb-2 text-sm font-medium text-text-secondary">价格明细</p>
|
|
|
<ul className="space-y-1.5 text-sm">
|
|
|
{item.breakdown.map((b) => (
|
|
|
<li key={b.item} className="flex justify-between">
|
|
|
<span className="text-text-secondary">{b.item}</span>
|
|
|
<span className="num font-mono text-text-primary">
|
|
|
{formatUSD(b.amount)}
|
|
|
</span>
|
|
|
</li>
|
|
|
))}
|
|
|
<li className="flex justify-between border-t border-border pt-1.5 font-medium">
|
|
|
<span>客户最终价(加价 {formatPercent(item.markup_percent)})</span>
|
|
|
<span className="num font-mono text-primary">
|
|
|
{formatUSD(item.final_total)}
|
|
|
</span>
|
|
|
</li>
|
|
|
</ul>
|
|
|
</div>
|
|
|
|
|
|
<p className="mt-3 font-mono text-xs text-text-disabled">
|
|
|
单号 {quote.quote_id}
|
|
|
</p>
|
|
|
</Card>
|
|
|
);
|
|
|
}
|