diff --git a/src/app/(main)/settings/stockLedgerFix/page.tsx b/src/app/(main)/settings/stockLedgerFix/page.tsx new file mode 100644 index 00000000..d477b351 --- /dev/null +++ b/src/app/(main)/settings/stockLedgerFix/page.tsx @@ -0,0 +1,24 @@ +import { Metadata } from "next"; +import PageTitleBar from "@/components/PageTitleBar"; +import StockLedgerFixPageClient from "@/components/StockLedgerFix/StockLedgerFixPageClient"; +import { getServerI18n, I18nProvider } from "@/i18n"; + +export async function generateMetadata(): Promise { + const { t } = await getServerI18n("stockLedgerFix"); + return { title: t("pageTitle") }; +} + +const StockLedgerFixPage: React.FC = async () => { + const { t } = await getServerI18n("stockLedgerFix"); + + return ( + <> + + + + + + ); +}; + +export default StockLedgerFixPage; diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index 65af3d6e..60a0040d 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -216,7 +216,6 @@ export async function fetchWorkbenchStoreLaneSummary( return serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); } @@ -235,7 +234,6 @@ export async function fetchWorkbenchEtraLaneSummary( const data = await serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); return Array.isArray(data) ? data : []; } diff --git a/src/app/api/inventory/index.ts b/src/app/api/inventory/index.ts index 869bed2e..2e9431f5 100644 --- a/src/app/api/inventory/index.ts +++ b/src/app/api/inventory/index.ts @@ -14,6 +14,10 @@ export interface InventoryResult { onHoldQty: number; unavailableQty: number; availableQty: number; + /** Inventory bucket row. Optional; absent on the one-row-per-item search page. */ + stockUomId?: number | null; + stockUomCode?: string | null; + uomId?: number; uomCode: string; uomUdfudesc: string; uomShortDesc: string; diff --git a/src/app/api/inventory/inventoryBucket.ts b/src/app/api/inventory/inventoryBucket.ts new file mode 100644 index 00000000..52b6199c --- /dev/null +++ b/src/app/api/inventory/inventoryBucket.ts @@ -0,0 +1,82 @@ +/** Keys used to pick one inventory row when an item has multiple stock-UOM buckets. */ +export type InventoryBucketPick = { + itemId?: number | null; + itemCode?: string | null; + itemName?: string | null; + uomId?: number | null; + stockUomId?: number | null; + uom?: string | null; + shortUom?: string | null; + stockUom?: string | null; +}; + +export type InventoryBucketRow = { + itemId?: number | null; + itemCode?: string | null; + itemName?: string | null; + stockUomId?: number | null; + uomCode?: string | null; + uomUdfudesc?: string | null; + uomShortDesc?: string | null; + availableQty?: number | null; + onHandQty?: number | null; + unavailableQty?: number | null; +}; + +const norm = (s?: string | null) => (s ?? "").trim().toLowerCase(); + +/** Available = onHand − unavailable. Do not treat 0 as missing (`||` is wrong). */ +export function inventoryAvailableQty(inv: InventoryBucketRow): number { + if (inv.availableQty != null && !Number.isNaN(Number(inv.availableQty))) { + return Number(inv.availableQty); + } + return Number(inv.onHandQty ?? 0) - Number(inv.unavailableQty ?? 0); +} + +function itemMatches(inv: InventoryBucketRow, pick: InventoryBucketPick): boolean { + if (pick.itemId != null && inv.itemId != null) { + return Number(inv.itemId) === Number(pick.itemId); + } + if (pick.itemCode && inv.itemCode) { + return inv.itemCode === pick.itemCode; + } + if (pick.itemName && inv.itemName) { + return inv.itemName === pick.itemName; + } + return false; +} + +function uomMatches(inv: InventoryBucketRow, pick: InventoryBucketPick): boolean { + const pickUomId = pick.stockUomId ?? pick.uomId; + if (pickUomId != null && inv.stockUomId != null) { + return Number(inv.stockUomId) === Number(pickUomId); + } + const labels = [pick.uom, pick.shortUom, pick.stockUom].map(norm).filter(Boolean); + if (labels.length === 0) return true; + const invLabels = [inv.uomUdfudesc, inv.uomShortDesc, inv.uomCode].map(norm); + return labels.some((l) => invLabels.includes(l)); +} + +export function matchInventoryBucket( + inventories: InventoryBucketRow[], + pick: InventoryBucketPick, +): InventoryBucketRow | undefined { + const itemHits = inventories.filter((inv) => itemMatches(inv, pick)); + if (itemHits.length === 0) return undefined; + const hasUomPick = + pick.stockUomId != null || + pick.uomId != null || + [pick.uom, pick.shortUom, pick.stockUom].some((s) => Boolean(norm(s))); + if (hasUomPick) { + return itemHits.find((inv) => uomMatches(inv, pick)); + } + return itemHits[0]; +} + +export function getStockAvailableFromInventories( + inventories: InventoryBucketRow[], + pick: InventoryBucketPick, +): number { + const inv = matchInventoryBucket(inventories, pick); + return inv ? inventoryAvailableQty(inv) : 0; +} diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 75efe315..87d6fcfb 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -533,6 +533,7 @@ export interface JobOrderLineInfo { stockUom: string, stockBaseUom: string, + stockUomId?: number | null, availableStatus: string, bomProcessId: number, diff --git a/src/app/api/jo/index.ts b/src/app/api/jo/index.ts index ed8b99f4..67bd46e7 100644 --- a/src/app/api/jo/index.ts +++ b/src/app/api/jo/index.ts @@ -67,6 +67,8 @@ export interface JoDetail { export interface JoDetailPickLine { id: number; + itemId?: number; + uomId?: number; code: string; name: string; type: string; diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index f21a60b4..25409697 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -632,7 +632,6 @@ export async function fetchStoreLaneSummary(storeId: string, requiredDate?: stri const response = await serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); console.timeEnd(label); return response; @@ -856,7 +855,6 @@ export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => { method: "GET", // Must be fresh: determines whether shell shows Floor/Lane panel or Detail. cache: "no-store", - next: { revalidate: 0 }, }, ); }; diff --git a/src/app/api/settings/item/index.ts b/src/app/api/settings/item/index.ts index e85933e0..f23c1145 100644 --- a/src/app/api/settings/item/index.ts +++ b/src/app/api/settings/item/index.ts @@ -66,6 +66,9 @@ export type ItemsResult = { latestMarketUnitPrice?: number; latestMupUpdatedDate?: string; purchaseUnit?: string; + uomId?: number; + uom?: string; + uomDesc?: string; }; export type Result = { diff --git a/src/app/api/stockAdjustment/actions.ts b/src/app/api/stockAdjustment/actions.ts index c42e0c09..f7742248 100644 --- a/src/app/api/stockAdjustment/actions.ts +++ b/src/app/api/stockAdjustment/actions.ts @@ -16,6 +16,7 @@ export interface StockAdjustmentLineRequest { expiryDate: string; warehouseId: number; uom?: string | null; + uomId?: number | null; remarks?: string | null; } diff --git a/src/app/api/stockLedgerFix/client.ts b/src/app/api/stockLedgerFix/client.ts new file mode 100644 index 00000000..80577459 --- /dev/null +++ b/src/app/api/stockLedgerFix/client.ts @@ -0,0 +1,319 @@ +"use client"; + +import axiosInstance from "@/app/(main)/axios/axiosInstance"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; + +export type StockLedgerFixDayStatus = { + date: string; + cnt: number; + missLot: number; + missUom: number; + missInventoryId: number; + missLotQty: number; + dayTableLots: number; +}; + +export type StockLedgerFixCalendarResponse = { + from: string; + to: string; + days: StockLedgerFixDayStatus[]; +}; + +export type StockLedgerFixCheckPart = { + key: string; + label: string; + ok: number; + miss: number; + incorrect: number; + group?: string; +}; + +export type StockLedgerFixDayDetail = { + date: string; + cnt: number; + parts: StockLedgerFixCheckPart[]; +}; + +export type StockLedgerFixRunResponse = { + date: string; + filledLotLineId: number; + filledUomId: number; + filledInventoryId: number; + filledLotQty: number; + filledBalance: number; + dayRowsWritten: number; + stillMissLot: number; + stillMissUom: number; + stillMissInventoryId: number; + stillMissLotQty: number; +}; + +export type StockLedgerFixInventoryPreview = { + inventoryRows: number; + lotUomPairs: number; + missingUomPairs: number; + nullStockUomId: number; +}; + +export type StockLedgerFixInventoryResponse = { + inserted: number; + updated: number; + missingUomPairsAfter: number; + patchedStockUomId: number; + nullStockUomIdAfter: number; + orphansDeleted?: number; +}; + +export type StockLedgerFixSearchInventoryHit = { + inventoryId: number; + itemId: number | null; + itemCode: string | null; + uomId: number | null; + ledgerCnt: number; +}; + +export type StockLedgerFixSearchLotHit = { + inventoryLotLineId: number; + lotNo: string | null; + itemCode: string | null; + inventoryId: number | null; + ledgerCnt: number; +}; + +export type StockLedgerFixScopeDetail = { + kind: string; + id: number; + itemCode: string | null; + lotNo: string | null; + inventoryId: number | null; + uomId: number | null; + firstDate: string | null; + lastDate: string | null; + cnt: number; + lastBalance: string | null; + lastLotQtyAfter: string | null; + parts: StockLedgerFixCheckPart[]; +}; + +export type StockLedgerFixAdjRow = { + lotLineId: number; + inventoryId: number | null; + itemCode: string | null; + lineIn: string; + lineOut: string; + ledgerIn: string; + ledgerOut: string; + missIn: string; + missOut: string; + overIssue: string; +}; + +export type StockLedgerFixAdjPreview = { + adjDate: string; + lotCount: number; + adjInCount: number; + adjOutCount: number; + skippedNegCount: number; + sumMissIn: string; + sumMissOut: string; + skuNet: string; + overIssueCount: number; + sumOverIssue: string; + rows: StockLedgerFixAdjRow[]; +}; + +export type StockLedgerFixAdjResponse = { + adjDate: string; + insertedIn: number; + insertedOut: number; + overIssuePatched: number; + filledLotQty: number; + filledBalance: number; + dayRowsWritten: number; +}; + +export async function fetchStockLedgerFixAdjPreview( + adjDate?: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/adj`, + { + params: { + rowLimit: 20, + ...(adjDate ? { adjDate } : {}), + }, + timeout: 600000, + }, + ); + return response.data; +} + +export async function runStockLedgerFixAdj( + adjDate?: string, +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/adj`, + adjDate ? { adjDate } : {}, + { timeout: 1200000 }, + ); + return response.data; +} + +export async function fetchStockLedgerFixCalendar( + from: string, + to: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/calendar`, + { params: { from, to } }, + ); + return response.data; +} + +export async function fetchStockLedgerFixDay( + date: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/day`, + { params: { date } }, + ); + return response.data; +} + +export async function runStockLedgerFixDay( + date: string, + steps?: string[], +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`, + { date, ...(steps && steps.length > 0 ? { steps } : {}) }, + ); + return response.data; +} + +export async function runStockLedgerFixRange( + from: string, + to: string, + steps?: string[], +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`, + { + mode: "range", + from, + to, + ...(steps && steps.length > 0 ? { steps } : {}), + }, + { timeout: 1200000 }, + ); + return response.data; +} + +export async function fetchStockLedgerFixInventory(): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/inventory`, + ); + return response.data; +} + +export async function runStockLedgerFixInventory(): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/inventory`, + ); + return response.data; +} + +export async function searchStockLedgerFixInventory( + q: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/lookup/inventory`, + { params: { q } }, + ); + return response.data; +} + +export async function searchStockLedgerFixLot( + q: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/lookup/lot`, + { params: { q } }, + ); + return response.data; +} + +export async function fetchStockLedgerFixInventoryScope( + id: number, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/scope/inventory`, + { params: { id } }, + ); + return response.data; +} + +export async function fetchStockLedgerFixLotScope( + id: number, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/scope/lot`, + { params: { id } }, + ); + return response.data; +} + +export async function runStockLedgerFixInventoryScope( + inventoryId: number, +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`, + { mode: "inventory", inventoryId }, + ); + return response.data; +} + +export async function runStockLedgerFixLotScope( + lotLineId: number, +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`, + { mode: "lot", lotLineId }, + ); + return response.data; +} + +export async function downloadStockLedgerFixSql( + from: string, + to: string, + parts?: string[], +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/export`, + { + params: { + from, + to, + ...(parts && parts.length > 0 ? { parts } : {}), + }, + paramsSerializer: { + indexes: null, + }, + responseType: "blob", + timeout: 600000, + }, + ); + const blob = new Blob([response.data], { type: "application/sql;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const partTag = + parts && parts.length > 0 && parts.length < 5 + ? `_${parts.join("-").replaceAll(".", "")}` + : ""; + a.download = `stock_ledger_fix_${from.replaceAll("-", "")}_${to.replaceAll("-", "")}${partTag}.sql`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index a5804a95..cea41b5e 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -35,6 +35,7 @@ const pathToLabelKey: { [path: string]: string } = { "/settings/qrCodeHandle": "nav.breadcrumb.qrCodeHandle", "/settings/deliveryOrderFloor": "nav.breadcrumb.deliveryOrderFloor", "/settings/masterDataIssues": "nav.breadcrumb.masterDataIssues", + "/settings/stockLedgerFix": "nav.breadcrumb.stockLedgerFix", "/settings/rss": "nav.breadcrumb.demandForecast", "/settings/equipment": "nav.breadcrumb.equipment", "/settings/equipment/MaintenanceEdit": "nav.breadcrumb.equipmentMaintenanceEdit", diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index 70019418..6ab37110 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -186,7 +186,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom const confirmResult = await Swal.fire({ title: t("Batch Print"), - text: `${t("Confirm print: (")}${releasedOrders.length}${t("piece(s))")}`, + text: t("Confirm print drafts", { count: releasedOrders.length }), icon: "question", showCancelButton: true, confirmButtonText: t("Confirm"), @@ -276,18 +276,29 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom @@ -297,7 +308,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom sx={{ overflow: "visible", /* 徽章在標籤右側外凸,預留空間避免與下一個 Tab 貼死 */ - pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2, + pr: etraIncompleteDopoCount > 99 ? 2.5 : etraIncompleteDopoCount > 0 ? 2 : 1, }} label={ = ({ defaultTabIndex = 0, printerCom 0 ? 1 : 0 }} + sx={{ + pr: etraIncompleteDopoCount > 0 ? 1 : 0, + whiteSpace: "nowrap", + lineHeight: 1.2, + textAlign: "center", + }} > {t("Etra Pick Order Detail")} @@ -341,8 +357,8 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom - - + + diff --git a/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx b/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx index 3325a539..c47689f1 100644 --- a/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx +++ b/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material"; import DownloadIcon from "@mui/icons-material/Download"; +import { useTranslation } from "react-i18next"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { @@ -18,6 +19,7 @@ import { } from "@/lib/featureUsageLog"; const TruckRoutingSummaryTabWorkbench: React.FC = () => { + const { t } = useTranslation(); const [storeOptions, setStoreOptions] = useState([]); const [laneOptions, setLaneOptions] = useState([]); const [storeId, setStoreId] = useState(""); @@ -45,6 +47,9 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { const canDownload = storeId && truckLanceCode && date && !loading; + const displayOptionLabel = (label: string) => + String(label).trim() === "車線-X" ? t("Truck X") : label; + const onDownload = async () => { if (!canDownload) return; try { @@ -55,7 +60,9 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { }); if (precheck.hasUnpickedOrders) { const confirmed = window.confirm( - `此車線仍有 ${precheck.unpickedOrderCount} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?` + t("Unpicked orders confirm download", { + count: precheck.unpickedOrderCount, + }), ); if (!confirmed) return; } @@ -92,7 +99,7 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { ); } catch (error) { console.error("Failed to download Workbench Truck Routing Summary", error); - alert("下載 Workbench 送貨路線摘要失敗,請稍後再試。"); + alert(t("Failed to download Workbench truck routing summary. Please try again later.")); } finally { setLoading(false); } @@ -101,39 +108,39 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { return ( - 送貨路線摘要 (Workbench) + {t("Truck Routing Summary (Workbench)")} onStoreChange(e.target.value)} > {storeOptions.map((opt) => ( - {opt.label} + {displayOptionLabel(opt.label)} ))} setTruckLanceCode(e.target.value)} disabled={!storeId} > {laneOptions.map((opt) => ( - {opt.label} + {displayOptionLabel(opt.label)} ))} { disabled={!canDownload} onClick={onDownload} > - {loading ? "生成中..." : "下載報告 (PDF)"} + {loading ? t("Generating...") : t("Download report (PDF)")} ); diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 29536591..91578a07 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -421,22 +421,30 @@ function isWorkbenchSourceLotExpired(lot: any): boolean { return false; } -function getWorkbenchSourceLotStatusSummary(lot: any): { +type PickOrderT = (key: string, options?: Record) => string; + +function getWorkbenchSourceLotStatusSummary(lot: any, t: PickOrderT): { severity: "success" | "warning" | "error"; text: string; } { if (!lot) { - return { severity: "warning", text: "無法判斷此批號狀態" }; + return { severity: "warning", text: t("Cannot determine this lot status") }; } if (isWorkbenchSourceLotExpired(lot)) { - return { severity: "error", text: "此批號狀態:已過期" }; + return { severity: "error", text: t("Lot status: expired") }; } const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); if (solSt === "rejected") { - return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" }; + return { + severity: "warning", + text: t("This pick line was rejected. Please scan another lot."), + }; } if (solSt === "completed" || solSt === "partially_completed") { - return { severity: "warning", text: "此出庫行:已完成,無需再提貨" }; + return { + severity: "warning", + text: t("This pick line is already completed. No further pick needed."), + }; } /** * 無批次列:後端仍標 insufficient_stock,語意是「尚無可出庫批號」而非「已用畢」。 @@ -449,28 +457,28 @@ function getWorkbenchSourceLotStatusSummary(lot: any): { if (isNoLotRow) { return { severity: "warning", - text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR", + text: t( + "No lot bound / no available stock line. Please scan nearby inbound or transfer QR.", + ), }; } const av = String(lot.lotAvailability || "").toLowerCase(); if (av === "insufficient_stock") { - return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" }; + return { severity: "warning", text: t("Lot status: depleted (no remaining stock)") }; } const avail = Number(lot.availableQty); if (lot.lotNo && Number.isFinite(avail) && avail <= 0) { - return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" }; + return { severity: "warning", text: t("Lot status: depleted (available qty is 0)") }; } if (isInventoryLotLineUnavailable(lot)) { return { severity: "warning", - text: "此批號狀態:庫存不可用(未上架或行狀態不可用)", + text: t("Lot status: unavailable (not put away or line unavailable)"), }; } - return { severity: "success", text: "此批號狀態:可提貨" }; + return { severity: "success", text: t("Lot status: ready to pick") }; } -type PickOrderT = (key: string, options?: Record) => string; - function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { const msg = raw.trim(); if (!msg) return msg; @@ -1334,9 +1342,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO severity: undefined as "success" | "warning" | "error" | undefined, }; } - const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot); + const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot, t); return { text: s.text, severity: s.severity }; - }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]); + }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot, t]); const workbenchLotLabelSubmitQty = useMemo(() => { if (!workbenchLotLabelContextLot) return 0; @@ -1811,7 +1819,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg( - `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` + t("This lot ({{lot}}) was rejected and cannot be used. Please scan another lot.", { + lot: scannedLot.lotNo || scannedStockInLineId, + }), ); }); // Mark this SOL as processed to prevent re-processing @@ -1864,7 +1874,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO startTransition(() => { setQrScanError(true); setQrScanSuccess(false); - setQrScanErrorMsg("当前订单中没有此物品的批次信息"); + setQrScanErrorMsg(t("No lot information for this item in the current order")); }); return; } diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx index 2863a5b9..1d477005 100644 --- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -37,6 +37,7 @@ import { printWorkbenchLotLabel, } from "@/app/api/doworkbench/actions"; import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; type ScanPayload = { itemId: number; @@ -167,6 +168,7 @@ const WorkbenchLotLabelPrintModal: React.FC = submitQty = null, onSubmitQtyChange, }) => { + const { t } = useTranslation(); const scanInputRef = useRef(null); const [scanInput, setScanInput] = useState(""); const [scanError, setScanError] = useState(null); @@ -210,8 +212,8 @@ const WorkbenchLotLabelPrintModal: React.FC = useEffect(() => { if (!open) return; resetAll(); - const t = setTimeout(() => scanInputRef.current?.focus(), 50); - return () => clearTimeout(t); + const focusTimer = setTimeout(() => scanInputRef.current?.focus(), 50); + return () => clearTimeout(focusTimer); }, [open, resetAll]); const loadPrinters = useCallback(async () => { @@ -224,13 +226,13 @@ const WorkbenchLotLabelPrintModal: React.FC = setPrinters([]); setSnackbar({ open: true, - message: e instanceof Error ? e.message : "載入印表機清單失敗", + message: e instanceof Error ? e.message : t("Failed to load printer list"), severity: "error", }); } finally { setPrintersLoading(false); } - }, []); + }, [t]); useEffect(() => { if (!open) return; @@ -283,23 +285,23 @@ const WorkbenchLotLabelPrintModal: React.FC = setAnalysis(data); setSnackbar({ open: true, - message: "已載入同品可用批號清單", + message: t("Loaded available lots for this item"), severity: "success", }); } catch (e) { setAnalysis(null); - setScanError(e instanceof Error ? e.message : "分析失敗"); + setScanError(e instanceof Error ? e.message : t("Analysis failed")); } finally { setAnalysisLoading(false); } }, - [resolveExpectedUomId], + [resolveExpectedUomId, t], ); const analyzeByItem = useCallback( async (itemId: number) => { if (!Number.isFinite(itemId) || itemId <= 0) { - setScanError("無效 itemId,無法載入批號清單。"); + setScanError(t("Invalid itemId, cannot load lot list.")); return; } setLastItemId(itemId); @@ -325,17 +327,17 @@ const WorkbenchLotLabelPrintModal: React.FC = }); setSnackbar({ open: true, - message: "已載入同品可用批號清單", + message: t("Loaded available lots for this item"), severity: "success", }); } catch (e) { setAnalysis(null); - setScanError(e instanceof Error ? e.message : "分析失敗"); + setScanError(e instanceof Error ? e.message : t("Analysis failed")); } finally { setAnalysisLoading(false); } }, - [resolveExpectedUomId], + [resolveExpectedUomId, t], ); const handleAnalyze = useCallback(async () => { @@ -343,13 +345,13 @@ const WorkbenchLotLabelPrintModal: React.FC = const payload = safeParseScanPayload(raw); if (!payload) { setScanError( - '掃碼內容格式錯誤,請重新掃碼', + t("Invalid scan format. Please scan again."), ); setAnalysis(null); return; } await analyzePayload(payload); - }, [scanInput, analyzePayload]); + }, [scanInput, analyzePayload, t]); const handleRefreshLots = useCallback(async () => { const payload = lastPayload ?? safeParseScanPayload(scanInput.trim()); @@ -368,12 +370,12 @@ const WorkbenchLotLabelPrintModal: React.FC = if (!payload) { setSnackbar({ open: true, - message: "請先掃碼或查詢一次,才可刷新批號清單。", + message: t("Scan or look up once before refreshing the lot list."), severity: "info", }); return; } - }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput]); + }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput, t]); useEffect(() => { if (!open) return; @@ -470,7 +472,7 @@ const WorkbenchLotLabelPrintModal: React.FC = if (selectedPrinterId === "") { setSnackbar({ open: true, - message: "請先選擇印表機", + message: t("Please select a printer first"), severity: "error", }); return; @@ -478,7 +480,7 @@ const WorkbenchLotLabelPrintModal: React.FC = if (printQty < 1 || !Number.isFinite(printQty)) { setSnackbar({ open: true, - message: "列印張數需為大於等於 1 的整數", + message: t("Print quantity must be an integer of 1 or more"), severity: "error", }); return; @@ -493,25 +495,25 @@ const WorkbenchLotLabelPrintModal: React.FC = }); setSnackbar({ open: true, - message: `已送出列印:Lot ${lotNo}`, + message: t("Print sent: Lot {{lotNo}}", { lotNo }), severity: "success", }); } catch (e) { setSnackbar({ open: true, - message: e instanceof Error ? e.message : "列印失敗", + message: e instanceof Error ? e.message : t("Print failed"), severity: "error", }); } finally { setPrintingLotLineId(null); } }, - [selectedPrinterId, printQty], + [selectedPrinterId, printQty, t], ); return ( - 批號標籤列印(提貨台) + {t("Lot label print (pick station)")} {statusTitleText ? ( @@ -548,13 +550,13 @@ const WorkbenchLotLabelPrintModal: React.FC = > setScanInput(e.target.value)} fullWidth size="small" error={!!scanError} - helperText={scanError || "掃描後按 Enter 或點「查詢」"} + helperText={scanError || t("Scan then press Enter or click Look up")} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -568,7 +570,7 @@ const WorkbenchLotLabelPrintModal: React.FC = onClick={() => void handleAnalyze()} disabled={analysisLoading || !scanInput.trim()} > - {analysisLoading ? : "查詢"} + {analysisLoading ? : t("Look up")} @@ -594,16 +596,16 @@ const WorkbenchLotLabelPrintModal: React.FC = sx={{ minWidth: 260 }} disabled={printersLoading} > - 印表機 + {t("Printer")}