| Autor | SHA1 | Mensagem | Data |
|---|---|---|---|
|
|
bc1784fffc | Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-frontend into production | 1 semana atrás |
|
|
ef46091858 | 補貨 turkc scheduler update | 1 semana atrás |
| @@ -29,6 +29,8 @@ export interface DoDetail { | |||||
| isExtra?: boolean; | isExtra?: boolean; | ||||
| /** 揀貨員名稱(delivery_order_pick_order.handlerName) */ | /** 揀貨員名稱(delivery_order_pick_order.handlerName) */ | ||||
| handlerName?: string | null; | handlerName?: string | null; | ||||
| /** 來源 DO 車線 */ | |||||
| truckLaneCode?: string | null; | |||||
| deliveryOrderLines: DoDetailLine[]; | deliveryOrderLines: DoDetailLine[]; | ||||
| } | } | ||||
| @@ -671,3 +673,60 @@ export async function fetchAllDoSearch( | |||||
| return data.records; | return data.records; | ||||
| } | } | ||||
| export interface SubmitDoReplenishmentLineRequest { | |||||
| deliveryDate: string; | |||||
| sourceDoId: number; | |||||
| sourceDoLineId: number; | |||||
| replenishQty: number; | |||||
| truckLaneCode?: string; | |||||
| } | |||||
| export interface DoReplenishmentRecord { | |||||
| id: number; | |||||
| code: string; | |||||
| deliveryDate: string; | |||||
| sourceDoId: number; | |||||
| sourceDoCode?: string; | |||||
| sourceDoLineId: number; | |||||
| itemId: number; | |||||
| itemNo?: string; | |||||
| itemName?: string; | |||||
| replenishQty: number; | |||||
| shortUom?: string; | |||||
| shopCode?: string; | |||||
| shopName?: string; | |||||
| truckLaneCode?: string; | |||||
| targetDoId?: number; | |||||
| targetDoCode?: string; | |||||
| pickOrderLineId?: number; | |||||
| status: string; | |||||
| created?: string; | |||||
| } | |||||
| export async function submitDoReplenishment( | |||||
| lines: SubmitDoReplenishmentLineRequest[], | |||||
| ): Promise<DoReplenishmentRecord[]> { | |||||
| return serverFetchJson<DoReplenishmentRecord[]>(`${BASE_API_URL}/do/replenishment`, { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify({ lines }), | |||||
| }); | |||||
| } | |||||
| export async function fetchDoReplenishmentList(params: { | |||||
| deliveryDate?: string; | |||||
| status?: string; | |||||
| }): Promise<DoReplenishmentRecord[]> { | |||||
| const query = convertObjToURLSearchParams({ | |||||
| deliveryDate: params.deliveryDate || undefined, | |||||
| status: params.status && params.status !== "all" ? params.status : undefined, | |||||
| }); | |||||
| const url = query | |||||
| ? `${BASE_API_URL}/do/replenishment?${query}` | |||||
| : `${BASE_API_URL}/do/replenishment`; | |||||
| return serverFetchJson<DoReplenishmentRecord[]>(url, { | |||||
| method: "GET", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| } | |||||
| @@ -620,6 +620,7 @@ export type TruckLaneMoveTargetRequest = { | |||||
| toStoreId: string; | toStoreId: string; | ||||
| toLoadingSequence: number; | toLoadingSequence: number; | ||||
| toDistrictReference?: string | null; | toDistrictReference?: string | null; | ||||
| departureTime?: string | null; | |||||
| }; | }; | ||||
| export type TruckLaneScheduleLineRequest = { | export type TruckLaneScheduleLineRequest = { | ||||
| @@ -723,7 +724,10 @@ export type TruckLaneScheduleResponse = { | |||||
| }; | }; | ||||
| export type PendingTruckRowIdsResponse = { | export type PendingTruckRowIdsResponse = { | ||||
| /** 所有開放排程(PENDING/APPLYING)涉及的 truck rows(標記、排程驗證用) */ | |||||
| truckRowIds: number[]; | truckRowIds: number[]; | ||||
| /** 已進入鎖定時間窗(或 APPLYING)的 truck rows,看板不可手改 */ | |||||
| lockedTruckRowIds?: number[]; | |||||
| }; | }; | ||||
| export type TruckLaneScheduleExcelPreviewRow = { | export type TruckLaneScheduleExcelPreviewRow = { | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useMemo, useRef, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||||
| import { | import { | ||||
| Autocomplete, | Autocomplete, | ||||
| Box, | Box, | ||||
| @@ -36,7 +36,15 @@ import { useTranslation } from "react-i18next"; | |||||
| import { GridColDef } from "@mui/x-data-grid"; | import { GridColDef } from "@mui/x-data-grid"; | ||||
| import Swal from "sweetalert2"; | import Swal from "sweetalert2"; | ||||
| import StyledDataGrid from "../StyledDataGrid"; | import StyledDataGrid from "../StyledDataGrid"; | ||||
| import { DoDetail, DoDetailLine, fetchDoDetail, fetchDoSearch } from "@/app/api/do/actions"; | |||||
| import { | |||||
| DoDetail, | |||||
| DoDetailLine, | |||||
| DoReplenishmentRecord, | |||||
| fetchDoDetail, | |||||
| fetchDoReplenishmentList, | |||||
| fetchDoSearch, | |||||
| submitDoReplenishment, | |||||
| } from "@/app/api/do/actions"; | |||||
| import { arrayToDateString } from "@/app/utils/formatUtil"; | import { arrayToDateString } from "@/app/utils/formatUtil"; | ||||
| import { | import { | ||||
| REPLENISHMENT_FIELD_ICON_SX, | REPLENISHMENT_FIELD_ICON_SX, | ||||
| @@ -95,13 +103,30 @@ type SourceDoContext = { | |||||
| lines: DoDetailLine[]; | lines: DoDetailLine[]; | ||||
| }; | }; | ||||
| let localIdSeq = 1; | |||||
| let replenishmentCodeSeq = 1; | |||||
| function nextReplenishmentCode(deliveryDate: string): string { | |||||
| const ymd = deliveryDate.replace(/-/g, ""); | |||||
| const seq = String(replenishmentCodeSeq++).padStart(3, "0"); | |||||
| return `RP-${ymd}-${seq}`; | |||||
| function mapApiRecord(record: DoReplenishmentRecord): ReplenishmentRecord { | |||||
| return { | |||||
| rowId: `record-${record.id}`, | |||||
| deliveryDate: record.deliveryDate, | |||||
| sourceDoId: record.sourceDoId, | |||||
| sourceDoCode: record.sourceDoCode ?? "", | |||||
| sourceDoLineId: record.sourceDoLineId, | |||||
| itemId: record.itemId, | |||||
| itemNo: record.itemNo ?? "", | |||||
| itemName: record.itemName ?? "", | |||||
| originalQty: 0, | |||||
| replenishQty: Number(record.replenishQty), | |||||
| shortUom: record.shortUom, | |||||
| shopCode: record.shopCode, | |||||
| shopName: record.shopName, | |||||
| truckLaneCode: record.truckLaneCode, | |||||
| id: record.id, | |||||
| code: record.code, | |||||
| targetDoId: record.targetDoId, | |||||
| targetDoCode: record.targetDoCode, | |||||
| pickOrderLineId: record.pickOrderLineId, | |||||
| status: record.status as ReplenishmentStatus, | |||||
| created: record.created ?? "", | |||||
| }; | |||||
| } | } | ||||
| /** Shop code: partial match. Shop name: prefix match (e.g. first 4 characters). */ | /** Shop code: partial match. Shop name: prefix match (e.g. first 4 characters). */ | ||||
| @@ -142,6 +167,7 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| const [draftRows, setDraftRows] = useState<ReplenishmentDraftRow[]>([]); | const [draftRows, setDraftRows] = useState<ReplenishmentDraftRow[]>([]); | ||||
| const [records, setRecords] = useState<ReplenishmentRecord[]>([]); | const [records, setRecords] = useState<ReplenishmentRecord[]>([]); | ||||
| const [isLoadingTracking, setIsLoadingTracking] = useState(false); | |||||
| const [trackStatusFilter, setTrackStatusFilter] = useState<ReplenishmentStatus | "all">("all"); | const [trackStatusFilter, setTrackStatusFilter] = useState<ReplenishmentStatus | "all">("all"); | ||||
| const [trackDateFilter, setTrackDateFilter] = useState<Dayjs | null>(null); | const [trackDateFilter, setTrackDateFilter] = useState<Dayjs | null>(null); | ||||
| const [trackingDialogOpen, setTrackingDialogOpen] = useState(false); | const [trackingDialogOpen, setTrackingDialogOpen] = useState(false); | ||||
| @@ -210,6 +236,8 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| } | } | ||||
| const detail = matched[0]; | const detail = matched[0]; | ||||
| const matchedCandidate = candidates.find((c) => c.id === detail.id); | const matchedCandidate = candidates.find((c) => c.id === detail.id); | ||||
| const resolvedTruckLaneCode = | |||||
| detail.truckLaneCode?.trim() || matchedCandidate?.truckLanceCode?.trim() || null; | |||||
| if (detail.status !== "completed") { | if (detail.status !== "completed") { | ||||
| await Swal.fire({ | await Swal.fire({ | ||||
| icon: "error", | icon: "error", | ||||
| @@ -224,7 +252,7 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| doCode: detail.code, | doCode: detail.code, | ||||
| shopCode: detail.shopCode, | shopCode: detail.shopCode, | ||||
| shopName: detail.shopName, | shopName: detail.shopName, | ||||
| truckLaneCode: matchedCandidate?.truckLanceCode ?? null, | |||||
| truckLaneCode: resolvedTruckLaneCode, | |||||
| status: detail.status, | status: detail.status, | ||||
| lines: detail.deliveryOrderLines ?? [], | lines: detail.deliveryOrderLines ?? [], | ||||
| }); | }); | ||||
| @@ -258,32 +286,38 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| } | } | ||||
| const line = selectedLine; | const line = selectedLine; | ||||
| const duplicate = draftRows.some( | |||||
| const existingRowIndex = draftRows.findIndex( | |||||
| (r) => r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, | (r) => r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, | ||||
| ); | ); | ||||
| if (duplicate) { | |||||
| void Swal.fire({ icon: "warning", title: t("This item is already in the draft list") }); | |||||
| return; | |||||
| } | |||||
| setDraftRows((prev) => [ | |||||
| ...prev, | |||||
| { | |||||
| rowId: `draft-${Date.now()}-${prev.length}`, | |||||
| deliveryDate: deliveryDateStr, | |||||
| sourceDoId: sourceDo.doId, | |||||
| sourceDoCode: sourceDo.doCode, | |||||
| sourceDoLineId: line.id, | |||||
| itemNo: line.itemNo ?? "", | |||||
| itemName: line.itemName ?? line.itemNo ?? "", | |||||
| originalQty: line.qty ?? 0, | |||||
| replenishQty: qty, | |||||
| shortUom: lineUomDisplay(line) || undefined, | |||||
| shopCode: sourceDo.shopCode, | |||||
| shopName: sourceDo.shopName, | |||||
| truckLaneCode: undefined, | |||||
| }, | |||||
| ]); | |||||
| if (existingRowIndex >= 0) { | |||||
| setDraftRows((prev) => | |||||
| prev.map((row, index) => | |||||
| index === existingRowIndex | |||||
| ? { ...row, replenishQty: row.replenishQty + qty } | |||||
| : row, | |||||
| ), | |||||
| ); | |||||
| } else { | |||||
| setDraftRows((prev) => [ | |||||
| ...prev, | |||||
| { | |||||
| rowId: `draft-${Date.now()}-${prev.length}`, | |||||
| deliveryDate: deliveryDateStr, | |||||
| sourceDoId: sourceDo.doId, | |||||
| sourceDoCode: sourceDo.doCode, | |||||
| sourceDoLineId: line.id, | |||||
| itemNo: line.itemNo ?? "", | |||||
| itemName: line.itemName ?? line.itemNo ?? "", | |||||
| originalQty: line.qty ?? 0, | |||||
| replenishQty: qty, | |||||
| shortUom: lineUomDisplay(line) || undefined, | |||||
| shopCode: sourceDo.shopCode, | |||||
| shopName: sourceDo.shopName, | |||||
| truckLaneCode: sourceDo.truckLaneCode?.trim() || undefined, | |||||
| }, | |||||
| ]); | |||||
| } | |||||
| setSelectedLine(null); | setSelectedLine(null); | ||||
| setReplenishQtyInput(""); | setReplenishQtyInput(""); | ||||
| window.setTimeout(() => itemCodeInputRef.current?.focus(), 0); | window.setTimeout(() => itemCodeInputRef.current?.focus(), 0); | ||||
| @@ -309,27 +343,52 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| inFlightRef.current = true; | inFlightRef.current = true; | ||||
| setIsSubmitting(true); | setIsSubmitting(true); | ||||
| try { | try { | ||||
| const now = new Date().toISOString(); | |||||
| const newRecords: ReplenishmentRecord[] = draftRows.map((row) => ({ | |||||
| ...row, | |||||
| id: localIdSeq++, | |||||
| code: nextReplenishmentCode(row.deliveryDate), | |||||
| status: "pending", | |||||
| created: now, | |||||
| })); | |||||
| setRecords((prev) => [...newRecords, ...prev]); | |||||
| const created = await submitDoReplenishment( | |||||
| draftRows.map((row) => ({ | |||||
| deliveryDate: row.deliveryDate, | |||||
| sourceDoId: row.sourceDoId, | |||||
| sourceDoLineId: row.sourceDoLineId, | |||||
| replenishQty: row.replenishQty, | |||||
| truckLaneCode: row.truckLaneCode, | |||||
| })), | |||||
| ); | |||||
| setDraftRows([]); | setDraftRows([]); | ||||
| await Swal.fire({ | await Swal.fire({ | ||||
| icon: "info", | |||||
| title: t("Replenishment API not ready"), | |||||
| text: t("Records saved locally for preview. Backend integration pending."), | |||||
| icon: "success", | |||||
| title: t("Replenishment submitted successfully"), | |||||
| text: created.map((row) => row.code).join(", "), | |||||
| }); | }); | ||||
| } catch (error: unknown) { | |||||
| const message = | |||||
| error instanceof Error ? error.message : t("Failed to submit replenishment"); | |||||
| await Swal.fire({ icon: "error", title: message }); | |||||
| } finally { | } finally { | ||||
| setIsSubmitting(false); | setIsSubmitting(false); | ||||
| inFlightRef.current = false; | inFlightRef.current = false; | ||||
| } | } | ||||
| }, [draftRows, t]); | }, [draftRows, t]); | ||||
| const loadTrackingRecords = useCallback(async () => { | |||||
| setIsLoadingTracking(true); | |||||
| try { | |||||
| const data = await fetchDoReplenishmentList({ | |||||
| deliveryDate: trackDateFilter?.format("YYYY-MM-DD"), | |||||
| status: trackStatusFilter, | |||||
| }); | |||||
| setRecords(data.map(mapApiRecord)); | |||||
| } catch { | |||||
| await Swal.fire({ icon: "error", title: t("Failed to load replenishment records") }); | |||||
| } finally { | |||||
| setIsLoadingTracking(false); | |||||
| } | |||||
| }, [trackDateFilter, trackStatusFilter, t]); | |||||
| useEffect(() => { | |||||
| if (trackingDialogOpen) { | |||||
| void loadTrackingRecords(); | |||||
| } | |||||
| }, [trackingDialogOpen, loadTrackingRecords]); | |||||
| const trackColumns: GridColDef<ReplenishmentRecord>[] = useMemo( | const trackColumns: GridColDef<ReplenishmentRecord>[] = useMemo( | ||||
| () => [ | () => [ | ||||
| { field: "code", headerName: t("Replenishment Code"), width: 140 }, | { field: "code", headerName: t("Replenishment Code"), width: 140 }, | ||||
| @@ -377,16 +436,6 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| const selectedLineUom = lineUomDisplay(selectedLine); | const selectedLineUom = lineUomDisplay(selectedLine); | ||||
| const filteredRecords = useMemo(() => { | |||||
| return records.filter((r) => { | |||||
| if (trackStatusFilter !== "all" && r.status !== trackStatusFilter) return false; | |||||
| if (trackDateFilter && r.deliveryDate !== trackDateFilter.format("YYYY-MM-DD")) { | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| }); | |||||
| }, [records, trackDateFilter, trackStatusFilter]); | |||||
| const datePickerSlotProps = useMemo( | const datePickerSlotProps = useMemo( | ||||
| () => ({ | () => ({ | ||||
| textField: { | textField: { | ||||
| @@ -559,6 +608,9 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| <TableCell sx={{ width: { md: "8%" }, minWidth: { md: 48 }, whiteSpace: "nowrap" }}> | <TableCell sx={{ width: { md: "8%" }, minWidth: { md: 48 }, whiteSpace: "nowrap" }}> | ||||
| {t("uom")} | {t("uom")} | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell sx={{ width: { md: "10%" }, minWidth: { md: 88 }, whiteSpace: "nowrap" }}> | |||||
| {t("Truck Lance Code")} | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ width: { md: 120 }, whiteSpace: "nowrap" }}> | <TableCell align="center" sx={{ width: { md: 120 }, whiteSpace: "nowrap" }}> | ||||
| {t("Action")} | {t("Action")} | ||||
| </TableCell> | </TableCell> | ||||
| @@ -572,6 +624,9 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| <TableCell align="right">{row.originalQty}</TableCell> | <TableCell align="right">{row.originalQty}</TableCell> | ||||
| <TableCell align="right">{row.replenishQty}</TableCell> | <TableCell align="right">{row.replenishQty}</TableCell> | ||||
| <TableCell>{row.shortUom || "—"}</TableCell> | <TableCell>{row.shortUom || "—"}</TableCell> | ||||
| <TableCell> | |||||
| {row.truckLaneCode?.trim() || sourceDo.truckLaneCode?.trim() || t("Truck X")} | |||||
| </TableCell> | |||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | ||||
| <IconButton | <IconButton | ||||
| @@ -591,13 +646,7 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| <Autocomplete | <Autocomplete | ||||
| size="small" | size="small" | ||||
| fullWidth | fullWidth | ||||
| options={sourceDo.lines.filter( | |||||
| (line) => | |||||
| !draftRows.some( | |||||
| (r) => | |||||
| r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, | |||||
| ), | |||||
| )} | |||||
| options={sourceDo.lines} | |||||
| value={selectedLine} | value={selectedLine} | ||||
| onChange={(_, newValue) => setSelectedLine(newValue)} | onChange={(_, newValue) => setSelectedLine(newValue)} | ||||
| getOptionLabel={(line) => line.itemNo ?? ""} | getOptionLabel={(line) => line.itemNo ?? ""} | ||||
| @@ -675,6 +724,17 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| sx={{ whiteSpace: "nowrap" }} | sx={{ whiteSpace: "nowrap" }} | ||||
| /> | /> | ||||
| </TableCell> | </TableCell> | ||||
| <TableCell> | |||||
| <ReplenishmentItemEntryPlainText | |||||
| reserveSpace | |||||
| value={ | |||||
| sourceDo.truckLaneCode?.trim() | |||||
| ? sourceDo.truckLaneCode | |||||
| : t("Truck X") | |||||
| } | |||||
| sx={{ whiteSpace: "nowrap" }} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | <TableCell align="center"> | ||||
| <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | ||||
| <Button | <Button | ||||
| @@ -769,9 +829,10 @@ const DoReplenishmentTab: React.FC = () => { | |||||
| </Stack> | </Stack> | ||||
| </Box> | </Box> | ||||
| <StyledDataGrid | <StyledDataGrid | ||||
| rows={filteredRecords} | |||||
| rows={records} | |||||
| columns={trackColumns} | columns={trackColumns} | ||||
| autoHeight | autoHeight | ||||
| loading={isLoadingTracking} | |||||
| disableRowSelectionOnClick | disableRowSelectionOnClick | ||||
| pageSizeOptions={[10, 25, 50]} | pageSizeOptions={[10, 25, 50]} | ||||
| initialState={{ pagination: { paginationModel: { pageSize: 10 } } }} | initialState={{ pagination: { paginationModel: { pageSize: 10 } } }} | ||||
| @@ -871,7 +871,7 @@ const RouteBoard: React.FC = () => { | |||||
| const logisticsLaneDragIdRef = useRef<string | null>(null); | const logisticsLaneDragIdRef = useRef<string | null>(null); | ||||
| /** baseline: 後端目前 lane logisticId(用於判斷「只改物流商」也要能 Save) */ | /** baseline: 後端目前 lane logisticId(用於判斷「只改物流商」也要能 Save) */ | ||||
| const laneLogisticBaselineRef = useRef<Map<string, number | null>>(new Map()); | const laneLogisticBaselineRef = useRef<Map<string, number | null>>(new Map()); | ||||
| /** 店鋪列地區顯示 baseline(載入/refresh 後同步),供未儲存清單標註地區差 */ | |||||
| /** 店鋪列地區 baseline(載入/refresh 後同步),供未儲存清單標註地區差 */ | |||||
| const shopDistrictBaselineRef = useRef<Map<number, string>>(new Map()); | const shopDistrictBaselineRef = useRef<Map<number, string>>(new Map()); | ||||
| const shopRowBaselineRef = useRef<Map<number, ShopRowBaseline>>(new Map()); | const shopRowBaselineRef = useRef<Map<number, ShopRowBaseline>>(new Map()); | ||||
| const [districtBaselineEpoch, setDistrictBaselineEpoch] = useState(0); | const [districtBaselineEpoch, setDistrictBaselineEpoch] = useState(0); | ||||
| @@ -995,6 +995,20 @@ const RouteBoard: React.FC = () => { | |||||
| [logVersions], | [logVersions], | ||||
| ); | ); | ||||
| const displayedVersionLabel = useMemo(() => { | |||||
| if ( | |||||
| pendingRestoreVersionId != null && | |||||
| Number.isFinite(pendingRestoreVersionId) && | |||||
| pendingRestoreVersionId > 0 | |||||
| ) { | |||||
| return t("version_ui_pendingRestore", { id: pendingRestoreVersionId }); | |||||
| } | |||||
| if (headVersionId != null) { | |||||
| return t("version_ui_id", { id: headVersionId }); | |||||
| } | |||||
| return t("version_ui_none"); | |||||
| }, [pendingRestoreVersionId, headVersionId, t]); | |||||
| const versionFilterActive = | const versionFilterActive = | ||||
| String(versionFilterQuery || "").trim() !== "" || | String(versionFilterQuery || "").trim() !== "" || | ||||
| String(versionFilterDate || "").trim() !== ""; | String(versionFilterDate || "").trim() !== ""; | ||||
| @@ -1043,11 +1057,13 @@ const RouteBoard: React.FC = () => { | |||||
| scheduleModalOpenRef.current = scheduleModalOpen; | scheduleModalOpenRef.current = scheduleModalOpen; | ||||
| const { | const { | ||||
| pendingScheduleShopIds, | pendingScheduleShopIds, | ||||
| lockedScheduleShopIds, | |||||
| failedScheduleShopIds, | failedScheduleShopIds, | ||||
| failedScheduleCount, | failedScheduleCount, | ||||
| refreshScheduleIndicators, | refreshScheduleIndicators, | ||||
| } = useRouteBoardScheduleIndicators({ paused: scheduleModalOpen }); | } = useRouteBoardScheduleIndicators({ paused: scheduleModalOpen }); | ||||
| const scheduledShopIdSet = pendingScheduleShopIds; | |||||
| /** 硬鎖:APPLYING 或進入鎖定時間窗的排程;遠期排程僅標記不鎖。 */ | |||||
| const scheduledShopIdSet = lockedScheduleShopIds; | |||||
| const [addShopDialogOpen, setAddShopDialogOpen] = useState(false); | const [addShopDialogOpen, setAddShopDialogOpen] = useState(false); | ||||
| const [addShopLaneId, setAddShopLaneId] = useState<string | null>(null); | const [addShopLaneId, setAddShopLaneId] = useState<string | null>(null); | ||||
| @@ -2772,6 +2788,7 @@ const RouteBoard: React.FC = () => { | |||||
| truckLanceCode: lane.truckLanceCode, | truckLanceCode: lane.truckLanceCode, | ||||
| remark: lane.remark, | remark: lane.remark, | ||||
| storeId: normalizeStoreId(lane.storeId), | storeId: normalizeStoreId(lane.storeId), | ||||
| departureTime: parseTimeForBackend(lane.startTime) || "00:00:00", | |||||
| shops: lane.shops | shops: lane.shops | ||||
| .filter((s) => s.id >= 0) | .filter((s) => s.id >= 0) | ||||
| .map((s) => ({ | .map((s) => ({ | ||||
| @@ -4222,10 +4239,7 @@ const RouteBoard: React.FC = () => { | |||||
| {t("pageTitle")} | {t("pageTitle")} | ||||
| </Typography> | </Typography> | ||||
| <Typography variant="caption" color="text.secondary"> | <Typography variant="caption" color="text.secondary"> | ||||
| {t("Current version")}:{" "} | |||||
| {headVersionId != null | |||||
| ? t("version_ui_id", { id: headVersionId }) | |||||
| : t("version_ui_none")} | |||||
| {t("Current version")}: {displayedVersionLabel} | |||||
| </Typography> | </Typography> | ||||
| </Box> | </Box> | ||||
| @@ -4584,6 +4598,7 @@ const RouteBoard: React.FC = () => { | |||||
| onClose={() => setScheduleModalOpen(false)} | onClose={() => setScheduleModalOpen(false)} | ||||
| lanes={scheduleLaneOptions} | lanes={scheduleLaneOptions} | ||||
| shops={scheduleShopRows} | shops={scheduleShopRows} | ||||
| allShopsMaster={allShopsMaster} | |||||
| pendingTruckRowIds={pendingScheduleShopIds} | pendingTruckRowIds={pendingScheduleShopIds} | ||||
| onConfirmManual={handleScheduleConfirmManual} | onConfirmManual={handleScheduleConfirmManual} | ||||
| onAfterScheduleChange={async () => { | onAfterScheduleChange={async () => { | ||||
| @@ -5812,9 +5827,6 @@ const RouteBoard: React.FC = () => { | |||||
| : t("shop_autocomplete_noOptions") | : t("shop_autocomplete_noOptions") | ||||
| } | } | ||||
| /> | /> | ||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {t("addShop_listHint")} | |||||
| </Typography> | |||||
| </Stack> | </Stack> | ||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| @@ -5845,7 +5857,6 @@ const RouteBoard: React.FC = () => { | |||||
| autoFocus | autoFocus | ||||
| margin="dense" | margin="dense" | ||||
| label={t("district_name_label")} | label={t("district_name_label")} | ||||
| placeholder={t("district_name_ph")} | |||||
| fullWidth | fullWidth | ||||
| value={districtEditDraft} | value={districtEditDraft} | ||||
| onChange={(e) => { | onChange={(e) => { | ||||
| @@ -7636,6 +7647,10 @@ const RouteBoard: React.FC = () => { | |||||
| const changed = dirtyMoves.has(shop.id); | const changed = dirtyMoves.has(shop.id); | ||||
| const isScheduledMove = | const isScheduledMove = | ||||
| shop.id > 0 && scheduledShopIdSet.has(shop.id); | shop.id > 0 && scheduledShopIdSet.has(shop.id); | ||||
| const isScheduledLater = | |||||
| shop.id > 0 && | |||||
| !isScheduledMove && | |||||
| pendingScheduleShopIds.has(shop.id); | |||||
| const isFailedScheduledMove = | const isFailedScheduledMove = | ||||
| shop.id > 0 && | shop.id > 0 && | ||||
| failedScheduleShopIds.has(shop.id); | failedScheduleShopIds.has(shop.id); | ||||
| @@ -7744,6 +7759,30 @@ const RouteBoard: React.FC = () => { | |||||
| </Box> | </Box> | ||||
| </Tooltip> | </Tooltip> | ||||
| )} | )} | ||||
| {isScheduledLater && | |||||
| !isFailedScheduledMove && ( | |||||
| <Tooltip | |||||
| title={t("schedule_shop_scheduled")} | |||||
| > | |||||
| <Box | |||||
| sx={{ | |||||
| position: "absolute", | |||||
| top: 0, | |||||
| right: 0, | |||||
| p: 0.75, | |||||
| bgcolor: "info.light", | |||||
| color: "info.contrastText", | |||||
| borderBottomLeftRadius: 8, | |||||
| display: "flex", | |||||
| alignItems: "center", | |||||
| justifyContent: "center", | |||||
| zIndex: 1, | |||||
| }} | |||||
| > | |||||
| <Clock size={12} /> | |||||
| </Box> | |||||
| </Tooltip> | |||||
| )} | |||||
| {showInsertLine && ( | {showInsertLine && ( | ||||
| <Box | <Box | ||||
| sx={{ | sx={{ | ||||
| @@ -10,6 +10,7 @@ import React, { | |||||
| } from "react"; | } from "react"; | ||||
| import { | import { | ||||
| Alert, | Alert, | ||||
| Autocomplete, | |||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| Chip, | Chip, | ||||
| @@ -48,9 +49,20 @@ import ScheduleDragWorkspacePane from "@/components/Shop/ScheduleDragWorkspacePa | |||||
| import ScheduleReviewQueue from "@/components/Shop/ScheduleReviewQueue"; | import ScheduleReviewQueue from "@/components/Shop/ScheduleReviewQueue"; | ||||
| import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers"; | import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers"; | ||||
| import { | import { | ||||
| computeTruckLaneWarnings, | |||||
| diffNewTruckLaneWarnings, | |||||
| } from "@/components/Shop/computeTruckLaneWarnings"; | |||||
| import { | |||||
| addShopToPlan, | |||||
| defaultFocusedLaneIds, | defaultFocusedLaneIds, | ||||
| initPlannedLanes, | initPlannedLanes, | ||||
| listDepartureModifications, | |||||
| listModifications, | listModifications, | ||||
| parseScheduleDepartureTime, | |||||
| plannedLanesToWarningInputRows, | |||||
| revertLaneDeparture, | |||||
| setLaneDepartureTime, | |||||
| listPendingCreates, | |||||
| moveShop, | moveShop, | ||||
| removeShopFromPlan, | removeShopFromPlan, | ||||
| restoreDeletedShop, | restoreDeletedShop, | ||||
| @@ -83,6 +95,8 @@ export type ScheduleLaneOption = { | |||||
| truckLanceCode: string; | truckLanceCode: string; | ||||
| remark?: string | null; | remark?: string | null; | ||||
| storeId: string; | storeId: string; | ||||
| /** Lane departure time (HH:mm:ss) for CREATE schedule lines. */ | |||||
| departureTime: string; | |||||
| shops: ScheduleLaneShopSnapshot[]; | shops: ScheduleLaneShopSnapshot[]; | ||||
| }; | }; | ||||
| @@ -114,11 +128,14 @@ export type ScheduleChangePayload = { | |||||
| pendingDeletes: ScheduledDeleteSnapshot[]; | pendingDeletes: ScheduledDeleteSnapshot[]; | ||||
| }; | }; | ||||
| type ShopMasterOption = { id: number; name: string; code: string }; | |||||
| type Props = { | type Props = { | ||||
| open: boolean; | open: boolean; | ||||
| onClose: () => void; | onClose: () => void; | ||||
| lanes: ScheduleLaneOption[]; | lanes: ScheduleLaneOption[]; | ||||
| shops: ScheduleShopRow[]; | shops: ScheduleShopRow[]; | ||||
| allShopsMaster: ShopMasterOption[]; | |||||
| pendingTruckRowIds: Set<number>; | pendingTruckRowIds: Set<number>; | ||||
| onConfirmManual: (payload: ScheduleChangePayload) => void | Promise<void>; | onConfirmManual: (payload: ScheduleChangePayload) => void | Promise<void>; | ||||
| onAfterScheduleChange?: () => void | Promise<void>; | onAfterScheduleChange?: () => void | Promise<void>; | ||||
| @@ -129,12 +146,14 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| onClose, | onClose, | ||||
| lanes, | lanes, | ||||
| shops, | shops, | ||||
| allShopsMaster, | |||||
| pendingTruckRowIds, | pendingTruckRowIds, | ||||
| onConfirmManual, | onConfirmManual, | ||||
| onAfterScheduleChange, | onAfterScheduleChange, | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("shop"); | const { t } = useTranslation("shop"); | ||||
| const importFileRef = useRef<HTMLInputElement>(null); | const importFileRef = useRef<HTMLInputElement>(null); | ||||
| const nextTempTruckRowIdRef = useRef(-1); | |||||
| const [tab, setTab] = useState<"manual" | "import">("manual"); | const [tab, setTab] = useState<"manual" | "import">("manual"); | ||||
| const [scheduledDate, setScheduledDate] = useState(""); | const [scheduledDate, setScheduledDate] = useState(""); | ||||
| const [scheduledTime, setScheduledTime] = useState(""); | const [scheduledTime, setScheduledTime] = useState(""); | ||||
| @@ -168,9 +187,13 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| const [pendingDeletes, setPendingDeletes] = useState<ScheduledDeleteSnapshot[]>( | const [pendingDeletes, setPendingDeletes] = useState<ScheduledDeleteSnapshot[]>( | ||||
| [], | [], | ||||
| ); | ); | ||||
| const [addShopDialogOpen, setAddShopDialogOpen] = useState(false); | |||||
| const [addShopLaneId, setAddShopLaneId] = useState<string | null>(null); | |||||
| const [addShopPick, setAddShopPick] = useState<ShopMasterOption | null>(null); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (!open) return; | if (!open) return; | ||||
| nextTempTruckRowIdRef.current = -1; | |||||
| setTab("manual"); | setTab("manual"); | ||||
| setScheduledDate(""); | setScheduledDate(""); | ||||
| setScheduledTime(""); | setScheduledTime(""); | ||||
| @@ -190,6 +213,9 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| setStagedPlanCounts(null); | setStagedPlanCounts(null); | ||||
| setStagedRowErrors([]); | setStagedRowErrors([]); | ||||
| setSubmitError(null); | setSubmitError(null); | ||||
| setAddShopDialogOpen(false); | |||||
| setAddShopLaneId(null); | |||||
| setAddShopPick(null); | |||||
| }, [open, lanes, shops]); | }, [open, lanes, shops]); | ||||
| const modifications = useMemo( | const modifications = useMemo( | ||||
| @@ -197,6 +223,41 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| [plannedLanes], | [plannedLanes], | ||||
| ); | ); | ||||
| const pendingCreates = useMemo( | |||||
| () => listPendingCreates(plannedLanes), | |||||
| [plannedLanes], | |||||
| ); | |||||
| const departureModifications = useMemo( | |||||
| () => listDepartureModifications(plannedLanes), | |||||
| [plannedLanes], | |||||
| ); | |||||
| const addShopCandidates = useMemo(() => { | |||||
| if (!addShopLaneId) return []; | |||||
| const codesOnPlan = new Set<string>(); | |||||
| const idsOnPlan = new Set<number>(); | |||||
| for (const lane of plannedLanes) { | |||||
| for (const s of lane.shops) { | |||||
| const c = String(s.shopCode || "") | |||||
| .trim() | |||||
| .toLowerCase(); | |||||
| if (c) codesOnPlan.add(c); | |||||
| if (s.shopEntityId != null && s.shopEntityId > 0) { | |||||
| idsOnPlan.add(s.shopEntityId); | |||||
| } | |||||
| } | |||||
| } | |||||
| return allShopsMaster.filter((m) => { | |||||
| const c = String(m.code || "") | |||||
| .trim() | |||||
| .toLowerCase(); | |||||
| if (c && codesOnPlan.has(c)) return false; | |||||
| if (idsOnPlan.has(m.id)) return false; | |||||
| return true; | |||||
| }); | |||||
| }, [addShopLaneId, plannedLanes, allShopsMaster]); | |||||
| const handleMoveShop = useCallback( | const handleMoveShop = useCallback( | ||||
| ( | ( | ||||
| shopId: number, | shopId: number, | ||||
| @@ -234,6 +295,38 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| [], | [], | ||||
| ); | ); | ||||
| const handleSetLaneDepartureTime = useCallback( | |||||
| (laneId: string, departureTime: string): boolean => { | |||||
| const backendTime = parseScheduleDepartureTime(departureTime); | |||||
| if (!backendTime) return false; | |||||
| let applied = false; | |||||
| setPlannedLanes((prev) => { | |||||
| const beforeRows = plannedLanesToWarningInputRows(prev); | |||||
| const beforeWarnings = computeTruckLaneWarnings(beforeRows).warnings; | |||||
| const next = setLaneDepartureTime(prev, laneId, backendTime); | |||||
| const afterWarnings = computeTruckLaneWarnings( | |||||
| plannedLanesToWarningInputRows(next), | |||||
| ).warnings; | |||||
| const newWarnings = diffNewTruckLaneWarnings(beforeWarnings, afterWarnings); | |||||
| if (newWarnings.length > 0) { | |||||
| const ok = window.confirm( | |||||
| t("confirm_departureConflict", { count: newWarnings.length }), | |||||
| ); | |||||
| if (!ok) return prev; | |||||
| } | |||||
| applied = true; | |||||
| return next; | |||||
| }); | |||||
| return applied; | |||||
| }, | |||||
| [t], | |||||
| ); | |||||
| const handleRevertLaneDeparture = useCallback((laneId: string) => { | |||||
| setPlannedLanes((prev) => revertLaneDeparture(prev, laneId)); | |||||
| }, []); | |||||
| const handleAddEmptyDistrict = useCallback((laneId: string, display: string) => { | const handleAddEmptyDistrict = useCallback((laneId: string, display: string) => { | ||||
| setPendingEmptyDistrictsByLane((prev) => { | setPendingEmptyDistrictsByLane((prev) => { | ||||
| const merged = dedupeDistrictPendingOrder([...(prev[laneId] ?? []), display]); | const merged = dedupeDistrictPendingOrder([...(prev[laneId] ?? []), display]); | ||||
| @@ -254,8 +347,39 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| }); | }); | ||||
| }, []); | }, []); | ||||
| const openAddShopDialog = useCallback((laneId: string) => { | |||||
| setAddShopLaneId(laneId); | |||||
| setAddShopPick(null); | |||||
| setAddShopDialogOpen(true); | |||||
| }, []); | |||||
| const closeAddShopDialog = useCallback(() => { | |||||
| setAddShopDialogOpen(false); | |||||
| setAddShopLaneId(null); | |||||
| setAddShopPick(null); | |||||
| }, []); | |||||
| const submitAddShop = useCallback(() => { | |||||
| if (!addShopLaneId || !addShopPick) return; | |||||
| const tempId = nextTempTruckRowIdRef.current; | |||||
| nextTempTruckRowIdRef.current -= 1; | |||||
| setPlannedLanes((prev) => | |||||
| addShopToPlan(prev, addShopLaneId, addShopPick, tempId), | |||||
| ); | |||||
| closeAddShopDialog(); | |||||
| }, [addShopLaneId, addShopPick, closeAddShopDialog]); | |||||
| const handleRevertCreate = useCallback((truckRowId: number) => { | |||||
| setPlannedLanes((prev) => removeShopFromPlan(prev, truckRowId).next); | |||||
| }, []); | |||||
| const handleDeleteShop = useCallback( | const handleDeleteShop = useCallback( | ||||
| (truckRowId: number, laneId: string) => { | (truckRowId: number, laneId: string) => { | ||||
| if (truckRowId < 0) { | |||||
| if (!window.confirm(t("confirm_schedule_removeShop"))) return; | |||||
| setPlannedLanes((prev) => removeShopFromPlan(prev, truckRowId).next); | |||||
| return; | |||||
| } | |||||
| if (truckRowId <= 0) return; | if (truckRowId <= 0) return; | ||||
| if (!window.confirm(t("confirm_schedule_removeShop"))) return; | if (!window.confirm(t("confirm_schedule_removeShop"))) return; | ||||
| @@ -507,10 +631,15 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| } | } | ||||
| }; | }; | ||||
| const hasDepartureMoves = departureModifications.some((d) => d.shopCount > 0); | |||||
| const canSubmitManual = | const canSubmitManual = | ||||
| !isProcessing && | !isProcessing && | ||||
| plannedSubmit.ok && | plannedSubmit.ok && | ||||
| (modifications.length > 0 || pendingDeletes.length > 0); | |||||
| (modifications.length > 0 || | |||||
| pendingDeletes.length > 0 || | |||||
| pendingCreates.length > 0 || | |||||
| hasDepartureMoves); | |||||
| const canSubmitImport = | const canSubmitImport = | ||||
| !isProcessing && | !isProcessing && | ||||
| @@ -691,6 +820,8 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| onAddEmptyDistrict={handleAddEmptyDistrict} | onAddEmptyDistrict={handleAddEmptyDistrict} | ||||
| onRemoveEmptyDistrict={handleRemoveEmptyDistrict} | onRemoveEmptyDistrict={handleRemoveEmptyDistrict} | ||||
| onDeleteShop={handleDeleteShop} | onDeleteShop={handleDeleteShop} | ||||
| onAddShop={openAddShopDialog} | |||||
| onSetLaneDepartureTime={handleSetLaneDepartureTime} | |||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| <Box | <Box | ||||
| @@ -706,8 +837,12 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| <ScheduleReviewQueue | <ScheduleReviewQueue | ||||
| modifications={modifications} | modifications={modifications} | ||||
| pendingDeletes={pendingDeletes} | pendingDeletes={pendingDeletes} | ||||
| pendingCreates={pendingCreates} | |||||
| departureModifications={departureModifications} | |||||
| onRevert={handleRevertShop} | onRevert={handleRevertShop} | ||||
| onRevertDelete={handleRevertDelete} | onRevertDelete={handleRevertDelete} | ||||
| onRevertCreate={handleRevertCreate} | |||||
| onRevertDeparture={handleRevertLaneDeparture} | |||||
| errorTruckRowIds={validationErrorTruckRowIds} | errorTruckRowIds={validationErrorTruckRowIds} | ||||
| validationErrorsByTruckRowId={validationErrorsByTruckRowId} | validationErrorsByTruckRowId={validationErrorsByTruckRowId} | ||||
| /> | /> | ||||
| @@ -933,7 +1068,9 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| )} | )} | ||||
| {tab === "manual" && | {tab === "manual" && | ||||
| (modifications.length > 0 || pendingDeletes.length > 0) && ( | |||||
| (modifications.length > 0 || | |||||
| pendingDeletes.length > 0 || | |||||
| pendingCreates.length > 0) && ( | |||||
| <Alert | <Alert | ||||
| severity="warning" | severity="warning" | ||||
| icon={<AlertCircle size={20} />} | icon={<AlertCircle size={20} />} | ||||
| @@ -945,7 +1082,10 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| > | > | ||||
| <Typography variant="body2" sx={{ fontWeight: 800 }}> | <Typography variant="body2" sx={{ fontWeight: 800 }}> | ||||
| {t("schedule_summary_changes", { | {t("schedule_summary_changes", { | ||||
| count: modifications.length + pendingDeletes.length, | |||||
| count: | |||||
| modifications.length + | |||||
| pendingDeletes.length + | |||||
| pendingCreates.length, | |||||
| })} | })} | ||||
| </Typography> | </Typography> | ||||
| {scheduledDate && scheduledTime && ( | {scheduledDate && scheduledTime && ( | ||||
| @@ -996,6 +1136,61 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||||
| )} | )} | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| <Dialog | |||||
| open={addShopDialogOpen} | |||||
| onClose={closeAddShopDialog} | |||||
| maxWidth="sm" | |||||
| fullWidth | |||||
| > | |||||
| <DialogTitle> | |||||
| {t("addShop_dialogTitle")}{" "} | |||||
| {(() => { | |||||
| const lane = addShopLaneId | |||||
| ? lanes.find((l) => l.id === addShopLaneId) | |||||
| : null; | |||||
| if (!lane) return ""; | |||||
| return `「${lane.truckLanceCode}${ | |||||
| lane.remark != null && String(lane.remark).trim() !== "" | |||||
| ? ` · ${lane.remark}` | |||||
| : "" | |||||
| }」`; | |||||
| })()} | |||||
| </DialogTitle> | |||||
| <DialogContent dividers> | |||||
| <Stack spacing={2} sx={{ pt: 1 }}> | |||||
| <Autocomplete | |||||
| options={addShopCandidates} | |||||
| getOptionLabel={(o) => `${o.name} (${o.code})`} | |||||
| isOptionEqualToValue={(a, b) => a.id === b.id} | |||||
| value={addShopPick} | |||||
| onChange={(_e, v) => setAddShopPick(v)} | |||||
| renderInput={(params) => ( | |||||
| <TextField | |||||
| {...params} | |||||
| label={t("shop_autocomplete_label")} | |||||
| placeholder={t("shop_autocomplete_ph")} | |||||
| /> | |||||
| )} | |||||
| noOptionsText={ | |||||
| allShopsMaster.length === 0 | |||||
| ? t("shop_autocomplete_loading") | |||||
| : t("shop_autocomplete_noOptions") | |||||
| } | |||||
| /> | |||||
| </Stack> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={closeAddShopDialog}>{t("cancel")}</Button> | |||||
| <Button | |||||
| onClick={submitAddShop} | |||||
| variant="contained" | |||||
| disabled={!addShopPick} | |||||
| > | |||||
| {t("addShop_confirm")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -55,8 +55,23 @@ type Props = { | |||||
| onAddEmptyDistrict: (laneId: string, display: string) => void; | onAddEmptyDistrict: (laneId: string, display: string) => void; | ||||
| onRemoveEmptyDistrict: (laneId: string, display: string) => void; | onRemoveEmptyDistrict: (laneId: string, display: string) => void; | ||||
| onDeleteShop: (shopId: number, laneId: string) => void; | onDeleteShop: (shopId: number, laneId: string) => void; | ||||
| onAddShop: (laneId: string) => void; | |||||
| onSetLaneDepartureTime: (laneId: string, departureTime: string) => boolean; | |||||
| }; | }; | ||||
| function toTimeInputValue(t: string | undefined): string { | |||||
| const s = String(t ?? "").trim(); | |||||
| if (!s) return "00:00"; | |||||
| const m = s.match(/^(\d{1,2}):(\d{2})(?::\d{2})?/); | |||||
| if (m) return `${m[1].padStart(2, "0")}:${m[2]}`; | |||||
| return "00:00"; | |||||
| } | |||||
| function formatDepartureChip(t: string): string { | |||||
| const m = String(t ?? "").trim().match(/^(\d{1,2}):(\d{2})/); | |||||
| return m ? `${m[1].padStart(2, "0")}:${m[2]}` : "-"; | |||||
| } | |||||
| function getBeforeShopIdByPointer( | function getBeforeShopIdByPointer( | ||||
| laneId: string, | laneId: string, | ||||
| clientY: number, | clientY: number, | ||||
| @@ -386,6 +401,11 @@ const LaneColumn = memo(function LaneColumn({ | |||||
| onStartSeqEdit, | onStartSeqEdit, | ||||
| dropHint, | dropHint, | ||||
| addDistrictLabel, | addDistrictLabel, | ||||
| addShopLabel, | |||||
| onAddShop, | |||||
| departureLabel, | |||||
| departureEditAriaLabel, | |||||
| onStartDepartureEdit, | |||||
| }: { | }: { | ||||
| lane: PlannedLane; | lane: PlannedLane; | ||||
| pendingEmptyDistricts: string[]; | pendingEmptyDistricts: string[]; | ||||
| @@ -411,6 +431,11 @@ const LaneColumn = memo(function LaneColumn({ | |||||
| onStartSeqEdit: (laneId: string, shopId: number, current: number) => void; | onStartSeqEdit: (laneId: string, shopId: number, current: number) => void; | ||||
| dropHint: string; | dropHint: string; | ||||
| addDistrictLabel: string; | addDistrictLabel: string; | ||||
| addShopLabel: string; | |||||
| onAddShop: () => void; | |||||
| departureLabel: string; | |||||
| departureEditAriaLabel: string; | |||||
| onStartDepartureEdit: (laneId: string, current: string) => void; | |||||
| }) { | }) { | ||||
| const districtSections = buildLaneDistrictSections( | const districtSections = buildLaneDistrictSections( | ||||
| lane.shops, | lane.shops, | ||||
| @@ -448,8 +473,8 @@ const LaneColumn = memo(function LaneColumn({ | |||||
| flexShrink: 0, | flexShrink: 0, | ||||
| }} | }} | ||||
| > | > | ||||
| <Box sx={{ minWidth: 0 }}> | |||||
| <Stack direction="row" spacing={0.75} alignItems="center"> | |||||
| <Box sx={{ minWidth: 0, flex: 1 }}> | |||||
| <Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap"> | |||||
| <Typography variant="body2" sx={{ fontWeight: 800 }} noWrap> | <Typography variant="body2" sx={{ fontWeight: 800 }} noWrap> | ||||
| {lane.label} | {lane.label} | ||||
| </Typography> | </Typography> | ||||
| @@ -459,6 +484,33 @@ const LaneColumn = memo(function LaneColumn({ | |||||
| sx={{ height: 18, fontSize: "0.65rem", fontFamily: "monospace" }} | sx={{ height: 18, fontSize: "0.65rem", fontFamily: "monospace" }} | ||||
| /> | /> | ||||
| </Stack> | </Stack> | ||||
| <Stack direction="row" alignItems="center" spacing={0.25} sx={{ mt: 0.35 }}> | |||||
| <Typography | |||||
| variant="caption" | |||||
| sx={{ | |||||
| fontWeight: 700, | |||||
| bgcolor: "grey.100", | |||||
| px: 0.75, | |||||
| py: 0.2, | |||||
| borderRadius: 1, | |||||
| whiteSpace: "nowrap", | |||||
| }} | |||||
| > | |||||
| {departureLabel}: {formatDepartureChip(lane.departureTime)} | |||||
| </Typography> | |||||
| <Tooltip title={departureEditAriaLabel}> | |||||
| <IconButton | |||||
| size="small" | |||||
| onClick={(e) => { | |||||
| e.stopPropagation(); | |||||
| onStartDepartureEdit(lane.id, lane.departureTime); | |||||
| }} | |||||
| aria-label={departureEditAriaLabel} | |||||
| > | |||||
| <Pencil size={14} /> | |||||
| </IconButton> | |||||
| </Tooltip> | |||||
| </Stack> | |||||
| </Box> | </Box> | ||||
| <Chip | <Chip | ||||
| size="small" | size="small" | ||||
| @@ -558,6 +610,28 @@ const LaneColumn = memo(function LaneColumn({ | |||||
| </Stack> | </Stack> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| <Box | |||||
| sx={{ | |||||
| px: 1.5, | |||||
| py: 1, | |||||
| borderTop: 1, | |||||
| borderColor: "divider", | |||||
| bgcolor: "background.paper", | |||||
| flexShrink: 0, | |||||
| }} | |||||
| > | |||||
| <Button | |||||
| fullWidth | |||||
| size="small" | |||||
| variant="outlined" | |||||
| startIcon={<Plus size={14} />} | |||||
| onClick={onAddShop} | |||||
| sx={{ textTransform: "none", fontWeight: 700 }} | |||||
| > | |||||
| {addShopLabel} | |||||
| </Button> | |||||
| </Box> | |||||
| </Paper> | </Paper> | ||||
| ); | ); | ||||
| }); | }); | ||||
| @@ -576,6 +650,8 @@ const ScheduleDragWorkspacePane: React.FC<Props> = ({ | |||||
| onAddEmptyDistrict, | onAddEmptyDistrict, | ||||
| onRemoveEmptyDistrict, | onRemoveEmptyDistrict, | ||||
| onDeleteShop, | onDeleteShop, | ||||
| onAddShop, | |||||
| onSetLaneDepartureTime, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("shop"); | const { t } = useTranslation("shop"); | ||||
| const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>( | const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>( | ||||
| @@ -596,6 +672,27 @@ const ScheduleDragWorkspacePane: React.FC<Props> = ({ | |||||
| const [districtAddError, setDistrictAddError] = useState<string | null>( | const [districtAddError, setDistrictAddError] = useState<string | null>( | ||||
| null, | null, | ||||
| ); | ); | ||||
| const [departureEditTarget, setDepartureEditTarget] = useState<{ | |||||
| laneId: string; | |||||
| draft: string; | |||||
| } | null>(null); | |||||
| const [departureEditError, setDepartureEditError] = useState<string | null>( | |||||
| null, | |||||
| ); | |||||
| const applyDepartureEdit = useCallback(() => { | |||||
| if (!departureEditTarget) return; | |||||
| const ok = onSetLaneDepartureTime( | |||||
| departureEditTarget.laneId, | |||||
| departureEditTarget.draft, | |||||
| ); | |||||
| if (!ok) { | |||||
| setDepartureEditError(t("route_err_departure")); | |||||
| return; | |||||
| } | |||||
| setDepartureEditError(null); | |||||
| setDepartureEditTarget(null); | |||||
| }, [departureEditTarget, onSetLaneDepartureTime, t]); | |||||
| const leftLane = plannedLanes.find((l) => l.id === leftLaneId); | const leftLane = plannedLanes.find((l) => l.id === leftLaneId); | ||||
| const rightLane = plannedLanes.find((l) => l.id === rightLaneId); | const rightLane = plannedLanes.find((l) => l.id === rightLaneId); | ||||
| @@ -862,6 +959,17 @@ const ScheduleDragWorkspacePane: React.FC<Props> = ({ | |||||
| onStartSeqEdit: handleStartSeqEdit, | onStartSeqEdit: handleStartSeqEdit, | ||||
| dropHint: t("schedule_drop_hint"), | dropHint: t("schedule_drop_hint"), | ||||
| addDistrictLabel: t("btn_addDistrict"), | addDistrictLabel: t("btn_addDistrict"), | ||||
| addShopLabel: t("btn_addShopToLane"), | |||||
| onAddShop: () => onAddShop(lane.id), | |||||
| departureLabel: t("Departure"), | |||||
| departureEditAriaLabel: t("departureTooltipEditSave"), | |||||
| onStartDepartureEdit: (laneId: string, current: string) => { | |||||
| setDepartureEditError(null); | |||||
| setDepartureEditTarget({ | |||||
| laneId, | |||||
| draft: toTimeInputValue(current), | |||||
| }); | |||||
| }, | |||||
| }); | }); | ||||
| return ( | return ( | ||||
| @@ -882,6 +990,64 @@ const ScheduleDragWorkspacePane: React.FC<Props> = ({ | |||||
| </Box> | </Box> | ||||
| </Stack> | </Stack> | ||||
| <Dialog | |||||
| open={departureEditTarget != null} | |||||
| onClose={() => { | |||||
| setDepartureEditError(null); | |||||
| setDepartureEditTarget(null); | |||||
| }} | |||||
| maxWidth="xs" | |||||
| fullWidth | |||||
| > | |||||
| <DialogTitle>{t("departureDialog_title")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <TextField | |||||
| margin="dense" | |||||
| fullWidth | |||||
| autoFocus | |||||
| type="time" | |||||
| label={t("seq_edit_departureLabel")} | |||||
| value={departureEditTarget?.draft ?? ""} | |||||
| error={departureEditError != null} | |||||
| helperText={departureEditError ?? undefined} | |||||
| onChange={(e) => { | |||||
| setDepartureEditError(null); | |||||
| setDepartureEditTarget((prev) => | |||||
| prev ? { ...prev, draft: e.target.value } : prev, | |||||
| ); | |||||
| }} | |||||
| onKeyDown={(e) => { | |||||
| if (e.key === "Enter" && departureEditTarget) { | |||||
| e.preventDefault(); | |||||
| applyDepartureEdit(); | |||||
| } | |||||
| }} | |||||
| InputLabelProps={{ shrink: true }} | |||||
| sx={{ mt: 1 }} | |||||
| /> | |||||
| <Typography | |||||
| variant="caption" | |||||
| color="text.secondary" | |||||
| sx={{ mt: 1, display: "block" }} | |||||
| > | |||||
| {t("departureDialog_hint")} | |||||
| </Typography> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button | |||||
| onClick={() => { | |||||
| setDepartureEditError(null); | |||||
| setDepartureEditTarget(null); | |||||
| }} | |||||
| > | |||||
| {t("cancel")} | |||||
| </Button> | |||||
| <Button variant="contained" onClick={applyDepartureEdit}> | |||||
| {t("btn_apply")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <Dialog | <Dialog | ||||
| open={seqEditTarget != null} | open={seqEditTarget != null} | ||||
| onClose={handleCancelSeqEdit} | onClose={handleCancelSeqEdit} | ||||
| @@ -10,32 +10,51 @@ import { | |||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { alpha } from "@mui/material/styles"; | import { alpha } from "@mui/material/styles"; | ||||
| import { ArrowRight, Clock, HelpCircle, Trash2, Undo2 } from "lucide-react"; | |||||
| import { ArrowRight, Clock, HelpCircle, Plus, Trash2, Undo2 } from "lucide-react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import type { | import type { | ||||
| ScheduleDepartureModification, | |||||
| ScheduleModification, | ScheduleModification, | ||||
| SchedulePendingCreate, | |||||
| ScheduledDeleteSnapshot, | ScheduledDeleteSnapshot, | ||||
| } from "@/components/Shop/scheduleDragWorkspace"; | } from "@/components/Shop/scheduleDragWorkspace"; | ||||
| type Props = { | type Props = { | ||||
| modifications: ScheduleModification[]; | modifications: ScheduleModification[]; | ||||
| pendingDeletes: ScheduledDeleteSnapshot[]; | pendingDeletes: ScheduledDeleteSnapshot[]; | ||||
| pendingCreates: SchedulePendingCreate[]; | |||||
| departureModifications?: ScheduleDepartureModification[]; | |||||
| onRevert: (shopId: number, currentLaneId: string) => void; | onRevert: (shopId: number, currentLaneId: string) => void; | ||||
| onRevertDelete: (truckRowId: number) => void; | onRevertDelete: (truckRowId: number) => void; | ||||
| onRevertCreate: (truckRowId: number) => void; | |||||
| onRevertDeparture?: (laneId: string) => void; | |||||
| errorTruckRowIds?: Set<number>; | errorTruckRowIds?: Set<number>; | ||||
| validationErrorsByTruckRowId?: Map<number, string>; | validationErrorsByTruckRowId?: Map<number, string>; | ||||
| }; | }; | ||||
| function formatDepartureShort(t: string): string { | |||||
| const m = String(t ?? "").trim().match(/^(\d{1,2}):(\d{2})/); | |||||
| return m ? `${m[1].padStart(2, "0")}:${m[2]}` : t || "-"; | |||||
| } | |||||
| const ScheduleReviewQueue: React.FC<Props> = ({ | const ScheduleReviewQueue: React.FC<Props> = ({ | ||||
| modifications, | modifications, | ||||
| pendingDeletes, | pendingDeletes, | ||||
| pendingCreates, | |||||
| departureModifications = [], | |||||
| onRevert, | onRevert, | ||||
| onRevertDelete, | onRevertDelete, | ||||
| onRevertCreate, | |||||
| onRevertDeparture, | |||||
| errorTruckRowIds = new Set(), | errorTruckRowIds = new Set(), | ||||
| validationErrorsByTruckRowId = new Map(), | validationErrorsByTruckRowId = new Map(), | ||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("shop"); | const { t } = useTranslation("shop"); | ||||
| const totalCount = modifications.length + pendingDeletes.length; | |||||
| const totalCount = | |||||
| modifications.length + | |||||
| pendingDeletes.length + | |||||
| pendingCreates.length + | |||||
| departureModifications.length; | |||||
| const isEmpty = totalCount === 0; | const isEmpty = totalCount === 0; | ||||
| return ( | return ( | ||||
| @@ -100,6 +119,176 @@ const ScheduleReviewQueue: React.FC<Props> = ({ | |||||
| </Stack> | </Stack> | ||||
| ) : ( | ) : ( | ||||
| <Stack spacing={1} sx={{ width: "100%" }}> | <Stack spacing={1} sx={{ width: "100%" }}> | ||||
| {departureModifications.map((dep) => ( | |||||
| <Paper | |||||
| key={`departure-${dep.laneId}`} | |||||
| variant="outlined" | |||||
| sx={{ | |||||
| p: 1.25, | |||||
| width: "100%", | |||||
| boxSizing: "border-box", | |||||
| borderColor: "warning.light", | |||||
| bgcolor: alpha("#ed6c02", 0.04), | |||||
| "&:hover": { | |||||
| borderColor: "warning.main", | |||||
| boxShadow: 1, | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <Stack | |||||
| direction="row" | |||||
| justifyContent="space-between" | |||||
| alignItems="flex-start" | |||||
| spacing={1} | |||||
| > | |||||
| <Box sx={{ minWidth: 0 }}> | |||||
| <Stack direction="row" spacing={0.5} alignItems="center"> | |||||
| <Clock size={12} color="#ed6c02" /> | |||||
| <Typography variant="caption" sx={{ fontWeight: 800 }}> | |||||
| {dep.laneLabel} | |||||
| </Typography> | |||||
| </Stack> | |||||
| <Typography | |||||
| variant="caption" | |||||
| color="text.secondary" | |||||
| display="block" | |||||
| sx={{ mt: 0.25 }} | |||||
| > | |||||
| {dep.shopCount > 0 | |||||
| ? t("schedule_review_departure_shops", { | |||||
| count: dep.shopCount, | |||||
| }) | |||||
| : t("schedule_review_departure_pending_creates", { | |||||
| count: dep.pendingCreateCount, | |||||
| })} | |||||
| </Typography> | |||||
| </Box> | |||||
| {onRevertDeparture ? ( | |||||
| <Button | |||||
| size="small" | |||||
| onClick={() => onRevertDeparture(dep.laneId)} | |||||
| startIcon={<Undo2 size={12} />} | |||||
| sx={{ | |||||
| minWidth: 0, | |||||
| px: 1, | |||||
| py: 0.25, | |||||
| fontSize: "0.65rem", | |||||
| fontWeight: 700, | |||||
| textTransform: "none", | |||||
| flexShrink: 0, | |||||
| }} | |||||
| > | |||||
| {t("schedule_review_revert")} | |||||
| </Button> | |||||
| ) : null} | |||||
| </Stack> | |||||
| <Box | |||||
| sx={{ | |||||
| mt: 1, | |||||
| p: 1, | |||||
| borderRadius: 1, | |||||
| bgcolor: "grey.50", | |||||
| fontSize: "0.65rem", | |||||
| }} | |||||
| > | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {t("schedule_line_departure_change", { | |||||
| from: formatDepartureShort(dep.oldDepartureTime), | |||||
| to: formatDepartureShort(dep.newDepartureTime), | |||||
| })} | |||||
| </Typography> | |||||
| </Box> | |||||
| </Paper> | |||||
| ))} | |||||
| {pendingCreates.map((create) => ( | |||||
| <Paper | |||||
| key={`create-${create.truckRowId}`} | |||||
| variant="outlined" | |||||
| sx={{ | |||||
| p: 1.25, | |||||
| width: "100%", | |||||
| boxSizing: "border-box", | |||||
| borderColor: "success.light", | |||||
| bgcolor: alpha("#2e7d32", 0.04), | |||||
| "&:hover": { | |||||
| borderColor: "success.main", | |||||
| boxShadow: 1, | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <Stack | |||||
| direction="row" | |||||
| justifyContent="space-between" | |||||
| alignItems="flex-start" | |||||
| spacing={1} | |||||
| > | |||||
| <Box sx={{ minWidth: 0 }}> | |||||
| <Stack direction="row" spacing={0.5} alignItems="center"> | |||||
| <Plus size={12} color="#2e7d32" /> | |||||
| <Typography variant="caption" sx={{ fontWeight: 800 }}> | |||||
| {create.shopCode} | |||||
| </Typography> | |||||
| </Stack> | |||||
| <Typography | |||||
| variant="caption" | |||||
| color="text.secondary" | |||||
| display="block" | |||||
| noWrap | |||||
| > | |||||
| {create.displayName} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Button | |||||
| size="small" | |||||
| onClick={() => onRevertCreate(create.truckRowId)} | |||||
| startIcon={<Undo2 size={12} />} | |||||
| sx={{ | |||||
| minWidth: 0, | |||||
| px: 1, | |||||
| py: 0.25, | |||||
| fontSize: "0.65rem", | |||||
| fontWeight: 700, | |||||
| textTransform: "none", | |||||
| flexShrink: 0, | |||||
| }} | |||||
| > | |||||
| {t("schedule_review_revert")} | |||||
| </Button> | |||||
| </Stack> | |||||
| <Box | |||||
| sx={{ | |||||
| mt: 1, | |||||
| p: 1, | |||||
| borderRadius: 1, | |||||
| bgcolor: "grey.50", | |||||
| fontSize: "0.65rem", | |||||
| }} | |||||
| > | |||||
| <Stack direction="row" spacing={0.5} alignItems="center" flexWrap="wrap"> | |||||
| <Typography variant="caption" color="text.secondary"> | |||||
| {t("schedule_review_create_action")} | |||||
| </Typography> | |||||
| <Typography variant="caption" sx={{ fontWeight: 700 }}> | |||||
| {create.toLaneLabel} | |||||
| </Typography> | |||||
| </Stack> | |||||
| {create.location && ( | |||||
| <Typography | |||||
| variant="caption" | |||||
| color="text.secondary" | |||||
| display="block" | |||||
| sx={{ mt: 0.5 }} | |||||
| noWrap | |||||
| > | |||||
| {create.location} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| </Paper> | |||||
| ))} | |||||
| {pendingDeletes.map((del) => ( | {pendingDeletes.map((del) => ( | ||||
| <Paper | <Paper | ||||
| key={`delete-${del.truckRowId}`} | key={`delete-${del.truckRowId}`} | ||||
| @@ -331,6 +331,28 @@ export function computeTruckLaneWarnings( | |||||
| return { warnings, weekdayParseFailures }; | return { warnings, weekdayParseFailures }; | ||||
| } | } | ||||
| /** Stable key for comparing warning sets before/after a hypothetical edit. */ | |||||
| export function truckLaneWarningFingerprint(w: TruckLaneWarning): string { | |||||
| const laneKeys = w.lanes | |||||
| .map((l) => l.laneKey) | |||||
| .sort() | |||||
| .join(","); | |||||
| const shopKey = | |||||
| w.shopEntityId != null && Number.isFinite(w.shopEntityId) && w.shopEntityId > 0 | |||||
| ? `id:${w.shopEntityId}` | |||||
| : `code:${String(w.shopCode || "").trim().toLowerCase()}`; | |||||
| return `${w.rule}|${shopKey}|${w.triggerValue}|${laneKeys}`; | |||||
| } | |||||
| /** Warnings present after but not before (e.g. only prompt on newly introduced conflicts). */ | |||||
| export function diffNewTruckLaneWarnings( | |||||
| before: readonly TruckLaneWarning[], | |||||
| after: readonly TruckLaneWarning[], | |||||
| ): TruckLaneWarning[] { | |||||
| const beforeKeys = new Set(before.map(truckLaneWarningFingerprint)); | |||||
| return after.filter((w) => !beforeKeys.has(truckLaneWarningFingerprint(w))); | |||||
| } | |||||
| /** 模擬「即將新增」的一筆店鋪列(`tempTruckRowId` 建議用負數);供 Rule1/2 試算。 */ | /** 模擬「即將新增」的一筆店鋪列(`tempTruckRowId` 建議用負數);供 Rule1/2 試算。 */ | ||||
| export function appendSyntheticPendingShopRow( | export function appendSyntheticPendingShopRow( | ||||
| baseRows: TruckLaneWarningInputRow[], | baseRows: TruckLaneWarningInputRow[], | ||||
| @@ -10,16 +10,44 @@ import type { | |||||
| ScheduleMoveSelection, | ScheduleMoveSelection, | ||||
| } from "@/components/Shop/ScheduleChangeModal"; | } from "@/components/Shop/ScheduleChangeModal"; | ||||
| import type { | import type { | ||||
| PlannedLane, | |||||
| PlannedShop, | |||||
| ScheduleModification, | ScheduleModification, | ||||
| ScheduleDragWorkspaceState, | ScheduleDragWorkspaceState, | ||||
| ScheduledDeleteSnapshot, | ScheduledDeleteSnapshot, | ||||
| } from "@/components/Shop/scheduleDragWorkspace"; | } from "@/components/Shop/scheduleDragWorkspace"; | ||||
| import { | |||||
| listModifications, | |||||
| normalizeDepartureTime, | |||||
| } from "@/components/Shop/scheduleDragWorkspace"; | |||||
| function plannedLaneById( | |||||
| plannedLanes: ScheduleDragWorkspaceState | undefined, | |||||
| ): Map<string, PlannedLane> { | |||||
| return new Map((plannedLanes ?? []).map((l) => [l.id, l])); | |||||
| } | |||||
| function departureTimeForPlannedLane( | |||||
| laneId: string, | |||||
| plannedLanes: ScheduleDragWorkspaceState | undefined, | |||||
| ): string | null { | |||||
| const planned = plannedLaneById(plannedLanes).get(laneId); | |||||
| if (!planned) return null; | |||||
| if ( | |||||
| normalizeDepartureTime(planned.departureTime) === | |||||
| normalizeDepartureTime(planned.originalDepartureTime) | |||||
| ) { | |||||
| return null; | |||||
| } | |||||
| return normalizeDepartureTime(planned.departureTime); | |||||
| } | |||||
| export function buildScheduleMoveFromSelection( | export function buildScheduleMoveFromSelection( | ||||
| truckRowId: number, | truckRowId: number, | ||||
| selection: ScheduleMoveSelection, | selection: ScheduleMoveSelection, | ||||
| lane: ScheduleLaneOption, | lane: ScheduleLaneOption, | ||||
| toDistrictReference?: string | null, | toDistrictReference?: string | null, | ||||
| plannedLanes?: ScheduleDragWorkspaceState, | |||||
| ): TruckLaneMoveTargetRequest { | ): TruckLaneMoveTargetRequest { | ||||
| return { | return { | ||||
| truckRowId, | truckRowId, | ||||
| @@ -31,6 +59,28 @@ export function buildScheduleMoveFromSelection( | |||||
| toStoreId: normalizeStoreId(lane.storeId), | toStoreId: normalizeStoreId(lane.storeId), | ||||
| toLoadingSequence: selection.toLoadingSequence, | toLoadingSequence: selection.toLoadingSequence, | ||||
| toDistrictReference: toDistrictReference ?? null, | toDistrictReference: toDistrictReference ?? null, | ||||
| departureTime: departureTimeForPlannedLane(selection.laneId, plannedLanes), | |||||
| }; | |||||
| } | |||||
| export function buildCreateScheduleLine( | |||||
| shop: PlannedShop, | |||||
| lane: ScheduleLaneOption, | |||||
| ): TruckLaneScheduleLineRequest { | |||||
| return { | |||||
| action: "CREATE", | |||||
| shopId: shop.shopEntityId ?? null, | |||||
| shopCode: shop.shopCode, | |||||
| shopName: shop.displayName, | |||||
| toTruckLanceCode: lane.truckLanceCode, | |||||
| toRemark: | |||||
| lane.remark != null && String(lane.remark).trim() !== "" | |||||
| ? String(lane.remark).trim() | |||||
| : null, | |||||
| toStoreId: normalizeStoreId(lane.storeId), | |||||
| toLoadingSequence: shop.loadingSequence, | |||||
| toDistrictReference: shop.districtReferenceRaw ?? null, | |||||
| departureTime: lane.departureTime || "00:00:00", | |||||
| }; | }; | ||||
| } | } | ||||
| @@ -87,12 +137,51 @@ export function buildScheduleMovesFromMappings( | |||||
| sel, | sel, | ||||
| lane, | lane, | ||||
| districtByTruckRowId.get(truckRowId), | districtByTruckRowId.get(truckRowId), | ||||
| plannedLanes, | |||||
| ), | ), | ||||
| ); | ); | ||||
| } | } | ||||
| return out; | return out; | ||||
| } | } | ||||
| function buildDepartureOnlyMoves( | |||||
| plannedLanes: ScheduleDragWorkspaceState, | |||||
| pendingDeleteIds: Set<number>, | |||||
| ): TruckLaneMoveTargetRequest[] { | |||||
| const modifiedIds = new Set( | |||||
| listModifications(plannedLanes).map((m) => m.truckRowId), | |||||
| ); | |||||
| const out: TruckLaneMoveTargetRequest[] = []; | |||||
| for (const lane of plannedLanes) { | |||||
| const departureTime = departureTimeForPlannedLane(lane.id, plannedLanes); | |||||
| if (!departureTime) continue; | |||||
| const laneOpt: ScheduleLaneOption = { | |||||
| id: lane.id, | |||||
| label: lane.label, | |||||
| truckLanceCode: lane.truckLanceCode, | |||||
| remark: lane.remark, | |||||
| storeId: lane.storeId, | |||||
| departureTime: lane.departureTime, | |||||
| shops: [], | |||||
| }; | |||||
| for (const shop of lane.shops) { | |||||
| if (shop.truckRowId < 0) continue; | |||||
| if (pendingDeleteIds.has(shop.truckRowId)) continue; | |||||
| if (modifiedIds.has(shop.truckRowId)) continue; | |||||
| out.push( | |||||
| buildScheduleMoveFromSelection( | |||||
| shop.truckRowId, | |||||
| { laneId: lane.id, toLoadingSequence: shop.loadingSequence }, | |||||
| laneOpt, | |||||
| shop.districtReferenceRaw, | |||||
| plannedLanes, | |||||
| ), | |||||
| ); | |||||
| } | |||||
| } | |||||
| return out; | |||||
| } | |||||
| export function buildScheduleLinesFromPlan(input: { | export function buildScheduleLinesFromPlan(input: { | ||||
| modifications: ScheduleModification[]; | modifications: ScheduleModification[]; | ||||
| pendingDeletes: ScheduledDeleteSnapshot[]; | pendingDeletes: ScheduledDeleteSnapshot[]; | ||||
| @@ -109,13 +198,17 @@ export function buildScheduleLinesFromPlan(input: { | |||||
| { laneId: m.toLaneId, toLoadingSequence: m.newLoadingSequence }, | { laneId: m.toLaneId, toLoadingSequence: m.newLoadingSequence }, | ||||
| ]), | ]), | ||||
| ); | ); | ||||
| const moveLines = movesToScheduleLines( | |||||
| buildScheduleMovesFromMappings( | |||||
| const moveTargets = [ | |||||
| ...buildScheduleMovesFromMappings( | |||||
| mappings, | mappings, | ||||
| input.lanes, | input.lanes, | ||||
| input.plannedLanes, | input.plannedLanes, | ||||
| ), | ), | ||||
| ); | |||||
| ...(input.plannedLanes | |||||
| ? buildDepartureOnlyMoves(input.plannedLanes, deleteIds) | |||||
| : []), | |||||
| ]; | |||||
| const moveLines = movesToScheduleLines(moveTargets); | |||||
| const deleteLines = input.pendingDeletes | const deleteLines = input.pendingDeletes | ||||
| .map((d) => { | .map((d) => { | ||||
| const lane = laneById.get(d.fromLaneId); | const lane = laneById.get(d.fromLaneId); | ||||
| @@ -128,7 +221,23 @@ export function buildScheduleLinesFromPlan(input: { | |||||
| ); | ); | ||||
| }) | }) | ||||
| .filter((line): line is TruckLaneScheduleLineRequest => line != null); | .filter((line): line is TruckLaneScheduleLineRequest => line != null); | ||||
| return [...moveLines, ...deleteLines]; | |||||
| const createLines: TruckLaneScheduleLineRequest[] = []; | |||||
| if (input.plannedLanes) { | |||||
| for (const lane of input.plannedLanes) { | |||||
| const laneOpt = laneById.get(lane.id); | |||||
| if (!laneOpt) continue; | |||||
| const effectiveLane: ScheduleLaneOption = { | |||||
| ...laneOpt, | |||||
| departureTime: lane.departureTime || laneOpt.departureTime, | |||||
| }; | |||||
| for (const shop of lane.shops) { | |||||
| if (shop.truckRowId >= 0) continue; | |||||
| if (!shop.shopEntityId || shop.shopEntityId <= 0) continue; | |||||
| createLines.push(buildCreateScheduleLine(shop, effectiveLane)); | |||||
| } | |||||
| } | |||||
| } | |||||
| return [...moveLines, ...deleteLines, ...createLines]; | |||||
| } | } | ||||
| export function buildScheduleMovesFromModifications( | export function buildScheduleMovesFromModifications( | ||||
| @@ -3,6 +3,7 @@ import type { | |||||
| ScheduleMoveSelection, | ScheduleMoveSelection, | ||||
| ScheduleShopRow, | ScheduleShopRow, | ||||
| } from "@/components/Shop/ScheduleChangeModal"; | } from "@/components/Shop/ScheduleChangeModal"; | ||||
| import type { TruckLaneWarningInputRow } from "@/components/Shop/computeTruckLaneWarnings"; | |||||
| import { | import { | ||||
| computeMovedLoadingSequence, | computeMovedLoadingSequence, | ||||
| flattenDisplayOrder, | flattenDisplayOrder, | ||||
| @@ -14,6 +15,7 @@ import { | |||||
| export type PlannedShop = { | export type PlannedShop = { | ||||
| id: number; | id: number; | ||||
| truckRowId: number; | truckRowId: number; | ||||
| shopEntityId?: number; | |||||
| shopCode: string; | shopCode: string; | ||||
| displayName: string; | displayName: string; | ||||
| branchName: string; | branchName: string; | ||||
| @@ -31,9 +33,21 @@ export type PlannedLane = { | |||||
| truckLanceCode: string; | truckLanceCode: string; | ||||
| remark?: string | null; | remark?: string | null; | ||||
| storeId: string; | storeId: string; | ||||
| /** Lane departure (HH:mm or HH:mm:ss). */ | |||||
| departureTime: string; | |||||
| originalDepartureTime: string; | |||||
| shops: PlannedShop[]; | shops: PlannedShop[]; | ||||
| }; | }; | ||||
| export type ScheduleDepartureModification = { | |||||
| laneId: string; | |||||
| laneLabel: string; | |||||
| oldDepartureTime: string; | |||||
| newDepartureTime: string; | |||||
| shopCount: number; | |||||
| pendingCreateCount: number; | |||||
| }; | |||||
| export type ScheduleDragWorkspaceState = PlannedLane[]; | export type ScheduleDragWorkspaceState = PlannedLane[]; | ||||
| export type ScheduleModification = { | export type ScheduleModification = { | ||||
| @@ -64,6 +78,16 @@ export type ScheduledDeleteSnapshot = { | |||||
| shop: PlannedShop; | shop: PlannedShop; | ||||
| }; | }; | ||||
| export type SchedulePendingCreate = { | |||||
| truckRowId: number; | |||||
| shopEntityId: number; | |||||
| shopCode: string; | |||||
| displayName: string; | |||||
| location: string; | |||||
| toLaneId: string; | |||||
| toLaneLabel: string; | |||||
| }; | |||||
| type MoveShopArgs = { | type MoveShopArgs = { | ||||
| shopId: number; | shopId: number; | ||||
| fromLaneId: string; | fromLaneId: string; | ||||
| @@ -78,6 +102,138 @@ function laneHasShopCode(lane: PlannedLane, shopCode: string, excludeId: number) | |||||
| ); | ); | ||||
| } | } | ||||
| /** Parse user-entered departure time; returns null when empty or invalid. */ | |||||
| export function parseScheduleDepartureTime(time: string): string | null { | |||||
| const s = String(time ?? "").trim(); | |||||
| if (!s) return null; | |||||
| if (!/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) return null; | |||||
| const [hh, mm, ss] = s.split(":"); | |||||
| const h = Number(hh); | |||||
| const m = Number(mm); | |||||
| if (!Number.isFinite(h) || !Number.isFinite(m) || h > 23 || m > 59) { | |||||
| return null; | |||||
| } | |||||
| if (ss != null) { | |||||
| const sec = Number(ss); | |||||
| if (!Number.isFinite(sec) || sec > 59) return null; | |||||
| } | |||||
| return normalizeDepartureTime(s); | |||||
| } | |||||
| export function normalizeDepartureTime(time: string): string { | |||||
| const s = String(time ?? "").trim(); | |||||
| if (!s) return "00:00:00"; | |||||
| if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) { | |||||
| const [hh, mm, ss] = s.split(":"); | |||||
| return `${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}${ | |||||
| ss != null ? `:${String(ss).padStart(2, "0")}` : ":00" | |||||
| }`; | |||||
| } | |||||
| return s; | |||||
| } | |||||
| export function plannedLanesToWarningInputRows( | |||||
| state: ScheduleDragWorkspaceState, | |||||
| ): TruckLaneWarningInputRow[] { | |||||
| const out: TruckLaneWarningInputRow[] = []; | |||||
| for (const lane of state) { | |||||
| for (const shop of lane.shops) { | |||||
| out.push({ | |||||
| truckRowId: shop.truckRowId, | |||||
| truckLanceCode: lane.truckLanceCode, | |||||
| laneRemark: lane.remark ?? null, | |||||
| storeId: shop.storeId, | |||||
| departureTime: lane.departureTime, | |||||
| shopEntityId: shop.shopEntityId ?? null, | |||||
| shopCode: shop.shopCode, | |||||
| shopDisplayName: shop.branchName || shop.displayName, | |||||
| }); | |||||
| } | |||||
| } | |||||
| return out; | |||||
| } | |||||
| function departureTimesEqual(a: string, b: string): boolean { | |||||
| return normalizeDepartureTime(a) === normalizeDepartureTime(b); | |||||
| } | |||||
| function planHasShopEntity( | |||||
| state: ScheduleDragWorkspaceState, | |||||
| shopEntityId: number, | |||||
| shopCode: string, | |||||
| excludeTruckRowId = -1, | |||||
| ): boolean { | |||||
| const code = shopCode.trim().toLowerCase(); | |||||
| for (const lane of state) { | |||||
| for (const shop of lane.shops) { | |||||
| if (shop.truckRowId === excludeTruckRowId) continue; | |||||
| if (shop.shopEntityId === shopEntityId) return true; | |||||
| if (code && shop.shopCode.trim().toLowerCase() === code) return true; | |||||
| } | |||||
| } | |||||
| return false; | |||||
| } | |||||
| export function addShopToPlan( | |||||
| state: ScheduleDragWorkspaceState, | |||||
| laneId: string, | |||||
| pick: { id: number; name: string; code: string }, | |||||
| tempTruckRowId: number, | |||||
| ): ScheduleDragWorkspaceState { | |||||
| const lane = state.find((l) => l.id === laneId); | |||||
| if (!lane) return state; | |||||
| const shopCode = String(pick.code || "").trim(); | |||||
| const shopName = String(pick.name || "").trim(); | |||||
| if (!shopCode || !shopName || pick.id <= 0) return state; | |||||
| if (planHasShopEntity(state, pick.id, shopCode)) return state; | |||||
| const flat = flattenDisplayOrder(lane.shops); | |||||
| const last = flat[flat.length - 1]; | |||||
| const seq = last != null ? Number(last.loadingSequence) || 0 : 0; | |||||
| const newShop: PlannedShop = { | |||||
| id: tempTruckRowId, | |||||
| truckRowId: tempTruckRowId, | |||||
| shopEntityId: pick.id, | |||||
| shopCode, | |||||
| displayName: `${shopCode} - ${shopName}`, | |||||
| branchName: shopName, | |||||
| storeId: lane.storeId, | |||||
| districtReferenceRaw: null, | |||||
| loadingSequence: seq, | |||||
| originalLaneId: laneId, | |||||
| originalLoadingSequence: seq, | |||||
| originalDistrictReferenceRaw: null, | |||||
| }; | |||||
| return state.map((l) => | |||||
| l.id !== laneId ? l : { ...l, shops: [...l.shops, newShop] }, | |||||
| ); | |||||
| } | |||||
| export function listPendingCreates( | |||||
| state: ScheduleDragWorkspaceState, | |||||
| ): SchedulePendingCreate[] { | |||||
| const out: SchedulePendingCreate[] = []; | |||||
| for (const lane of state) { | |||||
| for (const shop of lane.shops) { | |||||
| if (shop.truckRowId >= 0) continue; | |||||
| if (!shop.shopEntityId || shop.shopEntityId <= 0) continue; | |||||
| out.push({ | |||||
| truckRowId: shop.truckRowId, | |||||
| shopEntityId: shop.shopEntityId, | |||||
| shopCode: shop.shopCode, | |||||
| displayName: shop.displayName, | |||||
| location: formatPlannedShopLocation(shop), | |||||
| toLaneId: lane.id, | |||||
| toLaneLabel: lane.label, | |||||
| }); | |||||
| } | |||||
| } | |||||
| return out.sort((a, b) => a.shopCode.localeCompare(b.shopCode, "zh-Hant")); | |||||
| } | |||||
| export function initPlannedLanes( | export function initPlannedLanes( | ||||
| lanes: ScheduleLaneOption[], | lanes: ScheduleLaneOption[], | ||||
| shops: ScheduleShopRow[], | shops: ScheduleShopRow[], | ||||
| @@ -106,17 +262,58 @@ export function initPlannedLanes( | |||||
| }) | }) | ||||
| .filter((s): s is PlannedShop => s != null); | .filter((s): s is PlannedShop => s != null); | ||||
| const departureTime = normalizeDepartureTime(lane.departureTime || "00:00:00"); | |||||
| return { | return { | ||||
| id: lane.id, | id: lane.id, | ||||
| label: lane.label, | label: lane.label, | ||||
| truckLanceCode: lane.truckLanceCode, | truckLanceCode: lane.truckLanceCode, | ||||
| remark: lane.remark, | remark: lane.remark, | ||||
| storeId: lane.storeId, | storeId: lane.storeId, | ||||
| departureTime, | |||||
| originalDepartureTime: departureTime, | |||||
| shops: plannedShops, | shops: plannedShops, | ||||
| }; | }; | ||||
| }); | }); | ||||
| } | } | ||||
| export function setLaneDepartureTime( | |||||
| state: ScheduleDragWorkspaceState, | |||||
| laneId: string, | |||||
| departureTime: string, | |||||
| ): ScheduleDragWorkspaceState { | |||||
| const normalized = normalizeDepartureTime(departureTime); | |||||
| return state.map((lane) => | |||||
| lane.id !== laneId ? lane : { ...lane, departureTime: normalized }, | |||||
| ); | |||||
| } | |||||
| export function revertLaneDeparture( | |||||
| state: ScheduleDragWorkspaceState, | |||||
| laneId: string, | |||||
| ): ScheduleDragWorkspaceState { | |||||
| return state.map((lane) => | |||||
| lane.id !== laneId | |||||
| ? lane | |||||
| : { ...lane, departureTime: lane.originalDepartureTime }, | |||||
| ); | |||||
| } | |||||
| export function listDepartureModifications( | |||||
| state: ScheduleDragWorkspaceState, | |||||
| ): ScheduleDepartureModification[] { | |||||
| return state | |||||
| .filter((lane) => !departureTimesEqual(lane.departureTime, lane.originalDepartureTime)) | |||||
| .map((lane) => ({ | |||||
| laneId: lane.id, | |||||
| laneLabel: lane.label, | |||||
| oldDepartureTime: lane.originalDepartureTime, | |||||
| newDepartureTime: lane.departureTime, | |||||
| shopCount: lane.shops.filter((s) => s.truckRowId >= 0).length, | |||||
| pendingCreateCount: lane.shops.filter((s) => s.truckRowId < 0).length, | |||||
| })) | |||||
| .filter((d) => d.shopCount > 0 || d.pendingCreateCount > 0); | |||||
| } | |||||
| export function moveShop( | export function moveShop( | ||||
| state: ScheduleDragWorkspaceState, | state: ScheduleDragWorkspaceState, | ||||
| args: MoveShopArgs, | args: MoveShopArgs, | ||||
| @@ -324,6 +521,7 @@ export function listModifications( | |||||
| for (const lane of state) { | for (const lane of state) { | ||||
| for (const shop of lane.shops) { | for (const shop of lane.shops) { | ||||
| if (shop.truckRowId < 0) continue; | |||||
| const isLaneChanged = shop.originalLaneId !== lane.id; | const isLaneChanged = shop.originalLaneId !== lane.id; | ||||
| const isSeqChanged = shop.originalLoadingSequence !== shop.loadingSequence; | const isSeqChanged = shop.originalLoadingSequence !== shop.loadingSequence; | ||||
| const isDistrictChanged = | const isDistrictChanged = | ||||
| @@ -14,6 +14,7 @@ export function moveToScheduleLine( | |||||
| toStoreId: move.toStoreId, | toStoreId: move.toStoreId, | ||||
| toLoadingSequence: move.toLoadingSequence, | toLoadingSequence: move.toLoadingSequence, | ||||
| toDistrictReference: move.toDistrictReference ?? null, | toDistrictReference: move.toDistrictReference ?? null, | ||||
| departureTime: move.departureTime ?? null, | |||||
| }; | }; | ||||
| } | } | ||||
| @@ -79,8 +79,19 @@ export function listPlannedModifications( | |||||
| export function buildShopCodeByTruckRowId( | export function buildShopCodeByTruckRowId( | ||||
| shops: ScheduleShopRow[], | shops: ScheduleShopRow[], | ||||
| plannedLanes?: ScheduleDragWorkspaceState, | |||||
| ): Map<number, string> { | ): Map<number, string> { | ||||
| return new Map(shops.map((s) => [s.truckRowId, s.shopCode])); | |||||
| const map = new Map(shops.map((s) => [s.truckRowId, s.shopCode])); | |||||
| if (plannedLanes) { | |||||
| for (const lane of plannedLanes) { | |||||
| for (const shop of lane.shops) { | |||||
| if (shop.truckRowId < 0 && shop.shopCode) { | |||||
| map.set(shop.truckRowId, shop.shopCode); | |||||
| } | |||||
| } | |||||
| } | |||||
| } | |||||
| return map; | |||||
| } | } | ||||
| export function validatePlannedSubmit( | export function validatePlannedSubmit( | ||||
| @@ -106,7 +117,10 @@ export function validatePlannedSubmit( | |||||
| plannedLanes: input.plannedLanes, | plannedLanes: input.plannedLanes, | ||||
| pendingTruckRowIds: input.pendingTruckRowIds, | pendingTruckRowIds: input.pendingTruckRowIds, | ||||
| executeAt, | executeAt, | ||||
| shopCodeByTruckRowId: buildShopCodeByTruckRowId(input.shops), | |||||
| shopCodeByTruckRowId: buildShopCodeByTruckRowId( | |||||
| input.shops, | |||||
| input.plannedLanes, | |||||
| ), | |||||
| }); | }); | ||||
| if (!validation.ok) { | if (!validation.ok) { | ||||
| return validation; | return validation; | ||||
| @@ -150,7 +164,10 @@ export function validatePayloadSubmit(input: { | |||||
| plannedLanes: input.payload.plannedLanes, | plannedLanes: input.payload.plannedLanes, | ||||
| pendingTruckRowIds: input.pendingTruckRowIds, | pendingTruckRowIds: input.pendingTruckRowIds, | ||||
| executeAt, | executeAt, | ||||
| shopCodeByTruckRowId: buildShopCodeByTruckRowId(input.shops), | |||||
| shopCodeByTruckRowId: buildShopCodeByTruckRowId( | |||||
| input.shops, | |||||
| input.payload.plannedLanes, | |||||
| ), | |||||
| }); | }); | ||||
| } | } | ||||
| @@ -22,6 +22,9 @@ export function useRouteBoardScheduleIndicators(options?: { | |||||
| const [pendingScheduleShopIds, setPendingScheduleShopIds] = useState< | const [pendingScheduleShopIds, setPendingScheduleShopIds] = useState< | ||||
| Set<number> | Set<number> | ||||
| >(new Set()); | >(new Set()); | ||||
| const [lockedScheduleShopIds, setLockedScheduleShopIds] = useState< | |||||
| Set<number> | |||||
| >(new Set()); | |||||
| const [failedScheduleShopIds, setFailedScheduleShopIds] = useState< | const [failedScheduleShopIds, setFailedScheduleShopIds] = useState< | ||||
| Set<number> | Set<number> | ||||
| >(new Set()); | >(new Set()); | ||||
| @@ -37,6 +40,9 @@ export function useRouteBoardScheduleIndicators(options?: { | |||||
| ), | ), | ||||
| ]); | ]); | ||||
| setPendingScheduleShopIds(new Set(pendingIds.truckRowIds ?? [])); | setPendingScheduleShopIds(new Set(pendingIds.truckRowIds ?? [])); | ||||
| setLockedScheduleShopIds( | |||||
| new Set(pendingIds.lockedTruckRowIds ?? pendingIds.truckRowIds ?? []), | |||||
| ); | |||||
| const failedSchedules = filterFailedSchedules(scheduleList); | const failedSchedules = filterFailedSchedules(scheduleList); | ||||
| setFailedScheduleCount(failedSchedules.length); | setFailedScheduleCount(failedSchedules.length); | ||||
| @@ -75,6 +81,7 @@ export function useRouteBoardScheduleIndicators(options?: { | |||||
| return { | return { | ||||
| pendingScheduleShopIds, | pendingScheduleShopIds, | ||||
| lockedScheduleShopIds, | |||||
| failedScheduleShopIds, | failedScheduleShopIds, | ||||
| failedScheduleCount, | failedScheduleCount, | ||||
| refreshScheduleIndicators, | refreshScheduleIndicators, | ||||
| @@ -91,7 +91,11 @@ | |||||
| "Replenish Qty": "Replenish Qty", | "Replenish Qty": "Replenish Qty", | ||||
| "Replenish qty must be greater than zero": "Replenish qty must be greater than zero", | "Replenish qty must be greater than zero": "Replenish qty must be greater than zero", | ||||
| "Replenishment": "Replenishment", | "Replenishment": "Replenishment", | ||||
| "Delivery date is required": "Delivery date is required", | |||||
| "Failed to load replenishment records": "Failed to load replenishment records", | |||||
| "Failed to submit replenishment": "Failed to submit replenishment", | |||||
| "Replenishment API not ready": "Replenishment API not ready", | "Replenishment API not ready": "Replenishment API not ready", | ||||
| "Replenishment submitted successfully": "Replenishment submitted successfully", | |||||
| "Replenishment Entry": "Replenishment Entry", | "Replenishment Entry": "Replenishment Entry", | ||||
| "Replenishment item code": "Item Code", | "Replenishment item code": "Item Code", | ||||
| "Replenishment Tracking": "Replenishment Tracking", | "Replenishment Tracking": "Replenishment Tracking", | ||||
| @@ -174,7 +174,6 @@ | |||||
| "district_dialog_add": "Add district", | "district_dialog_add": "Add district", | ||||
| "district_dialog_edit": "Edit district", | "district_dialog_edit": "Edit district", | ||||
| "district_name_label": "District display name", | "district_name_label": "District display name", | ||||
| "district_name_ph": "Blank means \"Unclassified\"", | |||||
| "seq_edit_departureLabel": "Departure time", | "seq_edit_departureLabel": "Departure time", | ||||
| "seq_edit_seqLabel": "Load sequence (Seq)", | "seq_edit_seqLabel": "Load sequence (Seq)", | ||||
| "route_new_code_label": "Lane code", | "route_new_code_label": "Lane code", | ||||
| @@ -200,8 +199,6 @@ | |||||
| "dialog_editLogisticsTitle": "Edit logistics master", | "dialog_editLogisticsTitle": "Edit logistics master", | ||||
| "btn_apply": "Apply", | "btn_apply": "Apply", | ||||
| "addShop_confirm": "Confirm", | "addShop_confirm": "Confirm", | ||||
| "addShop_listHint": "Shop codes already on this lane are hidden from the list. After adding, reorder by drag; like other edits, press \"Save changes\" to persist to truck rows.", | |||||
| "departureDialog_title": "Edit departure time", | |||||
| "departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.", | "departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.", | ||||
| "seqDialog_title": "Edit load sequence", | "seqDialog_title": "Edit load sequence", | ||||
| "logistics_colLaneCount": "{{count}} lane(s)", | "logistics_colLaneCount": "{{count}} lane(s)", | ||||
| @@ -96,7 +96,6 @@ | |||||
| "addRoute_submitting": "Adding…", | "addRoute_submitting": "Adding…", | ||||
| "addShop_confirm": "Confirm", | "addShop_confirm": "Confirm", | ||||
| "addShop_dialogTitle": "Add shop to lane", | "addShop_dialogTitle": "Add shop to lane", | ||||
| "addShop_listHint": "Shop codes already on this lane are hidden from the list. After adding, reorder by drag; like other edits, press \"Save changes\" to persist to truck rows.", | |||||
| "api_fail_addShop": "Failed to add shop", | "api_fail_addShop": "Failed to add shop", | ||||
| "api_fail_createLane": "Failed to create lane", | "api_fail_createLane": "Failed to create lane", | ||||
| "api_fail_deleteShop": "Failed to delete shop", | "api_fail_deleteShop": "Failed to delete shop", | ||||
| @@ -197,7 +196,6 @@ | |||||
| "district_err_name": "Enter a district name", | "district_err_name": "Enter a district name", | ||||
| "district_err_reserved": "\"Unclassified\" is built-in; do not add it again", | "district_err_reserved": "\"Unclassified\" is built-in; do not add it again", | ||||
| "district_name_label": "District display name", | "district_name_label": "District display name", | ||||
| "district_name_ph": "Blank means \"Unclassified\"", | |||||
| "drag_blockDraftShop": "Unsaved \"new shop\" rows must be saved with \"Save changes\" or removed from the card before dragging.", | "drag_blockDraftShop": "Unsaved \"new shop\" rows must be saved with \"Save changes\" or removed from the card before dragging.", | ||||
| "drawerClose": "Close", | "drawerClose": "Close", | ||||
| "emDash": "—", | "emDash": "—", | ||||
| @@ -387,19 +385,23 @@ | |||||
| "schedule_retry_rejects_partial": "PARTIAL schedules cannot be retried. Restore the board and create a new schedule.", | "schedule_retry_rejects_partial": "PARTIAL schedules cannot be retried. Restore the board and create a new schedule.", | ||||
| "schedule_review_count": "{{count}} change(s)", | "schedule_review_count": "{{count}} change(s)", | ||||
| "schedule_review_delete_action": "Remove from:", | "schedule_review_delete_action": "Remove from:", | ||||
| "schedule_review_create_action": "Add to:", | |||||
| "schedule_review_district_change": "District:", | "schedule_review_district_change": "District:", | ||||
| "schedule_review_empty": "No pending changes", | "schedule_review_empty": "No pending changes", | ||||
| "schedule_review_empty_hint": "Drag shops to adjust lane or loading sequence, or use the delete button to remove shops", | |||||
| "schedule_review_empty_hint": "Drag shops to adjust lane or loading sequence, edit departure time, or use Add shop / delete to change lane contents", | |||||
| "schedule_review_lane_change": "Lane:", | "schedule_review_lane_change": "Lane:", | ||||
| "schedule_review_queue": "Change preview queue", | "schedule_review_queue": "Change preview queue", | ||||
| "schedule_review_revert": "Revert", | "schedule_review_revert": "Revert", | ||||
| "schedule_review_seq": "Sequence:", | "schedule_review_seq": "Sequence:", | ||||
| "schedule_review_departure_shops": "Affects {{count}} shop(s)", | |||||
| "schedule_review_departure_pending_creates": "Affects {{count}} pending new shop(s)", | |||||
| "schedule_search_ph": "Search shop name / code...", | "schedule_search_ph": "Search shop name / code...", | ||||
| "schedule_seq_dialog_hint": "The change is added to the preview queue and applied when you confirm the schedule.", | "schedule_seq_dialog_hint": "The change is added to the preview queue and applied when you confirm the schedule.", | ||||
| "schedule_seq_edit_btn": "Edit load sequence", | "schedule_seq_edit_btn": "Edit load sequence", | ||||
| "schedule_seq_hint": "Default: max sequence in target lane for same district + 1", | "schedule_seq_hint": "Default: max sequence in target lane for same district + 1", | ||||
| "schedule_shop_badge": "Scheduled change", | "schedule_shop_badge": "Scheduled change", | ||||
| "schedule_shop_locked": "Schedule is applying; this shop cannot be edited manually", | |||||
| "schedule_shop_locked": "Schedule executes soon (or is applying); this shop is locked", | |||||
| "schedule_shop_scheduled": "This shop has a pending schedule (not locked yet; it will lock before execution)", | |||||
| "schedule_step_method": "2. Open lanes to schedule and review planned changes", | "schedule_step_method": "2. Open lanes to schedule and review planned changes", | ||||
| "schedule_step_time": "1. Set execution time", | "schedule_step_time": "1. Set execution time", | ||||
| "schedule_summary_changes": "{{count}} change(s) (lane moves and sequence updates) will be scheduled", | "schedule_summary_changes": "{{count}} change(s) (lane moves and sequence updates) will be scheduled", | ||||
| @@ -445,6 +447,7 @@ | |||||
| "version_ui_filterAria": "Filter version list", | "version_ui_filterAria": "Filter version list", | ||||
| "version_ui_historyTitle": "Version history", | "version_ui_historyTitle": "Version history", | ||||
| "version_ui_id": "Version #{{id}}", | "version_ui_id": "Version #{{id}}", | ||||
| "version_ui_pendingRestore": "Version #{{id}} (restore pending)", | |||||
| "version_ui_listAria": "Version history list", | "version_ui_listAria": "Version history list", | ||||
| "version_ui_none": "No snapshot yet", | "version_ui_none": "No snapshot yet", | ||||
| "version_ui_snapshotBadge": "Current snapshot", | "version_ui_snapshotBadge": "Current snapshot", | ||||
| @@ -38,7 +38,11 @@ | |||||
| "Replenish Qty": "補貨數量", | "Replenish Qty": "補貨數量", | ||||
| "Replenish qty must be greater than zero": "補貨數量必須大於零", | "Replenish qty must be greater than zero": "補貨數量必須大於零", | ||||
| "Replenishment": "補貨", | "Replenishment": "補貨", | ||||
| "Delivery date is required": "請選擇送貨日期", | |||||
| "Failed to load replenishment records": "載入補貨記錄失敗", | |||||
| "Failed to submit replenishment": "提交補貨失敗", | |||||
| "Replenishment API not ready": "補貨 API 尚未就緒", | "Replenishment API not ready": "補貨 API 尚未就緒", | ||||
| "Replenishment submitted successfully": "補貨已提交", | |||||
| "Replenishment Entry": "補貨填表", | "Replenishment Entry": "補貨填表", | ||||
| "Replenishment item code": "貨品編號", | "Replenishment item code": "貨品編號", | ||||
| "Replenishment Tracking": "補貨進度追蹤", | "Replenishment Tracking": "補貨進度追蹤", | ||||
| @@ -53,7 +53,7 @@ | |||||
| "err_export": "匯出失敗", | "err_export": "匯出失敗", | ||||
| "err_noLanes": "目前無車線資料", | "err_noLanes": "目前無車線資料", | ||||
| "err_import": "匯入失敗", | "err_import": "匯入失敗", | ||||
| "err_dragDuplicateShop": "目標車線已有相同店鋪(同一 shop / 同一 shopCode),無法拖入", | |||||
| "err_dragDuplicateShop": "目標車線已有相同店鋪(同一店鋪/ 同一店鋪編號),無法拖入", | |||||
| "district_err_name": "請輸入地區名稱", | "district_err_name": "請輸入地區名稱", | ||||
| "district_err_reserved": "「未分類」已內建,請勿重複新增", | "district_err_reserved": "「未分類」已內建,請勿重複新增", | ||||
| "district_err_exists": "此地區已存在", | "district_err_exists": "此地區已存在", | ||||
| @@ -61,10 +61,10 @@ | |||||
| "route_err_departure": "請選擇或輸入出車時間", | "route_err_departure": "請選擇或輸入出車時間", | ||||
| "route_err_duplicate": "此車線(含備註組合)已存在", | "route_err_duplicate": "此車線(含備註組合)已存在", | ||||
| "route_err_create": "新增車線失敗", | "route_err_create": "新增車線失敗", | ||||
| "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。將先加入畫面,按「儲存更改」才寫入後端。仍要加入?", | |||||
| "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。仍要加入?", | |||||
| "confirm_discardDraftShop": "捨棄尚未儲存的「新增店鋪」?", | "confirm_discardDraftShop": "捨棄尚未儲存的「新增店鋪」?", | ||||
| "confirm_removeShop": "從此車線移除此店鋪?(按「儲存更改」才會寫入)", | "confirm_removeShop": "從此車線移除此店鋪?(按「儲存更改」才會寫入)", | ||||
| "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?(按「儲存更改」才會從後端刪除)", | |||||
| "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?", | |||||
| "confirm_departureConflict": "變更出車時間後,偵測到 {{count}} 筆可能衝突(見鈴鐺)。仍要套用?", | "confirm_departureConflict": "變更出車時間後,偵測到 {{count}} 筆可能衝突(見鈴鐺)。仍要套用?", | ||||
| "drag_blockDraftShop": "尚未儲存的「新增店鋪」請先按「儲存更改」寫入,或從卡片刪除草稿後再拖曳。", | "drag_blockDraftShop": "尚未儲存的「新增店鋪」請先按「儲存更改」寫入,或從卡片刪除草稿後再拖曳。", | ||||
| "nav_unsavedLeave": "有未儲存的更改,確定要離開?", | "nav_unsavedLeave": "有未儲存的更改,確定要離開?", | ||||
| @@ -78,8 +78,8 @@ | |||||
| "versionNote_saveFail": "備註儲存失敗", | "versionNote_saveFail": "備註儲存失敗", | ||||
| "diff_restoreFail": "恢復失敗", | "diff_restoreFail": "恢復失敗", | ||||
| "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", | "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", | ||||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", | |||||
| "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", | |||||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}}。", | |||||
| "diff_restoreAlreadyPending": "此版本已在排程還原中。", | |||||
| "restore_applied": "已從 版本還原並重新載入看板。", | "restore_applied": "已從 版本還原並重新載入看板。", | ||||
| "restore_appliedDroppedStaging": "已套用 版本還原;本次儲存略過其他暫存變更(請重新編輯)。", | "restore_appliedDroppedStaging": "已套用 版本還原;本次儲存略過其他暫存變更(請重新編輯)。", | ||||
| "confirm_restoreSaveWillDropStaging": "儲存時將先套用 版本還原,本次其他暫存變更會被略過。確定繼續?", | "confirm_restoreSaveWillDropStaging": "儲存時將先套用 版本還原,本次其他暫存變更會被略過。確定繼續?", | ||||
| @@ -168,13 +168,12 @@ | |||||
| "diff_noDiffFromPrev": "與上一版無差異", | "diff_noDiffFromPrev": "與上一版無差異", | ||||
| "diff_loadingEllipsis": "…", | "diff_loadingEllipsis": "…", | ||||
| "addShop_dialogTitle": "新增店鋪到車線", | "addShop_dialogTitle": "新增店鋪到車線", | ||||
| "addRoute_dialogTitle": "新增配送車線", | |||||
| "addRoute_dialogTitle": "新增車線", | |||||
| "addRoute_confirm": "確認新增車線", | "addRoute_confirm": "確認新增車線", | ||||
| "addRoute_submitting": "新增中…", | "addRoute_submitting": "新增中…", | ||||
| "district_dialog_add": "新增地區", | "district_dialog_add": "新增地區", | ||||
| "district_dialog_edit": "編輯地區", | "district_dialog_edit": "編輯地區", | ||||
| "district_name_label": "地區顯示名稱", | |||||
| "district_name_ph": "空白表示「未分類」", | |||||
| "district_name_label": "地區名稱", | |||||
| "seq_edit_departureLabel": "出車時間", | "seq_edit_departureLabel": "出車時間", | ||||
| "seq_edit_seqLabel": "裝車順序", | "seq_edit_seqLabel": "裝車順序", | ||||
| "route_new_code_label": "車線編號", | "route_new_code_label": "車線編號", | ||||
| @@ -200,7 +199,6 @@ | |||||
| "dialog_editLogisticsTitle": "編輯物流公司", | "dialog_editLogisticsTitle": "編輯物流公司", | ||||
| "btn_apply": "套用", | "btn_apply": "套用", | ||||
| "addShop_confirm": "確認", | "addShop_confirm": "確認", | ||||
| "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳。", | |||||
| "departureDialog_title": "編輯出車時間", | "departureDialog_title": "編輯出車時間", | ||||
| "departureDialog_hint": "套用至此車線所有店鋪列。", | "departureDialog_hint": "套用至此車線所有店鋪列。", | ||||
| "seqDialog_title": "編輯裝車順序", | "seqDialog_title": "編輯裝車順序", | ||||
| @@ -53,7 +53,7 @@ | |||||
| "err_export": "匯出失敗", | "err_export": "匯出失敗", | ||||
| "err_noLanes": "目前無車線資料", | "err_noLanes": "目前無車線資料", | ||||
| "err_import": "匯入失敗", | "err_import": "匯入失敗", | ||||
| "err_dragDuplicateShop": "目標車線已有相同店鋪(同一 shop / 同一 shopCode),無法拖入", | |||||
| "err_dragDuplicateShop": "目標車線已有相同店鋪(同一店鋪/ 同一店鋪編號),無法拖入", | |||||
| "district_err_name": "請輸入地區名稱", | "district_err_name": "請輸入地區名稱", | ||||
| "district_err_reserved": "「未分類」已內建,請勿重複新增", | "district_err_reserved": "「未分類」已內建,請勿重複新增", | ||||
| "district_err_exists": "此地區已存在", | "district_err_exists": "此地區已存在", | ||||
| @@ -61,11 +61,11 @@ | |||||
| "route_err_departure": "請選擇或輸入出車時間", | "route_err_departure": "請選擇或輸入出車時間", | ||||
| "route_err_duplicate": "此車線(含備註組合)已存在", | "route_err_duplicate": "此車線(含備註組合)已存在", | ||||
| "route_err_create": "新增車線失敗", | "route_err_create": "新增車線失敗", | ||||
| "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。將先加入畫面,按「儲存更改」才寫入後端。仍要加入?", | |||||
| "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。仍要加入?", | |||||
| "confirm_discardDraftShop": "捨棄尚未儲存的「新增店鋪」?", | "confirm_discardDraftShop": "捨棄尚未儲存的「新增店鋪」?", | ||||
| "confirm_removeShop": "從此車線移除此店鋪?(按「儲存更改」才會寫入)", | "confirm_removeShop": "從此車線移除此店鋪?(按「儲存更改」才會寫入)", | ||||
| "confirm_schedule_removeShop": "從此車線移除此店鋪?此操作將列入預約排程的刪除項目。", | "confirm_schedule_removeShop": "從此車線移除此店鋪?此操作將列入預約排程的刪除項目。", | ||||
| "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?(按「儲存更改」才會從後端刪除)", | |||||
| "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?", | |||||
| "confirm_departureConflict": "變更出車時間後,偵測到 {{count}} 筆可能衝突(見鈴鐺)。仍要套用?", | "confirm_departureConflict": "變更出車時間後,偵測到 {{count}} 筆可能衝突(見鈴鐺)。仍要套用?", | ||||
| "drag_blockDraftShop": "尚未儲存的「新增店鋪」請先按「儲存更改」寫入,或從卡片刪除草稿後再拖曳。", | "drag_blockDraftShop": "尚未儲存的「新增店鋪」請先按「儲存更改」寫入,或從卡片刪除草稿後再拖曳。", | ||||
| "nav_unsavedLeave": "有未儲存的更改,確定要離開?", | "nav_unsavedLeave": "有未儲存的更改,確定要離開?", | ||||
| @@ -79,8 +79,8 @@ | |||||
| "versionNote_saveFail": "備註儲存失敗", | "versionNote_saveFail": "備註儲存失敗", | ||||
| "diff_restoreFail": "恢復失敗", | "diff_restoreFail": "恢復失敗", | ||||
| "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", | "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", | ||||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", | |||||
| "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", | |||||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}}。", | |||||
| "diff_restoreAlreadyPending": "此版本已在排程還原中。", | |||||
| "restore_applied": "已從版本還原並重新載入看板。", | "restore_applied": "已從版本還原並重新載入看板。", | ||||
| "restore_appliedDroppedStaging": "已套用版本還原;本次儲存略過其他暫存變更(請重新編輯)。", | "restore_appliedDroppedStaging": "已套用版本還原;本次儲存略過其他暫存變更(請重新編輯)。", | ||||
| "confirm_restoreSaveWillDropStaging": "儲存時將先套用版本還原,本次其他暫存變更會被略過。確定繼續?", | "confirm_restoreSaveWillDropStaging": "儲存時將先套用版本還原,本次其他暫存變更會被略過。確定繼續?", | ||||
| @@ -120,6 +120,7 @@ | |||||
| "version_ui_listAria": "版本歷史列表", | "version_ui_listAria": "版本歷史列表", | ||||
| "version_ui_snapshotBadge": "目前版本", | "version_ui_snapshotBadge": "目前版本", | ||||
| "version_ui_id": "版本#{{id}}", | "version_ui_id": "版本#{{id}}", | ||||
| "version_ui_pendingRestore": "版本#{{id}}(待還原)", | |||||
| "version_ui_none": "尚無版本", | "version_ui_none": "尚無版本", | ||||
| "version_ui_editedBy": "編輯者:{{name}}", | "version_ui_editedBy": "編輯者:{{name}}", | ||||
| "version_note_placeholder": "備註(離開欄位即儲存)", | "version_note_placeholder": "備註(離開欄位即儲存)", | ||||
| @@ -161,8 +162,8 @@ | |||||
| "diff_logisticMaster_added": "新增", | "diff_logisticMaster_added": "新增", | ||||
| "diff_logisticMaster_edited": "修改", | "diff_logisticMaster_edited": "修改", | ||||
| "diff_noShopDiffHasBoardStaged": "與上一版本相比,店鋪列無差異;下列為看板上尚未按「儲存更改」寫入的變更(含新增物流公司)。", | "diff_noShopDiffHasBoardStaged": "與上一版本相比,店鋪列無差異;下列為看板上尚未按「儲存更改」寫入的變更(含新增物流公司)。", | ||||
| "diff_export_blockedTooltip": "匯出檔為後端兩版本比對,不含看板未儲存變更。請先按「儲存更改」或取消變更後再匯出。", | |||||
| "diff_export_blockedError": "有看板未儲存變更時無法匯出(Excel 僅含已落庫版本)。", | |||||
| "diff_export_blockedTooltip": "匯出檔為後端兩版本比對,不含未儲存變更。請先按「儲存更改」或取消變更後再匯出。", | |||||
| "diff_export_blockedError": "有未儲存變更時無法匯出。", | |||||
| "diff_markedCount": "{{count}} 筆變更", | "diff_markedCount": "{{count}} 筆變更", | ||||
| "diff_noDiffFromPrev": "與上一版無差異", | "diff_noDiffFromPrev": "與上一版無差異", | ||||
| "diff_loadingEllipsis": "…", | "diff_loadingEllipsis": "…", | ||||
| @@ -173,8 +174,7 @@ | |||||
| "addRoute_submitting": "新增中…", | "addRoute_submitting": "新增中…", | ||||
| "district_dialog_add": "新增地區", | "district_dialog_add": "新增地區", | ||||
| "district_dialog_edit": "編輯地區", | "district_dialog_edit": "編輯地區", | ||||
| "district_name_label": "地區顯示名稱", | |||||
| "district_name_ph": "空白表示「未分類」", | |||||
| "district_name_label": "地區名稱", | |||||
| "seq_edit_departureLabel": "出車時間", | "seq_edit_departureLabel": "出車時間", | ||||
| "seq_edit_seqLabel": "裝車順序", | "seq_edit_seqLabel": "裝車順序", | ||||
| "route_new_code_label": "車線編號", | "route_new_code_label": "車線編號", | ||||
| @@ -200,21 +200,20 @@ | |||||
| "dialog_editLogisticsTitle": "編輯物流公司", | "dialog_editLogisticsTitle": "編輯物流公司", | ||||
| "btn_apply": "套用", | "btn_apply": "套用", | ||||
| "addShop_confirm": "確認", | "addShop_confirm": "確認", | ||||
| "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳;與其他編輯相同,需按「儲存更改」才會寫入後端 truck。", | |||||
| "departureDialog_title": "編輯出車時間", | "departureDialog_title": "編輯出車時間", | ||||
| "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", | |||||
| "departureDialog_hint": "套用至此車線所有店鋪列。", | |||||
| "seqDialog_title": "編輯裝車順序", | "seqDialog_title": "編輯裝車順序", | ||||
| "logistics_colLaneCount": "{{count}} 條車線", | "logistics_colLaneCount": "{{count}} 條車線", | ||||
| "tooltip_openLaneBoard": "在車線看板開此車線", | "tooltip_openLaneBoard": "在車線看板開此車線", | ||||
| "aria_openLaneBoard": "開啟車線看板", | "aria_openLaneBoard": "開啟車線看板", | ||||
| "tooltip_removeFromLane": "從此車線移除", | "tooltip_removeFromLane": "從此車線移除", | ||||
| "tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)", | |||||
| "tooltip_clearLaneShops": "清空此車線所有店鋪", | |||||
| "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", | "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", | ||||
| "aria_pickLane": "選擇車線", | "aria_pickLane": "選擇車線", | ||||
| "aria_searchLanes": "搜索車線", | "aria_searchLanes": "搜索車線", | ||||
| "logistics_colShopCount": "{{count}} 家店鋪", | "logistics_colShopCount": "{{count}} 家店鋪", | ||||
| "tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)", | |||||
| "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | |||||
| "tooltip_editLogisticsDb": "編輯物流公司", | |||||
| "tooltip_deleteLogistics": "刪除物流公司", | |||||
| "aria_editLogistics": "編輯物流公司", | "aria_editLogistics": "編輯物流公司", | ||||
| "aria_deleteLogistics": "刪除物流公司", | "aria_deleteLogistics": "刪除物流公司", | ||||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?", | "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?", | ||||
| @@ -301,12 +300,15 @@ | |||||
| "schedule_review_queue": "變更預覽佇列", | "schedule_review_queue": "變更預覽佇列", | ||||
| "schedule_review_count": "{{count}} 項變更", | "schedule_review_count": "{{count}} 項變更", | ||||
| "schedule_review_empty": "暫無待排程變更", | "schedule_review_empty": "暫無待排程變更", | ||||
| "schedule_review_empty_hint": "請拖曳店鋪以調整其車線分配或裝載排程順序,或使用刪除按鈕移除店鋪", | |||||
| "schedule_review_empty_hint": "請拖曳店鋪以調整其車線分配或裝載排程順序,或編輯出車時間、新增店鋪、刪除按鈕變更車線內容", | |||||
| "schedule_review_revert": "復原", | "schedule_review_revert": "復原", | ||||
| "schedule_review_create_action": "新增至:", | |||||
| "schedule_review_delete_action": "刪除自:", | "schedule_review_delete_action": "刪除自:", | ||||
| "schedule_review_lane_change": "車線變更:", | "schedule_review_lane_change": "車線變更:", | ||||
| "schedule_review_district_change": "區域參考:", | "schedule_review_district_change": "區域參考:", | ||||
| "schedule_review_seq": "裝載順序:", | "schedule_review_seq": "裝載順序:", | ||||
| "schedule_review_departure_shops": "影響 {{count}} 間店鋪", | |||||
| "schedule_review_departure_pending_creates": "影響 {{count}} 筆待新增店鋪", | |||||
| "schedule_drop_hint": "請拖曳店鋪卡片至此車線", | "schedule_drop_hint": "請拖曳店鋪卡片至此車線", | ||||
| "schedule_moved_badge": "移入", | "schedule_moved_badge": "移入", | ||||
| "schedule_drag_seq": "裝載順序: {{seq}}", | "schedule_drag_seq": "裝載順序: {{seq}}", | ||||
| @@ -325,7 +327,8 @@ | |||||
| "schedule_err_generic": "排程請求失敗,請稍後再試。", | "schedule_err_generic": "排程請求失敗,請稍後再試。", | ||||
| "schedule_reschedule_time_adjusted": "原執行時間已過去,已自動調整為 {{at}}。", | "schedule_reschedule_time_adjusted": "原執行時間已過去,已自動調整為 {{at}}。", | ||||
| "schedule_shop_badge": "已排程變更", | "schedule_shop_badge": "已排程變更", | ||||
| "schedule_shop_locked": "排程執行中,此店鋪暫不可手改", | |||||
| "schedule_shop_locked": "排程即將執行(或執行中),此店鋪已鎖定不可手改", | |||||
| "schedule_shop_scheduled": "此店鋪有待執行排程(尚未鎖定,仍可編輯;執行前將鎖定)", | |||||
| "schedule_retry_rejects_partial": "部分排程不可重試,請先還原看板後重新建立排程", | "schedule_retry_rejects_partial": "部分排程不可重試,請先還原看板後重新建立排程", | ||||
| "schedule_registered_snackbar": "已登記 {{count}} 筆預約變更,將於執行時間由伺服器自動套用。", | "schedule_registered_snackbar": "已登記 {{count}} 筆預約變更,將於執行時間由伺服器自動套用。", | ||||
| "schedule_import_parse_summary": "解析完成:有效 {{valid}} 筆,錯誤 {{errors}} 筆", | "schedule_import_parse_summary": "解析完成:有效 {{valid}} 筆,錯誤 {{errors}} 筆", | ||||