|
|
/**
|
|
|
* MotherShip 登录后一级查价界面(Create a new shipment)
|
|
|
* 对齐官网 dashboard「创建新货件」字段结构;中文文案供嵌入宿主展示
|
|
|
*/
|
|
|
"use client";
|
|
|
|
|
|
import { useCallback, useId, useMemo, useState, type FormEvent } from "react";
|
|
|
import { Plus, X, CaretDown } from "@phosphor-icons/react";
|
|
|
import { MothershipAddressSuggestField } from "@/components/mothership/mothership-address-suggest-field";
|
|
|
import type { MothershipAddressCandidate } from "@/lib/frontend/types";
|
|
|
import {
|
|
|
QuoteFieldTooltip,
|
|
|
QuoteFloatHint,
|
|
|
QuotePageTitle,
|
|
|
QuoteRequiredMark,
|
|
|
QuoteSectionTitle,
|
|
|
QuoteStepProgress,
|
|
|
quoteAddRowCls,
|
|
|
quoteInputCls,
|
|
|
quoteInputErrCls,
|
|
|
} from "@/components/quote/quote-form-chrome";
|
|
|
import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker";
|
|
|
import {
|
|
|
defaultMothershipReadyDateIso,
|
|
|
isMothershipWeightEachAllowed,
|
|
|
MOTHERSHIP_MAX_WEIGHT_EACH_LB,
|
|
|
normalizeMothershipReadyDateIso,
|
|
|
} from "@/lib/mothership/logged-in-constraints";
|
|
|
import {
|
|
|
evaluateMothershipAccessorialCompat,
|
|
|
mothershipAccessorialHasBlock,
|
|
|
MS_PICKUP_BLOCKED_ACCESSORIALS,
|
|
|
toggleMothershipAccessorial,
|
|
|
type MsCompatIssue,
|
|
|
} from "@/lib/mothership/option-compat";
|
|
|
|
|
|
export const MS_PICKUP_ACCESSORIALS = [
|
|
|
{ id: "cfs", label: "CFS" },
|
|
|
{ id: "liftgate", label: "尾板(Liftgate)" },
|
|
|
{ id: "limitedAccess", label: "受限通道" },
|
|
|
{ id: "inside", label: "LTL 室内服务" },
|
|
|
{ id: "residential", label: "住宅" },
|
|
|
{ id: "tradeshow", label: "展会" },
|
|
|
] as const;
|
|
|
|
|
|
export const MS_DELIVERY_ACCESSORIALS = [
|
|
|
{ id: "fbaAppointment", label: "Amazon 预约" },
|
|
|
{ id: "appointment", label: "预约" },
|
|
|
{ id: "cfs", label: "CFS" },
|
|
|
{ id: "liftgate", label: "尾板(Liftgate)" },
|
|
|
{ id: "limitedAccess", label: "受限通道" },
|
|
|
{ id: "inside", label: "LTL 室内服务" },
|
|
|
{ id: "residential", label: "住宅" },
|
|
|
{ id: "tradeshow", label: "展会" },
|
|
|
] as const;
|
|
|
|
|
|
/** 对齐官网 Cargo type 下拉(录制/截图 2026-07-15) */
|
|
|
export const MS_CARGO_TYPES = [
|
|
|
{ id: "pallet", label: "托盘(Pallet)" },
|
|
|
{ id: "box", label: "箱(Box)" },
|
|
|
{ id: "crate", label: "木箱(Crate)" },
|
|
|
{ id: "piece", label: "件(Piece)" },
|
|
|
{ id: "bale", label: "捆包(Bale)" },
|
|
|
{ id: "bucket", label: "桶(Bucket)" },
|
|
|
{ id: "carton", label: "纸箱(Carton)" },
|
|
|
{ id: "case", label: "箱件(Case)" },
|
|
|
{ id: "coil", label: "卷材(Coil)" },
|
|
|
{ id: "cylinder", label: "圆筒(Cylinder)" },
|
|
|
{ id: "drum", label: "圆桶(Drum)" },
|
|
|
{ id: "pail", label: "提桶(Pail)" },
|
|
|
{ id: "reel", label: "线盘(Reel)" },
|
|
|
{ id: "roll", label: "卷(Roll)" },
|
|
|
{ id: "skid", label: "滑托(Skid)" },
|
|
|
{ id: "tote", label: "集装袋(Tote)" },
|
|
|
{ id: "tube", label: "管(Tube)" },
|
|
|
] as const;
|
|
|
|
|
|
/** 官网 Ready for pick-up 时刻:整点、12 小时制、有序无重复 */
|
|
|
export const MS_READY_TIMES: readonly string[] = (() => {
|
|
|
const labels: string[] = [];
|
|
|
for (let h = 0; h < 24; h += 1) {
|
|
|
const hour12 = h % 12 === 0 ? 12 : h % 12;
|
|
|
const suffix = h < 12 ? "AM" : "PM";
|
|
|
labels.push(`${hour12}:00 ${suffix}`);
|
|
|
}
|
|
|
return labels;
|
|
|
})();
|
|
|
|
|
|
export const MS_DEFAULT_READY_TIME = "11:00 AM";
|
|
|
|
|
|
|
|
|
export type MsAccessorialId =
|
|
|
| (typeof MS_PICKUP_ACCESSORIALS)[number]["id"]
|
|
|
| (typeof MS_DELIVERY_ACCESSORIALS)[number]["id"];
|
|
|
|
|
|
export type MsCargoTypeId = (typeof MS_CARGO_TYPES)[number]["id"];
|
|
|
|
|
|
export type MsCargoLine = {
|
|
|
key: string;
|
|
|
cargoType: MsCargoTypeId;
|
|
|
quantity: string;
|
|
|
weightLb: string;
|
|
|
lengthIn: string;
|
|
|
widthIn: string;
|
|
|
heightIn: string;
|
|
|
};
|
|
|
|
|
|
export type MothershipLoggedInShipmentPayload = {
|
|
|
pickupQuery: string;
|
|
|
deliveryQuery: string;
|
|
|
/** MotherShip 联想点选结果(继续闸门必填) */
|
|
|
pickupConfirmed: MothershipAddressCandidate;
|
|
|
deliveryConfirmed: MothershipAddressCandidate;
|
|
|
pickupAccessorials: string[];
|
|
|
deliveryAccessorials: string[];
|
|
|
readyDate: string;
|
|
|
readyTime: string;
|
|
|
timezone: string;
|
|
|
cargo: Array<{
|
|
|
cargoType: MsCargoTypeId;
|
|
|
quantity: number;
|
|
|
weightLb: number;
|
|
|
lengthIn: number;
|
|
|
widthIn: number;
|
|
|
heightIn: number;
|
|
|
}>;
|
|
|
};
|
|
|
|
|
|
export interface MothershipLoggedInShipmentFormProps {
|
|
|
formId?: string;
|
|
|
disabled?: boolean;
|
|
|
customerId: string;
|
|
|
apiBaseUrl?: string;
|
|
|
serviceToken?: string;
|
|
|
onValidSubmit?: (payload: MothershipLoggedInShipmentPayload) => void;
|
|
|
}
|
|
|
|
|
|
function newCargoLine(): MsCargoLine {
|
|
|
return {
|
|
|
key: `c-${Math.random().toString(36).slice(2, 10)}`,
|
|
|
cargoType: "pallet",
|
|
|
quantity: "",
|
|
|
weightLb: "",
|
|
|
lengthIn: "",
|
|
|
widthIn: "",
|
|
|
heightIn: "",
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function RequiredMark() {
|
|
|
return <QuoteRequiredMark />;
|
|
|
}
|
|
|
|
|
|
function AccessorialMultiSelect({
|
|
|
label,
|
|
|
options,
|
|
|
selected,
|
|
|
disabled,
|
|
|
tip,
|
|
|
side,
|
|
|
onChange,
|
|
|
}: {
|
|
|
label: string;
|
|
|
options: readonly { id: string; label: string }[];
|
|
|
selected: string[];
|
|
|
disabled?: boolean;
|
|
|
tip?: string;
|
|
|
side: "pickup" | "delivery";
|
|
|
onChange: (next: string[], meta?: { message?: string; issues: MsCompatIssue[] }) => void;
|
|
|
}) {
|
|
|
const [open, setOpen] = useState(false);
|
|
|
const [hint, setHint] = useState<string | null>(null);
|
|
|
const summary = useMemo(() => {
|
|
|
if (selected.length === 0) return "选择";
|
|
|
if (selected.length === 1) {
|
|
|
return options.find((o) => o.id === selected[0])?.label ?? "已选择";
|
|
|
}
|
|
|
const first =
|
|
|
options.find((o) => o.id === selected[0])?.label ?? selected[0];
|
|
|
return `${first} 等 ${selected.length} 项`;
|
|
|
}, [options, selected]);
|
|
|
|
|
|
return (
|
|
|
<div className="relative space-y-1">
|
|
|
<div className="flex items-center gap-1 text-xs text-[#4B5563]">
|
|
|
<span>{label}</span>
|
|
|
{tip ? <QuoteFieldTooltip tip={tip} /> : null}
|
|
|
</div>
|
|
|
<button
|
|
|
type="button"
|
|
|
disabled={disabled}
|
|
|
onClick={() => setOpen((v) => !v)}
|
|
|
className="flex h-10 w-full items-center justify-between rounded-md border border-border bg-surface px-3 text-left text-sm transition-colors hover:border-[#1890FF]/40 focus:border-[#1890FF] focus:outline-none focus:ring-2 focus:ring-[#1890FF]/20 disabled:cursor-not-allowed disabled:opacity-60"
|
|
|
>
|
|
|
<span
|
|
|
className={
|
|
|
selected.length === 0 ? "text-text-disabled" : "text-text-primary"
|
|
|
}
|
|
|
>
|
|
|
{summary}
|
|
|
</span>
|
|
|
<CaretDown size={16} className="text-text-secondary" />
|
|
|
</button>
|
|
|
{open && !disabled && (
|
|
|
<div className="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-md border border-border bg-surface py-1 shadow-modal">
|
|
|
{options.map((opt) => {
|
|
|
const checked = selected.includes(opt.id);
|
|
|
const pickupBlocked =
|
|
|
side === "pickup" &&
|
|
|
Boolean(MS_PICKUP_BLOCKED_ACCESSORIALS[opt.id]);
|
|
|
const blockedReason = pickupBlocked
|
|
|
? MS_PICKUP_BLOCKED_ACCESSORIALS[opt.id]
|
|
|
: undefined;
|
|
|
return (
|
|
|
<label
|
|
|
key={opt.id}
|
|
|
className={[
|
|
|
"flex items-start gap-2 px-3 py-2 text-sm",
|
|
|
pickupBlocked && !checked
|
|
|
? "cursor-not-allowed opacity-60"
|
|
|
: "cursor-pointer hover:bg-bg",
|
|
|
].join(" ")}
|
|
|
title={blockedReason}
|
|
|
>
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
checked={checked}
|
|
|
disabled={pickupBlocked && !checked}
|
|
|
onChange={() => {
|
|
|
const r = toggleMothershipAccessorial({
|
|
|
side,
|
|
|
selected,
|
|
|
id: opt.id,
|
|
|
});
|
|
|
if (!r.applied) {
|
|
|
setHint(r.message ?? null);
|
|
|
return;
|
|
|
}
|
|
|
setHint(r.message ?? null);
|
|
|
onChange(r.next, { message: r.message, issues: r.issues });
|
|
|
}}
|
|
|
className="mt-0.5 h-4 w-4 rounded border-border text-[#1890FF] focus:ring-[#1890FF]/30 disabled:cursor-not-allowed"
|
|
|
/>
|
|
|
<span>
|
|
|
<span className="block">{opt.label}</span>
|
|
|
{blockedReason ? (
|
|
|
<span className="mt-0.5 block text-[11px] text-[#EF4444]">
|
|
|
{blockedReason}
|
|
|
</span>
|
|
|
) : null}
|
|
|
</span>
|
|
|
</label>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
)}
|
|
|
{hint ? (
|
|
|
<p className="text-[11px] leading-snug text-[#B45309]">{hint}</p>
|
|
|
) : null}
|
|
|
{selected.length > 0 && (
|
|
|
<div className="flex flex-wrap gap-1.5 pt-1">
|
|
|
{selected.map((id) => {
|
|
|
const opt = options.find((o) => o.id === id);
|
|
|
return (
|
|
|
<span
|
|
|
key={id}
|
|
|
className="inline-flex items-center gap-1 rounded-full border border-border bg-bg px-2 py-0.5 text-xs text-text-secondary"
|
|
|
>
|
|
|
{opt?.label ?? id}
|
|
|
</span>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** MotherShip 登录后一级:创建新货件 */
|
|
|
export function MothershipLoggedInShipmentForm({
|
|
|
formId = "ms-logged-in-shipment-form",
|
|
|
disabled,
|
|
|
customerId,
|
|
|
apiBaseUrl = "",
|
|
|
serviceToken,
|
|
|
onValidSubmit,
|
|
|
}: MothershipLoggedInShipmentFormProps) {
|
|
|
const baseId = useId();
|
|
|
const [pickupQuery, setPickupQuery] = useState("");
|
|
|
const [deliveryQuery, setDeliveryQuery] = useState("");
|
|
|
const [pickupConfirmed, setPickupConfirmed] =
|
|
|
useState<MothershipAddressCandidate | null>(null);
|
|
|
const [deliveryConfirmed, setDeliveryConfirmed] =
|
|
|
useState<MothershipAddressCandidate | null>(null);
|
|
|
const [pickupAccessorials, setPickupAccessorials] = useState<string[]>([]);
|
|
|
const [deliveryAccessorials, setDeliveryAccessorials] = useState<string[]>(
|
|
|
[],
|
|
|
);
|
|
|
const [readyDate, setReadyDate] = useState(defaultMothershipReadyDateIso);
|
|
|
const [readyTime, setReadyTime] = useState(MS_DEFAULT_READY_TIME);
|
|
|
const [timezone] = useState("GMT+8");
|
|
|
const [cargoLines, setCargoLines] = useState<MsCargoLine[]>([newCargoLine()]);
|
|
|
const [localError, setLocalError] = useState<string | null>(null);
|
|
|
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set());
|
|
|
|
|
|
const accessorialHints = useMemo(() => {
|
|
|
return [
|
|
|
...evaluateMothershipAccessorialCompat({
|
|
|
side: "pickup",
|
|
|
selected: pickupAccessorials,
|
|
|
}),
|
|
|
...evaluateMothershipAccessorialCompat({
|
|
|
side: "delivery",
|
|
|
selected: deliveryAccessorials,
|
|
|
}),
|
|
|
];
|
|
|
}, [pickupAccessorials, deliveryAccessorials]);
|
|
|
|
|
|
const updateCargo = useCallback(
|
|
|
(key: string, patch: Partial<MsCargoLine>) => {
|
|
|
setCargoLines((rows) =>
|
|
|
rows.map((r) => (r.key === key ? { ...r, ...patch } : r)),
|
|
|
);
|
|
|
},
|
|
|
[],
|
|
|
);
|
|
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
|
e.preventDefault();
|
|
|
setLocalError(null);
|
|
|
const nextInvalid = new Set<string>();
|
|
|
|
|
|
if (!pickupConfirmed) {
|
|
|
nextInvalid.add("pickup");
|
|
|
setInvalidFields(nextInvalid);
|
|
|
setLocalError("请填写提货地址");
|
|
|
return;
|
|
|
}
|
|
|
if (!deliveryConfirmed) {
|
|
|
nextInvalid.add("delivery");
|
|
|
setInvalidFields(nextInvalid);
|
|
|
setLocalError("请填写送货地址");
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const pickupIssues = evaluateMothershipAccessorialCompat({
|
|
|
side: "pickup",
|
|
|
selected: pickupAccessorials,
|
|
|
});
|
|
|
const deliveryIssues = evaluateMothershipAccessorialCompat({
|
|
|
side: "delivery",
|
|
|
selected: deliveryAccessorials,
|
|
|
});
|
|
|
const blockMsg = [...pickupIssues, ...deliveryIssues].find(
|
|
|
(i) => i.severity === "block",
|
|
|
)?.message;
|
|
|
if (blockMsg) {
|
|
|
setLocalError(blockMsg);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const cargo: MothershipLoggedInShipmentPayload["cargo"] = [];
|
|
|
for (const line of cargoLines) {
|
|
|
const quantity = Number(line.quantity);
|
|
|
const weightLb = Number(line.weightLb);
|
|
|
const lengthIn = Number(line.lengthIn);
|
|
|
const widthIn = Number(line.widthIn);
|
|
|
const heightIn = Number(line.heightIn);
|
|
|
if (!Number.isFinite(quantity) || quantity < 1) {
|
|
|
nextInvalid.add(`${line.key}-quantity`);
|
|
|
}
|
|
|
if (!Number.isFinite(weightLb) || weightLb <= 0) {
|
|
|
nextInvalid.add(`${line.key}-weight`);
|
|
|
} else if (!isMothershipWeightEachAllowed(weightLb)) {
|
|
|
nextInvalid.add(`${line.key}-weight`);
|
|
|
setInvalidFields(nextInvalid);
|
|
|
setLocalError(
|
|
|
`单件重量不能超过 ${MOTHERSHIP_MAX_WEIGHT_EACH_LB} 磅`,
|
|
|
);
|
|
|
return;
|
|
|
}
|
|
|
if (!Number.isFinite(lengthIn) || lengthIn <= 0) {
|
|
|
nextInvalid.add(`${line.key}-length`);
|
|
|
}
|
|
|
if (!Number.isFinite(widthIn) || widthIn <= 0) {
|
|
|
nextInvalid.add(`${line.key}-width`);
|
|
|
}
|
|
|
if (!Number.isFinite(heightIn) || heightIn <= 0) {
|
|
|
nextInvalid.add(`${line.key}-height`);
|
|
|
}
|
|
|
if (
|
|
|
nextInvalid.has(`${line.key}-quantity`) ||
|
|
|
nextInvalid.has(`${line.key}-weight`) ||
|
|
|
nextInvalid.has(`${line.key}-length`) ||
|
|
|
nextInvalid.has(`${line.key}-width`) ||
|
|
|
nextInvalid.has(`${line.key}-height`)
|
|
|
) {
|
|
|
setInvalidFields(nextInvalid);
|
|
|
setLocalError("请完整填写每行货物的数量、重量与尺寸");
|
|
|
return;
|
|
|
}
|
|
|
cargo.push({
|
|
|
cargoType: line.cargoType,
|
|
|
quantity,
|
|
|
weightLb,
|
|
|
lengthIn,
|
|
|
widthIn,
|
|
|
heightIn,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
setInvalidFields(new Set());
|
|
|
const normalizedReadyDate = normalizeMothershipReadyDateIso(readyDate) ?? readyDate;
|
|
|
onValidSubmit?.({
|
|
|
pickupQuery:
|
|
|
pickupConfirmed.display_label ||
|
|
|
pickupConfirmed.formatted_address ||
|
|
|
pickupQuery.trim(),
|
|
|
deliveryQuery:
|
|
|
deliveryConfirmed.display_label ||
|
|
|
deliveryConfirmed.formatted_address ||
|
|
|
deliveryQuery.trim(),
|
|
|
pickupConfirmed,
|
|
|
deliveryConfirmed,
|
|
|
pickupAccessorials,
|
|
|
deliveryAccessorials,
|
|
|
readyDate: normalizedReadyDate,
|
|
|
readyTime,
|
|
|
timezone,
|
|
|
cargo,
|
|
|
});
|
|
|
};
|
|
|
|
|
|
const fieldCls = (key: string) =>
|
|
|
invalidFields.has(key) ? quoteInputErrCls : quoteInputCls;
|
|
|
|
|
|
return (
|
|
|
<form id={formId} onSubmit={handleSubmit} className="space-y-6">
|
|
|
<div className="space-y-3">
|
|
|
<QuoteStepProgress current={1} />
|
|
|
<QuotePageTitle>创建新货件</QuotePageTitle>
|
|
|
</div>
|
|
|
{accessorialHints.length > 0 ? (
|
|
|
<div className="space-y-1 rounded-md border border-[#F59E0B]/40 bg-[#FFFBEB] px-3 py-2 text-[12px] leading-snug text-[#92400E]">
|
|
|
<p className="font-medium text-[#B45309]">附加服务提示</p>
|
|
|
<ul className="list-disc space-y-1 pl-4">
|
|
|
{accessorialHints.map((i) => (
|
|
|
<li
|
|
|
key={i.code}
|
|
|
className={i.severity === "block" ? "font-medium text-[#EF4444]" : ""}
|
|
|
>
|
|
|
{i.message}
|
|
|
</li>
|
|
|
))}
|
|
|
</ul>
|
|
|
</div>
|
|
|
) : null}
|
|
|
|
|
|
<QuoteFloatHint message={localError} />
|
|
|
|
|
|
{/* 地址 + 时间 */}
|
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
|
|
|
<div className="mb-4">
|
|
|
<QuoteSectionTitle>提货与送货</QuoteSectionTitle>
|
|
|
</div>
|
|
|
<div className="grid gap-4 md:grid-cols-[1fr_auto_1fr] md:items-start">
|
|
|
<div className="space-y-4 rounded-md border-l-[3px] border-[#1890FF]/50 bg-[#1890FF]/5 py-1 pl-3">
|
|
|
<MothershipAddressSuggestField
|
|
|
label="提货地址"
|
|
|
required
|
|
|
invalid={invalidFields.has("pickup")}
|
|
|
testId="quote-create-pickup-input-search"
|
|
|
disabled={disabled}
|
|
|
customerId={customerId}
|
|
|
apiBaseUrl={apiBaseUrl}
|
|
|
serviceToken={serviceToken}
|
|
|
value={pickupQuery}
|
|
|
confirmed={pickupConfirmed}
|
|
|
onQueryChange={(q) => {
|
|
|
setPickupQuery(q);
|
|
|
setInvalidFields((prev) => {
|
|
|
const next = new Set(prev);
|
|
|
next.delete("pickup");
|
|
|
return next;
|
|
|
});
|
|
|
}}
|
|
|
onConfirm={(c) => {
|
|
|
setPickupConfirmed(c);
|
|
|
if (c) {
|
|
|
setInvalidFields((prev) => {
|
|
|
const next = new Set(prev);
|
|
|
next.delete("pickup");
|
|
|
return next;
|
|
|
});
|
|
|
setLocalError(null);
|
|
|
}
|
|
|
}}
|
|
|
/>
|
|
|
<AccessorialMultiSelect
|
|
|
label="附加服务(可选)"
|
|
|
tip="提货地点可能产生的附加费用服务;住宅提货承运商不支持"
|
|
|
side="pickup"
|
|
|
options={MS_PICKUP_ACCESSORIALS}
|
|
|
selected={pickupAccessorials}
|
|
|
disabled={disabled}
|
|
|
onChange={(next, meta) => {
|
|
|
setPickupAccessorials(next);
|
|
|
if (meta?.message) setLocalError(meta.message);
|
|
|
else if (
|
|
|
meta?.issues &&
|
|
|
mothershipAccessorialHasBlock(meta.issues)
|
|
|
) {
|
|
|
setLocalError(
|
|
|
meta.issues.find((i) => i.severity === "block")!.message,
|
|
|
);
|
|
|
}
|
|
|
}}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
<div className="hidden items-center justify-center pt-8 text-text-disabled md:flex">
|
|
|
→
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-4 rounded-md border-l-[3px] border-[#64748B]/40 bg-bg/60 py-1 pl-3">
|
|
|
<MothershipAddressSuggestField
|
|
|
label="送货地址"
|
|
|
required
|
|
|
invalid={invalidFields.has("delivery")}
|
|
|
testId="quote-create-delivery-input-search"
|
|
|
disabled={disabled}
|
|
|
customerId={customerId}
|
|
|
apiBaseUrl={apiBaseUrl}
|
|
|
serviceToken={serviceToken}
|
|
|
value={deliveryQuery}
|
|
|
confirmed={deliveryConfirmed}
|
|
|
onQueryChange={(q) => {
|
|
|
setDeliveryQuery(q);
|
|
|
setInvalidFields((prev) => {
|
|
|
const next = new Set(prev);
|
|
|
next.delete("delivery");
|
|
|
return next;
|
|
|
});
|
|
|
}}
|
|
|
onConfirm={(c) => {
|
|
|
setDeliveryConfirmed(c);
|
|
|
if (c) {
|
|
|
setInvalidFields((prev) => {
|
|
|
const next = new Set(prev);
|
|
|
next.delete("delivery");
|
|
|
return next;
|
|
|
});
|
|
|
setLocalError(null);
|
|
|
}
|
|
|
}}
|
|
|
/>
|
|
|
<AccessorialMultiSelect
|
|
|
label="附加服务(可选)"
|
|
|
tip="送货地点可能产生的附加费用服务;预约与 Amazon 预约不能同时选"
|
|
|
side="delivery"
|
|
|
options={MS_DELIVERY_ACCESSORIALS}
|
|
|
selected={deliveryAccessorials}
|
|
|
disabled={disabled}
|
|
|
onChange={(next, meta) => {
|
|
|
setDeliveryAccessorials(next);
|
|
|
if (meta?.message) setLocalError(meta.message);
|
|
|
}}
|
|
|
/>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-6 flex flex-wrap items-end gap-4 border-t border-border pt-4">
|
|
|
<div className="space-y-1">
|
|
|
<div className="flex items-center gap-1 text-xs text-[#4B5563]">
|
|
|
<span>可提货时间</span>
|
|
|
<QuoteFieldTooltip tip="货物最早可被承运商提走的日期与时刻;周六、周日不可选" />
|
|
|
</div>
|
|
|
<MothershipWeekdayDatePicker
|
|
|
disabled={disabled}
|
|
|
value={readyDate}
|
|
|
onChange={setReadyDate}
|
|
|
/>
|
|
|
</div>
|
|
|
<span className="pb-2 text-sm text-text-secondary">之后</span>
|
|
|
<div className="space-y-1">
|
|
|
<label className="sr-only" htmlFor={`${baseId}-ready-time`}>
|
|
|
可提货时刻
|
|
|
</label>
|
|
|
<select
|
|
|
id={`${baseId}-ready-time`}
|
|
|
data-testid="ready-pickup-time-select"
|
|
|
disabled={disabled}
|
|
|
value={readyTime}
|
|
|
onChange={(e) => setReadyTime(e.target.value)}
|
|
|
className={`${quoteInputCls} min-w-[7.5rem]`}
|
|
|
>
|
|
|
{MS_READY_TIMES.map((t) => (
|
|
|
<option key={t} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</div>
|
|
|
<span className="pb-2 text-sm text-text-secondary">{timezone}</span>
|
|
|
</div>
|
|
|
</section>
|
|
|
|
|
|
{/* 货物 */}
|
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
|
|
|
<div className="mb-4 flex items-center justify-between gap-3">
|
|
|
<QuoteSectionTitle>货物</QuoteSectionTitle>
|
|
|
<button
|
|
|
type="button"
|
|
|
data-testid="cargo-add-button"
|
|
|
disabled={disabled}
|
|
|
onClick={() => setCargoLines((rows) => [...rows, newCargoLine()])}
|
|
|
className={quoteAddRowCls}
|
|
|
>
|
|
|
<Plus size={16} weight="bold" />
|
|
|
添加货物行
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
{cargoLines.map((line, idx) => (
|
|
|
<div
|
|
|
key={line.key}
|
|
|
className="grid min-h-12 gap-4 border-b border-border pb-4 last:border-0 last:pb-0 sm:grid-cols-2 lg:grid-cols-7"
|
|
|
>
|
|
|
<label className="space-y-1 lg:col-span-1">
|
|
|
<span className="flex items-center gap-1 text-xs text-[#4B5563]">
|
|
|
货物类型
|
|
|
<QuoteFieldTooltip tip="货物包装形态,影响承运商派车与装卸" />
|
|
|
</span>
|
|
|
<select
|
|
|
data-testid={idx === 0 ? "cargo-type-dropdown-input" : undefined}
|
|
|
disabled={disabled}
|
|
|
value={line.cargoType}
|
|
|
onChange={(e) =>
|
|
|
updateCargo(line.key, {
|
|
|
cargoType: e.target.value as MsCargoTypeId,
|
|
|
})
|
|
|
}
|
|
|
className={quoteInputCls}
|
|
|
>
|
|
|
{MS_CARGO_TYPES.map((t) => (
|
|
|
<option key={t.id} value={t.id}>
|
|
|
{t.label}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-1">
|
|
|
<span className="text-xs text-[#4B5563]">
|
|
|
数量
|
|
|
<RequiredMark />
|
|
|
</span>
|
|
|
<input
|
|
|
data-testid={idx === 0 ? "cargo-quantity-input" : undefined}
|
|
|
disabled={disabled}
|
|
|
inputMode="numeric"
|
|
|
placeholder="数量"
|
|
|
value={line.quantity}
|
|
|
onChange={(e) =>
|
|
|
updateCargo(line.key, { quantity: e.target.value })
|
|
|
}
|
|
|
className={fieldCls(`${line.key}-quantity`)}
|
|
|
/>
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-1">
|
|
|
<span className="text-xs text-[#4B5563]">
|
|
|
单件重量
|
|
|
<RequiredMark />
|
|
|
</span>
|
|
|
<input
|
|
|
data-testid={idx === 0 ? "cargo-weight-input" : undefined}
|
|
|
disabled={disabled}
|
|
|
inputMode="decimal"
|
|
|
placeholder="lbs"
|
|
|
value={line.weightLb}
|
|
|
onChange={(e) =>
|
|
|
updateCargo(line.key, { weightLb: e.target.value })
|
|
|
}
|
|
|
className={fieldCls(`${line.key}-weight`)}
|
|
|
/>
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-1">
|
|
|
<span className="text-xs text-[#4B5563]">
|
|
|
单件长
|
|
|
<RequiredMark />
|
|
|
</span>
|
|
|
<input
|
|
|
data-testid={idx === 0 ? "cargo-length-input" : undefined}
|
|
|
disabled={disabled}
|
|
|
inputMode="decimal"
|
|
|
placeholder="inch"
|
|
|
value={line.lengthIn}
|
|
|
onChange={(e) =>
|
|
|
updateCargo(line.key, { lengthIn: e.target.value })
|
|
|
}
|
|
|
className={fieldCls(`${line.key}-length`)}
|
|
|
/>
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-1">
|
|
|
<span className="text-xs text-[#4B5563]">
|
|
|
单件宽
|
|
|
<RequiredMark />
|
|
|
</span>
|
|
|
<input
|
|
|
data-testid={idx === 0 ? "cargo-width-input" : undefined}
|
|
|
disabled={disabled}
|
|
|
inputMode="decimal"
|
|
|
placeholder="inch"
|
|
|
value={line.widthIn}
|
|
|
onChange={(e) =>
|
|
|
updateCargo(line.key, { widthIn: e.target.value })
|
|
|
}
|
|
|
className={fieldCls(`${line.key}-width`)}
|
|
|
/>
|
|
|
</label>
|
|
|
|
|
|
<label className="space-y-1">
|
|
|
<span className="text-xs text-[#4B5563]">
|
|
|
单件高
|
|
|
<RequiredMark />
|
|
|
</span>
|
|
|
<input
|
|
|
data-testid={idx === 0 ? "cargo-height-input" : undefined}
|
|
|
disabled={disabled}
|
|
|
inputMode="decimal"
|
|
|
placeholder="inch"
|
|
|
value={line.heightIn}
|
|
|
onChange={(e) =>
|
|
|
updateCargo(line.key, { heightIn: e.target.value })
|
|
|
}
|
|
|
className={fieldCls(`${line.key}-height`)}
|
|
|
/>
|
|
|
</label>
|
|
|
|
|
|
<div className="flex items-end justify-end">
|
|
|
<button
|
|
|
type="button"
|
|
|
data-testid="cargo-remove-button"
|
|
|
disabled={disabled || cargoLines.length <= 1}
|
|
|
onClick={() =>
|
|
|
setCargoLines((rows) =>
|
|
|
rows.filter((r) => r.key !== line.key),
|
|
|
)
|
|
|
}
|
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-border text-text-secondary transition-colors hover:border-[#EF4444] hover:text-[#EF4444] disabled:cursor-not-allowed disabled:opacity-40"
|
|
|
aria-label="删除该行货物"
|
|
|
>
|
|
|
<X size={16} weight="bold" />
|
|
|
</button>
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</section>
|
|
|
</form>
|
|
|
);
|
|
|
}
|