/** * 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 *; } function FieldLabel({ children, required, tip, }: { children: React.ReactNode; required?: boolean; /** 官网 ⓘ 提示中文文案;有值则显示可点击/悬停说明 */ tip?: string; }) { return ( {children} {required ? : null} {tip ? ( ) : null} ); } 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 ( 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(null); const issues = useMemo( () => evaluateMothershipAccessorialCompat({ side, selected }), [side, selected], ); return (

选择提货/送货服务,避免追收附加费

{options.map((opt) => { const checked = selected.includes(opt.id); const pickupRisk = side === "pickup" && Boolean(MS_PICKUP_RISK_ACCESSORIALS[opt.id]); return ( ); })}
{hint ? (

{hint}

) : null} {issues.length > 0 ? ( ) : null}
); } 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; /** 对侧联系人邮箱:用于即时提示不得相同 */ peerEmail?: string; onChange: (next: MsLocationDetails) => void; }) { const set = ( 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 (

{title}

set("accessorials", next)} />