You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/components/mothership/mothership-logged-in-shipme...

1026 lines
36 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* MotherShip 登录后一级查价界面(Create a new shipment)
* 对齐官网 dashboard「创建新货件」字段结构;中文文案供嵌入宿主展示
*/
"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react";
import { Plus, X, CaretDown, WarningCircle } 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,
ceilMothershipNumeric,
findMothershipCargoWeightBlockMessage,
hasMothershipFractionalPart,
isMothershipWeightEachAllowed,
MOTHERSHIP_AVG_WEIGHT_BLOCK_MESSAGE,
MOTHERSHIP_CARGO_ENTRY_HINT_ITEMS,
MOTHERSHIP_CARGO_ENTRY_HINT_TITLE,
MOTHERSHIP_INTEGER_HINT,
normalizeMothershipReadyDateIso,
} from "@/lib/mothership/logged-in-constraints";
import {
evaluateMothershipAccessorialCompat,
mothershipAccessorialHasBlock,
MS_PICKUP_RISK_ACCESSORIALS,
toggleMothershipAccessorial,
type MsCompatIssue,
} from "@/lib/mothership/option-compat";
import { useHostBridgeOptional } from "@/lib/embed/host-bridge-react";
import {
msLoggedInAddressQueryText,
postToHost,
type MsLoggedInFillPayload,
} from "@/lib/embed/host-bridge";
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;
/**
* 官网 Piece count type 下拉(可选,非必填)
* 选项为内容包装形态,通常与 Cargo type 不同;value 用官网英文便于 RPA 点选
*/
export const MS_PIECE_COUNT_TYPES = [
{ id: "Pieces", label: "件(Pieces)" },
{ id: "Units", label: "单元(Units)" },
{ id: "Cartons", label: "纸箱(Cartons)" },
{ id: "Boxes", label: "箱(Boxes)" },
{ id: "Bags", label: "袋(Bags)" },
{ id: "Bales", label: "捆包(Bales)" },
{ id: "Bundles", label: "捆(Bundles)" },
{ id: "Cases", label: "箱件(Cases)" },
{ id: "Coils", label: "卷材(Coils)" },
{ id: "Crates", label: "木箱(Crates)" },
{ id: "Cylinders", label: "圆筒(Cylinders)" },
{ id: "Drums", label: "圆桶(Drums)" },
{ id: "Pails", label: "提桶(Pails)" },
{ id: "Reels", label: "线盘(Reels)" },
{ id: "Rolls", label: "卷(Rolls)" },
{ id: "Skids", label: "滑托(Skids)" },
{ id: "Totes", label: "集装袋(Totes)" },
{ id: "Tubes", label: "管(Tubes)" },
] 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 = {
businessCustomerId?: string;
/** ccnew 登录账号,缺 BC 时后端反查加价客户 */
businessUserAccount?: string;
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: "piece",
quantity: "",
weightLb: "",
lengthIn: "",
widthIn: "",
heightIn: "",
};
}
function cargoTypeFromFill(raw: string | undefined): MsCargoTypeId {
const id = (raw ?? "piece").trim().toLowerCase();
const hit = MS_CARGO_TYPES.find((t) => t.id === id);
return hit?.id ?? "piece";
}
function cargoLinesFromFill(
lines: MsLoggedInFillPayload["cargo_lines"],
): MsCargoLine[] | null {
if (!lines || lines.length === 0) return null;
return lines.map((line) => ({
key: `c-${Math.random().toString(36).slice(2, 10)}`,
cargoType: cargoTypeFromFill(line.cargo_type),
quantity:
line.quantity != null && Number.isFinite(line.quantity)
? String(line.quantity)
: "",
weightLb:
line.weight_lb != null && Number.isFinite(line.weight_lb)
? String(line.weight_lb)
: "",
lengthIn:
line.length_in != null && Number.isFinite(line.length_in)
? String(line.length_in)
: "",
widthIn:
line.width_in != null && Number.isFinite(line.width_in)
? String(line.width_in)
: "",
heightIn:
line.height_in != null && Number.isFinite(line.height_in)
? String(line.height_in)
: "",
}));
}
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)}
aria-label="Select Chevron down"
data-testid="address-book-accessorials-trigger"
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 pickupRisk =
side === "pickup" &&
Boolean(MS_PICKUP_RISK_ACCESSORIALS[opt.id]);
const riskReason = pickupRisk
? MS_PICKUP_RISK_ACCESSORIALS[opt.id]
: undefined;
return (
<label
key={opt.id}
data-testid={`address-book-accessorials-option-${opt.id}`}
className={[
"flex items-start gap-2 px-3 py-2 text-sm",
"cursor-pointer hover:bg-bg",
].join(" ")}
title={riskReason}
>
<input
type="checkbox"
checked={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>
{riskReason ? (
<span className="mt-0.5 block text-[11px] text-[#B45309]">
{riskReason}
</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 hostBridge = useHostBridgeOptional();
const appliedFillSeqRef = useRef<number | null>(null);
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 [businessCustomerId, setBusinessCustomerId] = useState<string | undefined>(
undefined,
);
const [businessUserAccount, setBusinessUserAccount] = useState<
string | undefined
>(undefined);
const [cargoLines, setCargoLines] = useState<MsCargoLine[]>([newCargoLine()]);
const [localError, setLocalError] = useState<string | null>(null);
const [invalidFields, setInvalidFields] = useState<Set<string>>(new Set());
const pickupConfirmedRef = useRef<MothershipAddressCandidate | null>(null);
const deliveryConfirmedRef = useRef<MothershipAddressCandidate | null>(null);
const cargoFilledOnceRef = useRef(false);
const cargoUserEditedRef = useRef(false);
pickupConfirmedRef.current = pickupConfirmed;
deliveryConfirmedRef.current = deliveryConfirmed;
// 宿主 chajia:fill(MS_LOGGED_IN):可晚到 / 可重复;写入搜索框 + 货物行
useEffect(() => {
const fill = hostBridge?.fill;
if (!fill || fill.module !== "MS_LOGGED_IN") return;
if (appliedFillSeqRef.current === fill.seq) return;
appliedFillSeqRef.current = fill.seq;
const form = (hostBridge?.msLoggedInPrefill ??
fill.form) as MsLoggedInFillPayload | null;
if (!form || typeof form !== "object") return;
setBusinessCustomerId(form.business_customer_id?.trim() || undefined);
setBusinessUserAccount(form.business_user_account?.trim() || undefined);
const pickupText = msLoggedInAddressQueryText(form.pickup_address);
const deliveryText = msLoggedInAddressQueryText(form.delivery_address);
const applied: string[] = [];
// 用户已点选联想确认后,重复 fill(自动重试/再点填入)不得冲掉确认态与绿色提示
if (pickupText) {
if (!pickupConfirmedRef.current) {
setPickupQuery(pickupText);
setPickupConfirmed(null);
applied.push("pickup_address");
} else {
applied.push("pickup_address(kept-confirmed)");
}
}
if (deliveryText) {
if (!deliveryConfirmedRef.current) {
setDeliveryQuery(deliveryText);
setDeliveryConfirmed(null);
applied.push("delivery_address");
} else {
applied.push("delivery_address(kept-confirmed)");
}
}
if (Array.isArray(form.pickup_accessorials)) {
setPickupAccessorials(
form.pickup_accessorials.map((x) => String(x).trim()).filter(Boolean),
);
applied.push("pickup_accessorials");
}
if (Array.isArray(form.delivery_accessorials)) {
setDeliveryAccessorials(
form.delivery_accessorials.map((x) => String(x).trim()).filter(Boolean),
);
applied.push("delivery_accessorials");
}
if (form.ready_date?.trim()) {
const normalized = normalizeMothershipReadyDateIso(form.ready_date.trim());
if (normalized) {
setReadyDate(normalized);
applied.push("ready_date");
}
}
if (form.ready_time?.trim()) {
setReadyTime(form.ready_time.trim());
applied.push("ready_time");
}
const nextCargo = cargoLinesFromFill(form.cargo_lines);
// 预填只灌一次;用户手改后禁止再覆盖,保证可手动改参数
if (nextCargo) {
if (!cargoUserEditedRef.current && !cargoFilledOnceRef.current) {
setCargoLines(nextCargo);
cargoFilledOnceRef.current = true;
applied.push("cargo_lines");
} else {
applied.push(
cargoUserEditedRef.current
? "cargo_lines(kept-user-edit)"
: "cargo_lines(kept-once)",
);
}
}
postToHost("chajia:fill-ack", {
request_id: fill.requestId,
module: "MS_LOGGED_IN",
payload: {
ok: true,
module: "MS_LOGGED_IN",
applied_keys: applied,
message:
applied.length > 0
? "已写入创建新货件表单(地址为搜索框文本,请点选联想确认后再继续)"
: "已收到 fill,但无可写入字段",
ui_applied: true,
},
});
}, [hostBridge?.fill, hostBridge?.msLoggedInPrefill]);
const updateCargo = useCallback(
(key: string, patch: Partial<MsCargoLine>) => {
cargoUserEditedRef.current = true;
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"] = [];
const ceilNotes: string[] = [];
for (const line of cargoLines) {
const quantity = Number(line.quantity);
const weightLbRaw = Number(line.weightLb);
const lengthInRaw = Number(line.lengthIn);
const widthInRaw = Number(line.widthIn);
const heightInRaw = Number(line.heightIn);
if (!Number.isFinite(quantity) || quantity < 1) {
nextInvalid.add(`${line.key}-quantity`);
}
if (!Number.isFinite(weightLbRaw) || weightLbRaw <= 0) {
nextInvalid.add(`${line.key}-weight`);
} else if (!isMothershipWeightEachAllowed(weightLbRaw)) {
nextInvalid.add(`${line.key}-weight`);
setInvalidFields(nextInvalid);
setLocalError(MOTHERSHIP_AVG_WEIGHT_BLOCK_MESSAGE);
return;
}
if (!Number.isFinite(lengthInRaw) || lengthInRaw <= 0) {
nextInvalid.add(`${line.key}-length`);
}
if (!Number.isFinite(widthInRaw) || widthInRaw <= 0) {
nextInvalid.add(`${line.key}-width`);
}
if (!Number.isFinite(heightInRaw) || heightInRaw <= 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;
}
const weightLb = ceilMothershipNumeric(weightLbRaw);
const lengthIn = ceilMothershipNumeric(lengthInRaw);
const widthIn = ceilMothershipNumeric(widthInRaw);
const heightIn = ceilMothershipNumeric(heightInRaw);
if (
hasMothershipFractionalPart(weightLbRaw) ||
hasMothershipFractionalPart(lengthInRaw) ||
hasMothershipFractionalPart(widthInRaw) ||
hasMothershipFractionalPart(heightInRaw)
) {
ceilNotes.push(
`重量 ${weightLbRaw}→${weightLb} lb,尺寸 ${lengthInRaw}×${widthInRaw}×${heightInRaw}→${lengthIn}×${widthIn}×${heightIn} in`,
);
}
cargo.push({
cargoType: line.cargoType,
quantity,
weightLb,
lengthIn,
widthIn,
heightIn,
});
}
// 回写表单显示进位后的整数,避免用户误以为仍带小数提交
if (ceilNotes.length > 0) {
setCargoLines((prev) =>
prev.map((row, idx) => {
const c = cargo[idx];
if (!c) return row;
return {
...row,
weightLb: String(c.weightLb),
lengthIn: String(c.lengthIn),
widthIn: String(c.widthIn),
heightIn: String(c.heightIn),
};
}),
);
setLocalError(
`${MOTHERSHIP_INTEGER_HINT}:${ceilNotes.join(";")}`,
);
} else {
setLocalError(null);
}
const weightBlock = findMothershipCargoWeightBlockMessage(
cargo.map((row) => ({ weightLb: row.weightLb, quantity: row.quantity })),
);
if (weightBlock) {
setLocalError(weightBlock);
return;
}
setInvalidFields(new Set());
const normalizedReadyDate = normalizeMothershipReadyDateIso(readyDate) ?? readyDate;
onValidSubmit?.({
businessCustomerId,
businessUserAccount,
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>
<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 lg:grid-cols-2">
<div className="space-y-4 rounded-lg border border-border bg-bg/60 p-4">
<div className="border-b border-border pb-3">
<p className="text-sm font-semibold text-text-primary">提货信息</p>
<p className="mt-1 text-xs text-text-secondary">
仅保留询价实际需要的地址搜索与附加服务。
</p>
</div>
<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) => {
pickupConfirmedRef.current = 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="space-y-4 rounded-lg border border-border bg-bg/60 p-4">
<div className="border-b border-border pb-3">
<p className="text-sm font-semibold text-text-primary">送货信息</p>
<p className="mt-1 text-xs text-text-secondary">
地址确认后再继续,右侧报价会基于已确认地址生成。
</p>
</div>
<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) => {
deliveryConfirmedRef.current = 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-5 rounded-lg border border-border bg-bg/40 p-4">
<div className="mb-3 flex items-center gap-1 text-xs text-[#4B5563]">
<span>可提货时间</span>
<QuoteFieldTooltip tip="货物最早可被承运商提走的日期与时刻;周六、周日不可选" />
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<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>
</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 overflow-visible">
<div className="flex items-center gap-1.5 overflow-visible">
<QuoteSectionTitle>
<span className="inline-flex items-start gap-0.5 overflow-visible">
<span>货物</span>
{/* 右上角红 !:内联上移,避免 absolute 被父级裁切 */}
<span className="group relative -mt-1 inline-flex shrink-0">
<button
type="button"
data-testid="cargo-limits-hint-dot"
aria-label="货物填写限制"
className="inline-flex h-4 w-4 cursor-help items-center justify-center rounded-full bg-[#EF4444] text-[10px] font-bold leading-none text-white shadow-sm outline-none ring-offset-1 hover:bg-[#DC2626] focus-visible:ring-2 focus-visible:ring-[#EF4444]/50"
>
!
</button>
<span
role="tooltip"
className="pointer-events-none absolute left-0 top-full z-50 mt-2 hidden w-[min(22rem,calc(100vw-2rem))] rounded-md border border-[#FDE68A] bg-[#FFFBEB] px-3 py-2.5 text-left shadow-modal group-hover:block group-focus-within:block"
>
<span className="mb-1.5 flex items-start gap-1.5">
<WarningCircle
size={16}
weight="fill"
className="mt-0.5 shrink-0 text-[#F59E0B]"
aria-hidden
/>
<span className="text-[12px] font-semibold text-[#B45309]">
{MOTHERSHIP_CARGO_ENTRY_HINT_TITLE}
</span>
</span>
<span className="block space-y-1 pl-5 text-[11px] leading-relaxed text-[#92400E]">
{MOTHERSHIP_CARGO_ENTRY_HINT_ITEMS.map((line) => (
<span key={line} className="block">
· {line}
</span>
))}
</span>
</span>
</span>
</span>
</QuoteSectionTitle>
</div>
<button
type="button"
data-testid="cargo-add-button"
disabled={disabled}
onClick={() => {
cargoUserEditedRef.current = true;
setCargoLines((rows) => [...rows, newCargoLine()]);
}}
className={quoteAddRowCls}
>
<Plus size={16} weight="bold" />
添加货物行
</button>
</div>
<p className="mb-3 text-xs leading-relaxed text-text-secondary">
{MOTHERSHIP_INTEGER_HINT}
</p>
<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={() => {
cargoUserEditedRef.current = true;
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>
);
}