diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 87d6fcfb..8153f8cd 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -906,7 +906,7 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n ); }); -/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */ export const fetchJoborderProductProcessesPage = cache(async (params: { /** Job order / process date(YYYY-MM-DD) */ date?: string | null; diff --git a/src/app/api/shop/actions.ts b/src/app/api/shop/actions.ts index d26404e0..aa9bc199 100644 --- a/src/app/api/shop/actions.ts +++ b/src/app/api/shop/actions.ts @@ -965,6 +965,8 @@ export interface TruckLaneRenameLogResponse { oldDepartureTime?: string | null; newDepartureTime?: string | null; ticketCount: number; + /** `truck.id` of shops in the renamed lane, comma-separated. */ + truckIds?: string | null; } export const previewTruckLaneRenameAction = async ( diff --git a/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx b/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx index 5c12aad5..55d7f5bb 100644 --- a/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx +++ b/src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx @@ -203,13 +203,13 @@ const TruckScheduleDashboard: React.FC = () => { return '-'; }; - // Compare a timestamp against the selected date (today/tomorrow/...) and return a short label. - // Used to avoid confusion when picking crosses midnight. + // Compare a timestamp against calendar today. Same-day picks show no chip; + // only midnight-crossing (yesterday / tomorrow / N days) gets a label. + // Does not filter rows — trucklane tickets for the selected delivery date still show. const relativeDayLabel = useCallback( - (dateTimeData: string | number[] | null, dateOption: string): string | null => { + (dateTimeData: string | number[] | null): string | null => { if (!dateTimeData) return null; - const dateOffset = getDateOffset(dateOption); - const baseDate = dayjs().startOf('day').add(dateOffset, 'day'); + const baseDate = dayjs().startOf('day'); let dt: dayjs.Dayjs; if (Array.isArray(dateTimeData)) { @@ -518,8 +518,8 @@ const TruckScheduleDashboard: React.FC = () => { filteredData.map((row, index) => { const timeRemaining = calculateTimeRemaining(row, selectedDate); const chipColor = getTimeChipColor(row, selectedDate); - const startRel = relativeDayLabel(row.firstTicketStartTime, selectedDate); - const endRel = relativeDayLabel(row.lastTicketEndTime, selectedDate); + const startRel = relativeDayLabel(row.firstTicketStartTime); + const endRel = relativeDayLabel(row.lastTicketEndTime); return ( & { + outputFromProcessQty: QtyInput; + byproductQty: QtyInput; + defectQty: QtyInput; + defect2Qty: QtyInput; + defect3Qty: QtyInput; + scrapQty: QtyInput; +}; + +const parseQtyInput = (raw: string): QtyInput => { + if (raw === "") return ""; + const n = parseInt(raw, 10); + return Number.isNaN(n) ? "" : n; +}; + +export const toSubmitQty = (v: QtyInput | null | undefined): number => + v === "" || v == null || Number.isNaN(Number(v)) ? 0 : Number(v); + interface ProductionOutputFormProps { - outputData: (UpdateProductProcessLineQtyRequest & { - byproductName: string; - byproductQty: number; - byproductUom: string; - }); - setOutputData: React.Dispatch< - React.SetStateAction< - UpdateProductProcessLineQtyRequest & { - byproductName: string; - byproductQty: number; - byproductUom: string; - } - > - >; + outputData: ProductionOutputFormData; + setOutputData: React.Dispatch>; onSubmit: () => Promise | void; onCancel: () => void; } +/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.1 | 2026-10-29 */ const ProductionOutputForm: React.FC = ({ outputData, setOutputData, @@ -68,7 +85,7 @@ const ProductionOutputForm: React.FC = ({ onChange={(e) => setOutputData({ ...outputData, - outputFromProcessQty: parseInt(e.target.value) || 0, + outputFromProcessQty: parseQtyInput(e.target.value), }) } /> @@ -103,7 +120,7 @@ const ProductionOutputForm: React.FC = ({ onChange={(e) => setOutputData({ ...outputData, - byproductQty: parseInt(e.target.value) || 0, + byproductQty: parseQtyInput(e.target.value), }) } /> @@ -138,7 +155,7 @@ const ProductionOutputForm: React.FC = ({ onChange={(e) => setOutputData({ ...outputData, - defectQty: parseInt(e.target.value) || 0, + defectQty: parseQtyInput(e.target.value), }) } /> @@ -173,7 +190,7 @@ const ProductionOutputForm: React.FC = ({ onChange={(e) => setOutputData({ ...outputData, - scrapQty: parseInt(e.target.value) || 0, + scrapQty: parseQtyInput(e.target.value), }) } /> @@ -207,4 +224,4 @@ const ProductionOutputForm: React.FC = ({ ); }; -export default ProductionOutputForm; \ No newline at end of file +export default ProductionOutputForm; diff --git a/src/components/ProductionProcess/ProductionOutputFormPage.tsx b/src/components/ProductionProcess/ProductionOutputFormPage.tsx index bdbbffba..002b4cee 100644 --- a/src/components/ProductionProcess/ProductionOutputFormPage.tsx +++ b/src/components/ProductionProcess/ProductionOutputFormPage.tsx @@ -5,47 +5,47 @@ import { Button, Typography, } from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { useTranslation } from "react-i18next"; import { fetchProductProcessLineDetail, updateProductProcessLineQty, - UpdateProductProcessLineQtyRequest, JobOrderProcessLineDetailResponse, } from "@/app/api/jo/actions"; -import ProductionOutputForm from "./ProductionOutputForm"; +import ProductionOutputForm, { + ProductionOutputFormData, + toSubmitQty, +} from "./ProductionOutputForm"; interface ProductionOutputFormPageProps { lineId: number | null; onBack: () => void; } +/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.1 | 2026-10-29 */ const ProductionOutputFormPage: React.FC = ({ lineId, onBack, }) => { - const { t } = useTranslation(); + const { t } = useTranslation(["common", "jo", "productionProcess"]); const [lineDetail, setLineDetail] = useState(null); - const [outputData, setOutputData] = useState({ + const [outputData, setOutputData] = useState({ productProcessLineId: lineId ?? 0, - outputFromProcessQty: 0, + outputFromProcessQty: "", outputFromProcessUom: "", - defectQty: 0, + defectQty: "", defectUom: "", - defect2Qty: 0, + defect2Qty: "", defect2Uom: "", - defect3Qty: 0, + defect3Qty: "", defect3Uom: "", defectDescription: "", defectDescription2: "", defectDescription3: "", - scrapQty: 0, + scrapQty: "", scrapUom: "", byproductName: "", - byproductQty: 0, + byproductQty: "", byproductUom: "", }); @@ -60,14 +60,14 @@ const ProductionOutputFormPage: React.FC = ({ setOutputData((prev) => ({ ...prev, productProcessLineId: detail.id, - outputFromProcessQty: (detail as any).outputFromProcessQty || 0, + outputFromProcessQty: (detail as any).outputFromProcessQty || "", outputFromProcessUom: (detail as any).outputFromProcessUom || "", - defectQty: detail.defectQty || 0, + defectQty: detail.defectQty || "", defectUom: detail.defectUom || "", - scrapQty: detail.scrapQty || 0, + scrapQty: detail.scrapQty || "", scrapUom: detail.scrapUom || "", byproductName: detail.byproductName || "", - byproductQty: detail.byproductQty || 0, + byproductQty: detail.byproductQty || "", byproductUom: detail.byproductUom || "", })); }) @@ -84,6 +84,12 @@ const ProductionOutputFormPage: React.FC = ({ await updateProductProcessLineQty({ ...outputData, productProcessLineId: lineDetail.id || 0, + outputFromProcessQty: toSubmitQty(outputData.outputFromProcessQty), + byproductQty: toSubmitQty(outputData.byproductQty), + defectQty: toSubmitQty(outputData.defectQty), + defect2Qty: toSubmitQty(outputData.defect2Qty), + defect3Qty: toSubmitQty(outputData.defect3Qty), + scrapQty: toSubmitQty(outputData.scrapQty), }); console.log("Output data submitted successfully"); @@ -94,14 +100,14 @@ const ProductionOutputFormPage: React.FC = ({ setOutputData((prev) => ({ ...prev, productProcessLineId: detail.id, - outputFromProcessQty: (detail as any).outputFromProcessQty || 0, + outputFromProcessQty: (detail as any).outputFromProcessQty || "", outputFromProcessUom: (detail as any).outputFromProcessUom || "", - defectQty: detail.defectQty || 0, + defectQty: detail.defectQty || "", defectUom: detail.defectUom || "", - scrapQty: detail.scrapQty || 0, + scrapQty: detail.scrapQty || "", scrapUom: detail.scrapUom || "", byproductName: detail.byproductName || "", - byproductQty: detail.byproductQty || 0, + byproductQty: detail.byproductQty || "", byproductUom: detail.byproductUom || "", })); @@ -116,8 +122,13 @@ const ProductionOutputFormPage: React.FC = ({ return ( - diff --git a/src/components/ProductionProcess/ProductionProcessDetail.tsx b/src/components/ProductionProcess/ProductionProcessDetail.tsx index 7700330f..2c30fa27 100644 --- a/src/components/ProductionProcess/ProductionProcessDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessDetail.tsx @@ -64,13 +64,16 @@ interface ProductProcessDetailProps { jobOrderId: number; onBack: () => void; fromJosave?: boolean; + /** 進入步驟執行頁時通知父層(用來隱藏「返回列表」) */ + onStepViewChange?: (inStepView: boolean) => void; } -/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.1 | 2026-08-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.2 | 2026-10-29 */ const ProductionProcessDetail: React.FC = ({ jobOrderId, onBack, fromJosave, + onStepViewChange, }) => { // console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave }); @@ -143,6 +146,12 @@ const fetchProcessDetailRef = useRef<() => Promise>(); useEffect(() => { onBackRef.current = onBack; }, [onBack]); + + useEffect(() => { + const inStepView = isExecutingLine && selectedLineId != null; + onStepViewChange?.(inStepView); + return () => onStepViewChange?.(false); + }, [isExecutingLine, selectedLineId, onStepViewChange]); // 获取 process 和 lines 数据 const fetchProcessDetail = useCallback(async () => { // console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() }); diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index 78818a53..8fb026bc 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -54,7 +54,7 @@ interface ProductProcessJobOrderDetailProps { initialTabIndex?: number; } -/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */ const ProductionProcessJobOrderDetail: React.FC = ({ jobOrderId, onBack, @@ -68,6 +68,7 @@ const ProductionProcessJobOrderDetail: React.FC([]); const [tabIndex, setTabIndex] = useState(initialTabIndex); const [selectedProcessId, setSelectedProcessId] = useState(null); + const [inStepView, setInStepView] = useState(false); const [operationPriority, setOperationPriority] = useState(50); const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false); @@ -320,6 +321,9 @@ const handleRelease = useCallback(async (jobOrderId: number) => { const handleTabChange = useCallback>( (_e, newValue) => { setTabIndex(newValue); + if (newValue !== 2) { + setInStepView(false); + } }, [], ); @@ -796,12 +800,14 @@ const handleRelease = useCallback(async (jobOrderId: number) => { return ( - {/* 返回按钮 */} - - - + {/* 步驟執行頁不顯示「返回列表」,避免誤離開工單 */} + {!inStepView && ( + + + + )} {/* 标签页 */} @@ -833,6 +839,7 @@ const handleRelease = useCallback(async (jobOrderId: number) => { }} fromJosave={Boolean(fromJosave && !isPutAwayed)} + onStepViewChange={setInStepView} /> )} {tabIndex === 3 && } diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index 38667525..48d6d8d1 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -179,7 +179,7 @@ function isWaitingQcPutAway( return s !== "completed" && s !== "rejected"; } -/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.10 | 2026-10-15 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */ const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo, diff --git a/src/components/ProductionProcess/ProductionProcessStepExecution.tsx b/src/components/ProductionProcess/ProductionProcessStepExecution.tsx index 096acbb5..3fa51bff 100644 --- a/src/components/ProductionProcess/ProductionProcessStepExecution.tsx +++ b/src/components/ProductionProcess/ProductionProcessStepExecution.tsx @@ -28,6 +28,7 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import StopIcon from "@mui/icons-material/Stop"; import PauseIcon from "@mui/icons-material/Pause"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { useTranslation } from "react-i18next"; import { JobOrderProcessLineDetailResponse, @@ -47,6 +48,35 @@ import { fetchNameList, NameList } from "@/app/api/user/actions"; import BagConsumptionForm from "./BagConsumptionForm"; // ✅ 添加导入 import OverallTimeRemainingCard from "./OverallTimeRemainingCard"; // ✅ 添加导入 import dayjs from "dayjs"; + +type QtyInput = number | ""; + +type OutputFormState = Omit< + UpdateProductProcessLineQtyRequest, + | "outputFromProcessQty" + | "byproductQty" + | "defectQty" + | "defect2Qty" + | "defect3Qty" + | "scrapQty" +> & { + outputFromProcessQty: QtyInput; + byproductQty: QtyInput; + defectQty: QtyInput; + defect2Qty: QtyInput; + defect3Qty: QtyInput; + scrapQty: QtyInput; +}; + +const parseQtyInput = (raw: string): QtyInput => { + if (raw === "") return ""; + const n = parseInt(raw, 10); + return Number.isNaN(n) ? "" : n; +}; + +const toSubmitQty = (v: QtyInput | null | undefined): number => + v === "" || v == null || Number.isNaN(Number(v)) ? 0 : Number(v); + interface ProductionProcessStepExecutionProps { lineId: number | null; onBack: () => void; @@ -55,7 +85,7 @@ interface ProductionProcessStepExecutionProps { jobOrderId?: number; // ✅ 添加 } -/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.0 | 2026-08-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.1 | 2026-10-29 */ const ProductionProcessStepExecution: React.FC = ({ lineId, onBack, @@ -75,24 +105,20 @@ const ProductionProcessStepExecution: React.FC({ + const [outputData, setOutputData] = useState({ productProcessLineId: lineId ?? 0, - outputFromProcessQty: 0, + outputFromProcessQty: "", outputFromProcessUom: "", - defectQty: 0, + defectQty: "", defectUom: "", - scrapQty: 0, + scrapQty: "", scrapUom: "", byproductName: "", - byproductQty: 0, + byproductQty: "", byproductUom: "", - defect2Qty: 0, + defect2Qty: "", defect2Uom: "", - defect3Qty: 0, + defect3Qty: "", defect3Uom: "", defectDescription: "", defectDescription2: "", @@ -153,14 +179,14 @@ const ProductionProcessStepExecution: React.FC ({ ...prev, productProcessLineId: detail.id, - outputFromProcessQty: (detail as any).outputFromProcessQty || 0, + outputFromProcessQty: (detail as any).outputFromProcessQty || "", outputFromProcessUom: (detail as any).outputFromProcessUom || "", - defectQty: detail.defectQty || 0, + defectQty: detail.defectQty || "", defectUom: detail.defectUom || "", - scrapQty: detail.scrapQty || 0, + scrapQty: detail.scrapQty || "", scrapUom: detail.scrapUom || "", byproductName: detail.byproductName || "", - byproductQty: detail.byproductQty || 0, + byproductQty: detail.byproductQty || "", byproductUom: detail.byproductUom || "" })); }) @@ -351,20 +377,20 @@ const ProductionProcessStepExecution: React.FC ({ ...prev, productProcessLineId: detail.id, - outputFromProcessQty: (detail as any).outputFromProcessQty || 0, + outputFromProcessQty: (detail as any).outputFromProcessQty || "", outputFromProcessUom: (detail as any).outputFromProcessUom || "", - defectQty: detail.defectQty || 0, + defectQty: detail.defectQty || "", defectUom: detail.defectUom || "", defectDescription: detail.defectDescription || "", defectDescription2: detail.defectDescription2 || "", defectDescription3: detail.defectDescription3 || "", - defectQty2: detail.defectQty2 || 0, - defectUom2: detail.defectUom2 || "", - defectQty3: detail.defectQty3 || 0, - defectUom3: detail.defectUom3 || "", - scrapQty: detail.scrapQty || 0, + defect2Qty: (detail as any).defect2Qty || "", + defect2Uom: (detail as any).defect2Uom || "", + defect3Qty: (detail as any).defect3Qty || "", + defect3Uom: (detail as any).defect3Uom || "", + scrapQty: detail.scrapQty || "", scrapUom: detail.scrapUom || "", byproductName: detail.byproductName || "", - byproductQty: detail.byproductQty || 0, + byproductQty: detail.byproductQty || "", byproductUom: detail.byproductUom || "" })); }) @@ -561,8 +587,13 @@ const ProductionProcessStepExecution: React.FC - {processData && ( @@ -812,7 +843,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - outputFromProcessQty: parseInt(e.target.value) || 0 + outputFromProcessQty: parseQtyInput(e.target.value) })} /> @@ -854,7 +885,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - defectQty: parseInt(e.target.value) || 0 + defectQty: parseQtyInput(e.target.value) })} /> @@ -902,7 +933,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - defect2Qty: parseInt(e.target.value) || 0 + defect2Qty: parseQtyInput(e.target.value) })} /> @@ -950,7 +981,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - defect3Qty: parseInt(e.target.value) || 0 + defect3Qty: parseQtyInput(e.target.value) })} /> @@ -998,7 +1029,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - scrapQty: parseInt(e.target.value) || 0 + scrapQty: parseQtyInput(e.target.value) })} /> diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx index 45fc18e9..1ccf20bf 100644 --- a/src/components/Shop/RouteBoard.tsx +++ b/src/components/Shop/RouteBoard.tsx @@ -99,7 +99,6 @@ import { deleteTruckLaneClient, previewTruckLaneRenameClient, renameTruckLaneClient, - listTruckLaneRenameLogsClient, } from "@/app/api/shop/client"; import { deleteLogisticClient, @@ -116,7 +115,6 @@ import type { SaveTruckLane, Truck, TruckLaneVersionDiffLine, - TruckLaneRenameLogResponse, } from "@/app/api/shop/actions"; import { formatDepartureTime, normalizeStoreId } from "@/app/utils/formatUtil"; import { @@ -259,6 +257,217 @@ const toTimeInputValue = (t: string | undefined): string => { return "00:00"; }; +const laneFilterBtnSx = { + minWidth: 0, + px: 1, + py: 0.25, + textTransform: "none" as const, + fontWeight: 700, + fontSize: "0.75rem", + lineHeight: 1.4, +}; + +/** Inject filter side panel beside Select MenuList without wrapping MenuItems (breaks selection). */ +const LaneSelectMenuExtrasContext = React.createContext<{ + open: boolean; + panel: React.ReactNode; +}>({ open: false, panel: null }); + +const LaneSelectMenuPaper = React.forwardRef< + HTMLDivElement, + React.ComponentProps +>(function LaneSelectMenuPaper(props, ref) { + const { open, panel } = React.useContext(LaneSelectMenuExtrasContext); + const { children, sx, ...other } = props; + return ( + + {children} + {open ? panel : null} + + ); +}); + +/** Single-select facet as wrap buttons (lane filter panel). + * No "全部" chip — click selected again to clear that facet; use Clear all for all. */ +function LaneFilterFacetButtons(props: { + label: string; + value: string; + options: string[]; + formatOption?: (v: string) => string; + onChange: (v: string) => void; +}) { + const { label, value, options, formatOption, onChange } = props; + return ( + + + {label} + + + {options.map((opt) => ( + + ))} + + + ); +} + +/** Multi-select facet (地區). Empty selection = no area filter. */ +function LaneFilterFacetMultiButtons(props: { + label: string; + values: string[]; + options: string[]; + formatOption?: (v: string) => string; + onChange: (v: string[]) => void; +}) { + const { label, values, options, formatOption, onChange } = props; + const selected = new Set(values); + return ( + + + {label} + + + {options.map((opt) => { + const on = selected.has(opt); + return ( + + ); + })} + + + ); +} + +/** 地區按鈕排序:港島 → 九龍 → 新界東 → 新界西 → 離島;未知名排後端。 */ +const HK_AREA_SORT_RANK: Record = { + 中環: 10, + 上環: 11, + 西環: 12, + 灣仔: 13, + 銅鑼灣: 14, + 北角: 15, + 鰂魚涌: 16, + 太古: 17, + 西灣河: 18, + 筲箕灣: 19, + 柴灣: 20, + 香港仔: 21, + 鴨脷洲: 22, + 薄扶林: 23, + 赤柱: 24, + 九龍: 100, + 尖沙咀: 101, + 油麻地: 102, + 旺角: 103, + 大角咀: 104, + 深水埗: 105, + 長沙灣: 106, + 荔枝角: 107, + 美孚: 108, + 紅磡: 109, + 土瓜灣: 110, + 九龍城: 111, + 新蒲崗: 112, + 黃大仙: 113, + 鑽石山: 114, + 慈雲山: 115, + 觀塘: 116, + 牛頭角: 117, + 藍田: 118, + 油塘: 119, + 九龍灣: 120, + 彩虹: 121, + 樂富: 122, + 沙田: 200, + 大圍: 201, + 火炭: 202, + 馬鞍山: 203, + 大埔: 204, + 粉嶺: 205, + 上水: 206, + 打鼓嶺: 207, + 西貢: 208, + 將軍澳: 209, + 坑口: 210, + 調景嶺: 211, + 荃灣: 300, + 葵涌: 301, + 青衣: 302, + 葵芳: 303, + 屯門: 304, + 元朗: 305, + 天水圍: 306, + 洪水橋: 307, + 錦田: 308, + 流浮山: 309, + 東涌: 400, + 大嶼山: 401, + 長洲: 402, + 南丫島: 403, + 坪洲: 404, + 機場: 405, +}; + +function compareLaneAreaName(a: string, b: string): number { + const ra = HK_AREA_SORT_RANK[a] ?? 9000; + const rb = HK_AREA_SORT_RANK[b] ?? 9000; + if (ra !== rb) return ra - rb; + return a.localeCompare(b, "zh-Hant"); +} + /** 物流商總覽欄標題用:條數、店數、樓層 */ function summarizeLogisticsColumnStats(lanes: Lane[]): { laneCount: number; @@ -395,6 +604,99 @@ function decodeLaneId(laneId: string): { } } +const LANE_WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const; + +type LanePickFacets = { + special: string; + zones: string[]; + weekdays: string[]; + areas: string[]; +}; + +function canonWeekdayToken(token: string): string | null { + const key = token.trim().toLowerCase(); + return LANE_WEEKDAYS.find((day) => day.toLowerCase() === key) ?? null; +} + +/** 車線-特12 → 特12;4F 的 P06B_Mon_區7_青衣,東涌 → 星期、區、地區。備註裡的星期一併算。 */ +function parseLanePickFacets( + codeRaw: string, + remarkRaw: string | null | undefined, +): LanePickFacets { + const code = String(codeRaw || "").trim(); + const remark = String(remarkRaw ?? "").trim(); + const special = code.startsWith("車線-") + ? code.slice("車線-".length).trim() + : ""; + const weekdays = new Set(); + const zones = new Set(); + const areas = new Set(); + + const scan = (text: string) => { + if (!text || text.startsWith("車線-")) return; + for (const part of text.split("_")) { + const token = part.trim(); + if (!token) continue; + const weekday = canonWeekdayToken(token); + if (weekday) { + weekdays.add(weekday); + continue; + } + const zone = /^區\d+/.exec(token)?.[0]; + if (zone && zone === token) { + zones.add(zone); + continue; + } + if (/^[A-Za-z0-9]+$/.test(token)) continue; + for (const area of token.split(/[,,]/)) { + const name = area.trim(); + if (name) areas.add(name); + } + } + }; + + if (!special) scan(code); + scan(remark); + return { + special, + zones: [...zones], + weekdays: [...weekdays], + areas: [...areas], + }; +} + +function compareSpecialLaneToken(a: string, b: string): number { + const na = /^特(\d+)$/.exec(a); + const nb = /^特(\d+)$/.exec(b); + if (na && nb) return Number(na[1]) - Number(nb[1]); + if (na) return -1; + if (nb) return 1; + return a.localeCompare(b, "zh-Hant"); +} + +function laneMatchesFacetFilter( + lane: { truckLanceCode: string; remark?: string | null }, + filter: { + special: string; + zone: string; + weekday: string; + area: string[]; + }, +): boolean { + const facets = parseLanePickFacets(lane.truckLanceCode, lane.remark); + if (filter.special !== "all" && facets.special !== filter.special) return false; + if (filter.zone !== "all" && !facets.zones.includes(filter.zone)) return false; + if (filter.weekday !== "all" && !facets.weekdays.includes(filter.weekday)) + return false; + if ( + filter.area.length > 0 && + !filter.area.some((a) => facets.areas.includes(a)) + ) { + return false; + } + return true; +} + function lanesToWarningInputRows(lanes: Lane[]): TruckLaneWarningInputRow[] { const out: TruckLaneWarningInputRow[] = []; for (const lane of lanes) { @@ -888,13 +1190,20 @@ const RouteBoard: React.FC = () => { const [laneFilter, setLaneFilter] = useState<{ floor: "all" | "2F" | "4F"; query: string; + special: string; + zone: string; + weekday: string; + /** Empty = no area filter; otherwise OR-match any selected 地區. */ + area: string[]; }>({ floor: "all", query: "", + special: "all", + zone: "all", + weekday: "all", + area: [], }); - const [laneFilterAnchor, setLaneFilterAnchor] = useState( - null, - ); + const [laneFilterPanelOpen, setLaneFilterPanelOpen] = useState(false); // drag state (HTML5 drag & drop) const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>( @@ -974,9 +1283,6 @@ const RouteBoard: React.FC = () => { const [departureEditDraft, setDepartureEditDraft] = useState(""); const [departureEditNameDraft, setDepartureEditNameDraft] = useState(""); const [departureEditBusy, setDepartureEditBusy] = useState(false); - const [renameLogs, setRenameLogs] = useState( - [], - ); const [seqEditTarget, setSeqEditTarget] = useState<{ laneId: string; shopId: number; @@ -1955,6 +2261,36 @@ const RouteBoard: React.FC = () => { })); }, [lanes, selectedLaneIds, searchTerm, shopNameByCodeMap]); + const laneFilterChoices = useMemo(() => { + const specials = new Set(); + const zones = new Set(); + const weekdays = new Set(); + const areas = new Set(); + for (const lane of lanes || []) { + if ( + laneFilter.floor !== "all" && + normalizeStoreId(lane.storeId) !== laneFilter.floor + ) { + continue; + } + const facets = parseLanePickFacets(lane.truckLanceCode, lane.remark); + if (facets.special) specials.add(facets.special); + for (const zone of facets.zones) zones.add(zone); + for (const day of facets.weekdays) weekdays.add(day); + for (const area of facets.areas) areas.add(area); + } + return { + specials: [...specials].sort(compareSpecialLaneToken), + zones: [...zones].sort((a, b) => { + const na = Number(/^區(\d+)/.exec(a)?.[1] ?? 0); + const nb = Number(/^區(\d+)/.exec(b)?.[1] ?? 0); + return na - nb || a.localeCompare(b, "zh-Hant"); + }), + weekdays: LANE_WEEKDAYS.filter((day) => weekdays.has(day)), + areas: [...areas].sort(compareLaneAreaName), + }; + }, [lanes, laneFilter.floor]); + const visibleLaneOptions = useMemo(() => { const q = String(laneFilter.query || "") .trim() @@ -1967,20 +2303,20 @@ const RouteBoard: React.FC = () => { ? true : normalizeStoreId(lane.storeId) === laneFilter.floor; const qOk = !q ? true : code.includes(q) || rem.includes(q); - return floorOk && qOk; + return floorOk && qOk && laneMatchesFacetFilter(lane, laneFilter); }); - }, [lanes, laneFilter.floor, laneFilter.query]); + }, [lanes, laneFilter]); - /** 「+」與看板欄:只吃樓層,不吃左欄關鍵字(與 `visibleLaneOptions` 分離) */ + /** 「+」與看板欄:吃樓層與篩選面板,不吃左欄關鍵字 */ const lanesMatchingFloorOnly = useMemo(() => { return (lanes || []).filter((lane) => { const floorOk = laneFilter.floor === "all" ? true : normalizeStoreId(lane.storeId) === laneFilter.floor; - return floorOk; + return floorOk && laneMatchesFacetFilter(lane, laneFilter); }); - }, [lanes, laneFilter.floor]); + }, [lanes, laneFilter]); const boardQuickPickFilteredLanes = useMemo(() => { const q = String(boardQuickPickSearch).trim().toLowerCase(); @@ -3608,7 +3944,7 @@ const RouteBoard: React.FC = () => { try { await createTruckLaneSnapshotClient({ truckLanceCode: null, - note: "board save", + note: null, }); await refreshLogVersions(); } catch (snapErr: any) { @@ -3907,7 +4243,9 @@ const RouteBoard: React.FC = () => { shops: lane.shops.map((s) => ({ ...s, departureTime: backendTime })), }; }); - if (shopIds.length > 0) { + const timeChanged = + toTimeInputValue(targetLane.startTime) !== departureEditDraft; + if (shopIds.length > 0 && timeChanged) { const wr = computeTruckLaneWarnings(lanesToWarningInputRows(nextLanes)); if (wr.warnings.length > 0) { const ok = window.confirm( @@ -4062,9 +4400,6 @@ const RouteBoard: React.FC = () => { setLoadingVersions(true); try { const arr = await refreshLogVersions(); - void listTruckLaneRenameLogsClient() - .then((rows) => setRenameLogs(Array.isArray(rows) ? rows : [])) - .catch(() => setRenameLogs([])); const head = resolveHeadVersionId(arr); setSelectedLogVersionId(head); if (head != null) { @@ -4414,7 +4749,7 @@ const RouteBoard: React.FC = () => { openDepartureEdit(lane); }} disabled={loading} - aria-label={t("departureEditAria")} + aria-label={t("laneEditTooltip")} > @@ -4941,33 +5276,6 @@ const RouteBoard: React.FC = () => { - {renameLogs.length > 0 && ( - - - {t("renameLog_title")} - - {renameLogs.slice(0, 8).map((row) => ( - - {t("renameLog_line", { - when: row.created ?? "", - who: row.createdBy ?? "", - from: row.oldCode, - to: row.newCode, - floor: row.storeId, - tickets: row.ticketCount, - })} - - ))} - - )} {loadingVersions ? ( { inputProps={{ maxLength: 50 }} sx={{ mt: 1 }} /> - {departureEditLaneId && - normalizeStoreId( - lanes.find((l) => l.id === departureEditLaneId)?.storeId, - ) === "4F" && - String( - lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "", - ).trim() !== "" && ( - - {t("departureDialog_remarkReadonly")}: - {String( - lanes.find((l) => l.id === departureEditLaneId)?.remark ?? "", - ).trim()} - - )} { ).length; const filterActive = laneFilter.floor !== "all" || + laneFilter.special !== "all" || + laneFilter.zone !== "all" || + laneFilter.weekday !== "all" || + laneFilter.area.length > 0 || String(laneFilter.query || "").trim() !== ""; + const laneFilterSidePanel = ( + e.stopPropagation()} + onMouseDown={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + onKeyDown={(e) => e.stopPropagation()} + > + + + { + const next = floor as "all" | "2F" | "4F"; + setLaneFilter((prev) => ({ + ...prev, + floor: next, + special: next === "4F" ? "all" : prev.special, + zone: next === "2F" ? "all" : prev.zone, + weekday: next === "2F" ? "all" : prev.weekday, + area: next === "2F" ? [] : prev.area, + })); + }} + /> + {laneFilter.floor !== "4F" && + laneFilterChoices.specials.length > 0 && ( + `車線-${token}`} + onChange={(special) => + setLaneFilter((prev) => ({ + ...prev, + special, + })) + } + /> + )} + {laneFilter.floor !== "2F" && + laneFilterChoices.zones.length > 0 && ( + + setLaneFilter((prev) => ({ ...prev, zone })) + } + /> + )} + {laneFilter.floor !== "2F" && + laneFilterChoices.weekdays.length > 0 && ( + + setLaneFilter((prev) => ({ + ...prev, + weekday, + })) + } + /> + )} + {laneFilter.floor !== "2F" && + laneFilterChoices.areas.length > 0 && ( + + setLaneFilter((prev) => ({ ...prev, area })) + } + /> + )} + + + + + + + + ); + return ( { {t("lane_selectTitle")} - - - setLaneFilterAnchor(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "left" }} - transformOrigin={{ vertical: "top", horizontal: "left" }} - > - - - - {t("floor_label")} - - - - - - - - - + + {routeBoardTab === "board" && (