|
|
/**
|
|
|
* Flock Freight 登录后 Quick 查价界面(Let's build a quote)
|
|
|
* 对齐录制 flock-logged-in-20260715-143321.js + 官网截图;中文文案供嵌入宿主展示
|
|
|
*/
|
|
|
"use client";
|
|
|
|
|
|
import { useId, useMemo, useState, useEffect, type FormEvent, type ReactNode } from "react";
|
|
|
import { Plus, Trash, CaretDown } from "@phosphor-icons/react";
|
|
|
import {
|
|
|
defaultFlockPickupDateIso,
|
|
|
flockPickupDateValidationMessageFromIso,
|
|
|
minFlockPickupDateIso,
|
|
|
} from "@/lib/flock/pickup-date";
|
|
|
import { MothershipWeekdayDatePicker } from "@/components/mothership/mothership-weekday-date-picker";
|
|
|
import {
|
|
|
FLOCK_LIMITS,
|
|
|
FLOCK_VALIDATION_MESSAGES,
|
|
|
} from "@/lib/constants/flock-limits";
|
|
|
import {
|
|
|
evaluateFlockOptionCompat,
|
|
|
flockOptionHasBlock,
|
|
|
isFlockReeferOnlyWithoutTemp,
|
|
|
isFlockSprinterOnly,
|
|
|
isFlockTempWithoutReefer,
|
|
|
} from "@/lib/flock/option-compat";
|
|
|
import {
|
|
|
FlockSavedFreightSearch,
|
|
|
FlockSaveFreightButton,
|
|
|
} from "@/components/flock/flock-saved-freight-controls";
|
|
|
import type { FlockSavedFreightPreset } from "@/lib/flock/saved-freight-presets";
|
|
|
import {
|
|
|
QuoteFieldTooltip,
|
|
|
QuoteFloatHint,
|
|
|
QuoteNoticeCollapse,
|
|
|
QuotePageTitle,
|
|
|
QuoteRequiredMark,
|
|
|
QuoteSectionTitle,
|
|
|
QuoteStepProgress,
|
|
|
QuoteTipDefs,
|
|
|
quoteAddRowCls,
|
|
|
quoteCtaCls,
|
|
|
quoteInputCls,
|
|
|
quoteInputErrCls,
|
|
|
} from "@/components/quote/quote-form-chrome";
|
|
|
|
|
|
export const FLOCK_LOCATION_TYPES = [
|
|
|
{ id: "business_with_dock", label: "商业地址(有月台)" },
|
|
|
{ id: "business_without_dock", label: "商业地址(无月台)" },
|
|
|
{ id: "residential", label: "住宅地址" },
|
|
|
{ id: "trade_show", label: "展会 / 展览" },
|
|
|
{ id: "limited_construction", label: "施工工地" },
|
|
|
{ id: "limited_farm", label: "农场 / 种养殖地" },
|
|
|
{ id: "limited_airport", label: "受限通道 · 机场" },
|
|
|
{ id: "limited_port", label: "受限通道 · 港口码头" },
|
|
|
{ id: "limited_military", label: "受限通道 · 军事基地" },
|
|
|
{ id: "limited_worship", label: "受限通道 · 宗教场所" },
|
|
|
{ id: "limited_school", label: "受限通道 · 学校" },
|
|
|
{ id: "limited_other", label: "受限通道 · 其他" },
|
|
|
] as const;
|
|
|
|
|
|
/** 各地点类型可见的附加选项(对齐 Flock 官网:类别不同选项不同) */
|
|
|
export type FlockLocationAccessorialFlags = {
|
|
|
liftgate: boolean;
|
|
|
inside: boolean;
|
|
|
palletJack: boolean;
|
|
|
};
|
|
|
|
|
|
export const FLOCK_LOCATION_ACCESSORIALS: Record<
|
|
|
(typeof FLOCK_LOCATION_TYPES)[number]["id"],
|
|
|
FlockLocationAccessorialFlags
|
|
|
> = {
|
|
|
// 有卸货台:无附加勾选项(官网无 Liftgate / Inside / Pallet Jack)
|
|
|
business_with_dock: { liftgate: false, inside: false, palletJack: false },
|
|
|
// 无卸货台:三项全开
|
|
|
business_without_dock: { liftgate: true, inside: true, palletJack: true },
|
|
|
// 住宅:仅尾板 + 室内
|
|
|
residential: { liftgate: true, inside: true, palletJack: false },
|
|
|
// 展会:仅托盘车
|
|
|
trade_show: { liftgate: false, inside: false, palletJack: true },
|
|
|
// 施工现场:尾板 + 托盘车(无室内)
|
|
|
limited_construction: { liftgate: true, inside: false, palletJack: true },
|
|
|
// 农场:三项
|
|
|
limited_farm: { liftgate: true, inside: true, palletJack: true },
|
|
|
limited_airport: { liftgate: true, inside: true, palletJack: true },
|
|
|
limited_port: { liftgate: true, inside: true, palletJack: true },
|
|
|
limited_military: { liftgate: true, inside: true, palletJack: true },
|
|
|
// 宗教场所:仅尾板 + 室内(截图 Limited Access – Church)
|
|
|
limited_worship: { liftgate: true, inside: true, palletJack: false },
|
|
|
limited_school: { liftgate: true, inside: true, palletJack: true },
|
|
|
limited_other: { liftgate: true, inside: true, palletJack: true },
|
|
|
};
|
|
|
|
|
|
export function getFlockLocationAccessorials(
|
|
|
typeId: (typeof FLOCK_LOCATION_TYPES)[number]["id"],
|
|
|
): FlockLocationAccessorialFlags {
|
|
|
return FLOCK_LOCATION_ACCESSORIALS[typeId];
|
|
|
}
|
|
|
|
|
|
export const FLOCK_PACKAGING_TYPES = [
|
|
|
{ id: "pallets_48x40", label: "标准托盘 48×40", length: 48, width: 40 },
|
|
|
{ id: "pallets_48x48", label: "标准托盘 48×48", length: 48, width: 48 },
|
|
|
{ id: "pallets_60x48", label: "标准托盘 60×48", length: 60, width: 48 },
|
|
|
{ id: "pallets_custom", label: "自定义托盘尺寸", length: null, width: null },
|
|
|
{ id: "bags", label: "袋装", length: null, width: null },
|
|
|
{ id: "bales", label: "捆包", length: null, width: null },
|
|
|
{ id: "boxes", label: "纸箱 / 纸盒", length: null, width: null },
|
|
|
{ id: "bundles", label: "捆扎件", length: null, width: null },
|
|
|
{ id: "coils", label: "卷料", length: null, width: null },
|
|
|
{ id: "crates", label: "木箱 / 板条箱", length: null, width: null },
|
|
|
{ id: "cylinders", label: "钢瓶 / 气瓶", length: null, width: null },
|
|
|
{ id: "drums", label: "铁桶 / 圆桶", length: null, width: null },
|
|
|
{ id: "pails", label: "小桶 / 提桶", length: null, width: null },
|
|
|
{ id: "reels", label: "线盘 / 卷盘", length: null, width: null },
|
|
|
{ id: "rolls", label: "卷筒", length: null, width: null },
|
|
|
{ id: "slipsheets", label: "滑托板", length: null, width: null },
|
|
|
{ id: "tubes_pipes", label: "管材", length: null, width: null },
|
|
|
{ id: "units", label: "单件 / 整机", length: null, width: null },
|
|
|
] as const;
|
|
|
|
|
|
export const FLOCK_FREIGHT_CLASSES = [
|
|
|
"density",
|
|
|
"50",
|
|
|
"55",
|
|
|
"60",
|
|
|
"65",
|
|
|
"70",
|
|
|
"77.5",
|
|
|
"85",
|
|
|
"92.5",
|
|
|
"100",
|
|
|
"110",
|
|
|
"125",
|
|
|
"150",
|
|
|
"175",
|
|
|
"200",
|
|
|
"250",
|
|
|
"300",
|
|
|
"400",
|
|
|
"500",
|
|
|
] as const;
|
|
|
|
|
|
export const FLOCK_ADDITIONAL_SERVICES = [
|
|
|
{ id: "blind_shipment", label: "盲运(隐藏发/收货方)" },
|
|
|
{ id: "food_grade", label: "食品级车厢" },
|
|
|
{ id: "load_to_ride", label: "全程直达(不换装)" },
|
|
|
{ id: "temperature_control", label: "温控 / 冷链" },
|
|
|
{ id: "unloading", label: "到站卸货服务" },
|
|
|
] as const;
|
|
|
|
|
|
export const FLOCK_VEHICLE_TYPES = [
|
|
|
{ id: "box_truck", label: "厢式货车" },
|
|
|
{ id: "dry_van", label: "干货挂车" },
|
|
|
{ id: "refrigerated", label: "冷藏车" },
|
|
|
{ id: "sprinter", label: "小型厢车" },
|
|
|
] as const;
|
|
|
|
|
|
export const FLOCK_DEFAULT_VEHICLES = ["box_truck", "dry_van", "refrigerated"] as const;
|
|
|
|
|
|
/** 官网提示文案(货运行业惯用译,对齐全票提货/派送语境) */
|
|
|
export const FLOCK_LOCATION_TIP_INTRO =
|
|
|
"请准确选择地点类型,便于承运商安排提货与派送车辆,减少压车与拒收。";
|
|
|
|
|
|
export const FLOCK_LOCATION_TIP_ITEMS = [
|
|
|
{
|
|
|
title: "商业地址(有月台):",
|
|
|
body: "具备标准高度装卸月台,车辆可直接对接上下货的商业场所。",
|
|
|
},
|
|
|
{
|
|
|
title: "商业地址(无月台):",
|
|
|
body: "无标准装卸月台,通常需尾板或地面装卸的商业场所。",
|
|
|
},
|
|
|
{
|
|
|
title: "住宅地址:",
|
|
|
body: "按当地区划认定的住宅地点,含住家办公、宅基地建筑等。",
|
|
|
},
|
|
|
{
|
|
|
title: "展会 / 展览:",
|
|
|
body: "进出展馆、展台及展会临时仓的货件。",
|
|
|
},
|
|
|
{
|
|
|
title: "施工工地:",
|
|
|
body: "正在施工、进出可能受限的工地现场。",
|
|
|
},
|
|
|
{
|
|
|
title: "农场 / 种养殖地:",
|
|
|
body: "农业种植、养殖或农产品生产用地。",
|
|
|
},
|
|
|
{
|
|
|
title: "受限通道地点:",
|
|
|
body: "进出不便,或需额外通行证/安检手续的地点(机场、港口、军营、校园等)。",
|
|
|
},
|
|
|
] as const;
|
|
|
|
|
|
export const FLOCK_FREIGHT_CLASS_TIP =
|
|
|
"填写件数、外尺寸(长×宽×高)与重量后,可据此估算或选择 NMFC 运价等级。";
|
|
|
|
|
|
export const FLOCK_NMFC_NOTICE =
|
|
|
"仅按密度推算的等级可能不准确。正式分等还要考虑装卸难度、积载性与承运责任。托运人须选用正确的 NMFC 代码与等级,否则可能被重新分等并产生改费、延误与追加费用。";
|
|
|
|
|
|
export const FLOCK_STACKABLE_TIP =
|
|
|
"货件可堆码或可调向摆放时,有助于提高装载率,运费最多约可省 20%。";
|
|
|
|
|
|
export const FLOCK_ADDITIONAL_SERVICE_TIP_ITEMS = [
|
|
|
{
|
|
|
title: "盲运:",
|
|
|
body: "提单及签收单据对发货方和/或收货方信息进行隐匿,适用于中转贸易等场景。",
|
|
|
},
|
|
|
{
|
|
|
title: "食品级车厢:",
|
|
|
body: "要求车厢洁净、无异味、无破损渗透,适用于食品及敏感货物。",
|
|
|
},
|
|
|
{
|
|
|
title: "全程直达(不换装):",
|
|
|
body: "要求货件途中不经其他站点翻装换车,降低破损与延误风险。",
|
|
|
},
|
|
|
{
|
|
|
title: "温控 / 冷链:",
|
|
|
body: "车厢需按指定温度区间控温运输。",
|
|
|
},
|
|
|
{
|
|
|
title: "到站卸货服务:",
|
|
|
body: "含到站分拣、装卸工协助等;不等于白手套式送货上门。",
|
|
|
},
|
|
|
] as const;
|
|
|
|
|
|
export const FLOCK_VEHICLE_TIP_ITEMS = [
|
|
|
{
|
|
|
title: "干货挂车:",
|
|
|
body: "提货或派送场地可停靠约 53 英尺干货厢式挂车时勾选。",
|
|
|
},
|
|
|
{
|
|
|
title: "冷藏车:",
|
|
|
body: "货件可装入各类尺寸冷藏车(如 48′、53′)时勾选;若需设定温度,请同时勾选「温控 / 冷链」。",
|
|
|
},
|
|
|
{
|
|
|
title: "厢式货车:",
|
|
|
body: "场地可停靠各尺寸厢货(如 18′–28′ 城市配送车)时勾选。",
|
|
|
},
|
|
|
{
|
|
|
title: "小型厢车:",
|
|
|
body: "场地可停靠厢式面包车/小型厢货,且无需叉车或坡道装卸时勾选。",
|
|
|
},
|
|
|
] as const;
|
|
|
|
|
|
export const FLOCK_INSURANCE_YES_TIP =
|
|
|
"若需在承运人责任限额之外另行加保,请选「是」。";
|
|
|
|
|
|
export const FLOCK_INSURANCE_NO_TIP =
|
|
|
"货件已有保险,或不需超出承运人责任限额的额外保额时,请选「否」。";
|
|
|
|
|
|
export const FLOCK_INSURANCE_WAIVE_HINT = "您已选择不投保额外货运险。";
|
|
|
|
|
|
export const FLOCK_INSURANCE_FOOTER =
|
|
|
"如需了解货运保险细则,可联系 support@flockfreight.com。";
|
|
|
|
|
|
/** 整票总重未超过阈值时,官网调度区部分选项为灰色不可选 */
|
|
|
export function isFlockSchedulingOptionLocked(
|
|
|
totalWeightLb: number,
|
|
|
weightLocked: boolean,
|
|
|
): boolean {
|
|
|
if (!weightLocked) return false;
|
|
|
return !Number.isFinite(totalWeightLb) || totalWeightLb <= FLOCK_LIMITS.schedulingWeightMinLb;
|
|
|
}
|
|
|
|
|
|
export function flockSchedulingWeightLockedHint(): string {
|
|
|
return `整票总重须大于 ${FLOCK_LIMITS.schedulingWeightMinLb} lb 才可选用`;
|
|
|
}
|
|
|
|
|
|
/** 官网时段下拉(12h,半小时步进) */
|
|
|
export const FLOCK_SCHEDULING_TIME_OPTIONS: string[] = (() => {
|
|
|
const opts: string[] = [];
|
|
|
for (let h = 0; h < 24; h += 1) {
|
|
|
for (const m of [0, 30] as const) {
|
|
|
const hour12 = h % 12 === 0 ? 12 : h % 12;
|
|
|
const ampm = h < 12 ? "am" : "pm";
|
|
|
opts.push(`${hour12}:${m === 0 ? "00" : "30"} ${ampm}`);
|
|
|
}
|
|
|
}
|
|
|
return opts;
|
|
|
})();
|
|
|
|
|
|
/** Quick 调度:weightLocked=true 表示总重 ≤5000 时灰色禁用 */
|
|
|
export const FLOCK_PICKUP_SERVICES = [
|
|
|
{
|
|
|
id: "standard_fcfs",
|
|
|
label: "标准提货(FCFS)",
|
|
|
hint: "在地点营业时间内提货",
|
|
|
detail: "",
|
|
|
weightLocked: false,
|
|
|
},
|
|
|
{
|
|
|
id: "during_window",
|
|
|
label: "指定提货时段",
|
|
|
hint: "",
|
|
|
detail:
|
|
|
"提货地点有预设时段(如 2PM–4PM)可供提货。",
|
|
|
weightLocked: true,
|
|
|
},
|
|
|
{
|
|
|
id: "have_appointment",
|
|
|
label: "已有提货预约",
|
|
|
hint: "",
|
|
|
detail: "",
|
|
|
weightLocked: true,
|
|
|
},
|
|
|
{
|
|
|
id: "need_appointment",
|
|
|
label: "需要提货预约",
|
|
|
hint: "",
|
|
|
detail: "",
|
|
|
weightLocked: true,
|
|
|
},
|
|
|
] as const;
|
|
|
|
|
|
/** 提货前致电:总重 ≤5000 时灰色 */
|
|
|
export const FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED = true;
|
|
|
|
|
|
export const FLOCK_DELIVERY_SERVICES = [
|
|
|
{
|
|
|
id: "standard_fcfs",
|
|
|
label: "标准派送(FCFS)",
|
|
|
hint: "在地点营业时间内派送",
|
|
|
detail: "",
|
|
|
weightLocked: false,
|
|
|
},
|
|
|
{
|
|
|
id: "during_window",
|
|
|
label: "指定派送时段",
|
|
|
hint: "",
|
|
|
detail:
|
|
|
"派送地点有预设时段(如 2PM–4PM)可供派送。",
|
|
|
weightLocked: true,
|
|
|
},
|
|
|
{
|
|
|
id: "have_appointment",
|
|
|
label: "已有派送预约",
|
|
|
hint: "",
|
|
|
detail: "",
|
|
|
weightLocked: true,
|
|
|
},
|
|
|
{
|
|
|
id: "need_appointment",
|
|
|
label: "需要派送预约",
|
|
|
hint: "",
|
|
|
detail: "",
|
|
|
/** 官网小批量下仍可选 */
|
|
|
weightLocked: false,
|
|
|
},
|
|
|
{
|
|
|
id: "must_arrive_by",
|
|
|
label: "须在指定日期前到达",
|
|
|
hint: "",
|
|
|
detail: "",
|
|
|
weightLocked: true,
|
|
|
},
|
|
|
] as const;
|
|
|
|
|
|
export const FLOCK_CALL_FOR_DELIVERY_APPOINTMENT_HINT =
|
|
|
"Flock Freight 将与派送地点及承运商协调预约。";
|
|
|
|
|
|
export type FlockLocationTypeId = (typeof FLOCK_LOCATION_TYPES)[number]["id"];
|
|
|
export type FlockPackagingTypeId = (typeof FLOCK_PACKAGING_TYPES)[number]["id"];
|
|
|
export type FlockFreightClass = (typeof FLOCK_FREIGHT_CLASSES)[number];
|
|
|
export type FlockAdditionalServiceId =
|
|
|
(typeof FLOCK_ADDITIONAL_SERVICES)[number]["id"];
|
|
|
export type FlockVehicleTypeId = (typeof FLOCK_VEHICLE_TYPES)[number]["id"];
|
|
|
|
|
|
export type FlockLoggedInItem = {
|
|
|
key: string;
|
|
|
quantity: string;
|
|
|
packagingType: FlockPackagingTypeId;
|
|
|
lengthIn: string;
|
|
|
widthIn: string;
|
|
|
heightIn: string;
|
|
|
totalWeightLb: string;
|
|
|
freightClass: FlockFreightClass;
|
|
|
description: string;
|
|
|
stackable: boolean;
|
|
|
turnable: boolean;
|
|
|
};
|
|
|
|
|
|
export type FlockLoggedInQuotePayload = {
|
|
|
mode: "quick";
|
|
|
pickupDate: string;
|
|
|
pickupZip: string;
|
|
|
pickupType: FlockLocationTypeId;
|
|
|
pickupLiftgate: boolean;
|
|
|
pickupInside: boolean;
|
|
|
pickupPalletJack: boolean;
|
|
|
deliveryZip: string;
|
|
|
deliveryType: FlockLocationTypeId;
|
|
|
deliveryLiftgate: boolean;
|
|
|
deliveryInside: boolean;
|
|
|
deliveryPalletJack: boolean;
|
|
|
items: Array<{
|
|
|
quantity: number;
|
|
|
packagingType: FlockPackagingTypeId;
|
|
|
lengthIn: number;
|
|
|
widthIn: number;
|
|
|
heightIn: number;
|
|
|
totalWeightLb: number;
|
|
|
freightClass: FlockFreightClass;
|
|
|
description: string;
|
|
|
stackable: boolean;
|
|
|
turnable: boolean;
|
|
|
}>;
|
|
|
additionalServices: FlockAdditionalServiceId[];
|
|
|
vehicleTypes: FlockVehicleTypeId[];
|
|
|
pickupService: string;
|
|
|
deliveryService: string;
|
|
|
/** 指定提货时段 */
|
|
|
pickupWindowStartTime?: string;
|
|
|
pickupWindowEndTime?: string;
|
|
|
/** 已有提货预约 */
|
|
|
pickupAppointmentTime?: string;
|
|
|
/** 指定派送时段 */
|
|
|
deliveryWindowStartTime?: string;
|
|
|
deliveryWindowEndTime?: string;
|
|
|
deliveryWindowDate?: string;
|
|
|
/** 已有派送预约 */
|
|
|
deliveryAppointmentTime?: string;
|
|
|
deliveryAppointmentDate?: string;
|
|
|
/** 须在指定日期前到达 */
|
|
|
deliveryMustArriveByDate?: string;
|
|
|
/** Must arrive by 下的「预约派送致电」 */
|
|
|
callForDeliveryAppointment?: boolean;
|
|
|
callBeforePickup: boolean;
|
|
|
callBeforeDelivery: boolean;
|
|
|
additionalInsurance: boolean;
|
|
|
};
|
|
|
|
|
|
export interface FlockLoggedInQuoteFormProps {
|
|
|
formId?: string;
|
|
|
disabled?: boolean;
|
|
|
submitLocked?: boolean;
|
|
|
/** 按客户隔离常用货件(localStorage) */
|
|
|
customerId?: string;
|
|
|
onValidSubmit?: (payload: FlockLoggedInQuotePayload) => void;
|
|
|
}
|
|
|
|
|
|
function RequiredMark() {
|
|
|
return <QuoteRequiredMark />;
|
|
|
}
|
|
|
|
|
|
function newItem(): FlockLoggedInItem {
|
|
|
return {
|
|
|
key: `fi-${Math.random().toString(36).slice(2, 10)}`,
|
|
|
quantity: "",
|
|
|
packagingType: "pallets_48x40",
|
|
|
lengthIn: "48",
|
|
|
widthIn: "40",
|
|
|
heightIn: "",
|
|
|
totalWeightLb: "",
|
|
|
freightClass: "density",
|
|
|
description: "",
|
|
|
stackable: false,
|
|
|
turnable: false,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function applyPackagingDims(
|
|
|
packagingType: FlockPackagingTypeId,
|
|
|
item: FlockLoggedInItem,
|
|
|
): FlockLoggedInItem {
|
|
|
const pack = FLOCK_PACKAGING_TYPES.find((p) => p.id === packagingType);
|
|
|
if (!pack) return { ...item, packagingType };
|
|
|
return {
|
|
|
...item,
|
|
|
packagingType,
|
|
|
lengthIn: pack.length != null ? String(pack.length) : item.lengthIn,
|
|
|
widthIn: pack.width != null ? String(pack.width) : item.widthIn,
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function applySavedFreightPreset(
|
|
|
item: FlockLoggedInItem,
|
|
|
preset: FlockSavedFreightPreset,
|
|
|
): FlockLoggedInItem {
|
|
|
const packOk = FLOCK_PACKAGING_TYPES.some((p) => p.id === preset.packagingType);
|
|
|
const classOk = (FLOCK_FREIGHT_CLASSES as readonly string[]).includes(
|
|
|
preset.freightClass,
|
|
|
);
|
|
|
return {
|
|
|
...item,
|
|
|
quantity: preset.quantity || item.quantity,
|
|
|
packagingType: packOk
|
|
|
? (preset.packagingType as FlockPackagingTypeId)
|
|
|
: item.packagingType,
|
|
|
lengthIn: preset.lengthIn || item.lengthIn,
|
|
|
widthIn: preset.widthIn || item.widthIn,
|
|
|
heightIn: preset.heightIn || item.heightIn,
|
|
|
totalWeightLb: preset.totalWeightLb || item.totalWeightLb,
|
|
|
freightClass: classOk
|
|
|
? (preset.freightClass as FlockFreightClass)
|
|
|
: item.freightClass,
|
|
|
description: preset.description || preset.name,
|
|
|
stackable: Boolean(preset.stackable),
|
|
|
turnable: Boolean(preset.turnable),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
function OutlinedField({
|
|
|
label,
|
|
|
required,
|
|
|
error,
|
|
|
tip,
|
|
|
children,
|
|
|
}: {
|
|
|
label: string;
|
|
|
required?: boolean;
|
|
|
error?: string;
|
|
|
tip?: ReactNode;
|
|
|
children: ReactNode;
|
|
|
}) {
|
|
|
return (
|
|
|
<label className="block space-y-1">
|
|
|
<span className="inline-flex items-center gap-1 text-xs text-[#4B5563]">
|
|
|
{label}
|
|
|
{required ? <RequiredMark /> : null}
|
|
|
{tip ? (
|
|
|
<QuoteFieldTooltip tip={tip} label={`${label}说明`} wide />
|
|
|
) : null}
|
|
|
</span>
|
|
|
{children}
|
|
|
{error ? (
|
|
|
<span className="block text-xs text-[#EF4444]">{error}</span>
|
|
|
) : null}
|
|
|
</label>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
const inputCls = quoteInputCls;
|
|
|
const inputErrCls = quoteInputErrCls;
|
|
|
|
|
|
function LocationAccessorialChecks({
|
|
|
side,
|
|
|
typeId,
|
|
|
disabled,
|
|
|
liftgate,
|
|
|
inside,
|
|
|
palletJack,
|
|
|
onLiftgate,
|
|
|
onInside,
|
|
|
onPalletJack,
|
|
|
}: {
|
|
|
side: "pickup" | "delivery";
|
|
|
typeId: FlockLocationTypeId;
|
|
|
disabled?: boolean;
|
|
|
liftgate: boolean;
|
|
|
inside: boolean;
|
|
|
palletJack: boolean;
|
|
|
onLiftgate: (v: boolean) => void;
|
|
|
onInside: (v: boolean) => void;
|
|
|
onPalletJack: (v: boolean) => void;
|
|
|
}) {
|
|
|
const flags = getFlockLocationAccessorials(typeId);
|
|
|
if (!flags.liftgate && !flags.inside && !flags.palletJack) return null;
|
|
|
|
|
|
const liftgateLabel =
|
|
|
side === "pickup"
|
|
|
? "提货需要尾板(Liftgate)"
|
|
|
: "派送需要尾板(Liftgate)";
|
|
|
const insideLabel =
|
|
|
side === "pickup"
|
|
|
? "室内提货(Inside Pickup)"
|
|
|
: "室内派送(Inside Delivery)";
|
|
|
|
|
|
return (
|
|
|
<div className="mt-4 space-y-2 md:ml-[50%] md:pl-2">
|
|
|
{flags.liftgate ? (
|
|
|
<label className="flex items-center gap-2 text-sm text-text-primary">
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
disabled={disabled}
|
|
|
checked={liftgate}
|
|
|
onChange={(e) => onLiftgate(e.target.checked)}
|
|
|
/>
|
|
|
{liftgateLabel}
|
|
|
</label>
|
|
|
) : null}
|
|
|
{flags.inside ? (
|
|
|
<label className="flex items-center gap-2 text-sm text-text-primary">
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
disabled={disabled}
|
|
|
checked={inside}
|
|
|
onChange={(e) => onInside(e.target.checked)}
|
|
|
/>
|
|
|
{insideLabel}
|
|
|
</label>
|
|
|
) : null}
|
|
|
{flags.palletJack ? (
|
|
|
<label className="flex items-start gap-2 text-sm text-text-primary">
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
className="mt-1"
|
|
|
disabled={disabled}
|
|
|
checked={palletJack}
|
|
|
onChange={(e) => onPalletJack(e.target.checked)}
|
|
|
/>
|
|
|
<span>
|
|
|
场地自备托盘车与叉车
|
|
|
<span className="mt-0.5 block text-xs text-text-secondary">
|
|
|
提供 FlockDirect® 时,该地点须具备托盘车与叉车
|
|
|
</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
) : null}
|
|
|
</div>
|
|
|
);
|
|
|
}
|
|
|
function ChipToggle({
|
|
|
selected,
|
|
|
disabled,
|
|
|
label,
|
|
|
onClick,
|
|
|
}: {
|
|
|
selected: boolean;
|
|
|
disabled?: boolean;
|
|
|
label: string;
|
|
|
onClick: () => void;
|
|
|
}) {
|
|
|
return (
|
|
|
<button
|
|
|
type="button"
|
|
|
disabled={disabled}
|
|
|
onClick={onClick}
|
|
|
className={[
|
|
|
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-left text-sm transition-colors",
|
|
|
selected
|
|
|
? "border-text-primary bg-bg font-medium text-text-primary"
|
|
|
: "border-border bg-surface text-text-primary hover:border-text-secondary",
|
|
|
disabled ? "cursor-not-allowed opacity-50" : "",
|
|
|
].join(" ")}
|
|
|
>
|
|
|
{selected ? <span aria-hidden>✓</span> : null}
|
|
|
{label}
|
|
|
</button>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
/** 登录态货物行尺寸/重量硬限(同源 FLOCK_LIMITS) */
|
|
|
export function validateFlockLoggedInCargoLine(input: {
|
|
|
quantity: number;
|
|
|
lengthIn: number;
|
|
|
widthIn: number;
|
|
|
heightIn: number;
|
|
|
totalWeightLb: number;
|
|
|
description: string;
|
|
|
}): Record<string, string> {
|
|
|
const err: Record<string, string> = {};
|
|
|
const { min, lengthMax, widthMax, heightMax } = FLOCK_LIMITS.dimIn;
|
|
|
if (!Number.isFinite(input.quantity) || input.quantity <= 0) {
|
|
|
err.qty = "件数必填";
|
|
|
}
|
|
|
if (!Number.isFinite(input.lengthIn) || input.lengthIn < min) {
|
|
|
err.len = "长度必填";
|
|
|
} else if (input.lengthIn > lengthMax) {
|
|
|
err.len = FLOCK_VALIDATION_MESSAGES.length;
|
|
|
}
|
|
|
if (!Number.isFinite(input.widthIn) || input.widthIn < min) {
|
|
|
err.wid = "宽度必填";
|
|
|
} else if (input.widthIn > widthMax) {
|
|
|
err.wid = FLOCK_VALIDATION_MESSAGES.width;
|
|
|
}
|
|
|
if (!Number.isFinite(input.heightIn) || input.heightIn < min) {
|
|
|
err.hei = "高度必填";
|
|
|
} else if (input.heightIn > heightMax) {
|
|
|
err.hei = FLOCK_VALIDATION_MESSAGES.height;
|
|
|
}
|
|
|
if (!Number.isFinite(input.totalWeightLb) || input.totalWeightLb <= 0) {
|
|
|
err.wt = "重量必填";
|
|
|
} else if (input.totalWeightLb > FLOCK_LIMITS.totalWeightLbMax) {
|
|
|
err.wt = FLOCK_VALIDATION_MESSAGES.totalWeight;
|
|
|
}
|
|
|
if (!input.description.trim()) {
|
|
|
err.desc = "货物描述必填";
|
|
|
}
|
|
|
return err;
|
|
|
}
|
|
|
|
|
|
/** 将登录态 payload 映射为 Flock API flock_input(含登录态 RPA 扩展字段) */
|
|
|
export function mapFlockLoggedInToApiInput(payload: FlockLoggedInQuotePayload): {
|
|
|
pickup_date: string;
|
|
|
pickup_zip: string;
|
|
|
delivery_zip: string;
|
|
|
pallet_count: number;
|
|
|
total_weight: { value: number; unit: "lb" };
|
|
|
dimensions: { length: number; width: number; height: number; unit: "in" };
|
|
|
form_mode: "logged_in_quick";
|
|
|
pickup_location_type: string;
|
|
|
delivery_location_type: string;
|
|
|
packaging_type: string;
|
|
|
freight_class: string;
|
|
|
description: string;
|
|
|
stackable: boolean;
|
|
|
turnable: boolean;
|
|
|
additional_services: string[];
|
|
|
vehicle_types: string[];
|
|
|
call_before_pickup: boolean;
|
|
|
call_before_delivery: boolean;
|
|
|
pickup_service: string;
|
|
|
delivery_service: string;
|
|
|
pickup_window_start_time?: string;
|
|
|
pickup_window_end_time?: string;
|
|
|
pickup_appointment_time?: string;
|
|
|
delivery_window_start_time?: string;
|
|
|
delivery_window_end_time?: string;
|
|
|
delivery_window_date?: string;
|
|
|
delivery_appointment_time?: string;
|
|
|
delivery_appointment_date?: string;
|
|
|
delivery_must_arrive_by_date?: string;
|
|
|
call_for_delivery_appointment?: boolean;
|
|
|
pickup_liftgate: boolean;
|
|
|
pickup_inside: boolean;
|
|
|
pickup_pallet_jack: boolean;
|
|
|
delivery_liftgate: boolean;
|
|
|
delivery_inside: boolean;
|
|
|
delivery_pallet_jack: boolean;
|
|
|
} {
|
|
|
const [y, m, d] = payload.pickupDate.split("-");
|
|
|
const pickup_date =
|
|
|
y && m && d ? `${m}/${d}/${y}` : payload.pickupDate;
|
|
|
const first = payload.items[0];
|
|
|
const pallet_count = payload.items.reduce((s, it) => s + it.quantity, 0);
|
|
|
const total_weight = payload.items.reduce((s, it) => s + it.totalWeightLb, 0);
|
|
|
const opt = (v?: string) => {
|
|
|
const t = (v ?? "").trim();
|
|
|
return t || undefined;
|
|
|
};
|
|
|
return {
|
|
|
pickup_date,
|
|
|
pickup_zip: payload.pickupZip,
|
|
|
delivery_zip: payload.deliveryZip,
|
|
|
pallet_count: Math.max(1, pallet_count),
|
|
|
total_weight: { value: total_weight || first?.totalWeightLb || 1, unit: "lb" },
|
|
|
dimensions: {
|
|
|
length: first?.lengthIn ?? 48,
|
|
|
width: first?.widthIn ?? 40,
|
|
|
height: first?.heightIn ?? 48,
|
|
|
unit: "in",
|
|
|
},
|
|
|
form_mode: "logged_in_quick",
|
|
|
pickup_location_type: payload.pickupType,
|
|
|
delivery_location_type: payload.deliveryType,
|
|
|
packaging_type: first?.packagingType ?? "pallets_48x40",
|
|
|
freight_class: first?.freightClass ?? "density",
|
|
|
description: first?.description?.trim() || "General freight",
|
|
|
stackable: Boolean(first?.stackable),
|
|
|
turnable: Boolean(first?.turnable),
|
|
|
additional_services: [...payload.additionalServices],
|
|
|
vehicle_types: [...payload.vehicleTypes],
|
|
|
call_before_pickup:
|
|
|
FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED &&
|
|
|
isFlockSchedulingOptionLocked(
|
|
|
payload.items.reduce((s, it) => s + it.totalWeightLb, 0),
|
|
|
true,
|
|
|
)
|
|
|
? false
|
|
|
: Boolean(payload.callBeforePickup),
|
|
|
call_before_delivery: Boolean(payload.callBeforeDelivery),
|
|
|
pickup_service: payload.pickupService,
|
|
|
delivery_service: payload.deliveryService,
|
|
|
pickup_window_start_time: opt(payload.pickupWindowStartTime),
|
|
|
pickup_window_end_time: opt(payload.pickupWindowEndTime),
|
|
|
pickup_appointment_time: opt(payload.pickupAppointmentTime),
|
|
|
delivery_window_start_time: opt(payload.deliveryWindowStartTime),
|
|
|
delivery_window_end_time: opt(payload.deliveryWindowEndTime),
|
|
|
delivery_window_date: opt(payload.deliveryWindowDate),
|
|
|
delivery_appointment_time: opt(payload.deliveryAppointmentTime),
|
|
|
delivery_appointment_date: opt(payload.deliveryAppointmentDate),
|
|
|
delivery_must_arrive_by_date: opt(payload.deliveryMustArriveByDate),
|
|
|
call_for_delivery_appointment:
|
|
|
payload.deliveryService === "must_arrive_by"
|
|
|
? Boolean(payload.callForDeliveryAppointment)
|
|
|
: undefined,
|
|
|
pickup_liftgate: Boolean(payload.pickupLiftgate),
|
|
|
pickup_inside: Boolean(payload.pickupInside),
|
|
|
pickup_pallet_jack: Boolean(payload.pickupPalletJack),
|
|
|
delivery_liftgate: Boolean(payload.deliveryLiftgate),
|
|
|
delivery_inside: Boolean(payload.deliveryInside),
|
|
|
delivery_pallet_jack: Boolean(payload.deliveryPalletJack),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export function FlockLoggedInQuoteForm({
|
|
|
formId = "flock-logged-in-quote-form",
|
|
|
disabled,
|
|
|
submitLocked,
|
|
|
customerId = "anon",
|
|
|
onValidSubmit,
|
|
|
}: FlockLoggedInQuoteFormProps) {
|
|
|
const baseId = useId();
|
|
|
const [pickupDate, setPickupDate] = useState(defaultFlockPickupDateIso);
|
|
|
const [pickupZip, setPickupZip] = useState("");
|
|
|
const [pickupType, setPickupType] =
|
|
|
useState<FlockLocationTypeId>("business_with_dock");
|
|
|
const [pickupLiftgate, setPickupLiftgate] = useState(false);
|
|
|
const [pickupInside, setPickupInside] = useState(false);
|
|
|
const [pickupPalletJack, setPickupPalletJack] = useState(false);
|
|
|
const [deliveryZip, setDeliveryZip] = useState("");
|
|
|
const [deliveryType, setDeliveryType] =
|
|
|
useState<FlockLocationTypeId>("business_with_dock");
|
|
|
const [deliveryLiftgate, setDeliveryLiftgate] = useState(false);
|
|
|
const [deliveryInside, setDeliveryInside] = useState(false);
|
|
|
const [deliveryPalletJack, setDeliveryPalletJack] = useState(false);
|
|
|
const [items, setItems] = useState<FlockLoggedInItem[]>(() => [newItem()]);
|
|
|
const [savedFreightRevision, setSavedFreightRevision] = useState(0); const [additionalServices, setAdditionalServices] = useState<
|
|
|
FlockAdditionalServiceId[]
|
|
|
>(["unloading"]);
|
|
|
const [vehicleTypes, setVehicleTypes] = useState<FlockVehicleTypeId[]>([
|
|
|
...FLOCK_DEFAULT_VEHICLES,
|
|
|
]);
|
|
|
const [pickupService, setPickupService] = useState("standard_fcfs");
|
|
|
const [deliveryService, setDeliveryService] = useState("standard_fcfs");
|
|
|
const [pickupWindowStartTime, setPickupWindowStartTime] = useState("8:00 am");
|
|
|
const [pickupWindowEndTime, setPickupWindowEndTime] = useState("5:00 pm");
|
|
|
const [pickupAppointmentTime, setPickupAppointmentTime] = useState("8:00 am");
|
|
|
const [deliveryWindowStartTime, setDeliveryWindowStartTime] =
|
|
|
useState("8:00 am");
|
|
|
const [deliveryWindowEndTime, setDeliveryWindowEndTime] = useState("5:00 pm");
|
|
|
const [deliveryWindowDate, setDeliveryWindowDate] = useState("");
|
|
|
const [deliveryAppointmentTime, setDeliveryAppointmentTime] =
|
|
|
useState("8:00 am");
|
|
|
const [deliveryAppointmentDate, setDeliveryAppointmentDate] = useState("");
|
|
|
const [deliveryMustArriveByDate, setDeliveryMustArriveByDate] = useState("");
|
|
|
const [callForDeliveryAppointment, setCallForDeliveryAppointment] =
|
|
|
useState(false);
|
|
|
const [callBeforePickup, setCallBeforePickup] = useState(false);
|
|
|
const [callBeforeDelivery, setCallBeforeDelivery] = useState(false);
|
|
|
const [additionalInsurance, setAdditionalInsurance] = useState(false);
|
|
|
const [showAdditional, setShowAdditional] = useState(true);
|
|
|
const [showScheduling, setShowScheduling] = useState(true);
|
|
|
const [showInsurance, setShowInsurance] = useState(true);
|
|
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
|
const [localError, setLocalError] = useState<string | null>(null);
|
|
|
|
|
|
const servicesSelected = additionalServices.length;
|
|
|
const vehiclesSelected = vehicleTypes.length;
|
|
|
|
|
|
const shipmentWeightLb = useMemo(
|
|
|
() =>
|
|
|
items.reduce((sum, it) => {
|
|
|
const w = Number(it.totalWeightLb);
|
|
|
return sum + (Number.isFinite(w) && w > 0 ? w : 0);
|
|
|
}, 0),
|
|
|
[items],
|
|
|
);
|
|
|
|
|
|
const schedulingLockedHint = flockSchedulingWeightLockedHint();
|
|
|
|
|
|
const optionCompatIssues = useMemo(
|
|
|
() =>
|
|
|
evaluateFlockOptionCompat({
|
|
|
pickupLocationType: pickupType,
|
|
|
deliveryLocationType: deliveryType,
|
|
|
packagingTypes: items.map((it) => it.packagingType),
|
|
|
vehicleTypes,
|
|
|
additionalServices,
|
|
|
shipmentWeightLb,
|
|
|
}),
|
|
|
[
|
|
|
pickupType,
|
|
|
deliveryType,
|
|
|
items,
|
|
|
vehicleTypes,
|
|
|
additionalServices,
|
|
|
shipmentWeightLb,
|
|
|
],
|
|
|
);
|
|
|
|
|
|
useEffect(() => {
|
|
|
const pickupSvc = FLOCK_PICKUP_SERVICES.find((s) => s.id === pickupService);
|
|
|
if (
|
|
|
pickupSvc &&
|
|
|
isFlockSchedulingOptionLocked(shipmentWeightLb, pickupSvc.weightLocked)
|
|
|
) {
|
|
|
setPickupService("standard_fcfs");
|
|
|
}
|
|
|
const deliverySvc = FLOCK_DELIVERY_SERVICES.find(
|
|
|
(s) => s.id === deliveryService,
|
|
|
);
|
|
|
if (
|
|
|
deliverySvc &&
|
|
|
isFlockSchedulingOptionLocked(shipmentWeightLb, deliverySvc.weightLocked)
|
|
|
) {
|
|
|
setDeliveryService("standard_fcfs");
|
|
|
}
|
|
|
if (
|
|
|
FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED &&
|
|
|
isFlockSchedulingOptionLocked(shipmentWeightLb, true) &&
|
|
|
callBeforePickup
|
|
|
) {
|
|
|
setCallBeforePickup(false);
|
|
|
}
|
|
|
}, [
|
|
|
shipmentWeightLb,
|
|
|
pickupService,
|
|
|
deliveryService,
|
|
|
callBeforePickup,
|
|
|
]);
|
|
|
|
|
|
const canSubmit = useMemo(
|
|
|
() => !disabled && !submitLocked,
|
|
|
[disabled, submitLocked],
|
|
|
);
|
|
|
|
|
|
const updateItem = (key: string, patch: Partial<FlockLoggedInItem>) => {
|
|
|
setItems((rows) =>
|
|
|
rows.map((row) => (row.key === key ? { ...row, ...patch } : row)),
|
|
|
);
|
|
|
};
|
|
|
|
|
|
const toggleService = (id: FlockAdditionalServiceId) => {
|
|
|
setAdditionalServices((prev) => {
|
|
|
const next = prev.includes(id)
|
|
|
? prev.filter((x) => x !== id)
|
|
|
: [...prev, id];
|
|
|
if (
|
|
|
isFlockReeferOnlyWithoutTemp({
|
|
|
vehicleTypes,
|
|
|
additionalServices: next,
|
|
|
})
|
|
|
) {
|
|
|
setLocalError(
|
|
|
"仅允许「冷藏车」时须同时勾选「温控 / 冷链」,否则官网无法提交询价",
|
|
|
);
|
|
|
} else if (
|
|
|
isFlockTempWithoutReefer({
|
|
|
vehicleTypes,
|
|
|
additionalServices: next,
|
|
|
})
|
|
|
) {
|
|
|
setLocalError("已勾选「温控 / 冷链」时,允许车型中须包含「冷藏车」");
|
|
|
} else {
|
|
|
setLocalError(null);
|
|
|
}
|
|
|
return next;
|
|
|
});
|
|
|
};
|
|
|
|
|
|
const toggleVehicle = (id: FlockVehicleTypeId) => {
|
|
|
setVehicleTypes((prev) => {
|
|
|
const next = prev.includes(id)
|
|
|
? prev.filter((x) => x !== id)
|
|
|
: [...prev, id];
|
|
|
if (next.length === 0) {
|
|
|
setLocalError("请至少选择一种车型");
|
|
|
return prev.includes(id) ? prev : next;
|
|
|
}
|
|
|
if (
|
|
|
isFlockReeferOnlyWithoutTemp({
|
|
|
vehicleTypes: next,
|
|
|
additionalServices,
|
|
|
})
|
|
|
) {
|
|
|
setLocalError(
|
|
|
"仅允许「冷藏车」时须同时勾选「温控 / 冷链」,否则官网无法提交询价",
|
|
|
);
|
|
|
} else if (
|
|
|
isFlockTempWithoutReefer({
|
|
|
vehicleTypes: next,
|
|
|
additionalServices,
|
|
|
})
|
|
|
) {
|
|
|
setLocalError("已勾选「温控 / 冷链」时,允许车型中须包含「冷藏车」");
|
|
|
} else if (isFlockSprinterOnly(next)) {
|
|
|
setLocalError(
|
|
|
"仅选「小型厢车」时易无报价,建议同时勾选厢式货车或干货挂车",
|
|
|
);
|
|
|
} else {
|
|
|
setLocalError(null);
|
|
|
}
|
|
|
return next;
|
|
|
});
|
|
|
};
|
|
|
|
|
|
const handleSubmit = (e: FormEvent) => {
|
|
|
e.preventDefault();
|
|
|
if (!canSubmit) return;
|
|
|
setLocalError(null);
|
|
|
const nextErr: Record<string, string> = {};
|
|
|
|
|
|
if (!/^\d{5}(-\d{4})?$/.test(pickupZip.trim())) {
|
|
|
nextErr.pickupZip = "提货邮编必填(5 位)";
|
|
|
}
|
|
|
if (!/^\d{5}(-\d{4})?$/.test(deliveryZip.trim())) {
|
|
|
nextErr.deliveryZip = "派送邮编必填(5 位)";
|
|
|
}
|
|
|
const pickupDateError = flockPickupDateValidationMessageFromIso(pickupDate);
|
|
|
if (pickupDateError) {
|
|
|
nextErr.pickupDate = pickupDateError;
|
|
|
}
|
|
|
if (vehicleTypes.length === 0) {
|
|
|
nextErr.vehicles = "请至少选择一种车型";
|
|
|
}
|
|
|
if (flockOptionHasBlock(optionCompatIssues)) {
|
|
|
const block = optionCompatIssues.find((i) => i.severity === "block")!;
|
|
|
setLocalError(block.message);
|
|
|
setErrors(nextErr);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const parsedItems: FlockLoggedInQuotePayload["items"] = [];
|
|
|
for (let i = 0; i < items.length; i += 1) {
|
|
|
const it = items[i]!;
|
|
|
const quantity = Number(it.quantity);
|
|
|
const lengthIn = Number(it.lengthIn);
|
|
|
const widthIn = Number(it.widthIn);
|
|
|
const heightIn = Number(it.heightIn);
|
|
|
const totalWeightLb = Number(it.totalWeightLb);
|
|
|
const lineErr = validateFlockLoggedInCargoLine({
|
|
|
quantity,
|
|
|
lengthIn,
|
|
|
widthIn,
|
|
|
heightIn,
|
|
|
totalWeightLb,
|
|
|
description: it.description,
|
|
|
});
|
|
|
if (lineErr.qty) nextErr[`item-${i}-qty`] = lineErr.qty;
|
|
|
if (lineErr.len) nextErr[`item-${i}-len`] = lineErr.len;
|
|
|
if (lineErr.wid) nextErr[`item-${i}-wid`] = lineErr.wid;
|
|
|
if (lineErr.hei) nextErr[`item-${i}-hei`] = lineErr.hei;
|
|
|
if (lineErr.wt) nextErr[`item-${i}-wt`] = lineErr.wt;
|
|
|
if (lineErr.desc) nextErr[`item-${i}-desc`] = lineErr.desc;
|
|
|
if (Object.keys(nextErr).some((k) => k.startsWith(`item-${i}-`))) {
|
|
|
continue;
|
|
|
}
|
|
|
parsedItems.push({
|
|
|
quantity,
|
|
|
packagingType: it.packagingType,
|
|
|
lengthIn,
|
|
|
widthIn,
|
|
|
heightIn,
|
|
|
totalWeightLb,
|
|
|
freightClass: it.freightClass,
|
|
|
description: it.description.trim(),
|
|
|
stackable: it.stackable,
|
|
|
turnable: it.turnable,
|
|
|
});
|
|
|
}
|
|
|
|
|
|
const shipmentWeight = parsedItems.reduce(
|
|
|
(s, it) => s + it.totalWeightLb,
|
|
|
0,
|
|
|
);
|
|
|
if (
|
|
|
parsedItems.length > 0 &&
|
|
|
shipmentWeight > FLOCK_LIMITS.totalWeightLbMax
|
|
|
) {
|
|
|
nextErr.shipmentWeight = FLOCK_VALIDATION_MESSAGES.totalWeight;
|
|
|
}
|
|
|
|
|
|
if (pickupService === "during_window") {
|
|
|
if (!pickupWindowStartTime.trim()) {
|
|
|
nextErr.pickupWindowStart = "请选择提货时段开始时间";
|
|
|
}
|
|
|
if (!pickupWindowEndTime.trim()) {
|
|
|
nextErr.pickupWindowEnd = "请选择提货时段结束时间";
|
|
|
}
|
|
|
}
|
|
|
if (pickupService === "have_appointment" && !pickupAppointmentTime.trim()) {
|
|
|
nextErr.pickupApptTime = "请选择提货预约时间";
|
|
|
}
|
|
|
if (deliveryService === "during_window") {
|
|
|
if (!deliveryWindowStartTime.trim()) {
|
|
|
nextErr.deliveryWindowStart = "请选择派送时段开始时间";
|
|
|
}
|
|
|
if (!deliveryWindowEndTime.trim()) {
|
|
|
nextErr.deliveryWindowEnd = "请选择派送时段结束时间";
|
|
|
}
|
|
|
if (!deliveryWindowDate.trim()) {
|
|
|
nextErr.deliveryWindowDate = "请选择派送时段日期";
|
|
|
}
|
|
|
}
|
|
|
if (deliveryService === "have_appointment") {
|
|
|
if (!deliveryAppointmentTime.trim()) {
|
|
|
nextErr.deliveryApptTime = "请选择派送预约时间";
|
|
|
}
|
|
|
if (!deliveryAppointmentDate.trim()) {
|
|
|
nextErr.deliveryApptDate = "请选择派送预约日期";
|
|
|
}
|
|
|
}
|
|
|
if (
|
|
|
deliveryService === "must_arrive_by" &&
|
|
|
!deliveryMustArriveByDate.trim()
|
|
|
) {
|
|
|
nextErr.deliveryMustArrive = "请选择须到达日期";
|
|
|
}
|
|
|
|
|
|
setErrors(nextErr);
|
|
|
if (Object.keys(nextErr).length > 0 || parsedItems.length === 0) {
|
|
|
const firstMsg =
|
|
|
nextErr.pickupZip ||
|
|
|
nextErr.deliveryZip ||
|
|
|
nextErr.pickupDate ||
|
|
|
nextErr.vehicles ||
|
|
|
nextErr.shipmentWeight ||
|
|
|
Object.values(nextErr)[0] ||
|
|
|
"请完善必填项后再生成报价";
|
|
|
setLocalError(firstMsg);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
onValidSubmit?.({
|
|
|
mode: "quick",
|
|
|
pickupDate,
|
|
|
pickupZip: pickupZip.trim(),
|
|
|
pickupType,
|
|
|
pickupLiftgate,
|
|
|
pickupInside,
|
|
|
pickupPalletJack,
|
|
|
deliveryZip: deliveryZip.trim(),
|
|
|
deliveryType,
|
|
|
deliveryLiftgate,
|
|
|
deliveryInside,
|
|
|
deliveryPalletJack,
|
|
|
items: parsedItems,
|
|
|
additionalServices,
|
|
|
vehicleTypes,
|
|
|
pickupService,
|
|
|
deliveryService,
|
|
|
pickupWindowStartTime:
|
|
|
pickupService === "during_window" ? pickupWindowStartTime : undefined,
|
|
|
pickupWindowEndTime:
|
|
|
pickupService === "during_window" ? pickupWindowEndTime : undefined,
|
|
|
pickupAppointmentTime:
|
|
|
pickupService === "have_appointment"
|
|
|
? pickupAppointmentTime
|
|
|
: undefined,
|
|
|
deliveryWindowStartTime:
|
|
|
deliveryService === "during_window"
|
|
|
? deliveryWindowStartTime
|
|
|
: undefined,
|
|
|
deliveryWindowEndTime:
|
|
|
deliveryService === "during_window" ? deliveryWindowEndTime : undefined,
|
|
|
deliveryWindowDate:
|
|
|
deliveryService === "during_window" ? deliveryWindowDate : undefined,
|
|
|
deliveryAppointmentTime:
|
|
|
deliveryService === "have_appointment"
|
|
|
? deliveryAppointmentTime
|
|
|
: undefined,
|
|
|
deliveryAppointmentDate:
|
|
|
deliveryService === "have_appointment"
|
|
|
? deliveryAppointmentDate
|
|
|
: undefined,
|
|
|
deliveryMustArriveByDate:
|
|
|
deliveryService === "must_arrive_by"
|
|
|
? deliveryMustArriveByDate
|
|
|
: undefined,
|
|
|
callForDeliveryAppointment:
|
|
|
deliveryService === "must_arrive_by"
|
|
|
? callForDeliveryAppointment
|
|
|
: undefined,
|
|
|
callBeforePickup:
|
|
|
FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED &&
|
|
|
isFlockSchedulingOptionLocked(shipmentWeight, true)
|
|
|
? false
|
|
|
: callBeforePickup,
|
|
|
callBeforeDelivery,
|
|
|
additionalInsurance,
|
|
|
});
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
<form id={formId} onSubmit={handleSubmit} className="space-y-6">
|
|
|
<div className="space-y-3">
|
|
|
<QuoteStepProgress current={1} />
|
|
|
<QuotePageTitle>创建报价</QuotePageTitle>
|
|
|
</div>
|
|
|
|
|
|
<QuoteFloatHint message={localError} />
|
|
|
{optionCompatIssues.length > 0 ? (
|
|
|
<div className="space-y-1.5 rounded-md border border-[#F59E0B]/40 bg-[#FFFBEB] px-3 py-2 text-[12px] leading-snug text-[#92400E]">
|
|
|
<p className="font-medium text-[#B45309]">选项提示(官网规则 / 易无价组合)</p>
|
|
|
<ul className="list-disc space-y-1 pl-4">
|
|
|
{optionCompatIssues.map((issue) => (
|
|
|
<li
|
|
|
key={issue.code}
|
|
|
className={
|
|
|
issue.severity === "block" ? "font-medium text-[#EF4444]" : ""
|
|
|
}
|
|
|
>
|
|
|
{issue.severity === "block" ? "不可提交:" : ""}
|
|
|
{issue.message}
|
|
|
</li>
|
|
|
))}
|
|
|
</ul>
|
|
|
</div>
|
|
|
) : null}
|
|
|
|
|
|
{/* 提货日期 */}
|
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
|
|
|
<div className="mb-4">
|
|
|
<QuoteSectionTitle>提货日期</QuoteSectionTitle>
|
|
|
</div>
|
|
|
<OutlinedField
|
|
|
label="提货日期"
|
|
|
required
|
|
|
error={errors.pickupDate}
|
|
|
>
|
|
|
<MothershipWeekdayDatePicker
|
|
|
value={pickupDate}
|
|
|
minIso={minFlockPickupDateIso()}
|
|
|
disabled={disabled}
|
|
|
className={errors.pickupDate ? inputErrCls : inputCls}
|
|
|
onChange={setPickupDate}
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
</section>
|
|
|
|
|
|
{/* 提货与派送地点 */}
|
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
|
|
|
<div className="mb-4 flex items-center gap-2">
|
|
|
<QuoteSectionTitle>提货与派送地点</QuoteSectionTitle>
|
|
|
<QuoteFieldTooltip
|
|
|
wide
|
|
|
position="below"
|
|
|
label="地点类型说明"
|
|
|
tip={
|
|
|
<QuoteTipDefs
|
|
|
intro={FLOCK_LOCATION_TIP_INTRO}
|
|
|
items={FLOCK_LOCATION_TIP_ITEMS}
|
|
|
/>
|
|
|
}
|
|
|
/>
|
|
|
</div>
|
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
|
<OutlinedField
|
|
|
label="提货邮编"
|
|
|
required
|
|
|
error={errors.pickupZip}
|
|
|
>
|
|
|
<input
|
|
|
disabled={disabled}
|
|
|
value={pickupZip}
|
|
|
onChange={(e) => setPickupZip(e.target.value)}
|
|
|
placeholder="如 60611"
|
|
|
className={errors.pickupZip ? inputErrCls : inputCls}
|
|
|
inputMode="numeric"
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
<OutlinedField label="提货地点类型" required>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={pickupType}
|
|
|
onChange={(e) => {
|
|
|
const next = e.target.value as FlockLocationTypeId;
|
|
|
const flags = getFlockLocationAccessorials(next);
|
|
|
setPickupType(next);
|
|
|
// 对齐官网:切换类型时默认勾选该类可见项(住宅→尾板+室内)
|
|
|
setPickupLiftgate(flags.liftgate);
|
|
|
setPickupInside(flags.inside);
|
|
|
setPickupPalletJack(flags.palletJack);
|
|
|
}}
|
|
|
className={inputCls}
|
|
|
>
|
|
|
{FLOCK_LOCATION_TYPES.map((t) => (
|
|
|
<option key={t.id} value={t.id}>
|
|
|
{t.label}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
|
|
|
<LocationAccessorialChecks
|
|
|
side="pickup"
|
|
|
typeId={pickupType}
|
|
|
disabled={disabled}
|
|
|
liftgate={pickupLiftgate}
|
|
|
inside={pickupInside}
|
|
|
palletJack={pickupPalletJack}
|
|
|
onLiftgate={setPickupLiftgate}
|
|
|
onInside={setPickupInside}
|
|
|
onPalletJack={setPickupPalletJack}
|
|
|
/>
|
|
|
|
|
|
<div className="mt-6 grid gap-4 border-t border-border pt-4 md:grid-cols-2">
|
|
|
<OutlinedField
|
|
|
label="派送邮编"
|
|
|
required
|
|
|
error={errors.deliveryZip}
|
|
|
>
|
|
|
<input
|
|
|
disabled={disabled}
|
|
|
value={deliveryZip}
|
|
|
onChange={(e) => setDeliveryZip(e.target.value)}
|
|
|
placeholder="如 78701"
|
|
|
className={errors.deliveryZip ? inputErrCls : inputCls}
|
|
|
inputMode="numeric"
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
<OutlinedField label="派送地点类型" required>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={deliveryType}
|
|
|
onChange={(e) => {
|
|
|
const next = e.target.value as FlockLocationTypeId;
|
|
|
const flags = getFlockLocationAccessorials(next);
|
|
|
setDeliveryType(next);
|
|
|
// 对齐官网:切换类型时默认勾选该类可见项(农场→三项全选)
|
|
|
setDeliveryLiftgate(flags.liftgate);
|
|
|
setDeliveryInside(flags.inside);
|
|
|
setDeliveryPalletJack(flags.palletJack);
|
|
|
}}
|
|
|
className={inputCls}
|
|
|
>
|
|
|
{FLOCK_LOCATION_TYPES.map((t) => (
|
|
|
<option key={t.id} value={t.id}>
|
|
|
{t.label}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
|
|
|
<LocationAccessorialChecks
|
|
|
side="delivery"
|
|
|
typeId={deliveryType}
|
|
|
disabled={disabled}
|
|
|
liftgate={deliveryLiftgate}
|
|
|
inside={deliveryInside}
|
|
|
palletJack={deliveryPalletJack}
|
|
|
onLiftgate={setDeliveryLiftgate}
|
|
|
onInside={setDeliveryInside}
|
|
|
onPalletJack={setDeliveryPalletJack}
|
|
|
/>
|
|
|
|
|
|
<div className="mt-6 border-t border-border pt-4">
|
|
|
<OutlinedField
|
|
|
label="搜索常用货件(可选)"
|
|
|
tip="将货品描述存为常用后,可在此按名称快速回填件数、尺寸与描述"
|
|
|
>
|
|
|
<FlockSavedFreightSearch
|
|
|
customerId={customerId}
|
|
|
disabled={disabled}
|
|
|
revision={savedFreightRevision}
|
|
|
onSelect={(preset) => {
|
|
|
setItems((rows) => {
|
|
|
if (!rows.length) return [applySavedFreightPreset(newItem(), preset)];
|
|
|
const [first, ...rest] = rows;
|
|
|
return [applySavedFreightPreset(first, preset), ...rest];
|
|
|
});
|
|
|
}}
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
</section>
|
|
|
{/* 货物行 */}
|
|
|
{items.map((item, idx) => (
|
|
|
<section
|
|
|
key={item.key}
|
|
|
className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5"
|
|
|
>
|
|
|
<div className="mb-4 flex items-center justify-between">
|
|
|
<QuoteSectionTitle>货物 {idx + 1}</QuoteSectionTitle>
|
|
|
{items.length > 1 ? (
|
|
|
<button
|
|
|
type="button"
|
|
|
disabled={disabled}
|
|
|
aria-label="删除货物行"
|
|
|
onClick={() =>
|
|
|
setItems((rows) => rows.filter((r) => r.key !== item.key))
|
|
|
}
|
|
|
className="rounded p-1 text-text-secondary hover:bg-bg hover:text-[#EF4444] disabled:opacity-50"
|
|
|
>
|
|
|
<Trash size={18} />
|
|
|
</button>
|
|
|
) : null}
|
|
|
</div>
|
|
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
<OutlinedField
|
|
|
label="件数"
|
|
|
required
|
|
|
error={errors[`item-${idx}-qty`]}
|
|
|
>
|
|
|
<input
|
|
|
disabled={disabled}
|
|
|
value={item.quantity}
|
|
|
onChange={(e) =>
|
|
|
updateItem(item.key, { quantity: e.target.value })
|
|
|
}
|
|
|
className={
|
|
|
errors[`item-${idx}-qty`] ? inputErrCls : inputCls
|
|
|
}
|
|
|
inputMode="numeric"
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
<OutlinedField label="包装类型" required>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={item.packagingType}
|
|
|
onChange={(e) =>
|
|
|
updateItem(
|
|
|
item.key,
|
|
|
applyPackagingDims(
|
|
|
e.target.value as FlockPackagingTypeId,
|
|
|
item,
|
|
|
),
|
|
|
)
|
|
|
}
|
|
|
className={inputCls}
|
|
|
>
|
|
|
{FLOCK_PACKAGING_TYPES.map((p) => (
|
|
|
<option key={p.id} value={p.id}>
|
|
|
{p.label}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-4 grid grid-cols-3 gap-4">
|
|
|
<OutlinedField
|
|
|
label="长(英寸)"
|
|
|
required
|
|
|
tip={`上限 ${FLOCK_LIMITS.dimIn.lengthMax} in`}
|
|
|
error={errors[`item-${idx}-len`]}
|
|
|
>
|
|
|
<input
|
|
|
disabled={disabled}
|
|
|
value={item.lengthIn}
|
|
|
min={FLOCK_LIMITS.dimIn.min}
|
|
|
max={FLOCK_LIMITS.dimIn.lengthMax}
|
|
|
onChange={(e) =>
|
|
|
updateItem(item.key, { lengthIn: e.target.value })
|
|
|
}
|
|
|
className={
|
|
|
errors[`item-${idx}-len`] ? inputErrCls : inputCls
|
|
|
}
|
|
|
inputMode="decimal"
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
<OutlinedField
|
|
|
label="宽(英寸)"
|
|
|
required
|
|
|
tip={`上限 ${FLOCK_LIMITS.dimIn.widthMax} in`}
|
|
|
error={errors[`item-${idx}-wid`]}
|
|
|
>
|
|
|
<input
|
|
|
disabled={disabled}
|
|
|
value={item.widthIn}
|
|
|
min={FLOCK_LIMITS.dimIn.min}
|
|
|
max={FLOCK_LIMITS.dimIn.widthMax}
|
|
|
onChange={(e) =>
|
|
|
updateItem(item.key, { widthIn: e.target.value })
|
|
|
}
|
|
|
className={
|
|
|
errors[`item-${idx}-wid`] ? inputErrCls : inputCls
|
|
|
}
|
|
|
inputMode="decimal"
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
<OutlinedField
|
|
|
label="高(英寸)"
|
|
|
required
|
|
|
tip={`上限 ${FLOCK_LIMITS.dimIn.heightMax} in`}
|
|
|
error={errors[`item-${idx}-hei`]}
|
|
|
>
|
|
|
<input
|
|
|
disabled={disabled}
|
|
|
value={item.heightIn}
|
|
|
min={FLOCK_LIMITS.dimIn.min}
|
|
|
max={FLOCK_LIMITS.dimIn.heightMax}
|
|
|
onChange={(e) =>
|
|
|
updateItem(item.key, { heightIn: e.target.value })
|
|
|
}
|
|
|
className={
|
|
|
errors[`item-${idx}-hei`] ? inputErrCls : inputCls
|
|
|
}
|
|
|
inputMode="decimal"
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
|
<OutlinedField
|
|
|
label="本行总重(磅)"
|
|
|
required
|
|
|
tip={`上限 ${FLOCK_LIMITS.totalWeightLbMax} lb`}
|
|
|
error={errors[`item-${idx}-wt`]}
|
|
|
>
|
|
|
<input
|
|
|
disabled={disabled}
|
|
|
value={item.totalWeightLb}
|
|
|
min={0.01}
|
|
|
max={FLOCK_LIMITS.totalWeightLbMax}
|
|
|
onChange={(e) =>
|
|
|
updateItem(item.key, { totalWeightLb: e.target.value })
|
|
|
}
|
|
|
className={
|
|
|
errors[`item-${idx}-wt`] ? inputErrCls : inputCls
|
|
|
}
|
|
|
inputMode="decimal"
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
<OutlinedField
|
|
|
label="NMFC 运价等级"
|
|
|
required
|
|
|
tip={FLOCK_FREIGHT_CLASS_TIP}
|
|
|
>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={item.freightClass}
|
|
|
onChange={(e) =>
|
|
|
updateItem(item.key, {
|
|
|
freightClass: e.target.value as FlockFreightClass,
|
|
|
})
|
|
|
}
|
|
|
className={inputCls}
|
|
|
>
|
|
|
{FLOCK_FREIGHT_CLASSES.map((c) => (
|
|
|
<option key={c} value={c}>
|
|
|
{c === "density" ? "按密度估算" : c}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
|
|
|
{item.freightClass === "density" ? (
|
|
|
<div className="mt-4">
|
|
|
<QuoteNoticeCollapse title="注意事项">
|
|
|
{FLOCK_NMFC_NOTICE}
|
|
|
</QuoteNoticeCollapse>
|
|
|
</div>
|
|
|
) : null}
|
|
|
|
|
|
<div className="mt-4">
|
|
|
<OutlinedField
|
|
|
label="货品描述"
|
|
|
required
|
|
|
error={errors[`item-${idx}-desc`]}
|
|
|
>
|
|
|
<input
|
|
|
disabled={disabled}
|
|
|
value={item.description}
|
|
|
onChange={(e) =>
|
|
|
updateItem(item.key, { description: e.target.value })
|
|
|
}
|
|
|
className={
|
|
|
errors[`item-${idx}-desc`] ? inputErrCls : inputCls
|
|
|
}
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
<FlockSaveFreightButton
|
|
|
customerId={customerId}
|
|
|
disabled={disabled}
|
|
|
source={{
|
|
|
description: item.description,
|
|
|
quantity: item.quantity,
|
|
|
packagingType: item.packagingType,
|
|
|
lengthIn: item.lengthIn,
|
|
|
widthIn: item.widthIn,
|
|
|
heightIn: item.heightIn,
|
|
|
totalWeightLb: item.totalWeightLb,
|
|
|
freightClass: item.freightClass,
|
|
|
stackable: item.stackable,
|
|
|
turnable: item.turnable,
|
|
|
}}
|
|
|
onSaved={() => setSavedFreightRevision((n) => n + 1)}
|
|
|
/>
|
|
|
</div>
|
|
|
<div className="mt-4">
|
|
|
<p className="mb-2 text-xs text-[#4B5563]">装载选项</p>
|
|
|
<div className="flex flex-wrap gap-4">
|
|
|
<label className="flex h-12 items-center gap-2 text-sm">
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
disabled={disabled}
|
|
|
checked={item.stackable}
|
|
|
onChange={(e) =>
|
|
|
updateItem(item.key, { stackable: e.target.checked })
|
|
|
}
|
|
|
/>
|
|
|
可堆码
|
|
|
</label>
|
|
|
<label className="flex h-12 items-center gap-2 text-sm">
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
disabled={disabled}
|
|
|
checked={item.turnable}
|
|
|
onChange={(e) =>
|
|
|
updateItem(item.key, { turnable: e.target.checked })
|
|
|
}
|
|
|
/>
|
|
|
可调向摆放
|
|
|
</label>
|
|
|
</div>
|
|
|
<p className="mt-3 rounded-md border border-[#1890FF]/20 bg-[#1890FF]/5 px-3 py-2 text-xs text-[#4B5563]">
|
|
|
{FLOCK_STACKABLE_TIP}
|
|
|
</p>
|
|
|
</div>
|
|
|
</section>
|
|
|
))}
|
|
|
|
|
|
<button
|
|
|
type="button"
|
|
|
disabled={disabled}
|
|
|
onClick={() => setItems((rows) => [...rows, newItem()])}
|
|
|
className={`${quoteAddRowCls} h-11 w-full rounded-full`}
|
|
|
>
|
|
|
<Plus size={16} weight="bold" />
|
|
|
添加货物行
|
|
|
</button>
|
|
|
|
|
|
{/* 附加服务 */}
|
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
|
|
|
<button
|
|
|
type="button"
|
|
|
className="mb-3 flex w-full items-center justify-between text-left"
|
|
|
onClick={() => setShowAdditional((v) => !v)}
|
|
|
>
|
|
|
<div className="flex items-center gap-2">
|
|
|
<QuoteSectionTitle>附加服务</QuoteSectionTitle>
|
|
|
<QuoteFieldTooltip
|
|
|
wide
|
|
|
position="below"
|
|
|
label="附加服务说明"
|
|
|
tip={<QuoteTipDefs items={FLOCK_ADDITIONAL_SERVICE_TIP_ITEMS} />}
|
|
|
/>
|
|
|
</div>
|
|
|
<CaretDown
|
|
|
size={16}
|
|
|
className={[
|
|
|
"text-text-secondary transition-transform",
|
|
|
showAdditional ? "rotate-180" : "",
|
|
|
].join(" ")}
|
|
|
/>
|
|
|
</button>
|
|
|
{showAdditional ? (
|
|
|
<>
|
|
|
<p className="mb-3 text-xs text-[#4B5563]">
|
|
|
已选 {servicesSelected} 项
|
|
|
</p>
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
{FLOCK_ADDITIONAL_SERVICES.map((s) => (
|
|
|
<ChipToggle
|
|
|
key={s.id}
|
|
|
disabled={disabled}
|
|
|
selected={additionalServices.includes(s.id)}
|
|
|
label={s.label}
|
|
|
onClick={() => toggleService(s.id)}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-6 border-t border-border pt-4">
|
|
|
<div className="mb-3 flex flex-wrap items-center gap-1 text-xs text-[#4B5563]">
|
|
|
允许使用的车型 · 已选 {vehiclesSelected} 项
|
|
|
<QuoteFieldTooltip
|
|
|
wide
|
|
|
position="below"
|
|
|
label="车型说明"
|
|
|
tip={<QuoteTipDefs items={FLOCK_VEHICLE_TIP_ITEMS} />}
|
|
|
/>
|
|
|
{errors.vehicles ? (
|
|
|
<span className="ml-2 text-[#EF4444]">{errors.vehicles}</span>
|
|
|
) : null}
|
|
|
</div>
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
{FLOCK_VEHICLE_TYPES.map((v) => (
|
|
|
<ChipToggle
|
|
|
key={v.id}
|
|
|
disabled={disabled}
|
|
|
selected={vehicleTypes.includes(v.id)}
|
|
|
label={v.label}
|
|
|
onClick={() => toggleVehicle(v.id)}
|
|
|
/>
|
|
|
))}
|
|
|
</div>
|
|
|
</div>
|
|
|
</>
|
|
|
) : null}
|
|
|
</section>
|
|
|
|
|
|
{/* 调度服务 */}
|
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
|
|
|
<button
|
|
|
type="button"
|
|
|
className="mb-3 flex w-full items-center justify-between text-left"
|
|
|
onClick={() => setShowScheduling((v) => !v)}
|
|
|
>
|
|
|
<QuoteSectionTitle>调度服务</QuoteSectionTitle>
|
|
|
<CaretDown
|
|
|
size={16}
|
|
|
className={[
|
|
|
"text-text-secondary transition-transform",
|
|
|
showScheduling ? "rotate-180" : "",
|
|
|
].join(" ")}
|
|
|
/>
|
|
|
</button>
|
|
|
{showScheduling ? (
|
|
|
<div className="space-y-6">
|
|
|
<p className="text-xs text-text-secondary">
|
|
|
灰色选项在整票总重不超过 {FLOCK_LIMITS.schedulingWeightMinLb} lb
|
|
|
时不可用;整票总重超过 {FLOCK_LIMITS.schedulingWeightMinLb} lb 后启用。
|
|
|
{shipmentWeightLb > 0 ? (
|
|
|
<span className="ml-1 font-medium text-text-primary">
|
|
|
当前总重约 {shipmentWeightLb} lb
|
|
|
</span>
|
|
|
) : null}
|
|
|
</p>
|
|
|
<div>
|
|
|
<p className="mb-3 text-sm font-medium text-[#4B5563]">提货服务</p>
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
{FLOCK_PICKUP_SERVICES.map((s) => {
|
|
|
const locked = isFlockSchedulingOptionLocked(
|
|
|
shipmentWeightLb,
|
|
|
s.weightLocked,
|
|
|
);
|
|
|
return (
|
|
|
<label
|
|
|
key={s.id}
|
|
|
className={[
|
|
|
"flex min-h-12 items-start gap-2 text-sm",
|
|
|
locked
|
|
|
? "cursor-not-allowed text-text-disabled opacity-60"
|
|
|
: "text-text-primary",
|
|
|
].join(" ")}
|
|
|
>
|
|
|
<input
|
|
|
type="radio"
|
|
|
name={`${baseId}-pickup-svc`}
|
|
|
disabled={disabled || locked}
|
|
|
checked={pickupService === s.id}
|
|
|
onChange={() => {
|
|
|
if (!locked) setPickupService(s.id);
|
|
|
}}
|
|
|
className="mt-0.5"
|
|
|
/>
|
|
|
<span>
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
{s.label}
|
|
|
{!locked && s.hint ? (
|
|
|
<QuoteFieldTooltip tip={s.hint} />
|
|
|
) : null}
|
|
|
</span>
|
|
|
{locked ? (
|
|
|
<span className="mt-0.5 block text-xs text-text-disabled">
|
|
|
{schedulingLockedHint}
|
|
|
</span>
|
|
|
) : null}
|
|
|
{!locked && s.detail && pickupService === s.id ? (
|
|
|
<span className="mt-0.5 block text-xs text-text-secondary">
|
|
|
{s.detail}
|
|
|
</span>
|
|
|
) : null}
|
|
|
</span>
|
|
|
</label>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
{pickupService === "during_window" &&
|
|
|
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
|
|
|
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
|
|
|
<p className="text-sm font-medium text-text-primary">
|
|
|
提货时段是什么时候?
|
|
|
</p>
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
<OutlinedField
|
|
|
label="开始时间"
|
|
|
required
|
|
|
error={errors.pickupWindowStart}
|
|
|
>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={pickupWindowStartTime}
|
|
|
onChange={(e) =>
|
|
|
setPickupWindowStartTime(e.target.value)
|
|
|
}
|
|
|
className={
|
|
|
errors.pickupWindowStart ? inputErrCls : inputCls
|
|
|
}
|
|
|
>
|
|
|
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
|
|
|
<option key={`pw-s-${t}`} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
<OutlinedField
|
|
|
label="结束时间"
|
|
|
required
|
|
|
error={errors.pickupWindowEnd}
|
|
|
>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={pickupWindowEndTime}
|
|
|
onChange={(e) => setPickupWindowEndTime(e.target.value)}
|
|
|
className={
|
|
|
errors.pickupWindowEnd ? inputErrCls : inputCls
|
|
|
}
|
|
|
>
|
|
|
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
|
|
|
<option key={`pw-e-${t}`} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
</div>
|
|
|
) : null}
|
|
|
{pickupService === "have_appointment" &&
|
|
|
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
|
|
|
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
|
|
|
<p className="text-sm font-medium text-text-primary">
|
|
|
预设提货预约时间是?
|
|
|
</p>
|
|
|
<OutlinedField
|
|
|
label="时间"
|
|
|
required
|
|
|
error={errors.pickupApptTime}
|
|
|
>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={pickupAppointmentTime}
|
|
|
onChange={(e) =>
|
|
|
setPickupAppointmentTime(e.target.value)
|
|
|
}
|
|
|
className={errors.pickupApptTime ? inputErrCls : inputCls}
|
|
|
>
|
|
|
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
|
|
|
<option key={`pa-${t}`} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
) : null}
|
|
|
{(() => {
|
|
|
const callBeforeLocked = isFlockSchedulingOptionLocked(
|
|
|
shipmentWeightLb,
|
|
|
FLOCK_PICKUP_CALL_BEFORE_WEIGHT_LOCKED,
|
|
|
);
|
|
|
return (
|
|
|
<label
|
|
|
className={[
|
|
|
"mt-3 flex min-h-12 items-start gap-2 text-sm",
|
|
|
callBeforeLocked || disabled
|
|
|
? "cursor-not-allowed text-text-disabled opacity-60"
|
|
|
: "items-center text-text-primary",
|
|
|
].join(" ")}
|
|
|
>
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
className="mt-0.5"
|
|
|
disabled={disabled || callBeforeLocked}
|
|
|
checked={callBeforeLocked ? false : callBeforePickup}
|
|
|
onChange={(e) => {
|
|
|
if (!callBeforeLocked) {
|
|
|
setCallBeforePickup(e.target.checked);
|
|
|
}
|
|
|
}}
|
|
|
/>
|
|
|
<span>
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
承运商请在提货前致电
|
|
|
{!callBeforeLocked ? (
|
|
|
<QuoteFieldTooltip tip="勾选后,承运商须在提货前 1–2 小时联系提货人" />
|
|
|
) : null}
|
|
|
</span>
|
|
|
{callBeforeLocked ? (
|
|
|
<span className="mt-0.5 block text-xs text-text-disabled">
|
|
|
{schedulingLockedHint}
|
|
|
</span>
|
|
|
) : null}
|
|
|
</span>
|
|
|
</label>
|
|
|
);
|
|
|
})()}
|
|
|
</div>
|
|
|
|
|
|
<div className="border-t border-border pt-4">
|
|
|
<p className="mb-3 text-sm font-medium text-[#4B5563]">派送服务</p>
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
|
{FLOCK_DELIVERY_SERVICES.map((s) => {
|
|
|
const locked = isFlockSchedulingOptionLocked(
|
|
|
shipmentWeightLb,
|
|
|
s.weightLocked,
|
|
|
);
|
|
|
return (
|
|
|
<label
|
|
|
key={s.id}
|
|
|
className={[
|
|
|
"flex min-h-12 items-start gap-2 text-sm",
|
|
|
locked
|
|
|
? "cursor-not-allowed text-text-disabled opacity-60"
|
|
|
: "text-text-primary",
|
|
|
].join(" ")}
|
|
|
>
|
|
|
<input
|
|
|
type="radio"
|
|
|
name={`${baseId}-delivery-svc`}
|
|
|
disabled={disabled || locked}
|
|
|
checked={deliveryService === s.id}
|
|
|
onChange={() => {
|
|
|
if (!locked) setDeliveryService(s.id);
|
|
|
}}
|
|
|
className="mt-0.5"
|
|
|
/>
|
|
|
<span>
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
{s.label}
|
|
|
{!locked && s.hint ? (
|
|
|
<QuoteFieldTooltip tip={s.hint} />
|
|
|
) : null}
|
|
|
</span>
|
|
|
{locked ? (
|
|
|
<span className="mt-0.5 block text-xs text-text-disabled">
|
|
|
{schedulingLockedHint}
|
|
|
</span>
|
|
|
) : null}
|
|
|
{!locked && s.detail && deliveryService === s.id ? (
|
|
|
<span className="mt-0.5 block text-xs text-text-secondary">
|
|
|
{s.detail}
|
|
|
</span>
|
|
|
) : null}
|
|
|
</span>
|
|
|
</label>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
{deliveryService === "during_window" &&
|
|
|
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
|
|
|
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
|
|
|
<p className="text-sm font-medium text-text-primary">
|
|
|
派送时段是什么时候?
|
|
|
</p>
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
<OutlinedField
|
|
|
label="开始时间"
|
|
|
required
|
|
|
error={errors.deliveryWindowStart}
|
|
|
>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={deliveryWindowStartTime}
|
|
|
onChange={(e) =>
|
|
|
setDeliveryWindowStartTime(e.target.value)
|
|
|
}
|
|
|
className={
|
|
|
errors.deliveryWindowStart ? inputErrCls : inputCls
|
|
|
}
|
|
|
>
|
|
|
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
|
|
|
<option key={`dw-s-${t}`} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
<OutlinedField
|
|
|
label="结束时间"
|
|
|
required
|
|
|
error={errors.deliveryWindowEnd}
|
|
|
>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={deliveryWindowEndTime}
|
|
|
onChange={(e) =>
|
|
|
setDeliveryWindowEndTime(e.target.value)
|
|
|
}
|
|
|
className={
|
|
|
errors.deliveryWindowEnd ? inputErrCls : inputCls
|
|
|
}
|
|
|
>
|
|
|
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
|
|
|
<option key={`dw-e-${t}`} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
<OutlinedField
|
|
|
label="日期"
|
|
|
required
|
|
|
error={errors.deliveryWindowDate}
|
|
|
>
|
|
|
<input
|
|
|
type="date"
|
|
|
disabled={disabled}
|
|
|
value={deliveryWindowDate}
|
|
|
min={pickupDate || defaultFlockPickupDateIso()}
|
|
|
onChange={(e) => setDeliveryWindowDate(e.target.value)}
|
|
|
className={
|
|
|
errors.deliveryWindowDate ? inputErrCls : inputCls
|
|
|
}
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
) : null}
|
|
|
{deliveryService === "have_appointment" &&
|
|
|
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
|
|
|
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
|
|
|
<p className="text-sm font-medium text-text-primary">
|
|
|
预设派送预约时间是?
|
|
|
</p>
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
<OutlinedField
|
|
|
label="时间"
|
|
|
required
|
|
|
error={errors.deliveryApptTime}
|
|
|
>
|
|
|
<select
|
|
|
disabled={disabled}
|
|
|
value={deliveryAppointmentTime}
|
|
|
onChange={(e) =>
|
|
|
setDeliveryAppointmentTime(e.target.value)
|
|
|
}
|
|
|
className={
|
|
|
errors.deliveryApptTime ? inputErrCls : inputCls
|
|
|
}
|
|
|
>
|
|
|
{FLOCK_SCHEDULING_TIME_OPTIONS.map((t) => (
|
|
|
<option key={`da-t-${t}`} value={t}>
|
|
|
{t}
|
|
|
</option>
|
|
|
))}
|
|
|
</select>
|
|
|
</OutlinedField>
|
|
|
<OutlinedField
|
|
|
label="日期"
|
|
|
required
|
|
|
error={errors.deliveryApptDate}
|
|
|
>
|
|
|
<input
|
|
|
type="date"
|
|
|
disabled={disabled}
|
|
|
value={deliveryAppointmentDate}
|
|
|
min={pickupDate || defaultFlockPickupDateIso()}
|
|
|
onChange={(e) =>
|
|
|
setDeliveryAppointmentDate(e.target.value)
|
|
|
}
|
|
|
className={
|
|
|
errors.deliveryApptDate ? inputErrCls : inputCls
|
|
|
}
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
</div>
|
|
|
</div>
|
|
|
) : null}
|
|
|
{deliveryService === "must_arrive_by" &&
|
|
|
!isFlockSchedulingOptionLocked(shipmentWeightLb, true) ? (
|
|
|
<div className="mt-4 space-y-3 rounded-md border border-border bg-[#FAFAFA] p-3">
|
|
|
<p className="text-sm font-medium text-text-primary">
|
|
|
货物需要在哪一天之前送达?
|
|
|
</p>
|
|
|
<OutlinedField
|
|
|
label="日期"
|
|
|
required
|
|
|
error={errors.deliveryMustArrive}
|
|
|
>
|
|
|
<input
|
|
|
type="date"
|
|
|
disabled={disabled}
|
|
|
value={deliveryMustArriveByDate}
|
|
|
min={pickupDate || defaultFlockPickupDateIso()}
|
|
|
onChange={(e) =>
|
|
|
setDeliveryMustArriveByDate(e.target.value)
|
|
|
}
|
|
|
className={
|
|
|
errors.deliveryMustArrive ? inputErrCls : inputCls
|
|
|
}
|
|
|
/>
|
|
|
</OutlinedField>
|
|
|
<label className="flex min-h-12 items-start gap-2 text-sm text-text-primary">
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
className="mt-0.5"
|
|
|
disabled={disabled}
|
|
|
checked={callForDeliveryAppointment}
|
|
|
onChange={(e) =>
|
|
|
setCallForDeliveryAppointment(e.target.checked)
|
|
|
}
|
|
|
/>
|
|
|
<span>
|
|
|
<span>预约派送致电</span>
|
|
|
<span className="mt-0.5 block text-xs text-text-secondary">
|
|
|
{FLOCK_CALL_FOR_DELIVERY_APPOINTMENT_HINT}
|
|
|
</span>
|
|
|
</span>
|
|
|
</label>
|
|
|
</div>
|
|
|
) : null}
|
|
|
<label className="mt-3 flex h-12 items-center gap-2 text-sm text-text-primary">
|
|
|
<input
|
|
|
type="checkbox"
|
|
|
disabled={disabled}
|
|
|
checked={callBeforeDelivery}
|
|
|
onChange={(e) => setCallBeforeDelivery(e.target.checked)}
|
|
|
/>
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
承运商请在派送前致电
|
|
|
<QuoteFieldTooltip tip="勾选后,承运商须在派送前 1–2 小时联系收货人" />
|
|
|
</span>
|
|
|
</label>
|
|
|
</div>
|
|
|
</div>
|
|
|
) : null}
|
|
|
</section>
|
|
|
|
|
|
{/* 额外保险 */}
|
|
|
<section className="rounded-lg border border-border bg-surface p-4 shadow-card md:p-5">
|
|
|
<button
|
|
|
type="button"
|
|
|
className="mb-3 flex w-full items-center justify-between text-left"
|
|
|
onClick={() => setShowInsurance((v) => !v)}
|
|
|
>
|
|
|
<QuoteSectionTitle>额外货运险</QuoteSectionTitle>
|
|
|
<CaretDown
|
|
|
size={16}
|
|
|
className={[
|
|
|
"text-text-secondary transition-transform",
|
|
|
showInsurance ? "rotate-180" : "",
|
|
|
].join(" ")}
|
|
|
/>
|
|
|
</button>
|
|
|
{showInsurance ? (
|
|
|
<div className="space-y-3">
|
|
|
<label className="flex h-12 items-center gap-2 text-sm text-text-primary">
|
|
|
<input
|
|
|
type="radio"
|
|
|
name={`${baseId}-insurance`}
|
|
|
disabled={disabled}
|
|
|
checked={additionalInsurance}
|
|
|
onChange={() => setAdditionalInsurance(true)}
|
|
|
/>
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
需要额外货运险
|
|
|
<QuoteFieldTooltip tip={FLOCK_INSURANCE_YES_TIP} wide />
|
|
|
</span>
|
|
|
</label>
|
|
|
<label className="flex h-12 items-center gap-2 text-sm text-text-primary">
|
|
|
<input
|
|
|
type="radio"
|
|
|
name={`${baseId}-insurance`}
|
|
|
disabled={disabled}
|
|
|
checked={!additionalInsurance}
|
|
|
onChange={() => setAdditionalInsurance(false)}
|
|
|
/>
|
|
|
<span className="inline-flex items-center gap-1">
|
|
|
不投保额外货运险
|
|
|
<QuoteFieldTooltip tip={FLOCK_INSURANCE_NO_TIP} wide />
|
|
|
</span>
|
|
|
</label>
|
|
|
{!additionalInsurance ? (
|
|
|
<p className="text-xs text-text-secondary">
|
|
|
{FLOCK_INSURANCE_WAIVE_HINT}
|
|
|
</p>
|
|
|
) : null}
|
|
|
<p className="text-xs text-text-secondary">{FLOCK_INSURANCE_FOOTER}</p>
|
|
|
</div>
|
|
|
) : null}
|
|
|
</section>
|
|
|
|
|
|
<button
|
|
|
type="submit"
|
|
|
disabled={!canSubmit}
|
|
|
data-testid="flock-logged-in-generate-quote"
|
|
|
className={`${quoteCtaCls} h-12 w-full rounded-full disabled:bg-border disabled:text-text-disabled`}
|
|
|
>
|
|
|
{submitLocked ? "提交中…" : "生成我的报价"}
|
|
|
</button>
|
|
|
</form>
|
|
|
);
|
|
|
}
|