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-detail...

1276 lines
43 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 登录后二级 Details:提货 / 送货 / 货物明细
* 字段与一级 Create shipment 一一对应并预填
*/
"use client";
import { useMemo, useState } from "react";
import { Plus, X } from "@phosphor-icons/react";
import {
MS_CARGO_TYPES,
MS_DEFAULT_READY_TIME,
MS_DELIVERY_ACCESSORIALS,
MS_PICKUP_ACCESSORIALS,
MS_PIECE_COUNT_TYPES,
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_RISK_ACCESSORIALS,
toggleMothershipAccessorial,
} from "@/lib/mothership/option-compat";
import { QuoteFieldTooltip } from "@/components/quote/quote-form-chrome";
import { MS_DETAILS_FIELD_TIPS } from "@/lib/mothership/ms-details-field-tips";
import {
collectMsDetailsRefineInvalidFields,
MS_EMAILS_MUST_DIFFER_MESSAGE,
MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE,
MS_READY_BEFORE_OPENS_MESSAGE,
parseMsClockToMinutes,
validateMsDetailsForRefine,
type MsDetailsFieldKey,
} from "@/lib/mothership/ms-details-refine-validation";
import { ErrorBanner } from "@/components/ui/error-banner";
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;
accessorials: string[];
};
delivery: {
companyName: string;
suite: string;
contactFirst: string;
contactLast: string;
contactEmail: string;
contactPhone: string;
reference: string;
notes: string;
opensAt: string;
closesAt: string;
accessorials: string[];
};
requestDeliveryAppointment: boolean;
fbaNumber: string;
fbaPoNumber: string;
cargo: Array<{
pieceCountType: string;
pieceCountQty: number;
description: string;
nmfc: string;
hazmat: boolean;
alcohol: boolean;
tobacco: boolean;
}>;
};
/** 二级表单状态 → 提交 payload(含附加服务) */
export function detailsStateToPayload(
state: MothershipLoggedInDetailsState,
): MothershipLoggedInDetailsPayload {
return {
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(),
accessorials: [...state.pickup.accessorials],
},
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(),
accessorials: [...state.delivery.accessorials],
},
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,
})),
};
}
function RequiredMark() {
return <span className="text-error">*</span>;
}
function FieldLabel({
children,
required,
tip,
}: {
children: React.ReactNode;
required?: boolean;
/** 官网 ⓘ 提示中文文案;有值则显示可点击/悬停说明 */
tip?: string;
}) {
return (
<span className="mb-1 flex items-center gap-1 text-xs font-medium text-text-primary">
{children}
{required ? <RequiredMark /> : null}
{tip ? (
<QuoteFieldTooltip tip={tip} label={`${String(children)}说明`} wide />
) : null}
</span>
);
}
const INPUT_OK_CLS =
"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";
const INPUT_ERR_CLS =
"h-10 w-full rounded-md border-2 border-error bg-error/5 px-3 text-sm text-text-primary focus:border-error focus:outline-none focus:ring-2 focus:ring-error/25 disabled:opacity-60";
const SELECT_OK_CLS =
"h-10 min-w-[8rem] rounded-md border border-border px-2 text-sm";
const SELECT_ERR_CLS =
"h-10 min-w-[8rem] rounded-md border-2 border-error bg-error/5 px-2 text-sm focus:border-error focus:outline-none focus:ring-2 focus:ring-error/25";
function TextInput({
value,
onChange,
placeholder,
disabled,
className = "",
required,
type = "text",
name,
invalid,
"data-testid": testId,
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
required?: boolean;
type?: string;
name?: string;
/** 必填空/错:明显红边提醒 */
invalid?: boolean;
"data-testid"?: string;
}) {
return (
<input
name={name}
type={type}
required={required}
disabled={disabled}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
aria-invalid={invalid || undefined}
data-testid={testId}
data-invalid={invalid ? "true" : undefined}
className={`${invalid ? INPUT_ERR_CLS : INPUT_OK_CLS} ${className}`}
/>
);
}
type AccessorialOption = {
id: string;
label: string;
description?: string;
};
function AccessorialCardGrid({
options,
selected,
disabled,
onChange,
side,
}: {
options: readonly AccessorialOption[];
selected: string[];
disabled?: boolean;
onChange: (next: string[]) => void;
side: "pickup" | "delivery";
}) {
const [showDetails, setShowDetails] = useState(true);
const [hint, setHint] = useState<string | null>(null);
const issues = useMemo(
() => evaluateMothershipAccessorialCompat({ side, selected }),
[side, selected],
);
return (
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className="inline-flex items-center gap-1 text-sm font-medium text-text">
选择提货/送货服务,避免追收附加费
<QuoteFieldTooltip
tip={MS_DETAILS_FIELD_TIPS.accessorials}
label="附加服务说明"
wide
/>
</p>
<label className="inline-flex cursor-pointer items-center gap-2 text-xs text-text-secondary">
<span>查看服务详情</span>
<button
type="button"
role="switch"
aria-checked={showDetails}
disabled={disabled}
onClick={() => setShowDetails((v) => !v)}
className={[
"relative h-5 w-9 shrink-0 rounded-full transition-colors",
showDetails ? "bg-primary" : "bg-border",
disabled ? "opacity-50" : "",
].join(" ")}
>
<span
className={[
"absolute top-0.5 left-0.5 h-4 w-4 rounded-full bg-white shadow transition-transform",
showDetails ? "translate-x-4" : "translate-x-0",
].join(" ")}
/>
</button>
</label>
</div>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
{options.map((opt) => {
const checked = selected.includes(opt.id);
const pickupRisk =
side === "pickup" &&
Boolean(MS_PICKUP_RISK_ACCESSORIALS[opt.id]);
return (
<button
key={opt.id}
type="button"
disabled={disabled}
onClick={() => {
const r = toggleMothershipAccessorial({
side,
selected,
id: opt.id,
});
if (!r.applied) {
setHint(r.message ?? null);
return;
}
setHint(r.message ?? null);
onChange(r.next);
}}
className={[
"rounded-lg border px-3 py-2.5 text-left transition-colors",
checked
? "border-primary bg-primary/5 ring-1 ring-primary/30"
: "border-border bg-surface hover:border-primary/40",
disabled ? "cursor-not-allowed opacity-55" : "cursor-pointer",
].join(" ")}
>
<span className="flex items-start gap-2">
<span
className={[
"mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
checked
? "border-primary bg-primary text-white"
: "border-border bg-white",
].join(" ")}
aria-hidden
>
{checked ? (
<span className="text-[10px] font-bold leading-none">✓</span>
) : null}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-text">
{opt.label}
</span>
{showDetails && opt.description ? (
<span className="mt-0.5 block text-[11px] leading-snug text-text-secondary">
{opt.description}
</span>
) : null}
{pickupRisk ? (
<span className="mt-0.5 block text-[11px] text-[#B45309]">
{MS_PICKUP_RISK_ACCESSORIALS[opt.id]}
</span>
) : null}
</span>
</span>
</button>
);
})}
</div>
{hint ? (
<p className="mt-1.5 text-[11px] leading-snug text-[#B45309]">{hint}</p>
) : null}
{issues.length > 0 ? (
<ul className="mt-1.5 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,
side,
value,
disabled,
accessorialOptions,
hintPickUp,
showFreightReady,
invalidFields,
peerEmail,
onChange,
}: {
title: string;
side: "pickup" | "delivery";
value: MsLocationDetails;
disabled?: boolean;
accessorialOptions: readonly AccessorialOption[];
hintPickUp?: boolean;
showFreightReady?: boolean;
invalidFields?: ReadonlySet<MsDetailsFieldKey>;
/** 对侧联系人邮箱:用于即时提示不得相同 */
peerEmail?: string;
onChange: (next: MsLocationDetails) => void;
}) {
const set = <K extends keyof MsLocationDetails>(
key: K,
v: MsLocationDetails[K],
) => onChange({ ...value, [key]: v });
const bad = (field: string) =>
Boolean(invalidFields?.has(`${side}.${field}` as MsDetailsFieldKey));
const openMin = parseMsClockToMinutes(value.opensAt);
const closeMin = parseMsClockToMinutes(value.closesAt);
const hoursConflict =
openMin != null && closeMin != null && openMin >= closeMin;
const readyMin = showFreightReady
? parseMsClockToMinutes(value.readyTime || MS_DEFAULT_READY_TIME)
: null;
const readyConflict =
Boolean(showFreightReady) &&
readyMin != null &&
openMin != null &&
readyMin < openMin;
const emailDup =
Boolean(peerEmail?.trim()) &&
Boolean(value.contactEmail.trim()) &&
peerEmail!.trim().toLowerCase() === value.contactEmail.trim().toLowerCase();
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
required
disabled={disabled}
invalid={bad("address")}
data-testid={`ms-details-${side}-address`}
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
required
disabled={disabled}
invalid={bad("companyName")}
data-testid={`ms-details-${side}-company`}
value={value.companyName}
onChange={(v) => set("companyName", v)}
placeholder="Acme Co."
/>
<p className="mt-1 text-[11px] text-text-secondary">
非住宅件请勿用人名作公司名,以免承运商加收附加费。
</p>
{bad("companyName") ? (
<p className="mt-1 text-[11px] text-error">请填写完整公司名</p>
) : null}
</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
required
type="email"
disabled={disabled}
invalid={bad("contactEmail") || emailDup}
data-testid={`ms-details-${side}-email`}
value={value.contactEmail}
onChange={(v) => set("contactEmail", v)}
placeholder="email@company.com"
/>
{emailDup ? (
<p className="mt-1 text-[11px] text-error">
{MS_EMAILS_MUST_DIFFER_MESSAGE}
</p>
) : bad("contactEmail") ? (
<p className="mt-1 text-[11px] text-error">
请填写有效的现场联系人邮箱
</p>
) : (
<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
required
disabled={disabled}
invalid={bad("contactPhone")}
data-testid={`ms-details-${side}-phone`}
value={value.contactPhone}
onChange={(v) => set("contactPhone", v)}
placeholder="(555) 555-5555"
/>
{bad("contactPhone") ? (
<p className="mt-1 text-[11px] text-error">请填写现场联系人电话</p>
) : null}
</label>
<label className="block">
<FieldLabel>分机</FieldLabel>
<TextInput
disabled={disabled}
value={value.extension}
onChange={(v) => set("extension", v)}
placeholder="分机"
/>
</label>
</div>
<label className="block">
<FieldLabel tip={MS_DETAILS_FIELD_TIPS.reference}>参考号</FieldLabel>
<TextInput
disabled={disabled}
value={value.reference}
onChange={(v) => set("reference", v)}
placeholder="采购单或参考号"
/>
</label>
<AccessorialCardGrid
options={accessorialOptions}
selected={value.accessorials}
disabled={disabled}
side={hintPickUp ? "pickup" : "delivery"}
onChange={(next) => set("accessorials", next)}
/>
<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
required
disabled={disabled}
value={value.opensAt}
aria-invalid={bad("opensAt") || hoursConflict || undefined}
data-testid={`ms-details-${side}-opens`}
data-invalid={
bad("opensAt") || hoursConflict ? "true" : undefined
}
onChange={(e) => set("opensAt", e.target.value)}
className={
bad("opensAt") || hoursConflict
? SELECT_ERR_CLS
: SELECT_OK_CLS
}
>
<option value="">选择时间</option>
{MS_READY_TIMES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
{hoursConflict ? (
<p className="mt-1 text-[11px] text-error">
{MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE}
</p>
) : bad("opensAt") ? (
<p className="mt-1 text-[11px] text-error">请选择开门时间</p>
) : null}
</label>
<span className="pb-2 text-xs text-text-secondary">EDT</span>
<label className="block">
<FieldLabel required>关门</FieldLabel>
<select
required
disabled={disabled}
value={value.closesAt}
aria-invalid={bad("closesAt") || hoursConflict || undefined}
data-testid={`ms-details-${side}-closes`}
data-invalid={
bad("closesAt") || hoursConflict ? "true" : undefined
}
onChange={(e) => set("closesAt", e.target.value)}
className={
bad("closesAt") || hoursConflict
? SELECT_ERR_CLS
: SELECT_OK_CLS
}
>
<option value="">选择时间</option>
{MS_READY_TIMES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
{hoursConflict ? (
<p className="mt-1 text-[11px] text-error">
{MS_OPENS_NOT_BEFORE_CLOSES_MESSAGE}
</p>
) : bad("closesAt") ? (
<p className="mt-1 text-[11px] text-error">请选择关门时间</p>
) : null}
</label>
<span className="pb-2 text-xs text-text-secondary">EDT</span>
</div>
</div>
{showFreightReady && (
<div>
<FieldLabel required tip={MS_DETAILS_FIELD_TIPS.freightReady}>
货运就绪时间(不可选周六、周日)
</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}
aria-invalid={bad("readyTime") || readyConflict || undefined}
data-testid={`ms-details-${side}-ready-time`}
data-invalid={
bad("readyTime") || readyConflict ? "true" : undefined
}
onChange={(e) => set("readyTime", e.target.value)}
className={
bad("readyTime") || readyConflict
? "h-10 min-w-[7.5rem] rounded-md border-2 border-error bg-error/5 px-2 text-sm"
: "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>
<p
className={`mt-1 text-[11px] ${
bad("readyTime") || readyConflict
? "text-error"
: "text-text-secondary"
}`}
>
{readyConflict
? MS_READY_BEFORE_OPENS_MESSAGE
: "就绪时刻须不早于提货点开门时间,且开门须早于关门。"}
</p>
</div>
)}
</div>
</section>
);
}
export interface MothershipLoggedInDetailsFormProps {
formId?: string;
initial: MothershipLoggedInShipmentPayload;
disabled?: boolean;
/** 确认补填后:空/错必填项立即红边高亮 */
highlightRequiredGaps?: boolean;
onChange?: (state: MothershipLoggedInDetailsState) => void;
onValidSubmit?: (payload: MothershipLoggedInDetailsPayload) => void;
}
export function MothershipLoggedInDetailsForm({
formId = "ms-logged-in-details-form",
initial,
disabled,
highlightRequiredGaps = false,
onChange,
onValidSubmit,
}: MothershipLoggedInDetailsFormProps) {
const [state, setState] = useState(() => buildDetailsStateFromL1(initial));
const [formError, setFormError] = useState<string | null>(null);
const [forceShowFieldErrors, setForceShowFieldErrors] = useState(false);
const patch = (next: MothershipLoggedInDetailsState) => {
setState(next);
setFormError(null);
onChange?.(next);
};
const showFieldErrors = highlightRequiredGaps || forceShowFieldErrors;
const invalidFields = useMemo(() => {
if (!showFieldErrors) return new Set<MsDetailsFieldKey>();
const payload = detailsStateToPayload(state);
return collectMsDetailsRefineInvalidFields(payload, {
pickupAddress: state.pickup.address,
deliveryAddress: state.delivery.address,
readyTime: state.pickup.readyTime || initial.readyTime,
});
}, [showFieldErrors, state, initial.readyTime]);
const showFba = useMemo(
() =>
state.requestDeliveryAppointment ||
state.delivery.accessorials.includes("fbaAppointment"),
[state],
);
const toPayload = (): MothershipLoggedInDetailsPayload =>
detailsStateToPayload(state);
return (
<form
id={formId}
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
const payload = toPayload();
const check = validateMsDetailsForRefine(payload, {
pickupAddress: state.pickup.address,
deliveryAddress: state.delivery.address,
readyTime: state.pickup.readyTime || initial.readyTime,
});
if (!check.ok) {
setForceShowFieldErrors(true);
setFormError(check.message);
return;
}
setFormError(null);
setForceShowFieldErrors(false);
onValidSubmit?.(payload);
}}
>
{formError ? (
<div className="mb-2">
<ErrorBanner>{formError}</ErrorBanner>
</div>
) : null}
<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. 提货信息"
side="pickup"
value={state.pickup}
disabled={disabled}
accessorialOptions={MS_PICKUP_ACCESSORIALS}
hintPickUp
showFreightReady
invalidFields={invalidFields}
peerEmail={state.delivery.contactEmail}
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>
FBA 预约建议填写下方的{" "}
<strong className="text-text-primary">
件数类型 / 件数数量
</strong>
(如纸箱、箱)
</li>
</ul>
</div>
)}
</section>
)}
<LocationSection
title="2. 送货信息"
side="delivery"
value={state.delivery}
disabled={disabled}
accessorialOptions={MS_DELIVERY_ACCESSORIALS}
invalidFields={invalidFields}
peerEmail={state.pickup.contactEmail}
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={MS_DETAILS_FIELD_TIPS.cargoType}>
货物类型
</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={MS_DETAILS_FIELD_TIPS.freightClass}>
运费等级(可选)
</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 tip={MS_DETAILS_FIELD_TIPS.pieceCountType}>
件数类型
</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>
{MS_PIECE_COUNT_TYPES.map((t) => (
<option key={t.id} value={t.id}>
{t.label}
</option>
))}
</select>
</label>
<label className="block">
<FieldLabel>件数数量</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 });
}}
placeholder="该类型数量"
/>
</label>
</div>
<label className="mt-3 block">
<FieldLabel required>货物描述</FieldLabel>
<textarea
required
disabled={disabled}
value={line.description}
aria-invalid={
invalidFields.has(`cargo.${idx}.description`) || undefined
}
data-invalid={
invalidFields.has(`cargo.${idx}.description`)
? "true"
: undefined
}
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={
invalidFields.has(`cargo.${idx}.description`)
? "w-full rounded-md border-2 border-error bg-error/5 px-3 py-2 text-sm focus:border-error focus:outline-none focus:ring-2 focus:ring-error/25"
: "w-full rounded-md border border-border px-3 py-2 text-sm"
}
/>
</label>
<label className="mt-3 block">
<FieldLabel tip={MS_DETAILS_FIELD_TIPS.nmfc}>
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">
{MS_DETAILS_FIELD_TIPS.nmfc}
</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>
);
}