From 635d1b7604c590bf77c3d7b7df73e23a9aaeab6c Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 12 Oct 2026 11:44:22 +0800 Subject: [PATCH] =?UTF-8?q?Merge=20branch=20'production'=20into=20fix?= =?UTF-8?q?=E8=B2=A0=E6=95=B8=E5=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + scripts/warmup-menu.mjs | 88 ++ src/app/(main)/axios/AxiosProvider.tsx | 156 ++- src/app/(main)/axios/axiosInstance.ts | 86 +- src/app/(main)/chart/delivery/page.tsx | 19 +- src/app/(main)/m18Syn/page.tsx | 185 ++- .../report/AsyncItemCodeAutocomplete.tsx | 60 +- .../report/SemiFGProductionAnalysisReport.tsx | 110 +- src/app/(main)/report/bomShopSyncReportApi.ts | 12 +- src/app/(main)/report/grnReportApi.ts | 14 +- src/app/(main)/report/page.tsx | 206 +++- src/app/(main)/report/parseItemCodeTokens.ts | 62 + src/app/(main)/report/reportI18n.ts | 8 +- .../report/semiFGProductionAnalysisApi.ts | 73 +- src/app/(main)/settings/shop/layout.tsx | 27 + .../(main)/settings/stockLedgerFix/page.tsx | 18 +- src/app/api/doworkbench/actions.ts | 2 - src/app/api/itemTracing/schema.ts | 38 +- src/app/api/pickOrder/actions.ts | 2 - src/app/api/po/index.ts | 4 + src/app/api/settings/uom/index.ts | 2 + src/app/api/stockTake/actions.ts | 6 + src/app/utils/authToken.ts | 149 ++- src/app/utils/clientAuthFetch.ts | 47 +- src/authorities.ts | 4 + src/components/AppBar/Profile.tsx | 9 +- src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../DoWorkbench/DoWorkbenchTabs.tsx | 36 +- .../GoodPickExecutionWorkbenchRecord.tsx | 242 ++-- .../TruckRoutingSummaryTabWorkbench.tsx | 25 +- .../WorkbenchGoodPickExecutionDetail.tsx | 42 +- .../WorkbenchLotLabelPrintModal.tsx | 92 +- .../FinishedGoodCartonDashboardTab.tsx | 1082 ++++++++++++++--- src/components/LoginPage/LoginForm.tsx | 16 +- src/components/LogoutPage/LogoutPage.tsx | 2 + .../NavigationContent/NavigationContent.tsx | 19 +- .../WorkbenchPickExecution.tsx | 8 +- src/components/PoDetail/PoDetail.tsx | 10 +- src/components/PoDetail/PoDetailRow.tsx | 166 ++- src/components/PoDetail/PoInputGrid.tsx | 9 +- src/components/PoDetail/PutAwayForm.tsx | 13 +- src/components/PoDetail/QcStockInModal.tsx | 5 +- .../PoDetail/StockInLineRowActions.tsx | 2 +- .../PoDetail/originSilPutAwayQty.ts | 60 + src/components/PoDetail/poPurchaseUom.ts | 96 ++ src/components/PoDetail/stockQtyRound.ts | 90 +- src/components/PoSearch/PoSearch.tsx | 112 +- src/components/Qc/QcComponent.tsx | 7 +- src/components/Qc/QcStockInModal.tsx | 55 +- src/components/SearchBox/SearchBox.tsx | 21 +- .../StockLedgerFixPageClient.tsx | 449 ++++--- .../StockTakeManagement/ApproverStockTake.tsx | 47 +- .../ApproverStockTakeAll.tsx | 86 +- .../StockTakeManagement/PickerReStockTake.tsx | 99 +- .../StockTakeManagement/PickerStockTake.tsx | 249 +++- .../StockTakeQtyGapHint.tsx | 40 + .../StockTakeManagement/StockTakeTab.tsx | 106 +- .../qtyGapWarnSettingClient.ts | 49 + .../stockTakeQtyAdornment.tsx | 55 + .../stockTakeQtyGapWarning.ts | 62 + .../useStockTakeQtyGapWarnPercent.ts | 33 + src/components/charts/SafeApexCharts.tsx | 91 +- src/config/api.ts | 5 +- src/config/authConfig.ts | 121 +- src/config/reportConfig.ts | 22 + src/i18n/en/common.json | 222 ++-- src/i18n/en/doWorkbench.json | 44 +- src/i18n/en/navigation.json | 2 + src/i18n/en/pickOrder.json | 112 +- src/i18n/en/purchaseOrder.json | 3 + src/i18n/en/report.json | 54 +- src/i18n/en/stockLedgerFix.json | 155 +++ src/i18n/en/stockTake.json | 9 +- src/i18n/en/ticketReleaseTable.json | 6 +- src/i18n/zh/common.json | 2 + src/i18n/zh/doWorkbench.json | 39 +- src/i18n/zh/navigation.json | 2 + src/i18n/zh/pickOrder.json | 101 +- src/i18n/zh/purchaseOrder.json | 3 + src/i18n/zh/report.json | 54 +- src/i18n/zh/stockLedgerFix.json | 155 +++ src/i18n/zh/stockTake.json | 9 +- src/utils/workbenchPickLotUtils.ts | 38 +- 83 files changed, 4890 insertions(+), 1233 deletions(-) create mode 100644 scripts/warmup-menu.mjs create mode 100644 src/app/(main)/report/parseItemCodeTokens.ts create mode 100644 src/app/(main)/settings/shop/layout.tsx create mode 100644 src/components/PoDetail/originSilPutAwayQty.ts create mode 100644 src/components/PoDetail/poPurchaseUom.ts create mode 100644 src/components/StockTakeManagement/StockTakeQtyGapHint.tsx create mode 100644 src/components/StockTakeManagement/qtyGapWarnSettingClient.ts create mode 100644 src/components/StockTakeManagement/stockTakeQtyAdornment.tsx create mode 100644 src/components/StockTakeManagement/stockTakeQtyGapWarning.ts create mode 100644 src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts create mode 100644 src/i18n/en/stockLedgerFix.json create mode 100644 src/i18n/zh/stockLedgerFix.json diff --git a/package.json b/package.json index 3a630eb6..20f4f65d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "warmup": "node scripts/warmup-menu.mjs", "build": "next build", "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", "lint": "next lint", diff --git a/scripts/warmup-menu.mjs b/scripts/warmup-menu.mjs new file mode 100644 index 00000000..03910fa1 --- /dev/null +++ b/scripts/warmup-menu.mjs @@ -0,0 +1,88 @@ +const BASE = process.env.WARMUP_BASE || "http://localhost:3000"; +const COOKIE = process.env.WARMUP_COOKIE || ""; + +const PATHS = [ + "/dashboard", + "/po", + "/pickOrder", + "/inventory", + "/itemTracing", + "/stocktakemanagement", + "/stockIssue", + "/putAway", + "/finishedGood/management", + "/stockRecord", + "/doworkbench", + "/do", + "/ps", + "/jo", + "/jodetail", + "/productionProcess", + "/bag", + "/bagPrint", + "/laserPrint", + "/report", + "/m18Syn", + "/chart/purchase", + "/chart/joborder", + "/chart/joborder/board", + "/chart/delivery", + "/chart/warehouse", + "/chart/forecast", + "/settings/user", + "/settings/clientMonitor", + "/settings/items", + "/settings/itemDefaultShelfLife", + "/settings/equipment", + "/settings/warehouse", + "/settings/printer", + "/settings/itemPrice", + "/settings/qcItem", + "/settings/qcCategory", + "/settings/qcItemAll", + "/settings/shop/board", + "/settings/deliveryOrderFloor", + "/settings/rss", + "/settings/bomWeighting", + "/settings/masterDataIssues", + "/settings/qrCodeHandle", + "/settings/m18ImportTesting", + "/settings/importExcel", + "/settings/importBom", +]; + +async function waitReady() { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/login`); + if (res.status < 500) return; + } catch { + // server still starting + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`dev server not ready at ${BASE}`); +} + +console.log(`Waiting for ${BASE}/login (Next compiles this on first hit; the site will look stuck until it finishes)…`); +await waitReady(); +console.log(`Dev server answered. Warming ${PATHS.length} menu pages at ${BASE}`); +if (!COOKIE) { + console.log( + "No WARMUP_COOKIE — private pages (/dashboard, /ps, /report, /settings, …) will redirect and stay uncompiled.", + ); +} + +for (const path of PATHS) { + process.stdout.write(`compiling ${path} … `); + const t0 = Date.now(); + const res = await fetch(`${BASE}${path}`, { + redirect: "manual", + headers: COOKIE ? { cookie: COOKIE } : {}, + }); + const redirected = res.status >= 300 && res.status < 400; + console.log( + `${res.status} ${Date.now() - t0}ms${redirected ? " (redirect, page not compiled)" : ""}`, + ); +} diff --git a/src/app/(main)/axios/AxiosProvider.tsx b/src/app/(main)/axios/AxiosProvider.tsx index e2f5f2f2..26bd3808 100644 --- a/src/app/(main)/axios/AxiosProvider.tsx +++ b/src/app/(main)/axios/AxiosProvider.tsx @@ -2,12 +2,26 @@ "use client"; -import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; +import React, { + createContext, + useContext, + useEffect, + useState, + useCallback, + useRef, +} from "react"; import { getSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { + ACCESS_TOKEN_KEY, + ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + clearBackendTokens, + ensureFreshAccessToken, + getBackendJwtSecondsRemaining, isBackendJwtExpired, LOGIN_SESSION_EXPIRED_HREF, + persistBackendTokens, + readStoredRefreshToken, } from "@/app/utils/authToken"; import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance"; @@ -19,14 +33,48 @@ const TokenContext = createContext<{ setAccessToken: () => {}, }); -export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +/** + * Merge next-auth tokens into localStorage without clobbering a newer + * localStorage access token (e.g. after a client-side refresh). + */ +function syncSessionTokensToStorage(session: SessionWithTokens | null): void { + if (!session) return; + + let localAccess = ""; + try { + localAccess = localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ""; + } catch { + localAccess = ""; + } + const localRefresh = readStoredRefreshToken(); + const sessionAccess = session.accessToken?.trim() || ""; + const sessionRefresh = session.refreshToken?.trim() || ""; + + // local missing, or local expired while session already has a fresher JWT + if ( + sessionAccess && + (!localAccess || isBackendJwtExpired(localAccess)) + ) { + persistBackendTokens(sessionAccess, sessionRefresh || localRefresh); + return; + } + + // Keep existing access token; only backfill missing refresh token + if (localAccess && sessionRefresh && !localRefresh) { + persistBackendTokens(localAccess, sessionRefresh); + } +} + +export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { const [accessToken, setAccessToken] = useState(null); const [isHydrated, setIsHydrated] = useState(false); + const refreshTimerRef = useRef | null>(null); - // Hydrate token only on client useEffect(() => { try { - const token = localStorage.getItem("accessToken"); + const token = localStorage.getItem(ACCESS_TOKEN_KEY); if (token) setAccessToken(token); } catch (e) { console.warn("localStorage unavailable", e); @@ -35,10 +83,34 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre } }, []); + const scheduleProactiveRefresh = useCallback((token: string) => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + const remaining = getBackendJwtSecondsRemaining(token); + if (remaining === null) return; + + // Refresh a few minutes before expiry (clamp delay to a safe range for setTimeout) + const delayMs = Math.min( + Math.max(30_000, (remaining - ACCESS_TOKEN_REFRESH_SKEW_SECONDS) * 1000), + 2_147_483_647 + ); + refreshTimerRef.current = setTimeout(async () => { + const session = (await getSession()) as SessionWithTokens | null; + const next = await ensureFreshAccessToken( + readStoredRefreshToken() || session?.refreshToken + ); + if (next) { + setAccessToken(next); + scheduleProactiveRefresh(next); + } + }, delayMs); + }, []); + /** - * Detect expired/missing backend JWT before user actions (e.g. /report search). - * Sync accessToken from next-auth session into localStorage if missing, then - * redirect to login when the Bearer token is absent or past `exp`. + * Keep backend JWT fresh. Only force login redirect when we have neither a + * usable access token nor a successful refresh (avoid false logout on blips). */ useEffect(() => { if (!isHydrated || typeof window === "undefined") return; @@ -46,37 +118,47 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre let cancelled = false; (async () => { try { - let token = localStorage.getItem("accessToken")?.trim() ?? ""; + const session = (await getSession()) as SessionWithTokens | null; + if (cancelled) return; - if (!token) { - const session = (await getSession()) as SessionWithTokens | null; - if (cancelled) return; - if (session?.accessToken) { - token = session.accessToken; - localStorage.setItem("accessToken", token); - setAccessToken(token); - } - } + syncSessionTokensToStorage(session); + + const token = await ensureFreshAccessToken( + readStoredRefreshToken() || session?.refreshToken + ); + if (cancelled) return; if (!token) { + // Soft-fail if next-auth still has a session — avoid kicking users on + // transient refresh failures when an access token simply isn't readable yet. + if (session?.accessToken && !session.error) { + setAccessToken(session.accessToken); + persistBackendTokens(session.accessToken, session.refreshToken); + scheduleProactiveRefresh(session.accessToken); + return; + } + clearBackendTokens(); window.location.href = LOGIN_SESSION_EXPIRED_HREF; return; } - if (isBackendJwtExpired(token)) { - window.location.href = LOGIN_SESSION_EXPIRED_HREF; - } + setAccessToken(token); + scheduleProactiveRefresh(token); } catch (e) { + // Do not redirect on unexpected errors (network / getSession failures) console.warn("Auth token check failed", e); } })(); return () => { cancelled = true; + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } }; - }, [isHydrated]); + }, [isHydrated, scheduleProactiveRefresh]); - // Apply token + interceptors useEffect(() => { if (accessToken) { axiosInstance.defaults.headers.Authorization = `Bearer ${accessToken}`; @@ -86,24 +168,26 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre } }, [accessToken]); - const handleSetAccessToken = useCallback((token: string | null) => { - setAccessToken(token); - try { - if (token) { - localStorage.setItem("accessToken", token); - } else { - localStorage.removeItem("accessToken"); + const handleSetAccessToken = useCallback( + (token: string | null) => { + setAccessToken(token); + try { + if (token) { + localStorage.setItem(ACCESS_TOKEN_KEY, token); + scheduleProactiveRefresh(token); + } else { + clearBackendTokens(); + } + } catch { + // ignore (e.g. private mode) } - } catch (e) { - // ignore (e.g. private mode) - } - }, []); + }, + [scheduleProactiveRefresh] + ); - // Critical fix: never return null → always return children wrapped in fragment return ( - {/* Render children immediately – they will just not have the token for 1-2ms */} {children} @@ -111,4 +195,4 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre }; export const useAxios = () => useContext(AxiosContext); -export const useToken = () => useContext(TokenContext); \ No newline at end of file +export const useToken = () => useContext(TokenContext); diff --git a/src/app/(main)/axios/axiosInstance.ts b/src/app/(main)/axios/axiosInstance.ts index 1d24e5d9..2641a062 100644 --- a/src/app/(main)/axios/axiosInstance.ts +++ b/src/app/(main)/axios/axiosInstance.ts @@ -1,52 +1,76 @@ -import axios from "axios"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import { + LOGIN_SESSION_EXPIRED_HREF, + readStoredRefreshToken, + refreshBackendAccessToken, +} from "@/app/utils/authToken"; const axiosInstance = axios.create({ - baseURL: process.env.API_URL, + baseURL: process.env.API_URL || process.env.NEXT_PUBLIC_API_URL, }); -// Clear existing interceptors to prevent multiple registrations -const clearInterceptors = () => { - const requestInterceptor = axiosInstance.interceptors.request.use(); - const responseInterceptor = axiosInstance.interceptors.response.use(); +type RetriableConfig = InternalAxiosRequestConfig & { _retry?: boolean }; - if (requestInterceptor) { - axiosInstance.interceptors.request.eject(requestInterceptor); - } +let requestInterceptorId: number | null = null; +let responseInterceptorId: number | null = null; - if (responseInterceptor) { - axiosInstance.interceptors.response.eject(responseInterceptor); +const clearInterceptors = () => { + if (requestInterceptorId !== null) { + axiosInstance.interceptors.request.eject(requestInterceptorId); + requestInterceptorId = null; + } + if (responseInterceptorId !== null) { + axiosInstance.interceptors.response.eject(responseInterceptorId); + responseInterceptorId = null; } }; -export const SetupAxiosInterceptors = (inputToken: string | null) => { - console.log("[debug] set up interceptors", inputToken); - - // Clear existing interceptors +/** + * Always prefer localStorage accessToken (may have been refreshed) over any + * closed-over token passed into SetupAxiosInterceptors. + */ +export const SetupAxiosInterceptors = (_inputToken: string | null) => { clearInterceptors(); - // Request interceptor - axiosInstance.interceptors.request.use( + requestInterceptorId = axiosInstance.interceptors.request.use( (config) => { - // Use the token passed in or retrieve from localStorage - const token = inputToken ?? localStorage.getItem("accessToken"); - if (token) { - config.headers.Authorization = `Bearer ${token}`; + try { + const token = + (typeof window !== "undefined" + ? localStorage.getItem("accessToken") + : null) ?? _inputToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } catch { + if (_inputToken) { + config.headers.Authorization = `Bearer ${_inputToken}`; + } } return config; }, - (error) => { - return Promise.reject(error); - }, + (error) => Promise.reject(error), ); - console.log("[debug] set up interceptors2", inputToken); + responseInterceptorId = axiosInstance.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const status = error.response?.status; + const original = error.config as RetriableConfig | undefined; + + // Only 401 = unauthenticated. Do not treat 403 (forbidden) as session expiry. + if (status === 401 && original && !original._retry && typeof window !== "undefined") { + original._retry = true; + const refreshed = await refreshBackendAccessToken(readStoredRefreshToken()); + if (refreshed) { + original.headers = original.headers ?? {}; + original.headers.Authorization = `Bearer ${refreshed}`; + axiosInstance.defaults.headers.Authorization = `Bearer ${refreshed}`; + return axiosInstance(original); + } + window.location.href = LOGIN_SESSION_EXPIRED_HREF; + } - // Response interceptor - axiosInstance.interceptors.response.use( - (response) => { - return response; - }, - (error) => { console.error("API Error:", error.response?.data || error); return Promise.reject(error); }, diff --git a/src/app/(main)/chart/delivery/page.tsx b/src/app/(main)/chart/delivery/page.tsx index bbbf3bb8..4cc04524 100644 --- a/src/app/(main)/chart/delivery/page.tsx +++ b/src/app/(main)/chart/delivery/page.tsx @@ -1,6 +1,8 @@ "use client"; import React, { useCallback, useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; +import { AUTH, hasAbility } from "@/authorities"; import { Box, Typography, @@ -68,6 +70,9 @@ const defaultCriteria: Criteria = { /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ export default function DeliveryChartPage() { + const { data: session } = useSession(); + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const canSeeStaffPerformance = hasAbility(abilities, AUTH.PERFORMANCE); const [criteria, setCriteria] = useState(defaultCriteria); const [topItemsSelected, setTopItemsSelected] = useState([]); const [topItemOptions, setTopItemOptions] = useState([]); @@ -132,6 +137,10 @@ export default function DeliveryChartPage() { }, [criteria.topItems, topItemsSelected, setChartLoading]); React.useEffect(() => { + if (!canSeeStaffPerformance) { + setChartData((prev) => ({ ...prev, staffPerf: [] })); + return; + } const s = criteria.staffPerf.startDate; const e = criteria.staffPerf.endDate; if (!s || !e) { @@ -161,13 +170,17 @@ export default function DeliveryChartPage() { ) .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) .finally(() => setChartLoading("staffPerf", false)); - }, [criteria.staffPerf, staffSelected, setChartLoading]); + }, [canSeeStaffPerformance, criteria.staffPerf, staffSelected, setChartLoading]); React.useEffect(() => { + if (!canSeeStaffPerformance) { + setStaffOptions([]); + return; + } fetchStaffDeliveryPerformanceHandlers() .then(setStaffOptions) .catch(() => setStaffOptions([])); - }, []); + }, [canSeeStaffPerformance]); React.useEffect(() => { const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([])); @@ -317,6 +330,7 @@ export default function DeliveryChartPage() { )} + {canSeeStaffPerformance && ( )} + )} ); } diff --git a/src/app/(main)/m18Syn/page.tsx b/src/app/(main)/m18Syn/page.tsx index df97ff33..4c7f6b74 100644 --- a/src/app/(main)/m18Syn/page.tsx +++ b/src/app/(main)/m18Syn/page.tsx @@ -39,6 +39,7 @@ export default function M18SynPage() { type ParsedSyncResult = | { kind: "exists"; message: string } + | { kind: "skipped_not_pending"; message: string } | { kind: "not_found"; message: string } | { kind: "success"; message: string; totalSuccess: number } | { kind: "fail"; message: string; totalFail: number } @@ -60,15 +61,45 @@ export default function M18SynPage() { return { kind: "exists", message: `${docName}已存在系統` }; } - // "not found in M18" (sync-by-code returns processed=1, fail=1) + // Local PO/DO exists but status is not pending — sync intentionally skipped + if (query.includes("skipped (not pending")) { + const statusMatch = query.match(/status=([^\s;|]+)/); + const statusRaw = (statusMatch?.[1] ?? "").toLowerCase(); + const statusLabel = (() => { + if (statusRaw === "receiving") { + // DO uses receiving = 已放單; PO uses receiving = 收貨中 + return docName === "送貨訂單" ? "已放單" : "收貨中"; + } + if (statusRaw === "completed") return "已完成"; + if (statusRaw === "pending") return "待處理"; + return statusRaw || "非待處理"; + })(); + return { + kind: "skipped_not_pending", + message: `無法同步:此${docName}目前為「${statusLabel}」,不能再從 M18 覆寫(僅「待處理」可同步)。`, + }; + } + + // "not found in M18" (sync-by-code returns processed=1, fail=1) — may be wrong tab (PO vs DO) if ( - docName === "送貨訂單" && totalProcessed === 1 && totalSuccess === 0 && totalFail === 1 && query.includes("code=equal=") ) { - return { kind: "not_found", message: `在M18找不到${docName}` }; + if (docName === "採購單") { + return { + kind: "not_found", + message: "在M18找不到採購單", + }; + } + if (docName === "送貨訂單") { + return { + kind: "not_found", + message: + "在M18找不到送貨訂單。若此單為物料採購單,請改到「採購單」分頁同步。", + }; + } } if (totalSuccess > 0 && totalFail === 0) { @@ -108,7 +139,8 @@ export default function M18SynPage() { const handleSyncM18PoByCode = async () => { if (m18PoInFlightRef.current) return; - if (!m18PoCode.trim()) { + const code = m18PoCode.trim(); + if (!code) { alert("Please enter PO code."); return; } @@ -116,15 +148,58 @@ export default function M18SynPage() { setIsSyncingM18Po(true); setM18PoSyncResult(""); try { - const response = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`, + const poResponse = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(code)}`, { method: "GET" }, ); - if (response.status === 401 || response.status === 403) return; - const text = await response.text(); - setM18PoSyncResult(formatSyncResultText("採購單", text)); - if (!response.ok) { - alert(`Sync failed: ${response.status}`); + if (poResponse.status === 401 || poResponse.status === 403) return; + const poText = await poResponse.text(); + const poParsed = parseSyncResult("採購單", poText); + + if (poParsed.kind !== "not_found") { + setM18PoSyncResult(poParsed.message); + if (!poResponse.ok) { + alert(`Sync failed: ${poResponse.status}`); + } + return; + } + + // PO not found under material-PO scope — auto-try DO (shop PO) with same code + setM18PoSyncResult("採購單找不到,改嘗試同步送貨訂單…"); + const doResponse = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(code)}`, + { method: "GET" }, + ); + if (doResponse.status === 401 || doResponse.status === 403) return; + const doText = await doResponse.text(); + const doParsed = parseSyncResult("送貨訂單", doText); + + if (doParsed.kind === "success") { + setM18PoSyncResult( + [ + `採購單找不到(code=${code})`, + `已自動改以送貨訂單同步:成功`, + ].join("\n"), + ); + } else if (doParsed.kind === "not_found") { + setM18PoSyncResult( + [ + `採購單找不到(code=${code})`, + `已自動改試送貨訂單:亦找不到`, + "請確認單號是否正確。", + ].join("\n"), + ); + } else { + setM18PoSyncResult( + [ + `採購單找不到(code=${code})`, + `已自動改試送貨訂單:${doParsed.message}`, + ].join("\n"), + ); + } + + if (!doResponse.ok) { + alert(`DO fallback sync failed: ${doResponse.status}`); } } catch (e) { console.error("M18 PO Sync By Code Error:", e); @@ -135,26 +210,63 @@ export default function M18SynPage() { } }; + /** 送貨訂單:手動按 code 同步(可輸入多個 code,用逗號或換行分隔) */ const handleSyncM18DoByCode = async () => { if (m18DoInFlightRef.current) return; - if (!m18DoCode.trim()) { - alert("Please enter DO / shop PO code."); + const raw = m18DoCode.trim(); + if (!raw) { + alert("Please enter DO / shop PO code(s)."); + return; + } + const codes = raw + .split(/[\n,]+/g) + .map((s) => s.trim()) + .filter(Boolean); + if (codes.length === 0) { + alert("Please enter at least one code."); return; } m18DoInFlightRef.current = true; setIsSyncingM18Do(true); setM18DoSyncResult(""); try { - const response = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(m18DoCode.trim())}`, - { method: "GET" }, - ); - if (response.status === 401 || response.status === 403) return; - const text = await response.text(); - setM18DoSyncResult(formatSyncResultText("送貨訂單", text)); - if (!response.ok) { - alert(`Sync failed: ${response.status}`); + const outputs: string[] = []; + const okCodes: string[] = []; + const notFoundCodes: string[] = []; + const skippedReleasedCodes: string[] = []; + const otherFailCodes: string[] = []; + for (const code of codes) { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(code)}`, + { method: "GET" }, + ); + if (response.status === 401 || response.status === 403) return; + const text = await response.text(); + const parsed = parseSyncResult("送貨訂單", text || ""); + if (parsed.kind === "success") okCodes.push(code); + else if (parsed.kind === "not_found") notFoundCodes.push(code); + else if (parsed.kind === "skipped_not_pending") skippedReleasedCodes.push(code); + else otherFailCodes.push(code); + + const perCodeMsg = + parsed.kind === "success" ? "成功同步" : + parsed.kind === "not_found" ? parsed.message : + parsed.message; + outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`); + } + + outputs.push(""); + outputs.push(`共${okCodes.length}張送貨訂單成功`); + if (notFoundCodes.length > 0) { + outputs.push(`共${notFoundCodes.length}張在M18找不到送貨訂單 (${notFoundCodes.join(", ")})`); + outputs.push("提示:若為物料採購單,請改到「採購單」分頁同步。"); + } + if (skippedReleasedCodes.length > 0) { + outputs.push(`共${skippedReleasedCodes.length}張已放單/已完成,無法再同步 (${skippedReleasedCodes.join(", ")})`); } + if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`); + + setM18DoSyncResult(outputs.join("\n")); } catch (e) { console.error("M18 DO Sync By Code Error:", e); alert("M18 DO sync failed. Check console/network."); @@ -188,6 +300,7 @@ export default function M18SynPage() { const okCodes: string[] = []; const existsCodes: string[] = []; const notFoundCodes: string[] = []; + const skippedReleasedCodes: string[] = []; const otherFailCodes: string[] = []; for (const code of codes) { const response = await clientAuthFetch( @@ -200,6 +313,7 @@ export default function M18SynPage() { if (parsed.kind === "success") okCodes.push(code); else if (parsed.kind === "exists") existsCodes.push(code); else if (parsed.kind === "not_found") notFoundCodes.push(code); + else if (parsed.kind === "skipped_not_pending") skippedReleasedCodes.push(code); else otherFailCodes.push(code); const perCodeMsg = @@ -213,8 +327,14 @@ export default function M18SynPage() { // Summary outputs.push(""); outputs.push(`共${okCodes.length}張送貨訂單成功`); - if (notFoundCodes.length > 0) outputs.push(`共${notFoundCodes.length}張在M18找不到訂單 (${notFoundCodes.join(", ")})`); + if (notFoundCodes.length > 0) { + outputs.push(`共${notFoundCodes.length}張在M18找不到送貨訂單 (${notFoundCodes.join(", ")})`); + outputs.push("提示:若為物料採購單,請改到「採購單」分頁同步。"); + } if (existsCodes.length > 0) outputs.push(`共${existsCodes.length}張訂單已存在系統 (${existsCodes.join(", ")})`); + if (skippedReleasedCodes.length > 0) { + outputs.push(`共${skippedReleasedCodes.length}張已放單/已完成,無法再同步 (${skippedReleasedCodes.join(", ")})`); + } if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`); setM18DoExtraSyncResult(outputs.join("\n")); @@ -272,6 +392,7 @@ export default function M18SynPage() { Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. + 「採購單」找不到時會自動改試「送貨訂單」;「送貨訂單」找不到時請改到「採購單」分頁。 setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> @@ -312,18 +433,22 @@ export default function M18SynPage() {
- + setM18DoCode(e.target.value)} - placeholder="e.g. same document code as M18 shop PO" - sx={{ minWidth: 320 }} + placeholder="可輸入多個 code,用逗號或換行分隔" + fullWidth + multiline + minRows={6} + maxRows={14} /> - + + + {m18DoSyncResult ? ( void; + /** Live text in the box, including text that has not been picked from the list. */ + onInputValueChange?: (value: string) => void; placeholder?: string; disabled?: boolean; minChars?: number; @@ -17,11 +20,12 @@ type Props = { const hitLabel = (hit: ItemCodeSearchHit) => hit.name ? `${hit.code} ${hit.name}` : hit.code; -/** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.0 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.1 | 2026-09-28 */ const AsyncItemCodeAutocomplete: React.FC = ({ label, value, onChange, + onInputValueChange, placeholder, disabled = false, minChars = 2, @@ -73,21 +77,47 @@ const AsyncItemCodeAutocomplete: React.FC = ({ }; }, [trimmedInput, minChars]); + const selectedCodes = useMemo( + () => new Set(value.map((code) => code.toUpperCase())), + [value], + ); + const options = useMemo(() => { const seen = new Set(); const codes: string[] = []; suggestions.forEach((hit) => { - if (seen.has(hit.code)) return; - seen.add(hit.code); + const key = hit.code.toUpperCase(); + if (seen.has(key) || selectedCodes.has(key)) return; + seen.add(key); codes.push(hit.code); }); - value.forEach((code) => { - if (seen.has(code)) return; - seen.add(code); - codes.push(code); - }); return codes; - }, [suggestions, value]); + }, [suggestions, selectedCodes]); + + useEffect(() => { + const trimmed = inputValue.trim(); + if (!trimmed) return; + const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); + if (hit) onInputValueChange?.(hit.code); + }, [suggestions, inputValue, onInputValueChange]); + + const inputValueRef = useRef(inputValue); + inputValueRef.current = inputValue; + const previousValueRef = useRef(value); + useEffect(() => { + const previous = previousValueRef.current; + previousValueRef.current = value; + const tokens = parseItemCodeTokens(inputValueRef.current); + if (tokens.length === 0) return; + const previousKeys = new Set(previous.map((code) => code.toUpperCase())); + const nextKeys = new Set(value.map((code) => code.toUpperCase())); + const justCommitted = tokens.every( + (token) => nextKeys.has(token.toUpperCase()) && !previousKeys.has(token.toUpperCase()), + ); + if (!justCommitted) return; + setInputValue(""); + onInputValueChange?.(""); + }, [value, onInputValueChange]); const noOptionsText = needsMoreChars ? t("typeToSearchItemCode", { min: minChars }) @@ -112,9 +142,11 @@ const AsyncItemCodeAutocomplete: React.FC = ({ filterOptions={(opts) => trimmedInput.length < minChars ? [] - : opts.filter((code) => !value.includes(code)) + : opts.filter((code) => !selectedCodes.has(code.toUpperCase())) + } + isOptionEqualToValue={(option, selected) => + option.toUpperCase() === selected.toUpperCase() } - isOptionEqualToValue={(option, selected) => option === selected} autoHighlight noOptionsText={noOptionsText} sx={{ @@ -162,9 +194,13 @@ const AsyncItemCodeAutocomplete: React.FC = ({ onInputChange={(_, next, reason) => { if (reason === "reset") { setInputValue(""); + onInputValueChange?.(""); return; } setInputValue(next); + const trimmed = next.trim(); + const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); + onInputValueChange?.(hit?.code ?? next); }} onChange={(_, newValue) => { const codes = (Array.isArray(newValue) ? newValue : []) diff --git a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx index 5592a93c..af09bff2 100644 --- a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx +++ b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from "react-i18next"; import { Dialog, @@ -19,16 +19,21 @@ import { Typography, } from '@mui/material'; import DownloadIcon from '@mui/icons-material/Download'; +import dayjs from 'dayjs'; import { - fetchSemiFGItemCodes, fetchSemiFGItemCodesWithCategory, generateSemiFGProductionAnalysisReport, generateSemiFGProductionAnalysisReportExcel, + generateSemiFGProductionAnalysisReportExcelDaily, ItemCodeWithCategory, } from './semiFGProductionAnalysisApi'; +import { parseItemCodeTokens } from './parseItemCodeTokens'; + +type ExportFormat = 'pdf' | 'excel' | 'excel-daily'; interface SemiFGProductionAnalysisReportProps { criteria: Record; + prepareCriteria?: () => Record; requiredFieldLabels: string[]; loading: boolean; setLoading: (loading: boolean) => void; @@ -38,6 +43,7 @@ interface SemiFGProductionAnalysisReportProps { export default function SemiFGProductionAnalysisReport({ criteria, + prepareCriteria, requiredFieldLabels, loading, setLoading, @@ -48,7 +54,8 @@ export default function SemiFGProductionAnalysisReport({ const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState([]); const [itemCodesWithCategory, setItemCodesWithCategory] = useState>({}); - const [exportFormat, setExportFormat] = useState<'pdf' | 'excel'>('pdf'); + const [exportFormat, setExportFormat] = useState('pdf'); + const inFlightRef = useRef(false); // Fetch item codes with category when stockCategory changes useEffect(() => { @@ -68,24 +75,43 @@ export default function SemiFGProductionAnalysisReport({ } }, [criteria.stockCategory]); - const handleExportClick = async (format: 'pdf' | 'excel') => { + const dailyRangeError = (activeCriteria: Record): string | null => { + const start = (activeCriteria.lastOutDateStart || '').trim(); + const end = (activeCriteria.lastOutDateEnd || '').trim(); + if (!start || !end) return t('semiFgDailyRangeRequired'); + const startDate = dayjs(start); + const endDate = dayjs(end); + if (!startDate.isValid() || !endDate.isValid()) return t('semiFgDailyRangeRequired'); + if (endDate.isBefore(startDate, 'day')) return t('semiFgDailyRangeInvalid'); + if (endDate.diff(startDate, 'day') + 1 > 366) return t('semiFgDailyRangeTooLong'); + return null; + }; + + const handleExportClick = async (format: ExportFormat) => { + if (inFlightRef.current) return; + const activeCriteria = prepareCriteria?.() ?? criteria; setExportFormat(format); - // Validate required fields if (requiredFieldLabels.length > 0) { alert(t('missingRequired', { fields: requiredFieldLabels.join('\n- ') })); return; } + if (format === 'excel-daily') { + const rangeError = dailyRangeError(activeCriteria); + if (rangeError) { + alert(rangeError); + return; + } + } - // If no itemCode is selected, export directly without confirmation - if (!criteria.itemCode) { - await executeExport(format); + const selectedCodes = parseItemCodeTokens( + [activeCriteria.itemCode, activeCriteria.itemCodePaste].filter(Boolean).join(' '), + ); + if (selectedCodes.length === 0) { + await executeExport(format, activeCriteria); return; } - // If itemCode is selected, show confirmation dialog - const selectedCodes = criteria.itemCode.split(',').filter((code) => code.trim()); - const itemCodesInfo: ItemCodeWithCategory[] = selectedCodes.map((code) => { - const codeTrimmed = code.trim(); + const itemCodesInfo: ItemCodeWithCategory[] = selectedCodes.map((codeTrimmed) => { const categoryInfo = itemCodesWithCategory[codeTrimmed]; return { code: codeTrimmed, @@ -97,24 +123,47 @@ export default function SemiFGProductionAnalysisReport({ setShowConfirmDialog(true); }; - const executeExport = async (format: 'pdf' | 'excel' = exportFormat) => { + const executeExport = async ( + format: ExportFormat = exportFormat, + activeCriteria: Record = prepareCriteria?.() ?? criteria, + ) => { + if (inFlightRef.current) return; + inFlightRef.current = true; setLoading(true); try { - if (format === 'excel') { - await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle); + if (format === 'excel-daily') { + await generateSemiFGProductionAnalysisReportExcelDaily(activeCriteria, reportTitle); + onExportSuccess?.('excel'); + } else if (format === 'excel') { + await generateSemiFGProductionAnalysisReportExcel(activeCriteria, reportTitle); + onExportSuccess?.('excel'); } else { - await generateSemiFGProductionAnalysisReport(criteria, reportTitle); + await generateSemiFGProductionAnalysisReport(activeCriteria, reportTitle); + onExportSuccess?.('pdf'); } - onExportSuccess?.(format); setShowConfirmDialog(false); } catch (error) { console.error('Failed to generate report:', error); - alert(t('generateError')); + const message = error instanceof Error ? error.message.trim() : ''; + alert(message && !message.startsWith('HTTP error') ? message : t('generateError')); } finally { setLoading(false); + inFlightRef.current = false; } }; + const confirmLabel = loading + ? exportFormat === 'excel-daily' + ? t('semiFgGeneratingDailyExcel') + : exportFormat === 'excel' + ? t('generatingExcel') + : t('generatingPdf') + : exportFormat === 'excel-daily' + ? t('semiFgConfirmDailyExcel') + : exportFormat === 'excel' + ? t('confirmDownloadExcel') + : t('confirmDownloadPdf'); + return ( <>
@@ -126,7 +175,7 @@ export default function SemiFGProductionAnalysisReport({ disabled={loading} sx={{ px: 4 }} > - {loading ? t('generatingPdf') : t('downloadPdf')} + {loading && exportFormat === 'pdf' ? t('generatingPdf') : t('downloadPdf')} +
@@ -196,13 +258,7 @@ export default function SemiFGProductionAnalysisReport({ disabled={loading} startIcon={} > - {loading - ? exportFormat === 'excel' - ? t('generatingExcel') - : t('generatingPdf') - : exportFormat === 'excel' - ? t('confirmDownloadExcel') - : t('confirmDownloadPdf')} + {confirmLabel} diff --git a/src/app/(main)/report/bomShopSyncReportApi.ts b/src/app/(main)/report/bomShopSyncReportApi.ts index 08b31c48..d3eee465 100644 --- a/src/app/(main)/report/bomShopSyncReportApi.ts +++ b/src/app/(main)/report/bomShopSyncReportApi.ts @@ -2,6 +2,7 @@ import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { buildItemCodePasteRequestBody } from "./parseItemCodeTokens"; import { exportMultiSheetToXlsx, } from "@/app/(main)/chart/_components/exportChartToXlsx"; @@ -124,12 +125,15 @@ function emptyMaterialSheetRow( export async function fetchBomShopSyncReportData( criteria: Record, ): Promise { - const queryParams = new URLSearchParams(criteria).toString(); - const url = `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history?${queryParams}`; + const url = `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history`; const response = await clientAuthFetch(url, { - method: "GET", - headers: { Accept: "application/json" }, + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteria, "finishedItemCode")), }); if (response.status === 401 || response.status === 403) diff --git a/src/app/(main)/report/grnReportApi.ts b/src/app/(main)/report/grnReportApi.ts index a8972cec..2e2f001f 100644 --- a/src/app/(main)/report/grnReportApi.ts +++ b/src/app/(main)/report/grnReportApi.ts @@ -1,6 +1,7 @@ "use client"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { buildItemCodePasteRequestBody } from './parseItemCodeTokens'; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { exportChartToXlsx, @@ -64,17 +65,20 @@ export interface GrnReportResponse { /** * Fetch GRN (Goods Received Note) report data by date range. - * Backend: GET /report/grn-report?receiptDateStart=&receiptDateEnd=&itemCode= + * Backend: POST /report/grn-report { receiptDateStart, receiptDateEnd, itemCodes } */ export async function fetchGrnReportData( criteria: Record ): Promise<{ rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts }> { - const queryParams = new URLSearchParams(criteria).toString(); - const url = `${NEXT_PUBLIC_API_URL}/report/grn-report?${queryParams}`; + const url = `${NEXT_PUBLIC_API_URL}/report/grn-report`; const response = await clientAuthFetch(url, { - method: "GET", - headers: { Accept: "application/json" }, + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteria, "itemCode")), }); if (response.status === 401 || response.status === 403) diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 6dec1c10..0bd8e61b 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -26,6 +26,7 @@ import { import DownloadIcon from '@mui/icons-material/Download'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { REPORTS } from '@/config/reportConfig'; +import { mergePastedItemCodes, buildItemCodePasteRequestBody, parseItemCodeTokens } from './parseItemCodeTokens'; import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; @@ -74,13 +75,22 @@ const FIELD_ERROR_SX = { }, }; +const ITEM_CODE_PASTE_POST_REPORTS = new Set([ + 'rep-004', + 'rep-006', + 'rep-007', + 'rep-008', + 'rep-011', + 'rep-013', +]); + /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.3 | 2026-09-10 */ /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ -/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ -/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.3 | 2026-09-28 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-28 */ export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; - const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels(); + const { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel } = useReportLabels(); const isZh = (i18n.language || 'zh').startsWith('zh'); const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY'; const includeGrnFinancialColumns = @@ -88,6 +98,37 @@ export default function ReportPage() { const [selectedReportId, setSelectedReportId] = useState(''); const [criteria, setCriteria] = useState>({}); + const criteriaRef = useRef>({}); + const pendingItemCodeInputRef = useRef>({}); + const updateCriteria = ( + updater: Record | ((prev: Record) => Record), + ) => { + const next = typeof updater === 'function' ? updater(criteriaRef.current) : updater; + criteriaRef.current = next; + setCriteria(next); + }; + /** Typed item codes are not in criteria until a dropdown option is chosen. Merge them in before the request. */ + const flushPendingItemCodeInputs = (): Record => { + const pendingEntries = Object.entries(pendingItemCodeInputRef.current).filter(([, raw]) => raw.trim()); + if (pendingEntries.length === 0) return criteriaRef.current; + + const next = { ...criteriaRef.current }; + let changed = false; + for (const [field, raw] of pendingEntries) { + pendingItemCodeInputRef.current[field] = ''; + const merged = parseItemCodeTokens([next[field], raw].filter(Boolean).join(' ')); + const joined = merged.join(','); + if (joined && joined !== (next[field] || '')) { + next[field] = joined; + changed = true; + } + } + if (changed) { + criteriaRef.current = next; + setCriteria(next); + } + return criteriaRef.current; + }; const [loading, setLoading] = useState(false); const excelInFlightRef = useRef(false); const [dynamicOptions, setDynamicOptions] = useState>({}); @@ -114,14 +155,15 @@ export default function ReportPage() { if (reportId === selectedReportId) return; setSelectedReportId(reportId); setFieldErrors({}); + pendingItemCodeInputRef.current = {}; if (reportId === 'rep-010') { - setCriteria({ qcType: 'all', qcItemScope: 'all' }); + updateCriteria({ qcType: 'all', qcItemScope: 'all' }); } else if (reportId === 'rep-004') { - setCriteria({ storeId: 'All', poPrefix: 'All' }); + updateCriteria({ storeId: 'All', poPrefix: 'All' }); } else if (reportId === 'rep-021') { - setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); + updateCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); } else { - setCriteria({}); + updateCriteria({}); } }; @@ -133,7 +175,7 @@ export default function ReportPage() { delete next[name]; return next; }); - setCriteria((prev) => { + updateCriteria((prev) => { const next = { ...prev, [name]: stringValue }; if (currentReport?.id === 'rep-021' && name === 'warehouse') { const m = stringValue.trim().match(/^w(\d)/i); @@ -242,7 +284,7 @@ export default function ReportPage() { // Default "All" (no filter) for stock take variance report conditions. if (selectedReportId === 'rep-012') { - setCriteria({ + updateCriteria({ store_id: 'All', status: 'All', type: 'All', @@ -254,7 +296,7 @@ export default function ReportPage() { useEffect(() => { if (selectedReportId !== 'rep-012' || !rep012MultiRound) return; if (criteria.status === 'completed') return; - setCriteria((prev) => ({ ...prev, status: 'completed' })); + updateCriteria((prev) => ({ ...prev, status: 'completed' })); }, [selectedReportId, rep012MultiRound, criteria.status]); // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice. @@ -319,19 +361,20 @@ export default function ReportPage() { /** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */ const buildRep012QueryString = (): string => { + const active = criteriaRef.current; const p = new URLSearchParams(); p.set('stockTakeRoundId', rep012RoundIds.join(',')); - const code = criteria.itemCode?.trim(); + const code = active.itemCode?.trim(); if (code) p.set('itemCode', code); - const store = criteria.store_id?.trim(); + const store = active.store_id?.trim(); if (store && store !== 'All') p.set('store_id', store); if (rep012MultiRound) { p.set('status', 'completed'); } else { - const status = criteria.status?.trim(); + const status = active.status?.trim(); if (status && status !== 'All') p.set('status', status); } - const lotType = criteria.type?.trim(); + const lotType = active.type?.trim(); if (lotType && lotType !== 'All') p.set('type', lotType); return p.toString(); }; @@ -339,15 +382,16 @@ export default function ReportPage() { /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ /** rep-010:qcItemScope → includeMeasurable / includeOther;qcType=all 不傳篩選 */ const buildRep010QueryString = (): string => { + const active = criteriaRef.current; const p = new URLSearchParams(); - Object.entries(criteria).forEach(([key, value]) => { + Object.entries(active).forEach(([key, value]) => { if (key === 'qcItemScope') return; if (key === 'qcType' && String(value).trim().toLowerCase() === 'all') return; if (value != null && String(value).trim() !== '') { p.set(key, String(value)); } }); - const scope = (criteria.qcItemScope || 'all').trim().toLowerCase(); + const scope = (active.qcItemScope || 'all').trim().toLowerCase(); if (scope === 'measurable') { p.set('includeMeasurable', 'true'); p.set('includeOther', 'false'); @@ -358,7 +402,32 @@ export default function ReportPage() { return p.toString(); }; + const itemCodeFieldName = (): string => + currentReport?.fields.some((f) => f.name === 'finishedItemCode') + ? 'finishedItemCode' + : 'itemCode'; + + const isItemCodePastePostReport = (): boolean => + !!currentReport && ITEM_CODE_PASTE_POST_REPORTS.has(currentReport.id); + + const buildCriteriaQueryString = (): string => { + if (!currentReport) return ''; + if (currentReport.id === 'rep-012') return buildRep012QueryString(); + if (currentReport.id === 'rep-010') return buildRep010QueryString(); + const merged = mergePastedItemCodes(criteriaRef.current, itemCodeFieldName()); + const p = new URLSearchParams(merged); + if (currentReport.id === 'rep-016') { + const day = (merged.dateStart || '').trim(); + if (day) { + p.set('dateStart', day); + p.set('dateEnd', day); + } + } + return p.toString(); + }; + const handlePrint = async () => { + flushPendingItemCodeInputs(); if (!currentReport) return; if (!validateRequiredFields()) return; @@ -375,6 +444,7 @@ export default function ReportPage() { }; const handleExcelPrint = async () => { + flushPendingItemCodeInputs(); if (!currentReport) return; if (!validateRequiredFields()) return; await executeExcelReport(); @@ -386,41 +456,38 @@ export default function ReportPage() { excelInFlightRef.current = true; setLoading(true); try { + const activeCriteria = criteriaRef.current; if (currentReport.id === 'rep-014') { await generateGrnReportExcel( - criteria, + activeCriteria, reportTitle(currentReport), includeGrnFinancialColumns, t, ); } else if (currentReport.id === 'rep-015') { - await generateBomShopSyncReportExcel(criteria, reportTitle(currentReport), t); + await generateBomShopSyncReportExcel(activeCriteria, reportTitle(currentReport), t); } else if (currentReport.id === 'rep-017') { - await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); + await generateShopOrderReplenishmentReportExcel(activeCriteria, reportTitle(currentReport), t); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. - let queryParams = - currentReport.id === 'rep-012' - ? buildRep012QueryString() - : currentReport.id === 'rep-010' - ? buildRep010QueryString() - : new URLSearchParams(criteria).toString(); - // rep-016: single-day UI — mirror dateStart to dateEnd for backend API. - if (currentReport.id === 'rep-016') { - const p = new URLSearchParams(criteria); - const day = (criteria.dateStart || '').trim(); - if (day) { - p.set('dateStart', day); - p.set('dateEnd', day); - } - queryParams = p.toString(); - } - const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; + const usesItemCodePastePost = isItemCodePastePostReport(); + const excelUrl = usesItemCodePastePost + ? `${currentReport.apiEndpoint}-excel` + : `${currentReport.apiEndpoint}-excel?${buildCriteriaQueryString()}`; - const response = await clientAuthFetch(excelUrl, { - method: 'GET', - headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, - }); + const response = await clientAuthFetch(excelUrl, usesItemCodePastePost + ? { + method: 'POST', + headers: { + Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteriaRef.current, itemCodeFieldName())), + } + : { + method: 'GET', + headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + }); if (response.status === 401 || response.status === 403) return; if (response.status === 204) { @@ -472,18 +539,24 @@ export default function ReportPage() { setLoading(true); try { - let queryParams = - currentReport.id === 'rep-012' - ? buildRep012QueryString() - : currentReport.id === 'rep-010' - ? buildRep010QueryString() - : new URLSearchParams(criteria).toString(); - const url = `${currentReport.apiEndpoint}?${queryParams}`; + const usesItemCodePastePost = isItemCodePastePostReport(); + const url = usesItemCodePastePost + ? currentReport.apiEndpoint + : `${currentReport.apiEndpoint}?${buildCriteriaQueryString()}`; - const response = await clientAuthFetch(url, { - method: 'GET', - headers: { 'Accept': 'application/pdf' }, - }); + const response = await clientAuthFetch(url, usesItemCodePastePost + ? { + method: 'POST', + headers: { + Accept: 'application/pdf', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteriaRef.current, itemCodeFieldName())), + } + : { + method: 'GET', + headers: { Accept: 'application/pdf' }, + }); if (response.status === 401 || response.status === 403) return; if (!response.ok) { @@ -574,7 +647,10 @@ export default function ReportPage() { : currentValue; // Use larger grid size for 成品/半成品生產分析報告 - const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; + const gridSize = field.multiline + ? { xs: 12 } + : currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; + const hintText = fieldHint(currentReport.id, field.name); const disabledByCheckedCheckbox = currentReport.fields.some((f) => { if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false; @@ -653,7 +729,13 @@ export default function ReportPage() { label={translatedLabel} placeholder={field.placeholder || "e.g. FA0591"} value={selectedCodes} - onChange={(codes) => handleFieldChange(field.name, codes)} + onInputValueChange={(text) => { + pendingItemCodeInputRef.current[field.name] = text; + }} + onChange={(codes) => { + pendingItemCodeInputRef.current[field.name] = ""; + handleFieldChange(field.name, codes); + }} minChars={field.asyncSearchMinChars ?? 2} disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} /> @@ -672,8 +754,19 @@ export default function ReportPage() { opt.value)} value={autocompleteValue} + isOptionEqualToValue={(option, selected) => + String(option).toUpperCase() === String(selected).toUpperCase() + } + onInputChange={(_, input, reason) => { + if (reason === "reset" || reason === "clear") { + pendingItemCodeInputRef.current[field.name] = ""; + return; + } + pendingItemCodeInputRef.current[field.name] = input; + }} onChange={(event, newValue, reason) => { if (field.multiple) { // Handle multiple selection - newValue is an array @@ -762,9 +855,11 @@ export default function ReportPage() { fullWidth required={field.required} error={Boolean(fieldErrors[field.name])} - helperText={fieldErrors[field.name] || undefined} + helperText={fieldErrors[field.name] || hintText || undefined} label={translatedLabel} - type={field.type} + type={field.multiline ? 'text' : field.type} + multiline={field.multiline} + minRows={field.multiline ? (field.minRows ?? 4) : undefined} placeholder={field.placeholder} disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} sx={{ @@ -854,6 +949,7 @@ export default function ReportPage() { {currentReport.id === 'rep-005' ? ( f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))} loading={loading} setLoading={setLoading} diff --git a/src/app/(main)/report/parseItemCodeTokens.ts b/src/app/(main)/report/parseItemCodeTokens.ts new file mode 100644 index 00000000..fcddfe79 --- /dev/null +++ b/src/app/(main)/report/parseItemCodeTokens.ts @@ -0,0 +1,62 @@ +/** Split pasted / typed item codes from Excel or a text field. */ +const ITEM_CODE_SEPARATORS = /[\s,;,、\u00A0\u3000]+/; + +export function parseItemCodeTokens(raw: string | undefined | null): string[] { + if (!raw) return []; + const seen = new Set(); + const tokens: string[] = []; + for (const part of raw.split(ITEM_CODE_SEPARATORS)) { + const token = part.trim(); + if (!token) continue; + const key = token.toUpperCase(); + if (seen.has(key)) continue; + seen.add(key); + tokens.push(token); + } + return tokens; +} + +export function resolveItemCodeFieldName(criteria: Record): string { + return Object.prototype.hasOwnProperty.call(criteria, "finishedItemCode") + ? "finishedItemCode" + : "itemCode"; +} + +/** Merge multi-select item codes with pasted `itemCodePaste`; drop the paste field from API params. */ +export function mergePastedItemCodes( + criteria: Record, + codeField = resolveItemCodeFieldName(criteria), +): Record { + const next: Record = { ...criteria }; + delete next.itemCodePaste; + const merged = parseItemCodeTokens( + [criteria[codeField], criteria.itemCodePaste].filter(Boolean).join(" "), + ); + if (merged.length > 0) { + next[codeField] = merged.join(","); + } else { + delete next[codeField]; + } + return next; +} + +/** POST body so pasted codes are not limited by URL length. */ +export function buildItemCodePasteRequestBody( + criteria: Record, + codeField = resolveItemCodeFieldName(criteria), +): Record { + const itemCodes = parseItemCodeTokens( + [criteria[codeField], criteria.itemCodePaste].filter(Boolean).join(" "), + ); + const body: Record = {}; + for (const [key, value] of Object.entries(criteria)) { + if (key === "itemCodePaste" || key === codeField) continue; + const trimmed = value?.trim(); + if (trimmed) body[key] = trimmed; + } + if (itemCodes.length) { + body[codeField] = itemCodes.join(","); + body.itemCodes = itemCodes; + } + return body; +} diff --git a/src/app/(main)/report/reportI18n.ts b/src/app/(main)/report/reportI18n.ts index 7e8be629..7031a3af 100644 --- a/src/app/(main)/report/reportI18n.ts +++ b/src/app/(main)/report/reportI18n.ts @@ -19,6 +19,12 @@ export function useReportLabels() { defaultValue: field.label, }); + const fieldHint = (reportId: string, fieldName: string, fallback = "") => { + const key = `reports.${reportId}.fieldHints.${fieldName}`; + if (!i18n.exists(key, { ns: "report" })) return fallback; + return String(t(key, { defaultValue: fallback })); + }; + const optionLabel = ( reportId: string, fieldName: string, @@ -35,7 +41,7 @@ export function useReportLabels() { const categoryTitle = (id: string, fallback: string) => t(`categories.${id}`, { defaultValue: fallback }); - return { t, i18n, reportTitle, fieldLabel, optionLabel, categoryTitle }; + return { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel, categoryTitle }; } export function reportExcelT( diff --git a/src/app/(main)/report/semiFGProductionAnalysisApi.ts b/src/app/(main)/report/semiFGProductionAnalysisApi.ts index b4e8b69c..0f7f845a 100644 --- a/src/app/(main)/report/semiFGProductionAnalysisApi.ts +++ b/src/app/(main)/report/semiFGProductionAnalysisApi.ts @@ -2,6 +2,7 @@ import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; +import { buildItemCodePasteRequestBody } from './parseItemCodeTokens'; export interface ItemCodeWithName { code: string; @@ -72,12 +73,15 @@ export const generateSemiFGProductionAnalysisReport = async ( criteria: Record, reportTitle: string = '成品/半成品生產分析報告' ): Promise => { - const queryParams = new URLSearchParams(criteria).toString(); - const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis?${queryParams}`; + const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis`; const response = await clientAuthFetch(url, { - method: 'GET', - headers: { Accept: 'application/pdf' }, + method: 'POST', + headers: { + Accept: 'application/pdf', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteria, 'itemCode')), }); if (response.status === 401 || response.status === 403) throw new Error("Unauthorized"); @@ -111,12 +115,15 @@ export const generateSemiFGProductionAnalysisReportExcel = async ( criteria: Record, reportTitle: string = '成品/半成品生產分析報告' ): Promise => { - const queryParams = new URLSearchParams(criteria).toString(); - const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis-excel?${queryParams}`; + const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis-excel`; const response = await clientAuthFetch(url, { - method: 'GET', - headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + method: 'POST', + headers: { + Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteria, 'itemCode')), }); if (response.status === 401 || response.status === 403) throw new Error('Unauthorized'); @@ -139,3 +146,53 @@ export const generateSemiFGProductionAnalysisReportExcel = async ( link.remove(); window.URL.revokeObjectURL(downloadUrl); }; + +/** + * Excel with one column per day in the selected production date range. + */ +export const generateSemiFGProductionAnalysisReportExcelDaily = async ( + criteria: Record, + reportTitle: string = '成品/半成品生產分析報告' +): Promise => { + const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis-excel-daily`; + + const response = await clientAuthFetch(url, { + method: 'POST', + headers: { + Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteria, 'itemCode')), + }); + + if (response.status === 401 || response.status === 403) throw new Error('Unauthorized'); + if (!response.ok) throw new Error(await readReportErrorMessage(response)); + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + + const contentDisposition = response.headers.get('Content-Disposition'); + let fileName = `${reportTitle}_每日.xlsx`; + if (contentDisposition?.includes('filename=')) { + fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); + } + + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(downloadUrl); +}; + +async function readReportErrorMessage(response: Response): Promise { + const text = await response.text(); + try { + const parsed = JSON.parse(text) as { message?: string }; + if (parsed.message) return parsed.message; + } catch { + // Response body is not JSON. + } + return text.trim(); +} diff --git a/src/app/(main)/settings/shop/layout.tsx b/src/app/(main)/settings/shop/layout.tsx new file mode 100644 index 00000000..357f7952 --- /dev/null +++ b/src/app/(main)/settings/shop/layout.tsx @@ -0,0 +1,27 @@ +import { I18nProvider } from "@/i18n"; +import { authOptions } from "@/config/authConfig"; +import { AUTH, hasAbility } from "@/authorities"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; + +/** Route board and shop/truck lane pages: ADMIN or 車線 (TRUCK_LANE). */ +export default async function ShopSettingsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerSession(authOptions); + const abilities = session?.user?.abilities ?? []; + const canAccess = + hasAbility(abilities, AUTH.TRUCK_LANE) || hasAbility(abilities, AUTH.ADMIN); + + if (!canAccess) { + redirect("/dashboard"); + } + + return ( + + {children} + + ); +} diff --git a/src/app/(main)/settings/stockLedgerFix/page.tsx b/src/app/(main)/settings/stockLedgerFix/page.tsx index 1602d68c..d477b351 100644 --- a/src/app/(main)/settings/stockLedgerFix/page.tsx +++ b/src/app/(main)/settings/stockLedgerFix/page.tsx @@ -1,16 +1,22 @@ import { Metadata } from "next"; import PageTitleBar from "@/components/PageTitleBar"; import StockLedgerFixPageClient from "@/components/StockLedgerFix/StockLedgerFixPageClient"; +import { getServerI18n, I18nProvider } from "@/i18n"; -export const metadata: Metadata = { - title: "Stock Ledger Fix", -}; +export async function generateMetadata(): Promise { + const { t } = await getServerI18n("stockLedgerFix"); + return { title: t("pageTitle") }; +} + +const StockLedgerFixPage: React.FC = async () => { + const { t } = await getServerI18n("stockLedgerFix"); -const StockLedgerFixPage: React.FC = () => { return ( <> - - + + + + ); }; 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/itemTracing/schema.ts b/src/app/api/itemTracing/schema.ts index edf0774b..3bbb3fd4 100644 --- a/src/app/api/itemTracing/schema.ts +++ b/src/app/api/itemTracing/schema.ts @@ -85,7 +85,7 @@ const ItemLotTraceMaterialInputSchema: z.ZodType> = z.la consoCode: z.string(), pickedAt: nullableStr, stockInOrigin: ItemLotTraceOriginSchema.nullable().optional(), - purchaseEvents: z.array(z.record(z.unknown())).optional(), + purchaseEvents: z.array(z.record(z.string(), z.unknown())).optional(), qcResults: z.array(ItemLotTraceQcResultSchema), putawayEvents: z.array(ItemLotTracePutawayEventSchema).optional(), nestedJoPrelude: ItemLotTraceJoPreludeSchema.nullable().optional(), @@ -107,7 +107,7 @@ const ItemLotTraceJoPreludeSchema: z.ZodType> = z.lazy(( status: z.string(), }) .passthrough(), - pickOrders: z.array(z.record(z.unknown())), + pickOrders: z.array(z.record(z.string(), z.unknown())), materialInputs: z.array(ItemLotTraceMaterialInputSchema), }) .passthrough(), @@ -158,28 +158,28 @@ export const ItemLotTraceResponseSchema = z primaryStockInLineId: nullableNum, }) .passthrough(), - warehouseLines: z.array(z.record(z.unknown())), + warehouseLines: z.array(z.record(z.string(), z.unknown())), origins: z.array(ItemLotTraceOriginSchema), - purchaseEvents: z.array(z.record(z.unknown())).optional(), + purchaseEvents: z.array(z.record(z.string(), z.unknown())).optional(), qcResults: z.array(ItemLotTraceQcResultSchema), putawayEvents: z.array(ItemLotTracePutawayEventSchema), - movements: z.array(z.record(z.unknown())), - outboundUsage: z.array(z.record(z.unknown())), - stockTakeEvents: z.array(z.record(z.unknown())), - adjustments: z.array(z.record(z.unknown())), - transfers: z.array(z.record(z.unknown())), - bomTrace: z.record(z.unknown()), + movements: z.array(z.record(z.string(), z.unknown())), + outboundUsage: z.array(z.record(z.string(), z.unknown())), + stockTakeEvents: z.array(z.record(z.string(), z.unknown())), + adjustments: z.array(z.record(z.string(), z.unknown())), + transfers: z.array(z.record(z.string(), z.unknown())), + bomTrace: z.record(z.string(), z.unknown()), joPrelude: ItemLotTraceJoPreludeSchema.nullable(), productionSteps: z.array(ItemLotTraceProductionStepSchema), - byproductLots: z.array(z.record(z.unknown())), - openMovements: z.array(z.record(z.unknown())), - failEvents: z.array(z.record(z.unknown())), - doDeliveries: z.array(z.record(z.unknown())), - replenishmentEvents: z.array(z.record(z.unknown())).optional(), - returnEvents: z.array(z.record(z.unknown())), - lotRelations: z.array(z.record(z.unknown())), - alternateLocations: z.array(z.record(z.unknown())), - locationBlocks: z.array(z.record(z.unknown())).optional(), + byproductLots: z.array(z.record(z.string(), z.unknown())), + openMovements: z.array(z.record(z.string(), z.unknown())), + failEvents: z.array(z.record(z.string(), z.unknown())), + doDeliveries: z.array(z.record(z.string(), z.unknown())), + replenishmentEvents: z.array(z.record(z.string(), z.unknown())).optional(), + returnEvents: z.array(z.record(z.string(), z.unknown())), + lotRelations: z.array(z.record(z.string(), z.unknown())), + alternateLocations: z.array(z.record(z.string(), z.unknown())), + locationBlocks: z.array(z.record(z.string(), z.unknown())).optional(), traceGraph: ItemLotTraceGraphSchema, }) .passthrough(); 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/po/index.ts b/src/app/api/po/index.ts index ed051581..e20cb25f 100644 --- a/src/app/api/po/index.ts +++ b/src/app/api/po/index.ts @@ -54,11 +54,15 @@ export interface StockUomForPoLine { id: number; stockUomCode: string; stockUomDesc: string; + stockUomShortDesc?: string | null; stockQty: number; stockRatioN: number; stockRatioD: number; purchaseRatioN: number; purchaseRatioD: number; + sourceRatioN?: number | null; + sourceRatioD?: number | null; + stockQtyInteger?: boolean | null; } // DEPRECIATED diff --git a/src/app/api/settings/uom/index.ts b/src/app/api/settings/uom/index.ts index e7e8526e..4f5765ca 100644 --- a/src/app/api/settings/uom/index.ts +++ b/src/app/api/settings/uom/index.ts @@ -9,6 +9,8 @@ export interface Uom { id: number; code: string; udfudesc: string; + /** Short UoM label from M18 (`udfShortDesc`), e.g. 箱. */ + udfShortDesc?: string | null; unit1: string; unit1Qty: number; unit2?: string; diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index f674efe0..e588dc4c 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -24,6 +24,8 @@ export interface InventoryLotDetailResponse { holdQty: number; availableQty: number; uom: string; + /** Stock UoM short label (e.g. 包) for the count input. */ + uomShortDesc?: string | null; warehouseCode: string; warehouseName: string; warehouseSlot: string; @@ -51,6 +53,8 @@ export interface InventoryLotDetailResponse { stockTakeEndTime?: string | string[] | null; /** ISO string or backend LocalDateTime array */ approverTime?: string | string[] | null; + /** Times this saved first count has been changed. */ + pickerReviseCount?: number | null; } /** @@ -122,6 +126,8 @@ export interface SaveStockTakeRecordRequest { badQty: number; //stockTakerName: string; remark?: string | null; + /** Overwrite the saved first count while the section is still stock-taking. */ + reviseFirstQty?: boolean; } export interface AllPickedStockTakeListReponse { id: number; diff --git a/src/app/utils/authToken.ts b/src/app/utils/authToken.ts index 7dc3a48b..8f00cf5b 100644 --- a/src/app/utils/authToken.ts +++ b/src/app/utils/authToken.ts @@ -1,11 +1,19 @@ /** * Client-side helpers for the backend JWT stored in localStorage (`accessToken`). - * Used to detect expiry before API calls so pages like /report can redirect early. + * Used to detect expiry before API calls and to refresh via /refresh-token. */ +import { NEXT_PUBLIC_API_URL } from "@/config/api"; + /** Must match clientAuthFetch redirect target */ export const LOGIN_SESSION_EXPIRED_HREF = "/login?session=expired"; +export const ACCESS_TOKEN_KEY = "accessToken"; +export const REFRESH_TOKEN_KEY = "refreshToken"; + +/** Refresh when fewer than this many seconds remain on the access token. */ +export const ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 5 * 60; + /** * Decode JWT payload (no signature verification — UX only; server still validates). */ @@ -30,12 +38,143 @@ export function decodeJwtPayload( } } +/** Seconds until JWT `exp`, or null if missing/unreadable. Negative if already expired. */ +export function getBackendJwtSecondsRemaining(token: string): number | null { + const payload = decodeJwtPayload(token); + const exp = payload?.exp; + if (typeof exp !== "number") return null; + return exp - Date.now() / 1000; +} + /** * True if `exp` claim is reached or passed. False if no `exp` (let API decide). */ export function isBackendJwtExpired(token: string): boolean { - const payload = decodeJwtPayload(token); - const exp = payload?.exp; - if (typeof exp !== "number") return false; - return Date.now() / 1000 >= exp; + const remaining = getBackendJwtSecondsRemaining(token); + if (remaining === null) return false; + return remaining <= 0; +} + +/** True when token should be refreshed soon (or is already expired). */ +export function isBackendJwtExpiringSoon( + token: string, + skewSeconds: number = ACCESS_TOKEN_REFRESH_SKEW_SECONDS +): boolean { + const remaining = getBackendJwtSecondsRemaining(token); + if (remaining === null) return false; + return remaining <= skewSeconds; +} + +export function persistBackendTokens( + accessToken: string, + refreshToken?: string | null +): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + if (refreshToken) { + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + } + } catch { + // ignore (e.g. private mode) + } +} + +export function clearBackendTokens(): void { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + } catch { + // ignore + } +} + +export function readStoredRefreshToken(): string | null { + if (typeof window === "undefined") return null; + try { + return localStorage.getItem(REFRESH_TOKEN_KEY)?.trim() || null; + } catch { + return null; + } +} + +type RefreshResponse = { + accessToken?: string; + refreshToken?: string; +}; + +let refreshInFlight: Promise | null = null; + +/** + * Call backend `/refresh-token`. Returns new access token, or null if refresh failed. + * Single-flight: concurrent callers share one request. + */ +export async function refreshBackendAccessToken( + refreshTokenOverride?: string | null +): Promise { + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + const refreshToken = + refreshTokenOverride?.trim() || readStoredRefreshToken(); + if (!refreshToken) return null; + + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + if (!base) { + console.warn("refreshBackendAccessToken: NEXT_PUBLIC_API_URL missing"); + return null; + } + + try { + const res = await fetch(`${base}/refresh-token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + if (!res.ok) { + console.warn("refresh-token failed", res.status); + return null; + } + const data = (await res.json()) as RefreshResponse; + if (!data.accessToken) return null; + persistBackendTokens(data.accessToken, data.refreshToken ?? refreshToken); + return data.accessToken; + } catch (e) { + console.warn("refresh-token error", e); + return null; + } + })().finally(() => { + refreshInFlight = null; + }); + + return refreshInFlight; +} + +/** + * Ensure a non-expired access token is in localStorage. + * Tries refresh when missing/expired/near expiry. Returns the token to use, or null. + */ +export async function ensureFreshAccessToken( + refreshTokenFallback?: string | null +): Promise { + if (typeof window === "undefined") return null; + + let token = ""; + try { + token = localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ""; + } catch { + token = ""; + } + + if (token && !isBackendJwtExpiringSoon(token)) { + return token; + } + + const refreshed = await refreshBackendAccessToken(refreshTokenFallback); + if (refreshed) return refreshed; + + // Refresh failed: if current token still valid (not yet expired), keep using it + if (token && !isBackendJwtExpired(token)) return token; + return null; } diff --git a/src/app/utils/clientAuthFetch.ts b/src/app/utils/clientAuthFetch.ts index 67ed99a9..659442ce 100644 --- a/src/app/utils/clientAuthFetch.ts +++ b/src/app/utils/clientAuthFetch.ts @@ -1,28 +1,55 @@ "use client"; -import { LOGIN_SESSION_EXPIRED_HREF } from "@/app/utils/authToken"; +import { + clearBackendTokens, + ensureFreshAccessToken, + LOGIN_SESSION_EXPIRED_HREF, + readStoredRefreshToken, + refreshBackendAccessToken, +} from "@/app/utils/authToken"; +import { getSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; + +async function resolveRefreshTokenFallback(): Promise { + const stored = readStoredRefreshToken(); + if (stored) return stored; + const session = (await getSession()) as SessionWithTokens | null; + return session?.refreshToken ?? null; +} /** - * Client-side fetch that adds Bearer token from localStorage and redirects - * to /login?session=expired on 401 or 403 (session timeout / unauthorized). - * Use this for all authenticated API requests so session expiry is handled consistently. + * Client-side fetch that adds Bearer token from localStorage. + * On 401, tries one refresh + retry; only then redirects to login. + * 403 (forbidden) is NOT treated as session expiry. */ export async function clientAuthFetch( input: RequestInfo | URL, init?: RequestInit ): Promise { - const token = - typeof window !== "undefined" ? localStorage.getItem("accessToken") : null; + let token = await ensureFreshAccessToken(readStoredRefreshToken()); + if (!token) { + token = await ensureFreshAccessToken(await resolveRefreshTokenFallback()); + } + const headers = new Headers(init?.headers); if (token) { headers.set("Authorization", `Bearer ${token}`); } - const response = await fetch(input, { ...init, headers }); + let response = await fetch(input, { ...init, headers }); + + if (response.status === 401) { + const refreshed = await refreshBackendAccessToken( + await resolveRefreshTokenFallback() + ); + if (refreshed) { + headers.set("Authorization", `Bearer ${refreshed}`); + response = await fetch(input, { ...init, headers }); + } - if (response.status === 401 || response.status === 403) { - if (typeof window !== "undefined") { - console.warn(`Auth error ${response.status} → redirecting to login`); + if (response.status === 401 && typeof window !== "undefined") { + console.warn("Auth error 401 → redirecting to login"); + clearBackendTokens(); window.location.href = LOGIN_SESSION_EXPIRED_HREF; } } diff --git a/src/authorities.ts b/src/authorities.ts index ad4aeb9e..b601e86f 100644 --- a/src/authorities.ts +++ b/src/authorities.ts @@ -27,6 +27,10 @@ export const AUTH = { REPORT_MGMT: "REPORT_MGMT", /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 — Manual M18 sync page (/m18Syn): ADMIN or this ability */ M18_SYNC: "M18_SYNC", + /** Route board (/settings/shop/board): ADMIN or this ability (DB name: 車線) */ + TRUCK_LANE: "TRUCK_LANE", + /** 員工發貨績效 on /chart/delivery (DB name: 績效) */ + PERFORMANCE: "PERFORMANCE", } as const; /** diff --git a/src/components/AppBar/Profile.tsx b/src/components/AppBar/Profile.tsx index f5d7fdde..921d6101 100644 --- a/src/components/AppBar/Profile.tsx +++ b/src/components/AppBar/Profile.tsx @@ -10,6 +10,7 @@ import Divider from "@mui/material/Divider"; import Typography from "@mui/material/Typography"; import { useTranslation } from "react-i18next"; import { signOut } from "next-auth/react"; +import { clearBackendTokens } from "@/app/utils/authToken"; type Props = Pick; @@ -51,7 +52,13 @@ const Profile: React.FC = ({ avatarImageSrc, profileName }) => { {profileName} - signOut()} sx={{ py: 1.5, fontSize: "0.875rem" }}> + { + clearBackendTokens(); + void signOut(); + }} + sx={{ py: 1.5, fontSize: "0.875rem" }} + > {t("Sign out")} 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 69015ee6..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 - - + + @@ -401,7 +417,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom /** * FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 - * FP-MTMS Version Checklist | Functions Ref. No. 55 | v1.0.0 | 2026-08-06 + * FP-MTMS Version Checklist | Functions Ref. No. 55 | v1.0.1 | 2026-09-22 */ const DoWorkbenchTabs: React.FC = (props) => ( = ({ const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; const [loading, setLoading] = useState(false); + const [isPrinting, setIsPrinting] = useState(false); + const printInFlightRef = useRef(false); const [records, setRecords] = useState([]); const initialSearchTargetDate = useMemo( () => resolveWorkbenchRecordTargetDate(initialTargetDate), @@ -183,6 +185,65 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ return t("All dates"); }, [searchQuery.targetDate, t]); + const rememberCartonQty = useCallback((recordId: number, cartonQty: number) => { + setRecords((prev) => + prev.map((row) => + row.doPickOrderRecordId === recordId ? { ...row, numberOfCartons: cartonQty } : row, + ), + ); + }, []); + + const submitPrint = useCallback( + async (job: () => Promise<{ success: boolean }>) => { + await new Promise((resolve) => { + void Swal.fire({ + title: t("Printing..."), + allowOutsideClick: false, + allowEscapeKey: false, + showConfirmButton: false, + didOpen: () => { + Swal.showLoading(); + resolve(); + }, + }); + }); + try { + const response = await job(); + Swal.close(); + if (response.success) { + await Swal.fire({ + position: "bottom-end", + icon: "success", + text: t("Printed Successfully."), + showConfirmButton: false, + timer: 1500, + }); + return true; + } + await Swal.fire({ + position: "bottom-end", + icon: "error", + text: t("Print failed"), + showConfirmButton: false, + timer: 2000, + }); + return false; + } catch (error) { + console.error("print error:", error); + Swal.close(); + await Swal.fire({ + position: "bottom-end", + icon: "error", + text: t("Print failed"), + showConfirmButton: false, + timer: 2000, + }); + return false; + } + }, + [t], + ); + const askNumOfCarton = useCallback(async () => { const result = await Swal.fire({ title: t("Enter the number of cartons: "), @@ -210,6 +271,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ const handlePrintDN = useCallback( async (recordId: number) => { + if (printInFlightRef.current) return; if (!a4Printer) { await Swal.fire({ position: "bottom-end", @@ -220,31 +282,32 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ }); return; } - const cartonQty = await askNumOfCarton(); - if (!cartonQty) return; - const response = await printDNWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: a4Printer.id, - printQty: 1, - isDraft: false, - numOfCarton: cartonQty, - }); - if (response.success) { - await Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500, - }); - await loadData(); + printInFlightRef.current = true; + setIsPrinting(true); + try { + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const ok = await submitPrint(() => + printDNWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: a4Printer.id, + printQty: 1, + isDraft: false, + numOfCarton: cartonQty, + }), + ); + if (ok) rememberCartonQty(recordId, cartonQty); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } }, - [a4Printer, askNumOfCarton, loadData, t], + [a4Printer, askNumOfCarton, rememberCartonQty, submitPrint, t], ); const handlePrintLabel = useCallback( async (recordId: number) => { + if (printInFlightRef.current) return; if (!labelPrinter) { await Swal.fire({ position: "bottom-end", @@ -255,30 +318,31 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ }); return; } - const cartonQty = await askNumOfCarton(); - if (!cartonQty) return; - const response = await printDNLabelsWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: labelPrinter.id, - printQty: 1, - numOfCarton: cartonQty, - }); - if (response.success) { - await Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500, - }); - await loadData(); + printInFlightRef.current = true; + setIsPrinting(true); + try { + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const ok = await submitPrint(() => + printDNLabelsWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: labelPrinter.id, + printQty: 1, + numOfCarton: cartonQty, + }), + ); + if (ok) rememberCartonQty(recordId, cartonQty); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } }, - [askNumOfCarton, labelPrinter, loadData, t], + [askNumOfCarton, labelPrinter, rememberCartonQty, submitPrint, t], ); const handlePrintBoth = useCallback( async (recordId: number) => { + if (printInFlightRef.current) return; if (!a4Printer || !labelPrinter) { await Swal.fire({ position: "bottom-end", @@ -289,37 +353,39 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ }); return; } - const cartonQty = await askNumOfCarton(); - if (!cartonQty) return; - const [labelRes, dnRes] = await Promise.all([ - printDNLabelsWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: labelPrinter.id, - printQty: 1, - numOfCarton: cartonQty, - }), - printDNWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: a4Printer.id, - printQty: 1, - isDraft: false, - numOfCarton: cartonQty, - }), - ]); - if (labelRes.success && dnRes.success) { - await Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500, + printInFlightRef.current = true; + setIsPrinting(true); + try { + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const ok = await submitPrint(async () => { + const [labelRes, dnRes] = await Promise.all([ + printDNLabelsWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: labelPrinter.id, + printQty: 1, + numOfCarton: cartonQty, + }), + printDNWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: a4Printer.id, + printQty: 1, + isDraft: false, + numOfCarton: cartonQty, + }), + ]); + return { success: labelRes.success && dnRes.success }; }); - await loadData(); + if (ok) rememberCartonQty(recordId, cartonQty); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } }, - [a4Printer, askNumOfCarton, labelPrinter, loadData, t], + [a4Printer, askNumOfCarton, labelPrinter, rememberCartonQty, submitPrint, t], ); const handleLabelReprint = useCallback(async (doPickOrder: CompletedDoPickOrderResponse) => { + if (printInFlightRef.current) return; if (!labelPrinter) { Swal.fire({ position: "bottom-end", @@ -331,6 +397,9 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ return; } + printInFlightRef.current = true; + setIsPrinting(true); + try { const defaultTotalCartons = Math.max(1, doPickOrder.numberOfCartons || 1); const result = await Swal.fire({ title: t("Reprint DN Label"), @@ -390,31 +459,22 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ return; } - try { - const response = await printDNLabelsReprintWorkbench({ - deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId, - printerId: labelPrinter.id, - printQty: 1, - fromCarton: result.value.fromCarton, - toCarton: result.value.toCarton, - totalCartonsOnShipment: result.value.totalCartonsOnShipment, - }); - - if (response.success) { - Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500 - }); - } else { - console.error("Reprint failed:", response.message); - } - } catch (error) { - console.error("reprint error: ", error); + const ok = await submitPrint(() => + printDNLabelsReprintWorkbench({ + deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId, + printerId: labelPrinter.id, + printQty: 1, + fromCarton: result.value.fromCarton, + toCarton: result.value.toCarton, + totalCartonsOnShipment: result.value.totalCartonsOnShipment, + }), + ); + if (ok) rememberCartonQty(doPickOrder.doPickOrderRecordId, result.value.totalCartonsOnShipment); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } - }, [labelPrinter, t]); + }, [labelPrinter, rememberCartonQty, submitPrint, t]); const handleDetailClick = useCallback( async (record: CompletedDoPickOrderResponse) => { @@ -735,16 +795,16 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ - - - - 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")}