|
|
/**
|
|
|
* 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>
|
|
|
);
|
|
|
}
|