| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
bc1784fffc | Merge branch 'production' of https://git.2fi-solutions.com/jason/FPSMS-frontend into production | 1 week ago |
|
|
ef46091858 | 補貨 turkc scheduler update | 1 week ago |
| @@ -29,6 +29,8 @@ export interface DoDetail { | |||
| isExtra?: boolean; | |||
| /** 揀貨員名稱(delivery_order_pick_order.handlerName) */ | |||
| handlerName?: string | null; | |||
| /** 來源 DO 車線 */ | |||
| truckLaneCode?: string | null; | |||
| deliveryOrderLines: DoDetailLine[]; | |||
| } | |||
| @@ -671,3 +673,60 @@ export async function fetchAllDoSearch( | |||
| 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; | |||
| toLoadingSequence: number; | |||
| toDistrictReference?: string | null; | |||
| departureTime?: string | null; | |||
| }; | |||
| export type TruckLaneScheduleLineRequest = { | |||
| @@ -723,7 +724,10 @@ export type TruckLaneScheduleResponse = { | |||
| }; | |||
| export type PendingTruckRowIdsResponse = { | |||
| /** 所有開放排程(PENDING/APPLYING)涉及的 truck rows(標記、排程驗證用) */ | |||
| truckRowIds: number[]; | |||
| /** 已進入鎖定時間窗(或 APPLYING)的 truck rows,看板不可手改 */ | |||
| lockedTruckRowIds?: number[]; | |||
| }; | |||
| export type TruckLaneScheduleExcelPreviewRow = { | |||
| @@ -1,6 +1,6 @@ | |||
| "use client"; | |||
| import React, { useCallback, useMemo, useRef, useState } from "react"; | |||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||
| import { | |||
| Autocomplete, | |||
| Box, | |||
| @@ -36,7 +36,15 @@ import { useTranslation } from "react-i18next"; | |||
| import { GridColDef } from "@mui/x-data-grid"; | |||
| import Swal from "sweetalert2"; | |||
| 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 { | |||
| REPLENISHMENT_FIELD_ICON_SX, | |||
| @@ -95,13 +103,30 @@ type SourceDoContext = { | |||
| 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). */ | |||
| @@ -142,6 +167,7 @@ const DoReplenishmentTab: React.FC = () => { | |||
| const [draftRows, setDraftRows] = useState<ReplenishmentDraftRow[]>([]); | |||
| const [records, setRecords] = useState<ReplenishmentRecord[]>([]); | |||
| const [isLoadingTracking, setIsLoadingTracking] = useState(false); | |||
| const [trackStatusFilter, setTrackStatusFilter] = useState<ReplenishmentStatus | "all">("all"); | |||
| const [trackDateFilter, setTrackDateFilter] = useState<Dayjs | null>(null); | |||
| const [trackingDialogOpen, setTrackingDialogOpen] = useState(false); | |||
| @@ -210,6 +236,8 @@ const DoReplenishmentTab: React.FC = () => { | |||
| } | |||
| const detail = matched[0]; | |||
| const matchedCandidate = candidates.find((c) => c.id === detail.id); | |||
| const resolvedTruckLaneCode = | |||
| detail.truckLaneCode?.trim() || matchedCandidate?.truckLanceCode?.trim() || null; | |||
| if (detail.status !== "completed") { | |||
| await Swal.fire({ | |||
| icon: "error", | |||
| @@ -224,7 +252,7 @@ const DoReplenishmentTab: React.FC = () => { | |||
| doCode: detail.code, | |||
| shopCode: detail.shopCode, | |||
| shopName: detail.shopName, | |||
| truckLaneCode: matchedCandidate?.truckLanceCode ?? null, | |||
| truckLaneCode: resolvedTruckLaneCode, | |||
| status: detail.status, | |||
| lines: detail.deliveryOrderLines ?? [], | |||
| }); | |||
| @@ -258,32 +286,38 @@ const DoReplenishmentTab: React.FC = () => { | |||
| } | |||
| const line = selectedLine; | |||
| const duplicate = draftRows.some( | |||
| const existingRowIndex = draftRows.findIndex( | |||
| (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); | |||
| setReplenishQtyInput(""); | |||
| window.setTimeout(() => itemCodeInputRef.current?.focus(), 0); | |||
| @@ -309,27 +343,52 @@ const DoReplenishmentTab: React.FC = () => { | |||
| inFlightRef.current = true; | |||
| setIsSubmitting(true); | |||
| 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([]); | |||
| 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 { | |||
| setIsSubmitting(false); | |||
| inFlightRef.current = false; | |||
| } | |||
| }, [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( | |||
| () => [ | |||
| { field: "code", headerName: t("Replenishment Code"), width: 140 }, | |||
| @@ -377,16 +436,6 @@ const DoReplenishmentTab: React.FC = () => { | |||
| 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( | |||
| () => ({ | |||
| textField: { | |||
| @@ -559,6 +608,9 @@ const DoReplenishmentTab: React.FC = () => { | |||
| <TableCell sx={{ width: { md: "8%" }, minWidth: { md: 48 }, whiteSpace: "nowrap" }}> | |||
| {t("uom")} | |||
| </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" }}> | |||
| {t("Action")} | |||
| </TableCell> | |||
| @@ -572,6 +624,9 @@ const DoReplenishmentTab: React.FC = () => { | |||
| <TableCell align="right">{row.originalQty}</TableCell> | |||
| <TableCell align="right">{row.replenishQty}</TableCell> | |||
| <TableCell>{row.shortUom || "—"}</TableCell> | |||
| <TableCell> | |||
| {row.truckLaneCode?.trim() || sourceDo.truckLaneCode?.trim() || t("Truck X")} | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | |||
| <IconButton | |||
| @@ -591,13 +646,7 @@ const DoReplenishmentTab: React.FC = () => { | |||
| <Autocomplete | |||
| size="small" | |||
| fullWidth | |||
| options={sourceDo.lines.filter( | |||
| (line) => | |||
| !draftRows.some( | |||
| (r) => | |||
| r.sourceDoLineId === line.id && r.sourceDoId === sourceDo.doId, | |||
| ), | |||
| )} | |||
| options={sourceDo.lines} | |||
| value={selectedLine} | |||
| onChange={(_, newValue) => setSelectedLine(newValue)} | |||
| getOptionLabel={(line) => line.itemNo ?? ""} | |||
| @@ -675,6 +724,17 @@ const DoReplenishmentTab: React.FC = () => { | |||
| sx={{ whiteSpace: "nowrap" }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell> | |||
| <ReplenishmentItemEntryPlainText | |||
| reserveSpace | |||
| value={ | |||
| sourceDo.truckLaneCode?.trim() | |||
| ? sourceDo.truckLaneCode | |||
| : t("Truck X") | |||
| } | |||
| sx={{ whiteSpace: "nowrap" }} | |||
| /> | |||
| </TableCell> | |||
| <TableCell align="center"> | |||
| <Box sx={REPLENISHMENT_TABLE_ACTION_CELL_INNER_SX}> | |||
| <Button | |||
| @@ -769,9 +829,10 @@ const DoReplenishmentTab: React.FC = () => { | |||
| </Stack> | |||
| </Box> | |||
| <StyledDataGrid | |||
| rows={filteredRecords} | |||
| rows={records} | |||
| columns={trackColumns} | |||
| autoHeight | |||
| loading={isLoadingTracking} | |||
| disableRowSelectionOnClick | |||
| pageSizeOptions={[10, 25, 50]} | |||
| initialState={{ pagination: { paginationModel: { pageSize: 10 } } }} | |||
| @@ -871,7 +871,7 @@ const RouteBoard: React.FC = () => { | |||
| const logisticsLaneDragIdRef = useRef<string | null>(null); | |||
| /** baseline: 後端目前 lane logisticId(用於判斷「只改物流商」也要能 Save) */ | |||
| const laneLogisticBaselineRef = useRef<Map<string, number | null>>(new Map()); | |||
| /** 店鋪列地區顯示 baseline(載入/refresh 後同步),供未儲存清單標註地區差 */ | |||
| /** 店鋪列地區 baseline(載入/refresh 後同步),供未儲存清單標註地區差 */ | |||
| const shopDistrictBaselineRef = useRef<Map<number, string>>(new Map()); | |||
| const shopRowBaselineRef = useRef<Map<number, ShopRowBaseline>>(new Map()); | |||
| const [districtBaselineEpoch, setDistrictBaselineEpoch] = useState(0); | |||
| @@ -995,6 +995,20 @@ const RouteBoard: React.FC = () => { | |||
| [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 = | |||
| String(versionFilterQuery || "").trim() !== "" || | |||
| String(versionFilterDate || "").trim() !== ""; | |||
| @@ -1043,11 +1057,13 @@ const RouteBoard: React.FC = () => { | |||
| scheduleModalOpenRef.current = scheduleModalOpen; | |||
| const { | |||
| pendingScheduleShopIds, | |||
| lockedScheduleShopIds, | |||
| failedScheduleShopIds, | |||
| failedScheduleCount, | |||
| refreshScheduleIndicators, | |||
| } = useRouteBoardScheduleIndicators({ paused: scheduleModalOpen }); | |||
| const scheduledShopIdSet = pendingScheduleShopIds; | |||
| /** 硬鎖:APPLYING 或進入鎖定時間窗的排程;遠期排程僅標記不鎖。 */ | |||
| const scheduledShopIdSet = lockedScheduleShopIds; | |||
| const [addShopDialogOpen, setAddShopDialogOpen] = useState(false); | |||
| const [addShopLaneId, setAddShopLaneId] = useState<string | null>(null); | |||
| @@ -2772,6 +2788,7 @@ const RouteBoard: React.FC = () => { | |||
| truckLanceCode: lane.truckLanceCode, | |||
| remark: lane.remark, | |||
| storeId: normalizeStoreId(lane.storeId), | |||
| departureTime: parseTimeForBackend(lane.startTime) || "00:00:00", | |||
| shops: lane.shops | |||
| .filter((s) => s.id >= 0) | |||
| .map((s) => ({ | |||
| @@ -4222,10 +4239,7 @@ const RouteBoard: React.FC = () => { | |||
| {t("pageTitle")} | |||
| </Typography> | |||
| <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> | |||
| </Box> | |||
| @@ -4584,6 +4598,7 @@ const RouteBoard: React.FC = () => { | |||
| onClose={() => setScheduleModalOpen(false)} | |||
| lanes={scheduleLaneOptions} | |||
| shops={scheduleShopRows} | |||
| allShopsMaster={allShopsMaster} | |||
| pendingTruckRowIds={pendingScheduleShopIds} | |||
| onConfirmManual={handleScheduleConfirmManual} | |||
| onAfterScheduleChange={async () => { | |||
| @@ -5812,9 +5827,6 @@ const RouteBoard: React.FC = () => { | |||
| : t("shop_autocomplete_noOptions") | |||
| } | |||
| /> | |||
| <Typography variant="caption" color="text.secondary"> | |||
| {t("addShop_listHint")} | |||
| </Typography> | |||
| </Stack> | |||
| </DialogContent> | |||
| <DialogActions> | |||
| @@ -5845,7 +5857,6 @@ const RouteBoard: React.FC = () => { | |||
| autoFocus | |||
| margin="dense" | |||
| label={t("district_name_label")} | |||
| placeholder={t("district_name_ph")} | |||
| fullWidth | |||
| value={districtEditDraft} | |||
| onChange={(e) => { | |||
| @@ -7636,6 +7647,10 @@ const RouteBoard: React.FC = () => { | |||
| const changed = dirtyMoves.has(shop.id); | |||
| const isScheduledMove = | |||
| shop.id > 0 && scheduledShopIdSet.has(shop.id); | |||
| const isScheduledLater = | |||
| shop.id > 0 && | |||
| !isScheduledMove && | |||
| pendingScheduleShopIds.has(shop.id); | |||
| const isFailedScheduledMove = | |||
| shop.id > 0 && | |||
| failedScheduleShopIds.has(shop.id); | |||
| @@ -7744,6 +7759,30 @@ const RouteBoard: React.FC = () => { | |||
| </Box> | |||
| </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 && ( | |||
| <Box | |||
| sx={{ | |||
| @@ -10,6 +10,7 @@ import React, { | |||
| } from "react"; | |||
| import { | |||
| Alert, | |||
| Autocomplete, | |||
| Box, | |||
| Button, | |||
| Chip, | |||
| @@ -48,9 +49,20 @@ import ScheduleDragWorkspacePane from "@/components/Shop/ScheduleDragWorkspacePa | |||
| import ScheduleReviewQueue from "@/components/Shop/ScheduleReviewQueue"; | |||
| import { extractApiErrorMessage } from "@/components/Shop/scheduleClientHelpers"; | |||
| import { | |||
| computeTruckLaneWarnings, | |||
| diffNewTruckLaneWarnings, | |||
| } from "@/components/Shop/computeTruckLaneWarnings"; | |||
| import { | |||
| addShopToPlan, | |||
| defaultFocusedLaneIds, | |||
| initPlannedLanes, | |||
| listDepartureModifications, | |||
| listModifications, | |||
| parseScheduleDepartureTime, | |||
| plannedLanesToWarningInputRows, | |||
| revertLaneDeparture, | |||
| setLaneDepartureTime, | |||
| listPendingCreates, | |||
| moveShop, | |||
| removeShopFromPlan, | |||
| restoreDeletedShop, | |||
| @@ -83,6 +95,8 @@ export type ScheduleLaneOption = { | |||
| truckLanceCode: string; | |||
| remark?: string | null; | |||
| storeId: string; | |||
| /** Lane departure time (HH:mm:ss) for CREATE schedule lines. */ | |||
| departureTime: string; | |||
| shops: ScheduleLaneShopSnapshot[]; | |||
| }; | |||
| @@ -114,11 +128,14 @@ export type ScheduleChangePayload = { | |||
| pendingDeletes: ScheduledDeleteSnapshot[]; | |||
| }; | |||
| type ShopMasterOption = { id: number; name: string; code: string }; | |||
| type Props = { | |||
| open: boolean; | |||
| onClose: () => void; | |||
| lanes: ScheduleLaneOption[]; | |||
| shops: ScheduleShopRow[]; | |||
| allShopsMaster: ShopMasterOption[]; | |||
| pendingTruckRowIds: Set<number>; | |||
| onConfirmManual: (payload: ScheduleChangePayload) => void | Promise<void>; | |||
| onAfterScheduleChange?: () => void | Promise<void>; | |||
| @@ -129,12 +146,14 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||
| onClose, | |||
| lanes, | |||
| shops, | |||
| allShopsMaster, | |||
| pendingTruckRowIds, | |||
| onConfirmManual, | |||
| onAfterScheduleChange, | |||
| }) => { | |||
| const { t } = useTranslation("shop"); | |||
| const importFileRef = useRef<HTMLInputElement>(null); | |||
| const nextTempTruckRowIdRef = useRef(-1); | |||
| const [tab, setTab] = useState<"manual" | "import">("manual"); | |||
| const [scheduledDate, setScheduledDate] = useState(""); | |||
| const [scheduledTime, setScheduledTime] = useState(""); | |||
| @@ -168,9 +187,13 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||
| 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(() => { | |||
| if (!open) return; | |||
| nextTempTruckRowIdRef.current = -1; | |||
| setTab("manual"); | |||
| setScheduledDate(""); | |||
| setScheduledTime(""); | |||
| @@ -190,6 +213,9 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||
| setStagedPlanCounts(null); | |||
| setStagedRowErrors([]); | |||
| setSubmitError(null); | |||
| setAddShopDialogOpen(false); | |||
| setAddShopLaneId(null); | |||
| setAddShopPick(null); | |||
| }, [open, lanes, shops]); | |||
| const modifications = useMemo( | |||
| @@ -197,6 +223,41 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||
| [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( | |||
| ( | |||
| 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) => { | |||
| setPendingEmptyDistrictsByLane((prev) => { | |||
| 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( | |||
| (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 (!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 = | |||
| !isProcessing && | |||
| plannedSubmit.ok && | |||
| (modifications.length > 0 || pendingDeletes.length > 0); | |||
| (modifications.length > 0 || | |||
| pendingDeletes.length > 0 || | |||
| pendingCreates.length > 0 || | |||
| hasDepartureMoves); | |||
| const canSubmitImport = | |||
| !isProcessing && | |||
| @@ -691,6 +820,8 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||
| onAddEmptyDistrict={handleAddEmptyDistrict} | |||
| onRemoveEmptyDistrict={handleRemoveEmptyDistrict} | |||
| onDeleteShop={handleDeleteShop} | |||
| onAddShop={openAddShopDialog} | |||
| onSetLaneDepartureTime={handleSetLaneDepartureTime} | |||
| /> | |||
| </Box> | |||
| <Box | |||
| @@ -706,8 +837,12 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||
| <ScheduleReviewQueue | |||
| modifications={modifications} | |||
| pendingDeletes={pendingDeletes} | |||
| pendingCreates={pendingCreates} | |||
| departureModifications={departureModifications} | |||
| onRevert={handleRevertShop} | |||
| onRevertDelete={handleRevertDelete} | |||
| onRevertCreate={handleRevertCreate} | |||
| onRevertDeparture={handleRevertLaneDeparture} | |||
| errorTruckRowIds={validationErrorTruckRowIds} | |||
| validationErrorsByTruckRowId={validationErrorsByTruckRowId} | |||
| /> | |||
| @@ -933,7 +1068,9 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||
| )} | |||
| {tab === "manual" && | |||
| (modifications.length > 0 || pendingDeletes.length > 0) && ( | |||
| (modifications.length > 0 || | |||
| pendingDeletes.length > 0 || | |||
| pendingCreates.length > 0) && ( | |||
| <Alert | |||
| severity="warning" | |||
| icon={<AlertCircle size={20} />} | |||
| @@ -945,7 +1082,10 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||
| > | |||
| <Typography variant="body2" sx={{ fontWeight: 800 }}> | |||
| {t("schedule_summary_changes", { | |||
| count: modifications.length + pendingDeletes.length, | |||
| count: | |||
| modifications.length + | |||
| pendingDeletes.length + | |||
| pendingCreates.length, | |||
| })} | |||
| </Typography> | |||
| {scheduledDate && scheduledTime && ( | |||
| @@ -996,6 +1136,61 @@ const ScheduleChangeModal: React.FC<Props> = ({ | |||
| )} | |||
| </DialogActions> | |||
| </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; | |||
| onRemoveEmptyDistrict: (laneId: string, display: 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( | |||
| laneId: string, | |||
| clientY: number, | |||
| @@ -386,6 +401,11 @@ const LaneColumn = memo(function LaneColumn({ | |||
| onStartSeqEdit, | |||
| dropHint, | |||
| addDistrictLabel, | |||
| addShopLabel, | |||
| onAddShop, | |||
| departureLabel, | |||
| departureEditAriaLabel, | |||
| onStartDepartureEdit, | |||
| }: { | |||
| lane: PlannedLane; | |||
| pendingEmptyDistricts: string[]; | |||
| @@ -411,6 +431,11 @@ const LaneColumn = memo(function LaneColumn({ | |||
| onStartSeqEdit: (laneId: string, shopId: number, current: number) => void; | |||
| dropHint: string; | |||
| addDistrictLabel: string; | |||
| addShopLabel: string; | |||
| onAddShop: () => void; | |||
| departureLabel: string; | |||
| departureEditAriaLabel: string; | |||
| onStartDepartureEdit: (laneId: string, current: string) => void; | |||
| }) { | |||
| const districtSections = buildLaneDistrictSections( | |||
| lane.shops, | |||
| @@ -448,8 +473,8 @@ const LaneColumn = memo(function LaneColumn({ | |||
| 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> | |||
| {lane.label} | |||
| </Typography> | |||
| @@ -459,6 +484,33 @@ const LaneColumn = memo(function LaneColumn({ | |||
| sx={{ height: 18, fontSize: "0.65rem", fontFamily: "monospace" }} | |||
| /> | |||
| </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> | |||
| <Chip | |||
| size="small" | |||
| @@ -558,6 +610,28 @@ const LaneColumn = memo(function LaneColumn({ | |||
| </Stack> | |||
| )} | |||
| </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> | |||
| ); | |||
| }); | |||
| @@ -576,6 +650,8 @@ const ScheduleDragWorkspacePane: React.FC<Props> = ({ | |||
| onAddEmptyDistrict, | |||
| onRemoveEmptyDistrict, | |||
| onDeleteShop, | |||
| onAddShop, | |||
| onSetLaneDepartureTime, | |||
| }) => { | |||
| const { t } = useTranslation("shop"); | |||
| const draggedRef = useRef<{ shopId: number; fromLaneId: string } | null>( | |||
| @@ -596,6 +672,27 @@ const ScheduleDragWorkspacePane: React.FC<Props> = ({ | |||
| const [districtAddError, setDistrictAddError] = useState<string | 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 rightLane = plannedLanes.find((l) => l.id === rightLaneId); | |||
| @@ -862,6 +959,17 @@ const ScheduleDragWorkspacePane: React.FC<Props> = ({ | |||
| onStartSeqEdit: handleStartSeqEdit, | |||
| dropHint: t("schedule_drop_hint"), | |||
| 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 ( | |||
| @@ -882,6 +990,64 @@ const ScheduleDragWorkspacePane: React.FC<Props> = ({ | |||
| </Box> | |||
| </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 | |||
| open={seqEditTarget != null} | |||
| onClose={handleCancelSeqEdit} | |||
| @@ -10,32 +10,51 @@ import { | |||
| Typography, | |||
| } from "@mui/material"; | |||
| 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 type { | |||
| ScheduleDepartureModification, | |||
| ScheduleModification, | |||
| SchedulePendingCreate, | |||
| ScheduledDeleteSnapshot, | |||
| } from "@/components/Shop/scheduleDragWorkspace"; | |||
| type Props = { | |||
| modifications: ScheduleModification[]; | |||
| pendingDeletes: ScheduledDeleteSnapshot[]; | |||
| pendingCreates: SchedulePendingCreate[]; | |||
| departureModifications?: ScheduleDepartureModification[]; | |||
| onRevert: (shopId: number, currentLaneId: string) => void; | |||
| onRevertDelete: (truckRowId: number) => void; | |||
| onRevertCreate: (truckRowId: number) => void; | |||
| onRevertDeparture?: (laneId: string) => void; | |||
| errorTruckRowIds?: Set<number>; | |||
| 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> = ({ | |||
| modifications, | |||
| pendingDeletes, | |||
| pendingCreates, | |||
| departureModifications = [], | |||
| onRevert, | |||
| onRevertDelete, | |||
| onRevertCreate, | |||
| onRevertDeparture, | |||
| errorTruckRowIds = new Set(), | |||
| validationErrorsByTruckRowId = new Map(), | |||
| }) => { | |||
| const { t } = useTranslation("shop"); | |||
| const totalCount = modifications.length + pendingDeletes.length; | |||
| const totalCount = | |||
| modifications.length + | |||
| pendingDeletes.length + | |||
| pendingCreates.length + | |||
| departureModifications.length; | |||
| const isEmpty = totalCount === 0; | |||
| return ( | |||
| @@ -100,6 +119,176 @@ const ScheduleReviewQueue: React.FC<Props> = ({ | |||
| </Stack> | |||
| ) : ( | |||
| <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) => ( | |||
| <Paper | |||
| key={`delete-${del.truckRowId}`} | |||
| @@ -331,6 +331,28 @@ export function computeTruckLaneWarnings( | |||
| 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 試算。 */ | |||
| export function appendSyntheticPendingShopRow( | |||
| baseRows: TruckLaneWarningInputRow[], | |||
| @@ -10,16 +10,44 @@ import type { | |||
| ScheduleMoveSelection, | |||
| } from "@/components/Shop/ScheduleChangeModal"; | |||
| import type { | |||
| PlannedLane, | |||
| PlannedShop, | |||
| ScheduleModification, | |||
| ScheduleDragWorkspaceState, | |||
| ScheduledDeleteSnapshot, | |||
| } 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( | |||
| truckRowId: number, | |||
| selection: ScheduleMoveSelection, | |||
| lane: ScheduleLaneOption, | |||
| toDistrictReference?: string | null, | |||
| plannedLanes?: ScheduleDragWorkspaceState, | |||
| ): TruckLaneMoveTargetRequest { | |||
| return { | |||
| truckRowId, | |||
| @@ -31,6 +59,28 @@ export function buildScheduleMoveFromSelection( | |||
| toStoreId: normalizeStoreId(lane.storeId), | |||
| toLoadingSequence: selection.toLoadingSequence, | |||
| 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, | |||
| lane, | |||
| districtByTruckRowId.get(truckRowId), | |||
| plannedLanes, | |||
| ), | |||
| ); | |||
| } | |||
| 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: { | |||
| modifications: ScheduleModification[]; | |||
| pendingDeletes: ScheduledDeleteSnapshot[]; | |||
| @@ -109,13 +198,17 @@ export function buildScheduleLinesFromPlan(input: { | |||
| { laneId: m.toLaneId, toLoadingSequence: m.newLoadingSequence }, | |||
| ]), | |||
| ); | |||
| const moveLines = movesToScheduleLines( | |||
| buildScheduleMovesFromMappings( | |||
| const moveTargets = [ | |||
| ...buildScheduleMovesFromMappings( | |||
| mappings, | |||
| input.lanes, | |||
| input.plannedLanes, | |||
| ), | |||
| ); | |||
| ...(input.plannedLanes | |||
| ? buildDepartureOnlyMoves(input.plannedLanes, deleteIds) | |||
| : []), | |||
| ]; | |||
| const moveLines = movesToScheduleLines(moveTargets); | |||
| const deleteLines = input.pendingDeletes | |||
| .map((d) => { | |||
| const lane = laneById.get(d.fromLaneId); | |||
| @@ -128,7 +221,23 @@ export function buildScheduleLinesFromPlan(input: { | |||
| ); | |||
| }) | |||
| .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( | |||
| @@ -3,6 +3,7 @@ import type { | |||
| ScheduleMoveSelection, | |||
| ScheduleShopRow, | |||
| } from "@/components/Shop/ScheduleChangeModal"; | |||
| import type { TruckLaneWarningInputRow } from "@/components/Shop/computeTruckLaneWarnings"; | |||
| import { | |||
| computeMovedLoadingSequence, | |||
| flattenDisplayOrder, | |||
| @@ -14,6 +15,7 @@ import { | |||
| export type PlannedShop = { | |||
| id: number; | |||
| truckRowId: number; | |||
| shopEntityId?: number; | |||
| shopCode: string; | |||
| displayName: string; | |||
| branchName: string; | |||
| @@ -31,9 +33,21 @@ export type PlannedLane = { | |||
| truckLanceCode: string; | |||
| remark?: string | null; | |||
| storeId: string; | |||
| /** Lane departure (HH:mm or HH:mm:ss). */ | |||
| departureTime: string; | |||
| originalDepartureTime: string; | |||
| shops: PlannedShop[]; | |||
| }; | |||
| export type ScheduleDepartureModification = { | |||
| laneId: string; | |||
| laneLabel: string; | |||
| oldDepartureTime: string; | |||
| newDepartureTime: string; | |||
| shopCount: number; | |||
| pendingCreateCount: number; | |||
| }; | |||
| export type ScheduleDragWorkspaceState = PlannedLane[]; | |||
| export type ScheduleModification = { | |||
| @@ -64,6 +78,16 @@ export type ScheduledDeleteSnapshot = { | |||
| shop: PlannedShop; | |||
| }; | |||
| export type SchedulePendingCreate = { | |||
| truckRowId: number; | |||
| shopEntityId: number; | |||
| shopCode: string; | |||
| displayName: string; | |||
| location: string; | |||
| toLaneId: string; | |||
| toLaneLabel: string; | |||
| }; | |||
| type MoveShopArgs = { | |||
| shopId: number; | |||
| 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( | |||
| lanes: ScheduleLaneOption[], | |||
| shops: ScheduleShopRow[], | |||
| @@ -106,17 +262,58 @@ export function initPlannedLanes( | |||
| }) | |||
| .filter((s): s is PlannedShop => s != null); | |||
| const departureTime = normalizeDepartureTime(lane.departureTime || "00:00:00"); | |||
| return { | |||
| id: lane.id, | |||
| label: lane.label, | |||
| truckLanceCode: lane.truckLanceCode, | |||
| remark: lane.remark, | |||
| storeId: lane.storeId, | |||
| departureTime, | |||
| originalDepartureTime: departureTime, | |||
| 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( | |||
| state: ScheduleDragWorkspaceState, | |||
| args: MoveShopArgs, | |||
| @@ -324,6 +521,7 @@ export function listModifications( | |||
| for (const lane of state) { | |||
| for (const shop of lane.shops) { | |||
| if (shop.truckRowId < 0) continue; | |||
| const isLaneChanged = shop.originalLaneId !== lane.id; | |||
| const isSeqChanged = shop.originalLoadingSequence !== shop.loadingSequence; | |||
| const isDistrictChanged = | |||
| @@ -14,6 +14,7 @@ export function moveToScheduleLine( | |||
| toStoreId: move.toStoreId, | |||
| toLoadingSequence: move.toLoadingSequence, | |||
| toDistrictReference: move.toDistrictReference ?? null, | |||
| departureTime: move.departureTime ?? null, | |||
| }; | |||
| } | |||
| @@ -79,8 +79,19 @@ export function listPlannedModifications( | |||
| export function buildShopCodeByTruckRowId( | |||
| shops: ScheduleShopRow[], | |||
| plannedLanes?: ScheduleDragWorkspaceState, | |||
| ): 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( | |||
| @@ -106,7 +117,10 @@ export function validatePlannedSubmit( | |||
| plannedLanes: input.plannedLanes, | |||
| pendingTruckRowIds: input.pendingTruckRowIds, | |||
| executeAt, | |||
| shopCodeByTruckRowId: buildShopCodeByTruckRowId(input.shops), | |||
| shopCodeByTruckRowId: buildShopCodeByTruckRowId( | |||
| input.shops, | |||
| input.plannedLanes, | |||
| ), | |||
| }); | |||
| if (!validation.ok) { | |||
| return validation; | |||
| @@ -150,7 +164,10 @@ export function validatePayloadSubmit(input: { | |||
| plannedLanes: input.payload.plannedLanes, | |||
| pendingTruckRowIds: input.pendingTruckRowIds, | |||
| executeAt, | |||
| shopCodeByTruckRowId: buildShopCodeByTruckRowId(input.shops), | |||
| shopCodeByTruckRowId: buildShopCodeByTruckRowId( | |||
| input.shops, | |||
| input.payload.plannedLanes, | |||
| ), | |||
| }); | |||
| } | |||
| @@ -22,6 +22,9 @@ export function useRouteBoardScheduleIndicators(options?: { | |||
| const [pendingScheduleShopIds, setPendingScheduleShopIds] = useState< | |||
| Set<number> | |||
| >(new Set()); | |||
| const [lockedScheduleShopIds, setLockedScheduleShopIds] = useState< | |||
| Set<number> | |||
| >(new Set()); | |||
| const [failedScheduleShopIds, setFailedScheduleShopIds] = useState< | |||
| Set<number> | |||
| >(new Set()); | |||
| @@ -37,6 +40,9 @@ export function useRouteBoardScheduleIndicators(options?: { | |||
| ), | |||
| ]); | |||
| setPendingScheduleShopIds(new Set(pendingIds.truckRowIds ?? [])); | |||
| setLockedScheduleShopIds( | |||
| new Set(pendingIds.lockedTruckRowIds ?? pendingIds.truckRowIds ?? []), | |||
| ); | |||
| const failedSchedules = filterFailedSchedules(scheduleList); | |||
| setFailedScheduleCount(failedSchedules.length); | |||
| @@ -75,6 +81,7 @@ export function useRouteBoardScheduleIndicators(options?: { | |||
| return { | |||
| pendingScheduleShopIds, | |||
| lockedScheduleShopIds, | |||
| failedScheduleShopIds, | |||
| failedScheduleCount, | |||
| refreshScheduleIndicators, | |||
| @@ -91,7 +91,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": "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 submitted successfully": "Replenishment submitted successfully", | |||
| "Replenishment Entry": "Replenishment Entry", | |||
| "Replenishment item code": "Item Code", | |||
| "Replenishment Tracking": "Replenishment Tracking", | |||
| @@ -174,7 +174,6 @@ | |||
| "district_dialog_add": "Add district", | |||
| "district_dialog_edit": "Edit district", | |||
| "district_name_label": "District display name", | |||
| "district_name_ph": "Blank means \"Unclassified\"", | |||
| "seq_edit_departureLabel": "Departure time", | |||
| "seq_edit_seqLabel": "Load sequence (Seq)", | |||
| "route_new_code_label": "Lane code", | |||
| @@ -200,8 +199,6 @@ | |||
| "dialog_editLogisticsTitle": "Edit logistics master", | |||
| "btn_apply": "Apply", | |||
| "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.", | |||
| "seqDialog_title": "Edit load sequence", | |||
| "logistics_colLaneCount": "{{count}} lane(s)", | |||
| @@ -96,7 +96,6 @@ | |||
| "addRoute_submitting": "Adding…", | |||
| "addShop_confirm": "Confirm", | |||
| "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_createLane": "Failed to create lane", | |||
| "api_fail_deleteShop": "Failed to delete shop", | |||
| @@ -197,7 +196,6 @@ | |||
| "district_err_name": "Enter a district name", | |||
| "district_err_reserved": "\"Unclassified\" is built-in; do not add it again", | |||
| "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.", | |||
| "drawerClose": "Close", | |||
| "emDash": "—", | |||
| @@ -387,19 +385,23 @@ | |||
| "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_delete_action": "Remove from:", | |||
| "schedule_review_create_action": "Add to:", | |||
| "schedule_review_district_change": "District:", | |||
| "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_queue": "Change preview queue", | |||
| "schedule_review_revert": "Revert", | |||
| "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_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_hint": "Default: max sequence in target lane for same district + 1", | |||
| "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_time": "1. Set execution time", | |||
| "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_historyTitle": "Version history", | |||
| "version_ui_id": "Version #{{id}}", | |||
| "version_ui_pendingRestore": "Version #{{id}} (restore pending)", | |||
| "version_ui_listAria": "Version history list", | |||
| "version_ui_none": "No snapshot yet", | |||
| "version_ui_snapshotBadge": "Current snapshot", | |||
| @@ -38,7 +38,11 @@ | |||
| "Replenish Qty": "補貨數量", | |||
| "Replenish qty must be greater than zero": "補貨數量必須大於零", | |||
| "Replenishment": "補貨", | |||
| "Delivery date is required": "請選擇送貨日期", | |||
| "Failed to load replenishment records": "載入補貨記錄失敗", | |||
| "Failed to submit replenishment": "提交補貨失敗", | |||
| "Replenishment API not ready": "補貨 API 尚未就緒", | |||
| "Replenishment submitted successfully": "補貨已提交", | |||
| "Replenishment Entry": "補貨填表", | |||
| "Replenishment item code": "貨品編號", | |||
| "Replenishment Tracking": "補貨進度追蹤", | |||
| @@ -53,7 +53,7 @@ | |||
| "err_export": "匯出失敗", | |||
| "err_noLanes": "目前無車線資料", | |||
| "err_import": "匯入失敗", | |||
| "err_dragDuplicateShop": "目標車線已有相同店鋪(同一 shop / 同一 shopCode),無法拖入", | |||
| "err_dragDuplicateShop": "目標車線已有相同店鋪(同一店鋪/ 同一店鋪編號),無法拖入", | |||
| "district_err_name": "請輸入地區名稱", | |||
| "district_err_reserved": "「未分類」已內建,請勿重複新增", | |||
| "district_err_exists": "此地區已存在", | |||
| @@ -61,10 +61,10 @@ | |||
| "route_err_departure": "請選擇或輸入出車時間", | |||
| "route_err_duplicate": "此車線(含備註組合)已存在", | |||
| "route_err_create": "新增車線失敗", | |||
| "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。將先加入畫面,按「儲存更改」才寫入後端。仍要加入?", | |||
| "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。仍要加入?", | |||
| "confirm_discardDraftShop": "捨棄尚未儲存的「新增店鋪」?", | |||
| "confirm_removeShop": "從此車線移除此店鋪?(按「儲存更改」才會寫入)", | |||
| "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?(按「儲存更改」才會從後端刪除)", | |||
| "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?", | |||
| "confirm_departureConflict": "變更出車時間後,偵測到 {{count}} 筆可能衝突(見鈴鐺)。仍要套用?", | |||
| "drag_blockDraftShop": "尚未儲存的「新增店鋪」請先按「儲存更改」寫入,或從卡片刪除草稿後再拖曳。", | |||
| "nav_unsavedLeave": "有未儲存的更改,確定要離開?", | |||
| @@ -78,8 +78,8 @@ | |||
| "versionNote_saveFail": "備註儲存失敗", | |||
| "diff_restoreFail": "恢復失敗", | |||
| "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", | |||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", | |||
| "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", | |||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}}。", | |||
| "diff_restoreAlreadyPending": "此版本已在排程還原中。", | |||
| "restore_applied": "已從 版本還原並重新載入看板。", | |||
| "restore_appliedDroppedStaging": "已套用 版本還原;本次儲存略過其他暫存變更(請重新編輯)。", | |||
| "confirm_restoreSaveWillDropStaging": "儲存時將先套用 版本還原,本次其他暫存變更會被略過。確定繼續?", | |||
| @@ -168,13 +168,12 @@ | |||
| "diff_noDiffFromPrev": "與上一版無差異", | |||
| "diff_loadingEllipsis": "…", | |||
| "addShop_dialogTitle": "新增店鋪到車線", | |||
| "addRoute_dialogTitle": "新增配送車線", | |||
| "addRoute_dialogTitle": "新增車線", | |||
| "addRoute_confirm": "確認新增車線", | |||
| "addRoute_submitting": "新增中…", | |||
| "district_dialog_add": "新增地區", | |||
| "district_dialog_edit": "編輯地區", | |||
| "district_name_label": "地區顯示名稱", | |||
| "district_name_ph": "空白表示「未分類」", | |||
| "district_name_label": "地區名稱", | |||
| "seq_edit_departureLabel": "出車時間", | |||
| "seq_edit_seqLabel": "裝車順序", | |||
| "route_new_code_label": "車線編號", | |||
| @@ -200,7 +199,6 @@ | |||
| "dialog_editLogisticsTitle": "編輯物流公司", | |||
| "btn_apply": "套用", | |||
| "addShop_confirm": "確認", | |||
| "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳。", | |||
| "departureDialog_title": "編輯出車時間", | |||
| "departureDialog_hint": "套用至此車線所有店鋪列。", | |||
| "seqDialog_title": "編輯裝車順序", | |||
| @@ -53,7 +53,7 @@ | |||
| "err_export": "匯出失敗", | |||
| "err_noLanes": "目前無車線資料", | |||
| "err_import": "匯入失敗", | |||
| "err_dragDuplicateShop": "目標車線已有相同店鋪(同一 shop / 同一 shopCode),無法拖入", | |||
| "err_dragDuplicateShop": "目標車線已有相同店鋪(同一店鋪/ 同一店鋪編號),無法拖入", | |||
| "district_err_name": "請輸入地區名稱", | |||
| "district_err_reserved": "「未分類」已內建,請勿重複新增", | |||
| "district_err_exists": "此地區已存在", | |||
| @@ -61,11 +61,11 @@ | |||
| "route_err_departure": "請選擇或輸入出車時間", | |||
| "route_err_duplicate": "此車線(含備註組合)已存在", | |||
| "route_err_create": "新增車線失敗", | |||
| "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。將先加入畫面,按「儲存更改」才寫入後端。仍要加入?", | |||
| "confirm_addShopConflict": "偵測到 {{count}} 筆可能與其他車線衝突(見右上角鈴鐺)。仍要加入?", | |||
| "confirm_discardDraftShop": "捨棄尚未儲存的「新增店鋪」?", | |||
| "confirm_removeShop": "從此車線移除此店鋪?(按「儲存更改」才會寫入)", | |||
| "confirm_schedule_removeShop": "從此車線移除此店鋪?此操作將列入預約排程的刪除項目。", | |||
| "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?(按「儲存更改」才會從後端刪除)", | |||
| "confirm_clearLane": "確定清空車線「{{laneLabel}}」的 {{count}} 筆店鋪?", | |||
| "confirm_departureConflict": "變更出車時間後,偵測到 {{count}} 筆可能衝突(見鈴鐺)。仍要套用?", | |||
| "drag_blockDraftShop": "尚未儲存的「新增店鋪」請先按「儲存更改」寫入,或從卡片刪除草稿後再拖曳。", | |||
| "nav_unsavedLeave": "有未儲存的更改,確定要離開?", | |||
| @@ -79,8 +79,8 @@ | |||
| "versionNote_saveFail": "備註儲存失敗", | |||
| "diff_restoreFail": "恢復失敗", | |||
| "confirm_restoreDiscardsEdits": "排程版本還原會捨棄目前看板上其他未儲存變更(拖曳、刪除、新增店鋪/車線、物流欄等)。確定繼續?", | |||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}};請按「儲存更改」寫入後端。", | |||
| "diff_restoreAlreadyPending": "此版本已在排程還原中;請按「儲存更改」套用。", | |||
| "diff_restoreScheduled": "已排程還原至版本 #{{versionId}}。", | |||
| "diff_restoreAlreadyPending": "此版本已在排程還原中。", | |||
| "restore_applied": "已從版本還原並重新載入看板。", | |||
| "restore_appliedDroppedStaging": "已套用版本還原;本次儲存略過其他暫存變更(請重新編輯)。", | |||
| "confirm_restoreSaveWillDropStaging": "儲存時將先套用版本還原,本次其他暫存變更會被略過。確定繼續?", | |||
| @@ -120,6 +120,7 @@ | |||
| "version_ui_listAria": "版本歷史列表", | |||
| "version_ui_snapshotBadge": "目前版本", | |||
| "version_ui_id": "版本#{{id}}", | |||
| "version_ui_pendingRestore": "版本#{{id}}(待還原)", | |||
| "version_ui_none": "尚無版本", | |||
| "version_ui_editedBy": "編輯者:{{name}}", | |||
| "version_note_placeholder": "備註(離開欄位即儲存)", | |||
| @@ -161,8 +162,8 @@ | |||
| "diff_logisticMaster_added": "新增", | |||
| "diff_logisticMaster_edited": "修改", | |||
| "diff_noShopDiffHasBoardStaged": "與上一版本相比,店鋪列無差異;下列為看板上尚未按「儲存更改」寫入的變更(含新增物流公司)。", | |||
| "diff_export_blockedTooltip": "匯出檔為後端兩版本比對,不含看板未儲存變更。請先按「儲存更改」或取消變更後再匯出。", | |||
| "diff_export_blockedError": "有看板未儲存變更時無法匯出(Excel 僅含已落庫版本)。", | |||
| "diff_export_blockedTooltip": "匯出檔為後端兩版本比對,不含未儲存變更。請先按「儲存更改」或取消變更後再匯出。", | |||
| "diff_export_blockedError": "有未儲存變更時無法匯出。", | |||
| "diff_markedCount": "{{count}} 筆變更", | |||
| "diff_noDiffFromPrev": "與上一版無差異", | |||
| "diff_loadingEllipsis": "…", | |||
| @@ -173,8 +174,7 @@ | |||
| "addRoute_submitting": "新增中…", | |||
| "district_dialog_add": "新增地區", | |||
| "district_dialog_edit": "編輯地區", | |||
| "district_name_label": "地區顯示名稱", | |||
| "district_name_ph": "空白表示「未分類」", | |||
| "district_name_label": "地區名稱", | |||
| "seq_edit_departureLabel": "出車時間", | |||
| "seq_edit_seqLabel": "裝車順序", | |||
| "route_new_code_label": "車線編號", | |||
| @@ -200,21 +200,20 @@ | |||
| "dialog_editLogisticsTitle": "編輯物流公司", | |||
| "btn_apply": "套用", | |||
| "addShop_confirm": "確認", | |||
| "addShop_listHint": "同車線內已存在的店鋪代碼不會出現在清單。新增後若要改順序可拖曳;與其他編輯相同,需按「儲存更改」才會寫入後端 truck。", | |||
| "departureDialog_title": "編輯出車時間", | |||
| "departureDialog_hint": "套用至此車線所有店鋪列;按上方「儲存更改」寫入後端。", | |||
| "departureDialog_hint": "套用至此車線所有店鋪列。", | |||
| "seqDialog_title": "編輯裝車順序", | |||
| "logistics_colLaneCount": "{{count}} 條車線", | |||
| "tooltip_openLaneBoard": "在車線看板開此車線", | |||
| "aria_openLaneBoard": "開啟車線看板", | |||
| "tooltip_removeFromLane": "從此車線移除", | |||
| "tooltip_clearLaneShops": "清空此車線所有店鋪(按「儲存更改」才寫入後端)", | |||
| "tooltip_clearLaneShops": "清空此車線所有店鋪", | |||
| "tooltip_pickLane": "選擇車線(加入看板勾選並捲動到該欄)", | |||
| "aria_pickLane": "選擇車線", | |||
| "aria_searchLanes": "搜索車線", | |||
| "logistics_colShopCount": "{{count}} 家店鋪", | |||
| "tooltip_editLogisticsDb": "編輯物流公司(須按「儲存更改」寫入)", | |||
| "tooltip_deleteLogistics": "刪除物流公司(須按「儲存更改」寫入)", | |||
| "tooltip_editLogisticsDb": "編輯物流公司", | |||
| "tooltip_deleteLogistics": "刪除物流公司", | |||
| "aria_editLogistics": "編輯物流公司", | |||
| "aria_deleteLogistics": "刪除物流公司", | |||
| "confirm_deleteLogistic": "確定刪除物流公司「{{name}}」?", | |||
| @@ -301,12 +300,15 @@ | |||
| "schedule_review_queue": "變更預覽佇列", | |||
| "schedule_review_count": "{{count}} 項變更", | |||
| "schedule_review_empty": "暫無待排程變更", | |||
| "schedule_review_empty_hint": "請拖曳店鋪以調整其車線分配或裝載排程順序,或使用刪除按鈕移除店鋪", | |||
| "schedule_review_empty_hint": "請拖曳店鋪以調整其車線分配或裝載排程順序,或編輯出車時間、新增店鋪、刪除按鈕變更車線內容", | |||
| "schedule_review_revert": "復原", | |||
| "schedule_review_create_action": "新增至:", | |||
| "schedule_review_delete_action": "刪除自:", | |||
| "schedule_review_lane_change": "車線變更:", | |||
| "schedule_review_district_change": "區域參考:", | |||
| "schedule_review_seq": "裝載順序:", | |||
| "schedule_review_departure_shops": "影響 {{count}} 間店鋪", | |||
| "schedule_review_departure_pending_creates": "影響 {{count}} 筆待新增店鋪", | |||
| "schedule_drop_hint": "請拖曳店鋪卡片至此車線", | |||
| "schedule_moved_badge": "移入", | |||
| "schedule_drag_seq": "裝載順序: {{seq}}", | |||
| @@ -325,7 +327,8 @@ | |||
| "schedule_err_generic": "排程請求失敗,請稍後再試。", | |||
| "schedule_reschedule_time_adjusted": "原執行時間已過去,已自動調整為 {{at}}。", | |||
| "schedule_shop_badge": "已排程變更", | |||
| "schedule_shop_locked": "排程執行中,此店鋪暫不可手改", | |||
| "schedule_shop_locked": "排程即將執行(或執行中),此店鋪已鎖定不可手改", | |||
| "schedule_shop_scheduled": "此店鋪有待執行排程(尚未鎖定,仍可編輯;執行前將鎖定)", | |||
| "schedule_retry_rejects_partial": "部分排程不可重試,請先還原看板後重新建立排程", | |||
| "schedule_registered_snackbar": "已登記 {{count}} 筆預約變更,將於執行時間由伺服器自動套用。", | |||
| "schedule_import_parse_summary": "解析完成:有效 {{valid}} 筆,錯誤 {{errors}} 筆", | |||