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