Explorar el Código

Merge branch 'production' into fix負數倉

fix負數倉
CANCERYS\kw093 hace 13 horas
padre
commit
66145c66a1
Se han modificado 23 ficheros con 1342 adiciones y 378 borrados
  1. +1
    -1
      src/app/api/jo/actions.ts
  2. +74
    -0
      src/app/api/shop/actions.ts
  3. +20
    -1
      src/app/api/shop/client.ts
  4. +7
    -7
      src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx
  5. +21
    -6
      src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx
  6. +2
    -1
      src/components/JoWorkbench/newJobPickExecution.tsx
  7. +2
    -2
      src/components/NavigationContent/NavigationContent.tsx
  8. +36
    -19
      src/components/ProductionProcess/ProductionOutputForm.tsx
  9. +35
    -24
      src/components/ProductionProcess/ProductionOutputFormPage.tsx
  10. +10
    -1
      src/components/ProductionProcess/ProductionProcessDetail.tsx
  11. +14
    -7
      src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx
  12. +25
    -8
      src/components/ProductionProcess/ProductionProcessList.tsx
  13. +68
    -37
      src/components/ProductionProcess/ProductionProcessStepExecution.tsx
  14. +8
    -7
      src/components/Qc/QcStockInModal.tsx
  15. +957
    -249
      src/components/Shop/RouteBoard.tsx
  16. +2
    -0
      src/i18n/en/jo.json
  17. +2
    -0
      src/i18n/en/productionProcess.json
  18. +1
    -1
      src/i18n/en/routeboard.json
  19. +26
    -3
      src/i18n/en/shop.json
  20. +2
    -0
      src/i18n/zh/jo.json
  21. +2
    -0
      src/i18n/zh/productionProcess.json
  22. +1
    -1
      src/i18n/zh/routeboard.json
  23. +26
    -3
      src/i18n/zh/shop.json

+ 1
- 1
src/app/api/jo/actions.ts Ver fichero

@@ -906,7 +906,7 @@ export const fetchAllJoborderProductProcessInfo = cache(async (type?: string | n
);
});

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */
/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */
export const fetchJoborderProductProcessesPage = cache(async (params: {
/** Job order / process date(YYYY-MM-DD) */
date?: string | null;


+ 74
- 0
src/app/api/shop/actions.ts Ver fichero

@@ -924,4 +924,78 @@ export const importTruckLaneScheduleExcelAction = async (
);
}
return (await response.json()) as TruckLaneScheduleResponse[];
};

export interface PreviewTruckLaneRenameRequest {
truckLanceCode: string;
storeId: string;
remark?: string | null;
}

export interface PreviewTruckLaneRenameResponse {
unfinishedCount: number;
completedCount: number;
handlerNames: string[];
openScheduleCount: number;
}

export interface RenameTruckLaneRequest {
truckLanceCode: string;
storeId: string;
remark?: string | null;
newTruckLanceCode: string;
departureTimeChanged: boolean;
newDepartureTime?: string | null;
}

export interface RenameTruckLaneResponse {
truckLanceCode: string;
ticketCount: number;
scheduleLineCount: number;
}

export interface TruckLaneRenameLogResponse {
id: number;
created?: string | null;
createdBy?: string | null;
oldCode: string;
newCode: string;
storeId: string;
remark?: string | null;
oldDepartureTime?: string | null;
newDepartureTime?: string | null;
ticketCount: number;
/** `truck.id` of shops in the renamed lane, comma-separated. */
truckIds?: string | null;
}

export const previewTruckLaneRenameAction = async (
data: PreviewTruckLaneRenameRequest,
) => {
return serverFetchJson<PreviewTruckLaneRenameResponse>(
`${BASE_API_URL}/truck/previewTruckLaneRename`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
};

export const renameTruckLaneAction = async (data: RenameTruckLaneRequest) => {
return serverFetchJson<RenameTruckLaneResponse>(
`${BASE_API_URL}/truck/renameTruckLane`,
{
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
},
);
};

export const listTruckLaneRenameLogsAction = async () => {
return serverFetchJson<TruckLaneRenameLogResponse[]>(
`${BASE_API_URL}/truck/truckLaneRenameLog`,
{ method: "GET", headers: { "Content-Type": "application/json" } },
);
};

+ 20
- 1
src/app/api/shop/client.ts Ver fichero

@@ -66,7 +66,12 @@ import {
type SaveTruckRequest,
type UpdateTruckShopDetailsRequest,
type CreateTruckWithoutShopRequest,
type MessageResponse
type MessageResponse,
previewTruckLaneRenameAction,
renameTruckLaneAction,
listTruckLaneRenameLogsAction,
type PreviewTruckLaneRenameRequest,
type RenameTruckLaneRequest,
} from "./actions";

export const fetchAllShopsClient = async (params?: Record<string, string | number | null>) => {
@@ -276,6 +281,20 @@ export const parseTruckLaneScheduleExcelClient = async (
return await parseTruckLaneScheduleExcelAction(formData, defaultExecuteAt);
};

export const previewTruckLaneRenameClient = async (
data: PreviewTruckLaneRenameRequest,
) => {
return await previewTruckLaneRenameAction(data);
};

export const renameTruckLaneClient = async (data: RenameTruckLaneRequest) => {
return await renameTruckLaneAction(data);
};

export const listTruckLaneRenameLogsClient = async () => {
return await listTruckLaneRenameLogsAction();
};

export const importTruckLaneScheduleExcelClient = async (
formData: FormData,
defaultExecuteAt?: string | null,


+ 7
- 7
src/components/DashboardPage/truckSchedule/TruckScheduleDashboard.tsx Ver fichero

@@ -203,13 +203,13 @@ const TruckScheduleDashboard: React.FC = () => {
return '-';
};

// Compare a timestamp against the selected date (today/tomorrow/...) and return a short label.
// Used to avoid confusion when picking crosses midnight.
// Compare a timestamp against calendar today. Same-day picks show no chip;
// only midnight-crossing (yesterday / tomorrow / N days) gets a label.
// Does not filter rows — trucklane tickets for the selected delivery date still show.
const relativeDayLabel = useCallback(
(dateTimeData: string | number[] | null, dateOption: string): string | null => {
(dateTimeData: string | number[] | null): string | null => {
if (!dateTimeData) return null;
const dateOffset = getDateOffset(dateOption);
const baseDate = dayjs().startOf('day').add(dateOffset, 'day');
const baseDate = dayjs().startOf('day');

let dt: dayjs.Dayjs;
if (Array.isArray(dateTimeData)) {
@@ -518,8 +518,8 @@ const TruckScheduleDashboard: React.FC = () => {
filteredData.map((row, index) => {
const timeRemaining = calculateTimeRemaining(row, selectedDate);
const chipColor = getTimeChipColor(row, selectedDate);
const startRel = relativeDayLabel(row.firstTicketStartTime, selectedDate);
const endRel = relativeDayLabel(row.lastTicketEndTime, selectedDate);
const startRel = relativeDayLabel(row.firstTicketStartTime);
const endRel = relativeDayLabel(row.lastTicketEndTime);
return (
<TableRow


+ 21
- 6
src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx Ver fichero

@@ -91,6 +91,12 @@ export interface WorkbenchLotLabelPrintModalProps {
/** 與 statusTitleText 搭配;預設 error(舊版固定紅字) */
statusTitleSeverity?: "success" | "warning" | "error";
warehouseCodePrefixFilter?: string;
/**
* Hide lots whose warehouse code equals an entry or starts with `{entry}-`.
* JO passes `4F` so the pick-station list matches the suggestion block list.
* The row that opened the dialog stays visible.
*/
excludeWarehouseCodePrefixes?: string[];
/**
* When true, omit the API 「scanned」 lot from the merged list (legacy FG-style).
* Workbench should leave false so the current row’s lot appears for label print / scan-pick.
@@ -147,7 +153,7 @@ function isLabelPrinter(p: Printer): boolean {
return s.includes("label") && !s.includes("a4");
}

/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */
/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.2 | 2026-11-02 */
const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> = ({
open,
onClose,
@@ -159,6 +165,7 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
statusTitleText,
statusTitleSeverity = "error",
warehouseCodePrefixFilter,
excludeWarehouseCodePrefixes,
hideTriggeredLot = false,
triggerLotAvailableQty = null,
triggerLotUom = null,
@@ -450,14 +457,22 @@ const WorkbenchLotLabelPrintModal: React.FC<WorkbenchLotLabelPrintModalProps> =
]);

const filteredLots = useMemo(() => {
const prefix = String(warehouseCodePrefixFilter ?? "").trim();
if (!prefix) return availableLots;
const prefix = String(warehouseCodePrefixFilter ?? "").trim().toUpperCase();
const blocked = (excludeWarehouseCodePrefixes ?? [])
.map((entry) => entry.trim().toUpperCase())
.filter((entry) => entry.length > 0);
return availableLots.filter((lot) => {
// 使用者從本列開啟視窗:即使 API 未帶 warehouseCode,仍應顯示目前這筆批號
// 使用者從本列開啟視窗:即使倉位在排除清單,仍顯示目前這筆批號
if (lot._scanned) return true;
return String(lot.warehouseCode ?? "").startsWith(prefix);
const code = String(lot.warehouseCode ?? "").trim().toUpperCase();
const blockedHit = blocked.some(
(entry) => code === entry || code.startsWith(`${entry}-`),
);
if (blockedHit) return false;
if (!prefix) return true;
return code.startsWith(prefix);
});
}, [availableLots, warehouseCodePrefixFilter]);
}, [availableLots, warehouseCodePrefixFilter, excludeWarehouseCodePrefixes]);

const selectedPrinter = useMemo(() => {
if (selectedPrinterId === "") return null;


+ 2
- 1
src/components/JoWorkbench/newJobPickExecution.tsx Ver fichero

@@ -618,7 +618,7 @@ const QrCodeModal: React.FC<{
);
};

/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.1 | 2026-08-10 */
/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.2 | 2026-11-02 */
const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCombo = [] }) => {
const workbenchMode = true;
const { t } = useTranslation("jo");
@@ -4642,6 +4642,7 @@ const JobPickExecution: React.FC<Props> = ({ filterArgs, onBackToList, printerCo
workbenchLotLabelContextLot != null
}
warehouseCodePrefixFilter={lotFloorPrefixFilter}
excludeWarehouseCodePrefixes={["4F"]}
triggerLotAvailableQty={
workbenchLotLabelContextLot != null
? Number(workbenchLotLabelContextLot.availableQty)


+ 2
- 2
src/components/NavigationContent/NavigationContent.tsx Ver fichero

@@ -251,7 +251,7 @@ const NavigationContent: React.FC = () => {
icon: <ShowChart />,
labelKey: "nav.chartReports",
path: "",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE],
isHidden: false,
children: [
{
@@ -280,7 +280,7 @@ const NavigationContent: React.FC = () => {
icon: <LocalShipping />,
labelKey: "nav.chart.delivery",
path: "/chart/delivery",
requiredAbility: [AUTH.TESTING, AUTH.ADMIN],
requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE],
},
{
id: "nav.chart.warehouse",


+ 36
- 19
src/components/ProductionProcess/ProductionOutputForm.tsx Ver fichero

@@ -17,25 +17,42 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { useTranslation } from "react-i18next";
import { UpdateProductProcessLineQtyRequest } from "@/app/api/jo/actions";

type QtyInput = number | "";

export type ProductionOutputFormData = Omit<
UpdateProductProcessLineQtyRequest,
| "outputFromProcessQty"
| "byproductQty"
| "defectQty"
| "defect2Qty"
| "defect3Qty"
| "scrapQty"
> & {
outputFromProcessQty: QtyInput;
byproductQty: QtyInput;
defectQty: QtyInput;
defect2Qty: QtyInput;
defect3Qty: QtyInput;
scrapQty: QtyInput;
};

const parseQtyInput = (raw: string): QtyInput => {
if (raw === "") return "";
const n = parseInt(raw, 10);
return Number.isNaN(n) ? "" : n;
};

export const toSubmitQty = (v: QtyInput | null | undefined): number =>
v === "" || v == null || Number.isNaN(Number(v)) ? 0 : Number(v);

interface ProductionOutputFormProps {
outputData: (UpdateProductProcessLineQtyRequest & {
byproductName: string;
byproductQty: number;
byproductUom: string;
});
setOutputData: React.Dispatch<
React.SetStateAction<
UpdateProductProcessLineQtyRequest & {
byproductName: string;
byproductQty: number;
byproductUom: string;
}
>
>;
outputData: ProductionOutputFormData;
setOutputData: React.Dispatch<React.SetStateAction<ProductionOutputFormData>>;
onSubmit: () => Promise<void> | void;
onCancel: () => void;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.1 | 2026-10-29 */
const ProductionOutputForm: React.FC<ProductionOutputFormProps> = ({
outputData,
setOutputData,
@@ -68,7 +85,7 @@ const ProductionOutputForm: React.FC<ProductionOutputFormProps> = ({
onChange={(e) =>
setOutputData({
...outputData,
outputFromProcessQty: parseInt(e.target.value) || 0,
outputFromProcessQty: parseQtyInput(e.target.value),
})
}
/>
@@ -103,7 +120,7 @@ const ProductionOutputForm: React.FC<ProductionOutputFormProps> = ({
onChange={(e) =>
setOutputData({
...outputData,
byproductQty: parseInt(e.target.value) || 0,
byproductQty: parseQtyInput(e.target.value),
})
}
/>
@@ -138,7 +155,7 @@ const ProductionOutputForm: React.FC<ProductionOutputFormProps> = ({
onChange={(e) =>
setOutputData({
...outputData,
defectQty: parseInt(e.target.value) || 0,
defectQty: parseQtyInput(e.target.value),
})
}
/>
@@ -173,7 +190,7 @@ const ProductionOutputForm: React.FC<ProductionOutputFormProps> = ({
onChange={(e) =>
setOutputData({
...outputData,
scrapQty: parseInt(e.target.value) || 0,
scrapQty: parseQtyInput(e.target.value),
})
}
/>
@@ -207,4 +224,4 @@ const ProductionOutputForm: React.FC<ProductionOutputFormProps> = ({
);
};

export default ProductionOutputForm;
export default ProductionOutputForm;

+ 35
- 24
src/components/ProductionProcess/ProductionOutputFormPage.tsx Ver fichero

@@ -5,47 +5,47 @@ import {
Button,
Typography,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useTranslation } from "react-i18next";
import {
fetchProductProcessLineDetail,
updateProductProcessLineQty,
UpdateProductProcessLineQtyRequest,
JobOrderProcessLineDetailResponse,
} from "@/app/api/jo/actions";
import ProductionOutputForm from "./ProductionOutputForm";
import ProductionOutputForm, {
ProductionOutputFormData,
toSubmitQty,
} from "./ProductionOutputForm";

interface ProductionOutputFormPageProps {
lineId: number | null;
onBack: () => void;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.1 | 2026-10-29 */
const ProductionOutputFormPage: React.FC<ProductionOutputFormPageProps> = ({
lineId,
onBack,
}) => {
const { t } = useTranslation();
const { t } = useTranslation(["common", "jo", "productionProcess"]);
const [lineDetail, setLineDetail] = useState<JobOrderProcessLineDetailResponse | null>(null);
const [outputData, setOutputData] = useState<UpdateProductProcessLineQtyRequest & {
byproductName: string;
byproductQty: number;
byproductUom: string;
}>({
const [outputData, setOutputData] = useState<ProductionOutputFormData>({
productProcessLineId: lineId ?? 0,
outputFromProcessQty: 0,
outputFromProcessQty: "",
outputFromProcessUom: "",
defectQty: 0,
defectQty: "",
defectUom: "",
defect2Qty: 0,
defect2Qty: "",
defect2Uom: "",
defect3Qty: 0,
defect3Qty: "",
defect3Uom: "",
defectDescription: "",
defectDescription2: "",
defectDescription3: "",
scrapQty: 0,
scrapQty: "",
scrapUom: "",
byproductName: "",
byproductQty: 0,
byproductQty: "",
byproductUom: "",
});

@@ -60,14 +60,14 @@ const ProductionOutputFormPage: React.FC<ProductionOutputFormPageProps> = ({
setOutputData((prev) => ({
...prev,
productProcessLineId: detail.id,
outputFromProcessQty: (detail as any).outputFromProcessQty || 0,
outputFromProcessQty: (detail as any).outputFromProcessQty || "",
outputFromProcessUom: (detail as any).outputFromProcessUom || "",
defectQty: detail.defectQty || 0,
defectQty: detail.defectQty || "",
defectUom: detail.defectUom || "",
scrapQty: detail.scrapQty || 0,
scrapQty: detail.scrapQty || "",
scrapUom: detail.scrapUom || "",
byproductName: detail.byproductName || "",
byproductQty: detail.byproductQty || 0,
byproductQty: detail.byproductQty || "",
byproductUom: detail.byproductUom || "",
}));
})
@@ -84,6 +84,12 @@ const ProductionOutputFormPage: React.FC<ProductionOutputFormPageProps> = ({
await updateProductProcessLineQty({
...outputData,
productProcessLineId: lineDetail.id || 0,
outputFromProcessQty: toSubmitQty(outputData.outputFromProcessQty),
byproductQty: toSubmitQty(outputData.byproductQty),
defectQty: toSubmitQty(outputData.defectQty),
defect2Qty: toSubmitQty(outputData.defect2Qty),
defect3Qty: toSubmitQty(outputData.defect3Qty),
scrapQty: toSubmitQty(outputData.scrapQty),
});

console.log("Output data submitted successfully");
@@ -94,14 +100,14 @@ const ProductionOutputFormPage: React.FC<ProductionOutputFormPageProps> = ({
setOutputData((prev) => ({
...prev,
productProcessLineId: detail.id,
outputFromProcessQty: (detail as any).outputFromProcessQty || 0,
outputFromProcessQty: (detail as any).outputFromProcessQty || "",
outputFromProcessUom: (detail as any).outputFromProcessUom || "",
defectQty: detail.defectQty || 0,
defectQty: detail.defectQty || "",
defectUom: detail.defectUom || "",
scrapQty: detail.scrapQty || 0,
scrapQty: detail.scrapQty || "",
scrapUom: detail.scrapUom || "",
byproductName: detail.byproductName || "",
byproductQty: detail.byproductQty || 0,
byproductQty: detail.byproductQty || "",
byproductUom: detail.byproductUom || "",
}));

@@ -116,8 +122,13 @@ const ProductionOutputFormPage: React.FC<ProductionOutputFormPageProps> = ({
return (
<Box>
<Box sx={{ mb: 2 }}>
<Button variant="outlined" onClick={onBack}>
{t("Back to List")}
<Button
variant="outlined"
size="small"
onClick={onBack}
startIcon={<ArrowBackIcon />}
>
{t("Back to Process Steps")}
</Button>
</Box>



+ 10
- 1
src/components/ProductionProcess/ProductionProcessDetail.tsx Ver fichero

@@ -64,13 +64,16 @@ interface ProductProcessDetailProps {
jobOrderId: number;
onBack: () => void;
fromJosave?: boolean;
/** 進入步驟執行頁時通知父層(用來隱藏「返回列表」) */
onStepViewChange?: (inStepView: boolean) => void;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.1 | 2026-08-10 */
/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.2 | 2026-10-29 */
const ProductionProcessDetail: React.FC<ProductProcessDetailProps> = ({
jobOrderId,
onBack,
fromJosave,
onStepViewChange,
}) => {
// console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave });
@@ -143,6 +146,12 @@ const fetchProcessDetailRef = useRef<() => Promise<void>>();
useEffect(() => {
onBackRef.current = onBack;
}, [onBack]);

useEffect(() => {
const inStepView = isExecutingLine && selectedLineId != null;
onStepViewChange?.(inStepView);
return () => onStepViewChange?.(false);
}, [isExecutingLine, selectedLineId, onStepViewChange]);
// 获取 process 和 lines 数据
const fetchProcessDetail = useCallback(async () => {
// console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() });


+ 14
- 7
src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx Ver fichero

@@ -54,7 +54,7 @@ interface ProductProcessJobOrderDetailProps {
initialTabIndex?: number;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */
/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */
const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProps> = ({
jobOrderId,
onBack,
@@ -68,6 +68,7 @@ const ProductionProcessJobOrderDetail: React.FC<ProductProcessJobOrderDetailProp
const [inventoryData, setInventoryData] = useState<InventoryResult[]>([]);
const [tabIndex, setTabIndex] = useState(initialTabIndex);
const [selectedProcessId, setSelectedProcessId] = useState<number | null>(null);
const [inStepView, setInStepView] = useState(false);
const [operationPriority, setOperationPriority] = useState<number>(50);
const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false);
const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false);
@@ -320,6 +321,9 @@ const handleRelease = useCallback(async (jobOrderId: number) => {
const handleTabChange = useCallback<NonNullable<TabsProps["onChange"]>>(
(_e, newValue) => {
setTabIndex(newValue);
if (newValue !== 2) {
setInStepView(false);
}
},
[],
);
@@ -796,12 +800,14 @@ const handleRelease = useCallback(async (jobOrderId: number) => {

return (
<Box>
{/* 返回按钮 */}
<Box sx={{ mb: 2 }}>
<Button variant="outlined" onClick={onBack} startIcon={<ArrowBackIcon />}>
{t("Back to List")}
</Button>
</Box>
{/* 步驟執行頁不顯示「返回列表」,避免誤離開工單 */}
{!inStepView && (
<Box sx={{ mb: 2 }}>
<Button variant="outlined" onClick={onBack} startIcon={<ArrowBackIcon />}>
{t("Back to List")}
</Button>
</Box>
)}
{/* 标签页 */}
<Box sx={{ borderBottom: '1px solid #e0e0e0' }}>
@@ -833,6 +839,7 @@ const handleRelease = useCallback(async (jobOrderId: number) => {

}}
fromJosave={Boolean(fromJosave && !isPutAwayed)}
onStepViewChange={setInStepView}
/>
)}
{tabIndex === 3 && <ProductionProcessesLineRemarkTableContent />}


+ 25
- 8
src/components/ProductionProcess/ProductionProcessList.tsx Ver fichero

@@ -29,6 +29,7 @@ import {
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
import { useTranslation } from "react-i18next";
import type { StockInLine } from "@/app/api/stockIn";
import { fetchItemForPutAway } from "@/app/api/stockIn/actions";
import QcStockInModal from "../Qc/QcStockInModal";
import { useSession } from "next-auth/react";
@@ -178,7 +179,7 @@ function isWaitingQcPutAway(
return s !== "completed" && s !== "rejected";
}

/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.9 | 2026-08-10 */
/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */
const ProductProcessList: React.FC<ProductProcessListProps> = ({
onSelectProcess,
printerCombo,
@@ -191,6 +192,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
const { data: session } = useSession() as { data: SessionWithTokens | null };
const sessionToken = session as SessionWithTokens | null;
const [loading, setLoading] = useState(false);
/** Bumps on Search/Reset so the same criteria still refetch. */
const [listReloadKey, setListReloadKey] = useState(0);
const [productionCache, setProductionCache] = useState<
AllJoborderProductProcessInfoResponse[]
>([]);
@@ -234,6 +237,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
() => printerOptions[0] ?? null,
);
const printInFlightRef = useRef(false);
const loadSeqRef = useRef(0);
const hasLoadedOnceRef = useRef(false);
const [printingSilId, setPrintingSilId] = useState<number | null>(null);

useEffect(() => {
@@ -328,6 +333,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
const handleApplySearch = useCallback(
(inputs: Record<SearchParam | `${SearchParam}To`, string>) => {
const selectedProcessType = (inputs.processType || "all") as ProcessFilter;
setListReloadKey((k) => k + 1);
onListPersistedStateChange((prev) => ({
...prev,
filter: selectedProcessType,
@@ -342,6 +348,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
);

const handleResetSearch = useCallback(() => {
setListReloadKey((k) => k + 1);
onListPersistedStateChange((prev) => ({
...prev,
filter: "all",
@@ -355,8 +362,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
}, [disableDateFilter, onListPersistedStateChange]);

/** Load all tab datasets once per search; tab switches use cache only. */
const loadAllCaches = useCallback(async () => {
setLoading(true);
const loadAllCaches = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent === true;
const seq = ++loadSeqRef.current;
if (!silent) setLoading(true);
try {
const typeParam = filter === "all" ? undefined : filter;
const base = {
@@ -391,6 +400,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
}),
]);

if (seq !== loadSeqRef.current) return;

const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p));
const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p));
const putawayContent = putawayed?.content || [];
@@ -405,6 +416,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
setCarriedOverCount(prod?.carriedOverCount ?? 0);
} catch (e) {
console.error(e);
if (seq !== loadSeqRef.current || silent) return;
setProductionCache([]);
setPendingQcCache([]);
setPutawayedCache([]);
@@ -413,12 +425,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
setPutawayedCount(0);
setCarriedOverCount(0);
} finally {
setLoading(false);
if (seq === loadSeqRef.current) setLoading(false);
}
}, [appliedSearch, disableDateFilter, filter]);
}, [appliedSearch, disableDateFilter, filter, listReloadKey]);

useEffect(() => {
void loadAllCaches();
const silent = hasLoadedOnceRef.current;
hasLoadedOnceRef.current = true;
void loadAllCaches(silent ? { silent: true } : undefined);
}, [loadAllCaches]);

const offPlanRows = useMemo(() => {
@@ -670,9 +684,12 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({
closeConfirm();
}
}, [pendingConfirmAction, closeConfirm]);
const closeNewModal = useCallback(() => {
const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => {
setOpenModal(false);
}, []);
if (updatedStockInLine) {
void loadAllCaches({ silent: true });
}
}, [loadAllCaches]);

const handlePrintLabel = useCallback(
async (process: AllJoborderProductProcessInfoResponse) => {


+ 68
- 37
src/components/ProductionProcess/ProductionProcessStepExecution.tsx Ver fichero

@@ -28,6 +28,7 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import StopIcon from "@mui/icons-material/Stop";
import PauseIcon from "@mui/icons-material/Pause";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import { useTranslation } from "react-i18next";
import {
JobOrderProcessLineDetailResponse,
@@ -47,6 +48,35 @@ import { fetchNameList, NameList } from "@/app/api/user/actions";
import BagConsumptionForm from "./BagConsumptionForm"; // ✅ 添加导入
import OverallTimeRemainingCard from "./OverallTimeRemainingCard"; // ✅ 添加导入
import dayjs from "dayjs";

type QtyInput = number | "";

type OutputFormState = Omit<
UpdateProductProcessLineQtyRequest,
| "outputFromProcessQty"
| "byproductQty"
| "defectQty"
| "defect2Qty"
| "defect3Qty"
| "scrapQty"
> & {
outputFromProcessQty: QtyInput;
byproductQty: QtyInput;
defectQty: QtyInput;
defect2Qty: QtyInput;
defect3Qty: QtyInput;
scrapQty: QtyInput;
};

const parseQtyInput = (raw: string): QtyInput => {
if (raw === "") return "";
const n = parseInt(raw, 10);
return Number.isNaN(n) ? "" : n;
};

const toSubmitQty = (v: QtyInput | null | undefined): number =>
v === "" || v == null || Number.isNaN(Number(v)) ? 0 : Number(v);

interface ProductionProcessStepExecutionProps {
lineId: number | null;
onBack: () => void;
@@ -55,7 +85,7 @@ interface ProductionProcessStepExecutionProps {
jobOrderId?: number; // ✅ 添加
}

/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.0 | 2026-08-10 */
/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.1 | 2026-10-29 */
const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionProps> = ({
lineId,
onBack,
@@ -75,24 +105,20 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
lineStatusNorm === "autopass";
const isPassStatus = lineStatusNorm === "pass";
const isAutoPassStatus = lineStatusNorm === "autopass";
const [outputData, setOutputData] = useState<UpdateProductProcessLineQtyRequest & {
byproductName: string;
byproductQty: number;
byproductUom: string;
}>({
const [outputData, setOutputData] = useState<OutputFormState>({
productProcessLineId: lineId ?? 0,
outputFromProcessQty: 0,
outputFromProcessQty: "",
outputFromProcessUom: "",
defectQty: 0,
defectQty: "",
defectUom: "",
scrapQty: 0,
scrapQty: "",
scrapUom: "",
byproductName: "",
byproductQty: 0,
byproductQty: "",
byproductUom: "",
defect2Qty: 0,
defect2Qty: "",
defect2Uom: "",
defect3Qty: 0,
defect3Qty: "",
defect3Uom: "",
defectDescription: "",
defectDescription2: "",
@@ -153,14 +179,14 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
setOutputData(prev => ({
...prev,
productProcessLineId: detail.id,
outputFromProcessQty: (detail as any).outputFromProcessQty || 0,
outputFromProcessQty: (detail as any).outputFromProcessQty || "",
outputFromProcessUom: (detail as any).outputFromProcessUom || "",
defectQty: detail.defectQty || 0,
defectQty: detail.defectQty || "",
defectUom: detail.defectUom || "",
scrapQty: detail.scrapQty || 0,
scrapQty: detail.scrapQty || "",
scrapUom: detail.scrapUom || "",
byproductName: detail.byproductName || "",
byproductQty: detail.byproductQty || 0,
byproductQty: detail.byproductQty || "",
byproductUom: detail.byproductUom || ""
}));
})
@@ -351,20 +377,20 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
await updateProductProcessLineQty({
productProcessLineId: lineDetail?.id || 0 as number,
byproductName: outputData.byproductName,
byproductQty: outputData.byproductQty,
byproductQty: toSubmitQty(outputData.byproductQty),
byproductUom: outputData.byproductUom,
outputFromProcessQty: outputData.outputFromProcessQty,
outputFromProcessQty: toSubmitQty(outputData.outputFromProcessQty),
outputFromProcessUom: outputData.outputFromProcessUom,
defectQty: outputData.defectQty,
defectQty: toSubmitQty(outputData.defectQty),
defectUom: outputData.defectUom,
defect2Qty: outputData.defect2Qty,
defect2Qty: toSubmitQty(outputData.defect2Qty),
defect2Uom: outputData.defect2Uom,
defect3Qty: outputData.defect3Qty,
defect3Qty: toSubmitQty(outputData.defect3Qty),
defect3Uom: outputData.defect3Uom,
defectDescription: outputData.defectDescription,
defectDescription2: outputData.defectDescription2,
defectDescription3: outputData.defectDescription3,
scrapQty: outputData.scrapQty,
scrapQty: toSubmitQty(outputData.scrapQty),
scrapUom: outputData.scrapUom,
});

@@ -383,21 +409,21 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
setOutputData(prev => ({
...prev,
productProcessLineId: detail.id,
outputFromProcessQty: (detail as any).outputFromProcessQty || 0,
outputFromProcessQty: (detail as any).outputFromProcessQty || "",
outputFromProcessUom: (detail as any).outputFromProcessUom || "",
defectQty: detail.defectQty || 0,
defectQty: detail.defectQty || "",
defectUom: detail.defectUom || "",
defectDescription: detail.defectDescription || "",
defectDescription2: detail.defectDescription2 || "",
defectDescription3: detail.defectDescription3 || "",
defectQty2: detail.defectQty2 || 0,
defectUom2: detail.defectUom2 || "",
defectQty3: detail.defectQty3 || 0,
defectUom3: detail.defectUom3 || "",
scrapQty: detail.scrapQty || 0,
defect2Qty: (detail as any).defect2Qty || "",
defect2Uom: (detail as any).defect2Uom || "",
defect3Qty: (detail as any).defect3Qty || "",
defect3Uom: (detail as any).defect3Uom || "",
scrapQty: detail.scrapQty || "",
scrapUom: detail.scrapUom || "",
byproductName: detail.byproductName || "",
byproductQty: detail.byproductQty || 0,
byproductQty: detail.byproductQty || "",
byproductUom: detail.byproductUom || ""
}));
})
@@ -561,8 +587,13 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
return (
<Box>
<Box sx={{ mb: 2 }}>
<Button variant="outlined" onClick={onBack}>
{t("Back to List")}
<Button
variant="outlined"
size="small"
onClick={onBack}
startIcon={<ArrowBackIcon />}
>
{t("Back to Process Steps")}
</Button>
</Box>
{processData && (
@@ -812,7 +843,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
value={outputData.outputFromProcessQty}
onChange={(e) => setOutputData({
...outputData,
outputFromProcessQty: parseInt(e.target.value) || 0
outputFromProcessQty: parseQtyInput(e.target.value)
})}
/>
</TableCell>
@@ -854,7 +885,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
value={outputData.defectQty}
onChange={(e) => setOutputData({
...outputData,
defectQty: parseInt(e.target.value) || 0
defectQty: parseQtyInput(e.target.value)
})}
/>
</TableCell>
@@ -902,7 +933,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
value={outputData.defect2Qty}
onChange={(e) => setOutputData({
...outputData,
defect2Qty: parseInt(e.target.value) || 0
defect2Qty: parseQtyInput(e.target.value)
})}
/>
</TableCell>
@@ -950,7 +981,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
value={outputData.defect3Qty}
onChange={(e) => setOutputData({
...outputData,
defect3Qty: parseInt(e.target.value) || 0
defect3Qty: parseQtyInput(e.target.value)
})}
/>
</TableCell>
@@ -998,7 +1029,7 @@ const ProductionProcessStepExecution: React.FC<ProductionProcessStepExecutionPro
value={outputData.scrapQty}
onChange={(e) => setOutputData({
...outputData,
scrapQty: parseInt(e.target.value) || 0
scrapQty: parseQtyInput(e.target.value)
})}
/>
</TableCell>


+ 8
- 7
src/components/Qc/QcStockInModal.tsx Ver fichero

@@ -77,7 +77,7 @@ interface Props extends CommonProps {
// itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] };
}

/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 */
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.7 | 2026-10-15 */
const QcStockInModal: React.FC<Props> = ({
open,
onClose,
@@ -617,10 +617,10 @@ const QcStockInModal: React.FC<Props> = ({

}
closeWithResult(qcRes);
closeWithResult(qcRes ?? stockInLineInfo);
// setTabIndex(1); // Need to go Putaway tab?
} else {
closeWithResult(qcRes);
closeWithResult(qcRes ?? stockInLineInfo);
}
setIsSubmitting(false);
qcSubmitInFlightRef.current = false;
@@ -700,11 +700,12 @@ const QcStockInModal: React.FC<Props> = ({
// Handle putaway submission logic here
const res = await postStockInLine(putawayData);
console.log("result ", res);
// Close modal after successful putaway
closeHandler({}, "backdropClick");
const updated = Array.isArray(res.entity) ? res.entity[0] : res.entity;

// Close modal after successful putaway and tell the parent to refresh.
closeWithResult(updated ?? stockInLineInfo);
},
[closeHandler],
[closeWithResult, postStockInLine, stockInLineInfo],
);
// Print handler
useEffect(() => {


+ 957
- 249
src/components/Shop/RouteBoard.tsx
La diferencia del archivo ha sido suprimido porque es demasiado grande
Ver fichero


+ 2
- 0
src/i18n/en/jo.json Ver fichero

@@ -32,6 +32,8 @@
"BOM Type": "BOM Type",
"Back": "Back",
"Back to List": "Back to List",
"Back to Process": "Back to Process Steps",
"Back to Process Steps": "Back to Process Steps",
"Bad Item Qty": "Bad Item Qty",
"Bad Package Qty": "Bad Package Qty",
"Bag": "Bag",


+ 2
- 0
src/i18n/en/productionProcess.json Ver fichero

@@ -10,6 +10,8 @@
"Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes",
"Back": "Back",
"Back to List": "Back to List",
"Back to Process": "Back to Process Steps",
"Back to Process Steps": "Back to Process Steps",
"Bag": "Bag",
"Bag Consumption": "Bag Consumption",
"Balance": "Balance",


+ 1
- 1
src/i18n/en/routeboard.json Ver fichero

@@ -106,7 +106,7 @@
"lane_noMatchFilter": "No lanes match (clear search or floor filter)",
"floor_label": "Floor",
"floor_all": "All",
"filter_clear": "Clear",
"filter_clear": "Clear all",
"filter_apply": "OK",
"btn_addLane": "Add lane",
"tools_title": "Tools",


+ 26
- 3
src/i18n/en/shop.json Ver fichero

@@ -132,8 +132,26 @@
"confirm_restoreDiscardsEdits": "Scheduling a version restore will discard other unsaved board changes (drags, deletes, pending shops/lanes, logistics fields, etc.). Continue?",
"confirm_restoreSaveWillDropStaging": "Save will apply the snapshot restore first; other staged edits in this save will be skipped. Continue?",
"confirm_schedule_removeShop": "Remove this shop from the lane? It will be scheduled for deletion when you submit.",
"departureDialog_hint": "Applies to all shop rows on this lane; press \"Save changes\" above to persist.",
"departureDialog_title": "Edit departure time",
"departureDialog_hint": "Name and departure apply to every master row on this lane. Press \"Save changes\" to write them. Existing delivery tickets keep the old name; later releases use the new name.",
"departureDialog_title": "Edit lane",
"departureDialog_nameLabel": "Lane name",
"laneEditTooltip": "Edit lane name and departure time (written when you press Save changes)",
"confirm_weekdayToken": "The weekday token in the lane name will change or become unreadable. 4F assignment matches Mon–Sun inside the name. Apply anyway?",
"confirm_renameLane": "Rename \"{{from}}\" to \"{{to}}\".\n{{unfinished}} unfinished delivery ticket(s) stay on the old name (handlers: {{handlers}}).\n{{completed}} completed ticket(s) stay on the old name.\nLater releases use the new name.\n{{schedules}} open schedule line(s) will change from/to.\nNothing is written until you press Save changes.",
"confirm_restoreKeepsTickets": "Restore puts lane master data, and open schedules that point at the current name, back to this snapshot. Unfinished delivery tickets stay on the current name.",
"rename_noHandlers": "none",
"rename_none": "none",
"rename_savePartial": "Renamed: {{ok}}. Failed: {{fail}}. The failed lane was not written. Successful lanes stay renamed. Refresh, then save again.",
"renameErr_preview": "Could not load ticket and schedule counts, so this edit was not applied.",
"renameErr_LANE_NAME_EMPTY": "Lane name cannot be blank",
"renameErr_LANE_NAME_TOO_LONG": "Lane name is limited to 50 characters",
"renameErr_LANE_NAME_RESERVED": "\"車線-X\" cannot be used or renamed",
"renameErr_LANE_NAME_TAKEN": "This floor and remark already has that lane",
"renameErr_LANE_NAME_REMARK_AMBIGUOUS": "This floor already has the same name with a different remark. Delivery tickets cannot tell them apart.",
"renameErr_LANE_NOT_FOUND": "No live lane rows were found, so nothing was written",
"renameErr_LANE_TIME_INVALID": "Departure time is not a valid time",
"renameLog_title": "Lane renames",
"renameLog_line": "{{when}} {{who}}: {{from}} → {{to}} ({{floor}})",
"departureEditAria": "Edit departure time",
"departureTooltipEditSave": "Edit departure time (saved with \"Save changes\")",
"departureTooltipNeedShops": "Add shops before setting departure time",
@@ -212,9 +230,14 @@
"err_save": "Save failed",
"exportRoutes": "Export routes",
"filter_apply": "OK",
"filter_clear": "Clear",
"filter_clear": "Clear all",
"floor_all": "All",
"floor_label": "Floor",
"lane_filter": "Filter",
"laneFilter_special": "Lane code",
"laneFilter_zone": "Zone",
"laneFilter_weekday": "Weekday",
"laneFilter_area": "Area",
"id": "id",
"importRoutes": "Import routes",
"import_staged_preview": "Import preview loaded: {{file}} ({{sheets}} sheet(s) / {{rows}} rows). Press \"Save changes\" to persist.",


+ 2
- 0
src/i18n/zh/jo.json Ver fichero

@@ -34,6 +34,8 @@
"BOM Type": "BOM 類型",
"Back": "返回",
"Back to List": "返回列表",
"Back to Process": "返回列表",
"Back to Process Steps": "返回列表",
"Bad Item Qty": "不良物品數量",
"Bad Package Qty": "不良包裝數量",
"Bag": "包裝袋",


+ 2
- 0
src/i18n/zh/productionProcess.json Ver fichero

@@ -10,6 +10,8 @@
"Auto-refresh every 10 minutes": "每10分鐘自動刷新",
"Back": "返回",
"Back to List": "返回列表",
"Back to Process": "返回列表",
"Back to Process Steps": "返回列表",
"Bag": "包裝袋",
"Bag Consumption": "包裝袋消耗",
"Balance": "可用數量",


+ 1
- 1
src/i18n/zh/routeboard.json Ver fichero

@@ -106,7 +106,7 @@
"lane_noMatchFilter": "無符合條件的車線(清除搜索或樓層篩選)",
"floor_label": "樓層",
"floor_all": "全部",
"filter_clear": "清除",
"filter_clear": "清除全部",
"filter_apply": "確定",
"btn_addLane": "新增車線",
"tools_title": "操作工具",


+ 26
- 3
src/i18n/zh/shop.json Ver fichero

@@ -106,7 +106,12 @@
"lane_selectAll": "全選",
"floor_label": "樓層",
"floor_all": "全部",
"filter_clear": "清除",
"lane_filter": "篩選",
"laneFilter_special": "車線",
"laneFilter_zone": "區",
"laneFilter_weekday": "星期",
"laneFilter_area": "地區",
"filter_clear": "清除全部",
"filter_apply": "確定",
"btn_addLane": "新增車線",
"tools_title": "操作工具",
@@ -200,8 +205,26 @@
"dialog_editLogisticsTitle": "編輯物流公司",
"btn_apply": "套用",
"addShop_confirm": "確認",
"departureDialog_title": "編輯出車時間",
"departureDialog_hint": "套用至此車線所有店鋪列。",
"departureDialog_title": "編輯車線",
"departureDialog_nameLabel": "車線名稱",
"departureDialog_hint": "名稱與出車時間會套用到此車線全部店鋪。\n 既有送貨訂單維持舊名稱;之後放單才用新名稱。\n 按上方「儲存更改」才寫入。",
"laneEditTooltip": "編輯車線名稱與出車時間(按「儲存更改」才寫入)",
"confirm_weekdayToken": "車線名稱裡的星期代碼會改變,或改完後無法辨認。4F 派車是用名稱裡的 Mon–Sun 對日子。仍要套用?",
"confirm_renameLane": "把「{{from}}」改成「{{to}}」。\n未完成出倉票 {{unfinished}} 張維持舊名稱(負責人:{{handlers}})。\n已完成 {{completed}} 張維持舊名稱。\n之後放單才用新名稱。\n未完成排程 {{schedules}} 筆的起迄名稱會改。\n按「儲存更改」後才寫入。",
"confirm_restoreKeepsTickets": "還原會把車線主檔,以及指向目前名稱的未完成排程,改回此版本的名稱。未完成的出倉票會留在現在的名稱,不會跟回去。",
"rename_noHandlers": "無",
"rename_none": "無",
"rename_savePartial": "已改名:{{ok}}。失敗:{{fail}}。失敗的那一桶沒有寫入;已經成功的不會自動改回去。請重新整理後再存。",
"renameErr_preview": "讀不到出倉票與排程數量,這次沒有套用。",
"renameErr_LANE_NAME_EMPTY": "車線名稱不可空白",
"renameErr_LANE_NAME_TOO_LONG": "車線名稱最多 50 字",
"renameErr_LANE_NAME_RESERVED": "不能使用或改掉「車線-X」",
"renameErr_LANE_NAME_TAKEN": "同一樓層、同一備註已有這條車線",
"renameErr_LANE_NAME_REMARK_AMBIGUOUS": "同一樓層已有相同名稱但備註不同,出倉票無法分辨,已拒絕",
"renameErr_LANE_NOT_FOUND": "找不到這條車線的主檔,沒有寫入",
"renameErr_LANE_TIME_INVALID": "出車時間格式不正確",
"renameLog_title": "車線改名紀錄",
"renameLog_line": "{{when}} {{who}}:{{from}} → {{to}}({{floor}})",
"seqDialog_title": "編輯裝車順序",
"logistics_colLaneCount": "{{count}} 條車線",
"tooltip_openLaneBoard": "在車線看板開此車線",


Cargando…
Cancelar
Guardar