|
|
"use client";
|
|
|
|
|
|
import { useState } from "react";
|
|
|
import { CARGO_LIMITS } from "@/lib/constants/cargo-limits";
|
|
|
import type {
|
|
|
AddressInput,
|
|
|
CargoType,
|
|
|
DisplayUnit,
|
|
|
QuoteRequestBody,
|
|
|
ServiceLevel,
|
|
|
UnitDim,
|
|
|
UnitWeight,
|
|
|
} from "@/lib/frontend/types";
|
|
|
import { uuidV4 } from "@/lib/frontend/format";
|
|
|
import {
|
|
|
searchAddressSuggestions,
|
|
|
toAddressInput,
|
|
|
type AddressSuggestion,
|
|
|
} from "@/lib/frontend/address-suggestions";
|
|
|
import { formatAddressDisplayLabel } from "@/lib/frontend/format-address";
|
|
|
import { InputField } from "@/components/ui/input-field";
|
|
|
import { SelectField } from "@/components/ui/select-field";
|
|
|
import { StateComboboxField } from "@/components/ui/state-combobox-field";
|
|
|
import { isValidUsStateCode } from "@/lib/frontend/us-states";
|
|
|
import {
|
|
|
describeDimEquivalent,
|
|
|
describeWeightEquivalent,
|
|
|
displayUnitToCargoUnits,
|
|
|
} from "@/lib/frontend/cargo-form-units";
|
|
|
import {
|
|
|
cmToIn,
|
|
|
kgToLb,
|
|
|
toAxelWholeInches,
|
|
|
toAxelWholePounds,
|
|
|
} from "@/modules/quote/unit-converter";
|
|
|
|
|
|
interface AddressSectionProps {
|
|
|
title: string;
|
|
|
prefix: string;
|
|
|
street: string;
|
|
|
city: string;
|
|
|
state: string;
|
|
|
selected: AddressInput | null;
|
|
|
/** 用户已在 MotherShip 弹窗确认的地址(仅此来源展示「已选择」) */
|
|
|
confirmed: AddressInput | null;
|
|
|
errors: Record<string, string | undefined>;
|
|
|
disabled?: boolean;
|
|
|
onStreetChange: (v: string) => void;
|
|
|
onCityChange: (v: string) => void;
|
|
|
onStateChange: (v: string) => void;
|
|
|
onSelect: (addr: AddressInput) => void;
|
|
|
onBlurField: (key: string) => void;
|
|
|
}
|
|
|
|
|
|
function AddressSection({
|
|
|
title,
|
|
|
prefix,
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
selected,
|
|
|
confirmed,
|
|
|
errors,
|
|
|
disabled,
|
|
|
onStreetChange,
|
|
|
onCityChange,
|
|
|
onStateChange,
|
|
|
onSelect,
|
|
|
onBlurField,
|
|
|
}: AddressSectionProps) {
|
|
|
const [suggestions, setSuggestions] = useState<AddressSuggestion[]>([]);
|
|
|
|
|
|
const refreshSuggestions = () => {
|
|
|
const list = searchAddressSuggestions(street, city);
|
|
|
setSuggestions(list);
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
<section className="space-y-4">
|
|
|
<h3 className="text-base font-semibold text-text-primary">{title}</h3>
|
|
|
<InputField
|
|
|
label="街道"
|
|
|
name={`${prefix}_street`}
|
|
|
value={street}
|
|
|
disabled={disabled}
|
|
|
error={errors[`${prefix}_street`]}
|
|
|
onChange={(e) => onStreetChange(e.target.value)}
|
|
|
onBlur={() => {
|
|
|
onBlurField(`${prefix}_street`);
|
|
|
refreshSuggestions();
|
|
|
}}
|
|
|
/>
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
<InputField
|
|
|
label="城市"
|
|
|
name={`${prefix}_city`}
|
|
|
value={city}
|
|
|
disabled={disabled}
|
|
|
error={errors[`${prefix}_city`]}
|
|
|
onChange={(e) => onCityChange(e.target.value)}
|
|
|
onBlur={() => {
|
|
|
onBlurField(`${prefix}_city`);
|
|
|
refreshSuggestions();
|
|
|
}}
|
|
|
/>
|
|
|
<StateComboboxField
|
|
|
label="州"
|
|
|
name={`${prefix}_state`}
|
|
|
value={state}
|
|
|
disabled={disabled}
|
|
|
error={errors[`${prefix}_state`]}
|
|
|
onChange={onStateChange}
|
|
|
onBlur={() => onBlurField(`${prefix}_state`)}
|
|
|
/>
|
|
|
</div>
|
|
|
{suggestions.length > 0 && !selected && (
|
|
|
<ul className="rounded-md border border-border bg-surface shadow-card">
|
|
|
{suggestions.map((item) => (
|
|
|
<li key={item.place_id}>
|
|
|
<button
|
|
|
type="button"
|
|
|
className="w-full px-3 py-2 text-left text-sm hover:bg-bg"
|
|
|
onClick={() => onSelect(toAddressInput(item))}
|
|
|
>
|
|
|
{formatAddressDisplayLabel(item)}
|
|
|
</button>
|
|
|
</li>
|
|
|
))}
|
|
|
</ul>
|
|
|
)}
|
|
|
{confirmed?.selected_from_mothership && (
|
|
|
<p className="text-xs text-success">
|
|
|
已选择:{formatAddressDisplayLabel(confirmed)}
|
|
|
</p>
|
|
|
)}
|
|
|
{errors[`${prefix}_address`] && (
|
|
|
<p className="text-xs text-error">{errors[`${prefix}_address`]}</p>
|
|
|
)}
|
|
|
</section>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
interface QuoteFormProps {
|
|
|
customerId: string;
|
|
|
disabled?: boolean;
|
|
|
formId: string;
|
|
|
onValidSubmit: (body: QuoteRequestBody) => void;
|
|
|
/** MotherShip 弹窗确认后的提货地址(用于展示「已选择」) */
|
|
|
confirmedPickup?: AddressInput | null;
|
|
|
/** MotherShip 弹窗确认后的派送地址 */
|
|
|
confirmedDelivery?: AddressInput | null;
|
|
|
/** 用户修改地址草稿时回调(用于清除已过期的确认展示) */
|
|
|
onAddressDraftChange?: () => void;
|
|
|
/** 货物信息/单位变更时回调(用于清除旧报价) */
|
|
|
onCargoChange?: () => void;
|
|
|
}
|
|
|
|
|
|
interface FormState {
|
|
|
p_street: string;
|
|
|
p_city: string;
|
|
|
p_state: string;
|
|
|
d_street: string;
|
|
|
d_city: string;
|
|
|
d_state: string;
|
|
|
weight: string;
|
|
|
weight_unit: UnitWeight;
|
|
|
length: string;
|
|
|
width: string;
|
|
|
height: string;
|
|
|
dim_unit: UnitDim;
|
|
|
pallet_count: string;
|
|
|
cargo_type: CargoType;
|
|
|
display_unit: DisplayUnit;
|
|
|
service_level: ServiceLevel;
|
|
|
}
|
|
|
|
|
|
const DEFAULT_FORM: FormState = {
|
|
|
p_street: "1234 Warehouse Blvd",
|
|
|
p_city: "Los Angeles",
|
|
|
p_state: "CA",
|
|
|
d_street: "5678 Distribution Dr",
|
|
|
d_city: "Dallas",
|
|
|
d_state: "TX",
|
|
|
weight: "500",
|
|
|
weight_unit: "lb",
|
|
|
length: "48",
|
|
|
width: "40",
|
|
|
height: "48",
|
|
|
dim_unit: "in",
|
|
|
pallet_count: "2",
|
|
|
cargo_type: "general_freight",
|
|
|
display_unit: "imperial",
|
|
|
service_level: "standard",
|
|
|
};
|
|
|
|
|
|
function toQueryLb(value: number, unit: UnitWeight): number {
|
|
|
const rawLb = unit === "kg" ? kgToLb(value) : value;
|
|
|
return toAxelWholePounds(rawLb);
|
|
|
}
|
|
|
|
|
|
function toQueryIn(value: number, unit: UnitDim): number {
|
|
|
const rawIn = unit === "cm" ? cmToIn(value) : value;
|
|
|
return toAxelWholeInches(rawIn);
|
|
|
}
|
|
|
|
|
|
export function QuoteForm({
|
|
|
customerId,
|
|
|
disabled,
|
|
|
formId,
|
|
|
onValidSubmit,
|
|
|
confirmedPickup = null,
|
|
|
confirmedDelivery = null,
|
|
|
onAddressDraftChange,
|
|
|
onCargoChange,
|
|
|
}: QuoteFormProps) {
|
|
|
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
|
|
|
const [pickup, setPickup] = useState<AddressInput | null>(null);
|
|
|
const [delivery, setDelivery] = useState<AddressInput | null>(null);
|
|
|
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
|
|
|
|
|
const notifyCargoChange = () => {
|
|
|
onCargoChange?.();
|
|
|
};
|
|
|
|
|
|
const set = <K extends keyof FormState>(key: K, value: FormState[K]) => {
|
|
|
setForm((f) => ({ ...f, [key]: value }));
|
|
|
if (key.startsWith("p_") || key.startsWith("d_")) {
|
|
|
if (key.startsWith("p_")) setPickup(null);
|
|
|
if (key.startsWith("d_")) setDelivery(null);
|
|
|
onAddressDraftChange?.();
|
|
|
}
|
|
|
if (
|
|
|
key === "weight" ||
|
|
|
key === "weight_unit" ||
|
|
|
key === "length" ||
|
|
|
key === "width" ||
|
|
|
key === "height" ||
|
|
|
key === "dim_unit" ||
|
|
|
key === "display_unit" ||
|
|
|
key === "pallet_count"
|
|
|
) {
|
|
|
notifyCargoChange();
|
|
|
}
|
|
|
};
|
|
|
|
|
|
const handleWeightUnitChange = (nextUnit: UnitWeight) => {
|
|
|
set("weight_unit", nextUnit);
|
|
|
};
|
|
|
|
|
|
const handleDimUnitChange = (nextUnit: UnitDim) => {
|
|
|
set("dim_unit", nextUnit);
|
|
|
};
|
|
|
|
|
|
const handleDisplayUnitChange = (nextDisplay: DisplayUnit) => {
|
|
|
const targets = displayUnitToCargoUnits(nextDisplay);
|
|
|
setForm((prev) => ({
|
|
|
...prev,
|
|
|
display_unit: nextDisplay,
|
|
|
weight_unit: targets.weight,
|
|
|
dim_unit: targets.dim,
|
|
|
}));
|
|
|
notifyCargoChange();
|
|
|
};
|
|
|
|
|
|
const weightHint = describeWeightEquivalent(
|
|
|
Number(form.weight),
|
|
|
form.weight_unit,
|
|
|
);
|
|
|
const dimHint = describeDimEquivalent(
|
|
|
Number(form.length),
|
|
|
Number(form.width),
|
|
|
Number(form.height),
|
|
|
form.dim_unit,
|
|
|
);
|
|
|
|
|
|
const validateField = (key: string): string | undefined => {
|
|
|
const { palletCount, dimIn, weightLbPerPallet } = CARGO_LIMITS;
|
|
|
if (key === "weight") {
|
|
|
const lb = toQueryLb(Number(form.weight), form.weight_unit);
|
|
|
if (!form.weight || Number.isNaN(lb) || lb < weightLbPerPallet.min || lb > weightLbPerPallet.max) {
|
|
|
return `单托重量须在 ${weightLbPerPallet.min}–${weightLbPerPallet.max} lb 之间`;
|
|
|
}
|
|
|
}
|
|
|
if (key === "pallet_count") {
|
|
|
const n = Number(form.pallet_count);
|
|
|
if (!Number.isInteger(n) || n < palletCount.min || n > palletCount.max) {
|
|
|
return `托盘数须在 ${palletCount.min}–${palletCount.max} 之间`;
|
|
|
}
|
|
|
}
|
|
|
if (key === "length") {
|
|
|
const v = toQueryIn(Number(form.length), form.dim_unit);
|
|
|
if (!form.length || Number.isNaN(v) || v < dimIn.min || v > dimIn.lengthMax) {
|
|
|
return `长度须在 ${dimIn.min}–${dimIn.lengthMax} in 之间`;
|
|
|
}
|
|
|
}
|
|
|
if (key === "width") {
|
|
|
const v = toQueryIn(Number(form.width), form.dim_unit);
|
|
|
if (!form.width || Number.isNaN(v) || v < dimIn.min || v > dimIn.widthMax) {
|
|
|
return `宽度须在 ${dimIn.min}–${dimIn.widthMax} in 之间`;
|
|
|
}
|
|
|
}
|
|
|
if (key === "height") {
|
|
|
const v = toQueryIn(Number(form.height), form.dim_unit);
|
|
|
if (!form.height || Number.isNaN(v) || v < dimIn.min || v > dimIn.heightMax) {
|
|
|
return `高度须在 ${dimIn.min}–${dimIn.heightMax} in 之间`;
|
|
|
}
|
|
|
}
|
|
|
if (
|
|
|
["p_street", "p_city", "p_state", "d_street", "d_city", "d_state", "length", "width", "height"].includes(
|
|
|
key,
|
|
|
)
|
|
|
) {
|
|
|
const v = form[key as keyof FormState];
|
|
|
if (typeof v === "string" && !v.trim()) return "请填写该字段";
|
|
|
}
|
|
|
if (key === "p_state" || key === "d_state") {
|
|
|
const v = form[key as "p_state" | "d_state"];
|
|
|
if (!isValidUsStateCode(v)) return "请填写有效的美国州码(2 位字母)";
|
|
|
}
|
|
|
return undefined;
|
|
|
};
|
|
|
|
|
|
const handleBlur = (key: string) => {
|
|
|
const err = validateField(key);
|
|
|
setErrors((e) => ({ ...e, [key]: err }));
|
|
|
};
|
|
|
|
|
|
const validateAll = (): boolean => {
|
|
|
const keys = [
|
|
|
"p_street",
|
|
|
"p_city",
|
|
|
"p_state",
|
|
|
"d_street",
|
|
|
"d_city",
|
|
|
"d_state",
|
|
|
"weight",
|
|
|
"length",
|
|
|
"width",
|
|
|
"height",
|
|
|
"pallet_count",
|
|
|
];
|
|
|
const next: Record<string, string | undefined> = {};
|
|
|
keys.forEach((k) => {
|
|
|
const err = validateField(k);
|
|
|
if (err) next[k] = err;
|
|
|
});
|
|
|
// 不再强制要求从本地 mock 列表选择
|
|
|
// MotherShip 候选 API 会返回精准地址供用户在弹窗中确认
|
|
|
setErrors(next);
|
|
|
return Object.values(next).every((v) => !v);
|
|
|
};
|
|
|
|
|
|
/** 从表单字段构建地址(用户未从本地列表选择时) */
|
|
|
const buildAddressFromForm = (prefix: "p" | "d"): AddressInput => {
|
|
|
const street = prefix === "p" ? form.p_street : form.d_street;
|
|
|
const city = prefix === "p" ? form.p_city : form.d_city;
|
|
|
const state = prefix === "p" ? form.p_state : form.d_state;
|
|
|
return {
|
|
|
place_id: `draft_${prefix}_${Date.now()}`,
|
|
|
formatted_address: `${street}, ${city}, ${state}`,
|
|
|
street,
|
|
|
city,
|
|
|
state,
|
|
|
zip: "",
|
|
|
selected_from_suggestions: true, // 允许提交,MotherShip 会返回精准候选
|
|
|
};
|
|
|
};
|
|
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
|
e.preventDefault();
|
|
|
if (disabled) return;
|
|
|
if (!validateAll()) return;
|
|
|
|
|
|
// 优先使用用户从本地列表选择的地址,否则从表单字段构建
|
|
|
const finalPickup = pickup ?? buildAddressFromForm("p");
|
|
|
const finalDelivery = delivery ?? buildAddressFromForm("d");
|
|
|
|
|
|
onValidSubmit({
|
|
|
request_id: uuidV4(),
|
|
|
customer_id: customerId,
|
|
|
pickup_address: finalPickup,
|
|
|
delivery_address: finalDelivery,
|
|
|
weight: { value: Number(form.weight), unit: form.weight_unit },
|
|
|
dimensions: {
|
|
|
length: Number(form.length),
|
|
|
width: Number(form.width),
|
|
|
height: Number(form.height),
|
|
|
unit: form.dim_unit,
|
|
|
},
|
|
|
pallet_count: Number(form.pallet_count),
|
|
|
cargo_type: form.cargo_type,
|
|
|
service_level: form.service_level,
|
|
|
display_unit: form.display_unit,
|
|
|
});
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
<form id={formId} onSubmit={handleSubmit} className="space-y-6">
|
|
|
<fieldset disabled={disabled} className="space-y-6">
|
|
|
<AddressSection
|
|
|
title="提货地址"
|
|
|
prefix="p"
|
|
|
street={form.p_street}
|
|
|
city={form.p_city}
|
|
|
state={form.p_state}
|
|
|
selected={pickup}
|
|
|
confirmed={confirmedPickup}
|
|
|
errors={errors}
|
|
|
disabled={disabled}
|
|
|
onStreetChange={(v) => set("p_street", v)}
|
|
|
onCityChange={(v) => set("p_city", v)}
|
|
|
onStateChange={(v) => set("p_state", v)}
|
|
|
onSelect={setPickup}
|
|
|
onBlurField={handleBlur}
|
|
|
/>
|
|
|
<AddressSection
|
|
|
title="派送地址"
|
|
|
prefix="d"
|
|
|
street={form.d_street}
|
|
|
city={form.d_city}
|
|
|
state={form.d_state}
|
|
|
selected={delivery}
|
|
|
confirmed={confirmedDelivery}
|
|
|
errors={errors}
|
|
|
disabled={disabled}
|
|
|
onStreetChange={(v) => set("d_street", v)}
|
|
|
onCityChange={(v) => set("d_city", v)}
|
|
|
onStateChange={(v) => set("d_state", v)}
|
|
|
onSelect={setDelivery}
|
|
|
onBlurField={handleBlur}
|
|
|
/>
|
|
|
|
|
|
<section className="space-y-4">
|
|
|
<h3 className="text-base font-semibold text-text-primary">货物信息</h3>
|
|
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
|
|
<InputField
|
|
|
label="单托重量"
|
|
|
name="weight"
|
|
|
type="number"
|
|
|
value={form.weight}
|
|
|
error={errors.weight}
|
|
|
onChange={(e) => set("weight", e.target.value)}
|
|
|
onBlur={() => handleBlur("weight")}
|
|
|
/>
|
|
|
<SelectField
|
|
|
label="重量单位"
|
|
|
name="weight_unit"
|
|
|
value={form.weight_unit}
|
|
|
options={[
|
|
|
{ value: "lb", label: "磅 (lb)" },
|
|
|
{ value: "kg", label: "千克 (kg)" },
|
|
|
]}
|
|
|
onChange={(e) => handleWeightUnitChange(e.target.value as UnitWeight)}
|
|
|
/>
|
|
|
<InputField
|
|
|
label="托盘数"
|
|
|
name="pallet_count"
|
|
|
type="number"
|
|
|
value={form.pallet_count}
|
|
|
error={errors.pallet_count}
|
|
|
onChange={(e) => set("pallet_count", e.target.value)}
|
|
|
onBlur={() => handleBlur("pallet_count")}
|
|
|
/>
|
|
|
</div>
|
|
|
{weightHint ? (
|
|
|
<p className="text-xs text-text-secondary">{weightHint}</p>
|
|
|
) : null}
|
|
|
<div className="grid grid-cols-3 gap-4">
|
|
|
<InputField
|
|
|
label="长"
|
|
|
name="length"
|
|
|
type="number"
|
|
|
value={form.length}
|
|
|
error={errors.length}
|
|
|
onChange={(e) => set("length", e.target.value)}
|
|
|
onBlur={() => handleBlur("length")}
|
|
|
/>
|
|
|
<InputField
|
|
|
label="宽"
|
|
|
name="width"
|
|
|
type="number"
|
|
|
value={form.width}
|
|
|
error={errors.width}
|
|
|
onChange={(e) => set("width", e.target.value)}
|
|
|
onBlur={() => handleBlur("width")}
|
|
|
/>
|
|
|
<InputField
|
|
|
label="高"
|
|
|
name="height"
|
|
|
type="number"
|
|
|
value={form.height}
|
|
|
error={errors.height}
|
|
|
onChange={(e) => set("height", e.target.value)}
|
|
|
onBlur={() => handleBlur("height")}
|
|
|
/>
|
|
|
</div>
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
|
<SelectField
|
|
|
label="尺寸单位"
|
|
|
name="dim_unit"
|
|
|
value={form.dim_unit}
|
|
|
options={[
|
|
|
{ value: "in", label: "英寸 (in)" },
|
|
|
{ value: "cm", label: "厘米 (cm)" },
|
|
|
]}
|
|
|
onChange={(e) => handleDimUnitChange(e.target.value as UnitDim)}
|
|
|
/>
|
|
|
<SelectField
|
|
|
label="显示单位制"
|
|
|
name="display_unit"
|
|
|
value={form.display_unit}
|
|
|
options={[
|
|
|
{ value: "imperial", label: "英制" },
|
|
|
{ value: "metric", label: "公制" },
|
|
|
]}
|
|
|
onChange={(e) =>
|
|
|
handleDisplayUnitChange(e.target.value as DisplayUnit)
|
|
|
}
|
|
|
/>
|
|
|
</div>
|
|
|
{dimHint ? (
|
|
|
<p className="text-xs text-text-secondary">{dimHint}</p>
|
|
|
) : null}
|
|
|
<p className="text-xs text-text-secondary">
|
|
|
切换单位时会自动换算数值;修改货物后请重新点击「获取报价」。
|
|
|
</p>
|
|
|
</section>
|
|
|
</fieldset>
|
|
|
</form>
|
|
|
);
|
|
|
}
|