|
|
/**
|
|
|
* MotherShip 登录后二级 Details:提货 / 送货 / 货物明细
|
|
|
* 字段与一级 Create shipment 一一对应并预填
|
|
|
*/
|
|
|
"use client";
|
|
|
|
|
|
import { useMemo, useState } from "react";
|
|
|
import { CaretDown, Info, Plus, Warning, X } from "@phosphor-icons/react";
|
|
|
import {
|
|
|
MS_CARGO_TYPES,
|
|
|
MS_DEFAULT_READY_TIME,
|
|
|
MS_DELIVERY_ACCESSORIALS,
|
|
|
MS_PICKUP_ACCESSORIALS,
|
|
|
MS_READY_TIMES,
|
|
|
type MothershipLoggedInShipmentPayload,
|
|
|
type MsCargoTypeId,
|
|
|
} from "@/components/mothership/mothership-logged-in-shipment-form";
|
|
|
import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker";
|
|
|
import {
|
|
|
snapMothershipReadyDateToWeekday,
|
|
|
} from "@/lib/mothership/logged-in-constraints";
|
|
|
import {
|
|
|
evaluateMothershipAccessorialCompat,
|
|
|
MS_PICKUP_BLOCKED_ACCESSORIALS,
|
|
|
toggleMothershipAccessorial,
|
|
|
} from "@/lib/mothership/option-compat";
|
|
|
|
|
|
export type MsLocationDetails = {
|
|
|
address: string;
|
|
|
companyName: string;
|
|
|
suite: string;
|
|
|
contactFirst: string;
|
|
|
contactLast: string;
|
|
|
contactEmail: string;
|
|
|
contactPhone: string;
|
|
|
extension: string;
|
|
|
reference: string;
|
|
|
accessorials: string[];
|
|
|
notes: string;
|
|
|
opensAt: string;
|
|
|
closesAt: string;
|
|
|
readyDate: string;
|
|
|
readyTime: string;
|
|
|
};
|
|
|
|
|
|
export type MsDetailsCargoLine = {
|
|
|
key: string;
|
|
|
cargoType: MsCargoTypeId;
|
|
|
quantity: string;
|
|
|
freightClass: string;
|
|
|
weightLb: string;
|
|
|
lengthIn: string;
|
|
|
widthIn: string;
|
|
|
heightIn: string;
|
|
|
pieceCountType: string;
|
|
|
pieceCountQty: string;
|
|
|
description: string;
|
|
|
nmfc: string;
|
|
|
hazmat: boolean;
|
|
|
alcohol: boolean;
|
|
|
tobacco: boolean;
|
|
|
};
|
|
|
|
|
|
export type MothershipLoggedInDetailsState = {
|
|
|
pickup: MsLocationDetails;
|
|
|
delivery: MsLocationDetails;
|
|
|
requestDeliveryAppointment: boolean;
|
|
|
fbaNumber: string;
|
|
|
fbaPoNumber: string;
|
|
|
cargo: MsDetailsCargoLine[];
|
|
|
};
|
|
|
|
|
|
export type MothershipLoggedInDetailsPayload = {
|
|
|
pickup: {
|
|
|
companyName: string;
|
|
|
suite: string;
|
|
|
contactFirst: string;
|
|
|
contactLast: string;
|
|
|
contactEmail: string;
|
|
|
contactPhone: string;
|
|
|
reference: string;
|
|
|
notes: string;
|
|
|
opensAt: string;
|
|
|
closesAt: string;
|
|
|
};
|
|
|
delivery: {
|
|
|
companyName: string;
|
|
|
suite: string;
|
|
|
contactFirst: string;
|
|
|
contactLast: string;
|
|
|
contactEmail: string;
|
|
|
contactPhone: string;
|
|
|
reference: string;
|
|
|
notes: string;
|
|
|
opensAt: string;
|
|
|
closesAt: string;
|
|
|
};
|
|
|
requestDeliveryAppointment: boolean;
|
|
|
fbaNumber: string;
|
|
|
fbaPoNumber: string;
|
|
|
cargo: Array<{
|
|
|
pieceCountType: string;
|
|
|
pieceCountQty: number;
|
|
|
description: string;
|
|
|
nmfc: string;
|
|
|
hazmat: boolean;
|
|
|
alcohol: boolean;
|
|
|
tobacco: boolean;
|
|
|
}>;
|
|
|
};
|
|
|
|
|
|
function RequiredMark() {
|
|
|
return <span className="text-error">*</span>;
|
|
|
}
|
|
|
|
|
|
function FieldLabel({
|
|
|
children,
|
|
|
required,
|
|
|
tip,
|
|
|
}: {
|
|
|
children: React.ReactNode;
|
|
|
required?: boolean;
|
|
|
tip?: boolean;
|
|
|
}) {
|
|
|
return (
|
|
|
<span className="mb-1 flex items-center gap-1 text-xs font-medium text-text-primary">
|
|
|
{children}
|
|
|
{required ? <RequiredMark /> : null}
|
|
|
{tip ? <Info size={12} className="text-text-disabled" /> : null}
|
|
|
</span>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function TextInput({
|
|
|
value,
|
|
|
onChange,
|
|
|
placeholder,
|
|
|
disabled,
|
|
|
className = "",
|
|
|
}: {
|
|
|
value: string;
|
|
|
onChange: (v: string) => void;
|
|
|
placeholder?: string;
|
|
|
disabled?: boolean;
|
|
|
className?: string;
|
|
|
}) {
|
|
|
return (
|
|
|
<input
|
|
|
disabled={disabled}
|
|
|
value={value}
|
|
|
placeholder={placeholder}
|
|
|
onChange={(e) => onChange(e.target.value)}
|
|
|
className={`h-10 w-full rounded-md border border-border bg-surface px-3 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:opacity-60 ${className}`}
|
|
|
/>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function AccessorialSelect({
|
|
|
options,
|
|
|
selected,
|
|
|
disabled,
|
|
|
onChange,
|
|
|
hintPickUp,
|
|
|
side,
|
|
|
}: {
|
|
|
options: readonly { id: string; label: string }[];
|
|
|
selected: string[];
|
|
|
disabled?: boolean;
|
|
|
onChange: (next: string[]) => void;
|
|
|
hintPickUp?: boolean;
|
|
|
side: "pickup" | "delivery";
|
|
|
}) {
|
|
|
const [open, setOpen] = useState(false);
|
|
|
const [hint, setHint] = useState<string | null>(null);
|
|
|
const issues = useMemo(
|
|
|
() => evaluateMothershipAccessorialCompat({ side, selected }),
|
|
|
[side, selected],
|
|
|
);
|
|
|
const summary =
|
|
|
selected.length === 0
|
|
|
? "选择"
|
|
|
: selected.length === 1
|
|
|
? (options.find((o) => o.id === selected[0])?.label ?? "已选择")
|
|
|
: `${selected.length} 项已选`;
|
|
|
|
|
|
return (
|
|
|
<div className="relative">
|
|
|
<div className="mb-1 flex flex-wrap items-center gap-2">
|
|
|
<FieldLabel tip>附加服务</FieldLabel>
|
|
|
<span className="inline-flex items-center gap-1 rounded bg-warning/15 px-2 py-0.5 text-[11px] text-warning">
|
|
|
<Warning size={12} weight="fill" />
|
|
|
避免追收附加费
|
|
|
</span>
|
|
|
</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 px-3 text-left text-sm"
|
|
|
>
|
|
|
<span>{summary}</span>
|
|
|
<CaretDown size={14} />
|
|
|
</button>
|
|
|
{open && !disabled && (
|
|
|
<div className="absolute z-30 mt-1 max-h-64 w-full overflow-auto rounded-md border border-border bg-surface shadow-modal">
|
|
|
<p className="bg-warning/10 px-3 py-2 text-xs text-text-secondary">
|
|
|
下列附加服务建议勾选,以
|
|
|
{hintPickUp ? "避免提货问题" : "避免送货问题"}
|
|
|
并减少事后追收。住宅提货承运商不支持;预约与 Amazon 预约不能同选。
|
|
|
</p>
|
|
|
{options.map((opt) => {
|
|
|
const checked = selected.includes(opt.id);
|
|
|
const warn =
|
|
|
opt.id === "liftgate" ||
|
|
|
opt.id === "residential" ||
|
|
|
opt.id === "limitedAccess";
|
|
|
const pickupBlocked =
|
|
|
side === "pickup" &&
|
|
|
Boolean(MS_PICKUP_BLOCKED_ACCESSORIALS[opt.id]);
|
|
|
return (
|
|
|
<label
|
|
|
key={opt.id}
|
|
|
className={[
|
|
|
"flex items-start gap-2 border-b border-border/60 px-3 py-2 text-sm last:border-0",
|
|
|
pickupBlocked && !checked
|
|
|
? "cursor-not-allowed opacity-60"
|
|
|
: "cursor-pointer hover:bg-bg",
|
|
|
].join(" ")}
|
|
|
>
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
className="mt-0.5"
|
|
|
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);
|
|
|
}}
|
|
|
/>
|
|
|
<span>
|
|
|
<span className="block">{opt.label}</span>
|
|
|
{pickupBlocked ? (
|
|
|
<span className="text-[11px] text-[#EF4444]">
|
|
|
{MS_PICKUP_BLOCKED_ACCESSORIALS[opt.id]}
|
|
|
</span>
|
|
|
) : warn ? (
|
|
|
<span className="text-[11px] text-warning">
|
|
|
建议勾选以避免追收
|
|
|
</span>
|
|
|
) : null}
|
|
|
</span>
|
|
|
</label>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
)}
|
|
|
{hint ? (
|
|
|
<p className="mt-1 text-[11px] leading-snug text-[#B45309]">{hint}</p>
|
|
|
) : null}
|
|
|
{issues.length > 0 ? (
|
|
|
<ul className="mt-1 space-y-0.5 text-[11px] leading-snug text-[#92400E]">
|
|
|
{issues.map((i) => (
|
|
|
<li
|
|
|
key={i.code}
|
|
|
className={i.severity === "block" ? "text-[#EF4444]" : ""}
|
|
|
>
|
|
|
{i.message}
|
|
|
</li>
|
|
|
))}
|
|
|
</ul>
|
|
|
) : null}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
function locationFromL1(
|
|
|
address: string,
|
|
|
accessorials: string[],
|
|
|
readyDate: string,
|
|
|
readyTime: string,
|
|
|
): MsLocationDetails {
|
|
|
return {
|
|
|
address,
|
|
|
companyName: "",
|
|
|
suite: "",
|
|
|
contactFirst: "",
|
|
|
contactLast: "",
|
|
|
contactEmail: "",
|
|
|
contactPhone: "",
|
|
|
extension: "",
|
|
|
reference: "",
|
|
|
accessorials: [...accessorials],
|
|
|
notes: "",
|
|
|
opensAt: "",
|
|
|
closesAt: "",
|
|
|
readyDate: snapMothershipReadyDateToWeekday(readyDate),
|
|
|
readyTime,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function locationFromConfirmed(
|
|
|
confirmed: {
|
|
|
formatted_address: string;
|
|
|
display_label: string;
|
|
|
street: string;
|
|
|
city: string;
|
|
|
state: string;
|
|
|
zip: string;
|
|
|
},
|
|
|
fallbackQuery: string,
|
|
|
accessorials: string[],
|
|
|
readyDate: string,
|
|
|
readyTime: string,
|
|
|
): MsLocationDetails {
|
|
|
const label =
|
|
|
confirmed.formatted_address ||
|
|
|
confirmed.display_label ||
|
|
|
fallbackQuery;
|
|
|
const composed = [confirmed.street, confirmed.city, confirmed.state, confirmed.zip]
|
|
|
.map((p) => p?.trim())
|
|
|
.filter(Boolean)
|
|
|
.join(", ");
|
|
|
return {
|
|
|
...locationFromL1(label || composed || fallbackQuery, accessorials, readyDate, readyTime),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export function buildDetailsStateFromL1(
|
|
|
payload: MothershipLoggedInShipmentPayload,
|
|
|
): MothershipLoggedInDetailsState {
|
|
|
const wantFba = payload.deliveryAccessorials.includes("fbaAppointment");
|
|
|
return {
|
|
|
pickup: locationFromConfirmed(
|
|
|
payload.pickupConfirmed,
|
|
|
payload.pickupQuery,
|
|
|
payload.pickupAccessorials,
|
|
|
payload.readyDate,
|
|
|
payload.readyTime,
|
|
|
),
|
|
|
delivery: locationFromConfirmed(
|
|
|
payload.deliveryConfirmed,
|
|
|
payload.deliveryQuery,
|
|
|
payload.deliveryAccessorials,
|
|
|
payload.readyDate,
|
|
|
payload.readyTime,
|
|
|
),
|
|
|
requestDeliveryAppointment:
|
|
|
wantFba || payload.deliveryAccessorials.includes("appointment"),
|
|
|
fbaNumber: "",
|
|
|
fbaPoNumber: "",
|
|
|
cargo: payload.cargo.map((c, i) => ({
|
|
|
key: `d-${i}-${c.cargoType}`,
|
|
|
cargoType: c.cargoType,
|
|
|
quantity: String(c.quantity),
|
|
|
freightClass: "",
|
|
|
weightLb: String(c.weightLb),
|
|
|
lengthIn: String(c.lengthIn),
|
|
|
widthIn: String(c.widthIn),
|
|
|
heightIn: String(c.heightIn),
|
|
|
pieceCountType: "",
|
|
|
pieceCountQty: "0",
|
|
|
description: "",
|
|
|
nmfc: "",
|
|
|
hazmat: false,
|
|
|
alcohol: false,
|
|
|
tobacco: false,
|
|
|
})),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function LocationSection({
|
|
|
title,
|
|
|
value,
|
|
|
disabled,
|
|
|
accessorialOptions,
|
|
|
hintPickUp,
|
|
|
showFreightReady,
|
|
|
onChange,
|
|
|
}: {
|
|
|
title: string;
|
|
|
value: MsLocationDetails;
|
|
|
disabled?: boolean;
|
|
|
accessorialOptions: readonly { id: string; label: string }[];
|
|
|
hintPickUp?: boolean;
|
|
|
showFreightReady?: boolean;
|
|
|
onChange: (next: MsLocationDetails) => void;
|
|
|
}) {
|
|
|
const set = <K extends keyof MsLocationDetails>(
|
|
|
key: K,
|
|
|
v: MsLocationDetails[K],
|
|
|
) => onChange({ ...value, [key]: v });
|
|
|
|
|
|
return (
|
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
|
|
|
<h3 className="mb-4 text-base font-semibold text-text-primary">{title}</h3>
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
<label className="block">
|
|
|
<FieldLabel required>地址</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={value.address}
|
|
|
onChange={(v) => set("address", v)}
|
|
|
placeholder="街道、城市、州、邮编"
|
|
|
/>
|
|
|
</label>
|
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-[1fr_8rem]">
|
|
|
<label className="block">
|
|
|
<FieldLabel required>完整公司名</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={value.companyName}
|
|
|
onChange={(v) => set("companyName", v)}
|
|
|
placeholder="Acme Co."
|
|
|
/>
|
|
|
<p className="mt-1 text-[11px] text-text-secondary">
|
|
|
非住宅件请勿用人名作公司名,以免承运商加收附加费。
|
|
|
</p>
|
|
|
</label>
|
|
|
<label className="block">
|
|
|
<FieldLabel>套房 / 单元</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={value.suite}
|
|
|
onChange={(v) => set("suite", v)}
|
|
|
placeholder="Ste 301"
|
|
|
/>
|
|
|
</label>
|
|
|
</div>
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
<label className="block">
|
|
|
<FieldLabel>现场联系人名</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={value.contactFirst}
|
|
|
onChange={(v) => set("contactFirst", v)}
|
|
|
placeholder="名"
|
|
|
/>
|
|
|
</label>
|
|
|
<label className="block">
|
|
|
<FieldLabel>现场联系人姓</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={value.contactLast}
|
|
|
onChange={(v) => set("contactLast", v)}
|
|
|
placeholder="姓"
|
|
|
/>
|
|
|
</label>
|
|
|
</div>
|
|
|
|
|
|
<label className="block">
|
|
|
<FieldLabel required>现场联系人邮箱</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={value.contactEmail}
|
|
|
onChange={(v) => set("contactEmail", v)}
|
|
|
placeholder="email@company.com"
|
|
|
/>
|
|
|
<p className="mt-1 text-[11px] text-text-secondary">
|
|
|
{hintPickUp
|
|
|
? "填写提货方邮箱以接收提单与跟踪更新。"
|
|
|
: "填写收货方邮箱以接收跟踪更新。"}
|
|
|
</p>
|
|
|
</label>
|
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-[1fr_6rem]">
|
|
|
<label className="block">
|
|
|
<FieldLabel required>现场联系人电话</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={value.contactPhone}
|
|
|
onChange={(v) => set("contactPhone", v)}
|
|
|
placeholder="(123) 456-7890"
|
|
|
/>
|
|
|
</label>
|
|
|
<label className="block">
|
|
|
<FieldLabel>分机</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={value.extension}
|
|
|
onChange={(v) => set("extension", v)}
|
|
|
placeholder="1234"
|
|
|
/>
|
|
|
</label>
|
|
|
</div>
|
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
|
<label className="block">
|
|
|
<FieldLabel tip>参考号</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={value.reference}
|
|
|
onChange={(v) => set("reference", v)}
|
|
|
placeholder="采购单或参考号"
|
|
|
/>
|
|
|
</label>
|
|
|
<AccessorialSelect
|
|
|
options={accessorialOptions}
|
|
|
selected={value.accessorials}
|
|
|
disabled={disabled}
|
|
|
hintPickUp={hintPickUp}
|
|
|
side={hintPickUp ? "pickup" : "delivery"}
|
|
|
onChange={(next) => set("accessorials", next)}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
<label className="block">
|
|
|
<FieldLabel>备注</FieldLabel>
|
|
|
<textarea
|
|
|
disabled={disabled}
|
|
|
value={value.notes}
|
|
|
onChange={(e) => set("notes", e.target.value)}
|
|
|
placeholder="例如:便于司机找到正确位置的关键信息"
|
|
|
rows={2}
|
|
|
className="w-full rounded-md border border-border px-3 py-2 text-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
|
|
|
/>
|
|
|
</label>
|
|
|
|
|
|
<div>
|
|
|
<p className="mb-2 text-xs font-medium text-text-primary">营业时间</p>
|
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
|
<label className="block">
|
|
|
<FieldLabel required>开门</FieldLabel>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={value.opensAt}
|
|
|
onChange={(e) => set("opensAt", e.target.value)}
|
|
|
className="h-10 min-w-[8rem] rounded-md border border-border px-2 text-sm"
|
|
|
>
|
|
|
<option value="">选择时间</option>
|
|
|
{MS_READY_TIMES.map((t) => (
|
|
|
<option key={t} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</label>
|
|
|
<span className="pb-2 text-xs text-text-secondary">EDT</span>
|
|
|
<label className="block">
|
|
|
<FieldLabel required>关门</FieldLabel>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={value.closesAt}
|
|
|
onChange={(e) => set("closesAt", e.target.value)}
|
|
|
className="h-10 min-w-[8rem] rounded-md border border-border px-2 text-sm"
|
|
|
>
|
|
|
<option value="">选择时间</option>
|
|
|
{MS_READY_TIMES.map((t) => (
|
|
|
<option key={t} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</label>
|
|
|
<span className="pb-2 text-xs text-text-secondary">EDT</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{showFreightReady && (
|
|
|
<div>
|
|
|
<FieldLabel required tip>
|
|
|
货运就绪时间(不可选周六、周日)
|
|
|
</FieldLabel>
|
|
|
<div className="mt-1 flex flex-wrap items-end gap-2">
|
|
|
<MothershipWeekdayDatePicker
|
|
|
disabled={disabled}
|
|
|
value={value.readyDate}
|
|
|
onChange={(iso) => set("readyDate", iso)}
|
|
|
className="h-10 min-w-[11rem] rounded-md border border-border bg-surface px-3 text-sm"
|
|
|
/>
|
|
|
<span className="pb-2 text-sm text-text-secondary">之后</span>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={value.readyTime || MS_DEFAULT_READY_TIME}
|
|
|
onChange={(e) => set("readyTime", e.target.value)}
|
|
|
className="h-10 min-w-[7.5rem] rounded-md border border-border px-2 text-sm"
|
|
|
>
|
|
|
{MS_READY_TIMES.map((t) => (
|
|
|
<option key={t} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
<span className="pb-2 text-xs text-text-secondary">EDT</span>
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
</section>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export interface MothershipLoggedInDetailsFormProps {
|
|
|
formId?: string;
|
|
|
initial: MothershipLoggedInShipmentPayload;
|
|
|
disabled?: boolean;
|
|
|
onChange?: (state: MothershipLoggedInDetailsState) => void;
|
|
|
onValidSubmit?: (payload: MothershipLoggedInDetailsPayload) => void;
|
|
|
}
|
|
|
|
|
|
export function MothershipLoggedInDetailsForm({
|
|
|
formId = "ms-logged-in-details-form",
|
|
|
initial,
|
|
|
disabled,
|
|
|
onChange,
|
|
|
onValidSubmit,
|
|
|
}: MothershipLoggedInDetailsFormProps) {
|
|
|
const [state, setState] = useState(() => buildDetailsStateFromL1(initial));
|
|
|
|
|
|
const patch = (next: MothershipLoggedInDetailsState) => {
|
|
|
setState(next);
|
|
|
onChange?.(next);
|
|
|
};
|
|
|
|
|
|
const showFba = useMemo(
|
|
|
() =>
|
|
|
state.requestDeliveryAppointment ||
|
|
|
state.delivery.accessorials.includes("fbaAppointment"),
|
|
|
[state],
|
|
|
);
|
|
|
|
|
|
const toPayload = (): MothershipLoggedInDetailsPayload => ({
|
|
|
pickup: {
|
|
|
companyName: state.pickup.companyName.trim(),
|
|
|
suite: state.pickup.suite.trim(),
|
|
|
contactFirst: state.pickup.contactFirst.trim(),
|
|
|
contactLast: state.pickup.contactLast.trim(),
|
|
|
contactEmail: state.pickup.contactEmail.trim(),
|
|
|
contactPhone: state.pickup.contactPhone.trim(),
|
|
|
reference: state.pickup.reference.trim(),
|
|
|
notes: state.pickup.notes.trim(),
|
|
|
opensAt: state.pickup.opensAt.trim(),
|
|
|
closesAt: state.pickup.closesAt.trim(),
|
|
|
},
|
|
|
delivery: {
|
|
|
companyName: state.delivery.companyName.trim(),
|
|
|
suite: state.delivery.suite.trim(),
|
|
|
contactFirst: state.delivery.contactFirst.trim(),
|
|
|
contactLast: state.delivery.contactLast.trim(),
|
|
|
contactEmail: state.delivery.contactEmail.trim(),
|
|
|
contactPhone: state.delivery.contactPhone.trim(),
|
|
|
reference: state.delivery.reference.trim(),
|
|
|
notes: state.delivery.notes.trim(),
|
|
|
opensAt: state.delivery.opensAt.trim(),
|
|
|
closesAt: state.delivery.closesAt.trim(),
|
|
|
},
|
|
|
requestDeliveryAppointment: state.requestDeliveryAppointment,
|
|
|
fbaNumber: state.fbaNumber.trim(),
|
|
|
fbaPoNumber: state.fbaPoNumber.trim(),
|
|
|
cargo: state.cargo.map((line) => ({
|
|
|
pieceCountType: line.pieceCountType.trim(),
|
|
|
pieceCountQty: Number(line.pieceCountQty || 0),
|
|
|
description: line.description.trim(),
|
|
|
nmfc: line.nmfc.trim(),
|
|
|
hazmat: line.hazmat,
|
|
|
alcohol: line.alcohol,
|
|
|
tobacco: line.tobacco,
|
|
|
})),
|
|
|
});
|
|
|
|
|
|
return (
|
|
|
<form
|
|
|
id={formId}
|
|
|
className="space-y-5"
|
|
|
onSubmit={(e) => {
|
|
|
e.preventDefault();
|
|
|
onValidSubmit?.(toPayload());
|
|
|
}}
|
|
|
>
|
|
|
<div>
|
|
|
<p className="text-xs text-text-secondary">
|
|
|
报价 · <span className="font-medium text-primary">详情</span> · 复核
|
|
|
</p>
|
|
|
<h3 className="mt-1 text-xl font-semibold text-text-primary">
|
|
|
货件详情
|
|
|
</h3>
|
|
|
</div>
|
|
|
|
|
|
<LocationSection
|
|
|
title="1. 提货信息"
|
|
|
value={state.pickup}
|
|
|
disabled={disabled}
|
|
|
accessorialOptions={MS_PICKUP_ACCESSORIALS}
|
|
|
hintPickUp
|
|
|
showFreightReady
|
|
|
onChange={(pickup) => patch({ ...state, pickup })}
|
|
|
/>
|
|
|
|
|
|
{showFba && (
|
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
|
|
|
<label className="flex items-start gap-2">
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
disabled={disabled}
|
|
|
checked={state.requestDeliveryAppointment}
|
|
|
onChange={(e) =>
|
|
|
patch({
|
|
|
...state,
|
|
|
requestDeliveryAppointment: e.target.checked,
|
|
|
})
|
|
|
}
|
|
|
className="mt-1"
|
|
|
/>
|
|
|
<span>
|
|
|
<span className="block text-sm font-semibold text-text-primary">
|
|
|
申请送货预约
|
|
|
</span>
|
|
|
<span className="text-xs text-text-secondary">
|
|
|
自动为住宅/商业地址安排送货预约
|
|
|
</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
{state.requestDeliveryAppointment && (
|
|
|
<div className="mt-4 rounded-md bg-warning/10 p-4">
|
|
|
<p className="text-sm font-semibold text-text-primary">
|
|
|
填写 Fulfillment by Amazon(FBA)编号
|
|
|
</p>
|
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
|
|
<label className="block">
|
|
|
<FieldLabel>FBA 编号</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={state.fbaNumber}
|
|
|
onChange={(v) => patch({ ...state, fbaNumber: v })}
|
|
|
placeholder="输入 FBA 编号"
|
|
|
/>
|
|
|
</label>
|
|
|
<label className="block">
|
|
|
<FieldLabel>采购单号</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={state.fbaPoNumber}
|
|
|
onChange={(v) => patch({ ...state, fbaPoNumber: v })}
|
|
|
placeholder="输入采购单号"
|
|
|
/>
|
|
|
</label>
|
|
|
</div>
|
|
|
<ul className="mt-3 list-disc space-y-1 pl-5 text-xs text-text-secondary">
|
|
|
<li>FBA 编号将写入提单</li>
|
|
|
<li>
|
|
|
须在下方货物表单中填写{" "}
|
|
|
<strong className="text-text-primary">件数(piece count)</strong>
|
|
|
</li>
|
|
|
</ul>
|
|
|
</div>
|
|
|
)}
|
|
|
</section>
|
|
|
)}
|
|
|
|
|
|
<LocationSection
|
|
|
title="2. 送货信息"
|
|
|
value={state.delivery}
|
|
|
disabled={disabled}
|
|
|
accessorialOptions={MS_DELIVERY_ACCESSORIALS}
|
|
|
onChange={(delivery) => patch({ ...state, delivery })}
|
|
|
/>
|
|
|
|
|
|
<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">
|
|
|
<h3 className="text-base font-semibold text-text-primary">3. 货物</h3>
|
|
|
<button
|
|
|
type="button"
|
|
|
disabled={disabled}
|
|
|
onClick={() =>
|
|
|
patch({
|
|
|
...state,
|
|
|
cargo: [
|
|
|
...state.cargo,
|
|
|
{
|
|
|
key: `d-${Date.now()}`,
|
|
|
cargoType: "pallet",
|
|
|
quantity: "",
|
|
|
freightClass: "",
|
|
|
weightLb: "",
|
|
|
lengthIn: "",
|
|
|
widthIn: "",
|
|
|
heightIn: "",
|
|
|
pieceCountType: "",
|
|
|
pieceCountQty: "0",
|
|
|
description: "",
|
|
|
nmfc: "",
|
|
|
hazmat: false,
|
|
|
alcohol: false,
|
|
|
tobacco: false,
|
|
|
},
|
|
|
],
|
|
|
})
|
|
|
}
|
|
|
className="inline-flex h-9 items-center gap-1.5 rounded-md bg-text-primary px-3 text-sm font-medium text-white"
|
|
|
>
|
|
|
<Plus size={14} weight="bold" />
|
|
|
添加另一行
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
{state.cargo.map((line, idx) => (
|
|
|
<div
|
|
|
key={line.key}
|
|
|
className="relative rounded-md border border-border p-4"
|
|
|
>
|
|
|
<div className="mb-3 flex items-center justify-between">
|
|
|
<span className="text-sm font-semibold text-text-primary">
|
|
|
{idx + 1}
|
|
|
</span>
|
|
|
<button
|
|
|
type="button"
|
|
|
disabled={disabled || state.cargo.length <= 1}
|
|
|
aria-label="删除货物行"
|
|
|
onClick={() =>
|
|
|
patch({
|
|
|
...state,
|
|
|
cargo: state.cargo.filter((c) => c.key !== line.key),
|
|
|
})
|
|
|
}
|
|
|
className="inline-flex h-8 w-8 items-center justify-center rounded border border-border text-text-secondary disabled:opacity-40"
|
|
|
>
|
|
|
<X size={14} weight="bold" />
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
|
<label className="block">
|
|
|
<FieldLabel required tip>
|
|
|
货物类型
|
|
|
</FieldLabel>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={line.cargoType}
|
|
|
onChange={(e) => {
|
|
|
const cargo = state.cargo.map((c) =>
|
|
|
c.key === line.key
|
|
|
? {
|
|
|
...c,
|
|
|
cargoType: e.target.value as MsCargoTypeId,
|
|
|
}
|
|
|
: c,
|
|
|
);
|
|
|
patch({ ...state, cargo });
|
|
|
}}
|
|
|
className="h-10 w-full rounded-md border border-border px-2 text-sm"
|
|
|
>
|
|
|
{MS_CARGO_TYPES.map((t) => (
|
|
|
<option key={t.id} value={t.id}>
|
|
|
{t.label}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</label>
|
|
|
<label className="block">
|
|
|
<FieldLabel required>数量</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={line.quantity}
|
|
|
onChange={(v) => {
|
|
|
const cargo = state.cargo.map((c) =>
|
|
|
c.key === line.key ? { ...c, quantity: v } : c,
|
|
|
);
|
|
|
patch({ ...state, cargo });
|
|
|
}}
|
|
|
/>
|
|
|
</label>
|
|
|
<label className="block">
|
|
|
<FieldLabel tip>运费等级(可选)</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={line.freightClass}
|
|
|
onChange={(v) => {
|
|
|
const cargo = state.cargo.map((c) =>
|
|
|
c.key === line.key ? { ...c, freightClass: v } : c,
|
|
|
);
|
|
|
patch({ ...state, cargo });
|
|
|
}}
|
|
|
placeholder="选择"
|
|
|
/>
|
|
|
</label>
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-4">
|
|
|
{(
|
|
|
[
|
|
|
["weightLb", "单件重量", "lbs"],
|
|
|
["lengthIn", "单件长", "inch"],
|
|
|
["widthIn", "单件宽", "inch"],
|
|
|
["heightIn", "单件高", "inch"],
|
|
|
] as const
|
|
|
).map(([key, label, ph]) => (
|
|
|
<label key={key} className="block">
|
|
|
<FieldLabel required={key !== "weightLb"}>
|
|
|
{label}
|
|
|
</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={line[key]}
|
|
|
onChange={(v) => {
|
|
|
const cargo = state.cargo.map((c) =>
|
|
|
c.key === line.key ? { ...c, [key]: v } : c,
|
|
|
);
|
|
|
patch({ ...state, cargo });
|
|
|
}}
|
|
|
placeholder={ph}
|
|
|
/>
|
|
|
</label>
|
|
|
))}
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
|
|
<label className="block">
|
|
|
<FieldLabel required tip>
|
|
|
件数类型
|
|
|
</FieldLabel>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={line.pieceCountType}
|
|
|
onChange={(e) => {
|
|
|
const cargo = state.cargo.map((c) =>
|
|
|
c.key === line.key
|
|
|
? { ...c, pieceCountType: e.target.value }
|
|
|
: c,
|
|
|
);
|
|
|
patch({ ...state, cargo });
|
|
|
}}
|
|
|
className="h-10 w-full rounded-md border border-border px-2 text-sm"
|
|
|
>
|
|
|
<option value="">选择</option>
|
|
|
<option value="pieces">件(Pieces)</option>
|
|
|
<option value="cartons">纸箱(Cartons)</option>
|
|
|
<option value="units">单元(Units)</option>
|
|
|
</select>
|
|
|
</label>
|
|
|
<label className="block">
|
|
|
<FieldLabel required>件数数量</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={line.pieceCountQty}
|
|
|
onChange={(v) => {
|
|
|
const cargo = state.cargo.map((c) =>
|
|
|
c.key === line.key ? { ...c, pieceCountQty: v } : c,
|
|
|
);
|
|
|
patch({ ...state, cargo });
|
|
|
}}
|
|
|
/>
|
|
|
</label>
|
|
|
</div>
|
|
|
|
|
|
<label className="mt-3 block">
|
|
|
<FieldLabel required>货物描述</FieldLabel>
|
|
|
<textarea
|
|
|
disabled={disabled}
|
|
|
value={line.description}
|
|
|
onChange={(e) => {
|
|
|
const cargo = state.cargo.map((c) =>
|
|
|
c.key === line.key
|
|
|
? { ...c, description: e.target.value }
|
|
|
: c,
|
|
|
);
|
|
|
patch({ ...state, cargo });
|
|
|
}}
|
|
|
rows={2}
|
|
|
placeholder="准确描述货物以避免查验与加收。例如:用「地毯」而非「一般商品」。"
|
|
|
className="w-full rounded-md border border-border px-3 py-2 text-sm"
|
|
|
/>
|
|
|
</label>
|
|
|
|
|
|
<label className="mt-3 block">
|
|
|
<FieldLabel tip>NMFC 编码(可选)</FieldLabel>
|
|
|
<TextInput
|
|
|
disabled={disabled}
|
|
|
value={line.nmfc}
|
|
|
onChange={(v) => {
|
|
|
const cargo = state.cargo.map((c) =>
|
|
|
c.key === line.key ? { ...c, nmfc: v } : c,
|
|
|
);
|
|
|
patch({ ...state, cargo });
|
|
|
}}
|
|
|
placeholder="输入 NMFC 编码"
|
|
|
/>
|
|
|
<p className="mt-1 text-[11px] text-text-secondary">
|
|
|
添加该编码有助于减少承运商事后调整收费。
|
|
|
</p>
|
|
|
</label>
|
|
|
|
|
|
<div className="mt-4">
|
|
|
<p className="mb-2 text-xs font-medium text-text-primary">
|
|
|
受管制货物声明
|
|
|
</p>
|
|
|
<div className="flex flex-wrap gap-4 text-sm">
|
|
|
{(
|
|
|
[
|
|
|
["hazmat", "危险品(Hazmat)"],
|
|
|
["alcohol", "啤酒/葡萄酒/烈酒"],
|
|
|
["tobacco", "尼古丁/烟草"],
|
|
|
] as const
|
|
|
).map(([key, label]) => (
|
|
|
<label key={key} className="inline-flex items-center gap-2">
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
disabled={disabled}
|
|
|
checked={line[key]}
|
|
|
onChange={(e) => {
|
|
|
const cargo = state.cargo.map((c) =>
|
|
|
c.key === line.key
|
|
|
? { ...c, [key]: e.target.checked }
|
|
|
: c,
|
|
|
);
|
|
|
patch({ ...state, cargo });
|
|
|
}}
|
|
|
/>
|
|
|
{label}
|
|
|
</label>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
))}
|
|
|
</div>
|
|
|
</section>
|
|
|
</form>
|
|
|
);
|
|
|
}
|