Co-authored-by: Cursor <[email protected]> # Conflicts: # src/components/Qc/QcStockInModal.tsx # src/components/StockTakeManagement/PickerStockTake.tsx # src/components/StockTakeManagement/StockTakeTab.tsxstockTakeUpdate
| @@ -2,12 +2,26 @@ | |||||
| "use client"; | "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 { getSession } from "next-auth/react"; | ||||
| import { SessionWithTokens } from "@/config/authConfig"; | import { SessionWithTokens } from "@/config/authConfig"; | ||||
| import { | import { | ||||
| ACCESS_TOKEN_KEY, | |||||
| ACCESS_TOKEN_REFRESH_SKEW_SECONDS, | |||||
| clearBackendTokens, | |||||
| ensureFreshAccessToken, | |||||
| getBackendJwtSecondsRemaining, | |||||
| isBackendJwtExpired, | isBackendJwtExpired, | ||||
| LOGIN_SESSION_EXPIRED_HREF, | LOGIN_SESSION_EXPIRED_HREF, | ||||
| persistBackendTokens, | |||||
| readStoredRefreshToken, | |||||
| } from "@/app/utils/authToken"; | } from "@/app/utils/authToken"; | ||||
| import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance"; | import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance"; | ||||
| @@ -19,14 +33,48 @@ const TokenContext = createContext<{ | |||||
| setAccessToken: () => {}, | 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<string | null>(null); | const [accessToken, setAccessToken] = useState<string | null>(null); | ||||
| const [isHydrated, setIsHydrated] = useState(false); | const [isHydrated, setIsHydrated] = useState(false); | ||||
| const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |||||
| // Hydrate token only on client | |||||
| useEffect(() => { | useEffect(() => { | ||||
| try { | try { | ||||
| const token = localStorage.getItem("accessToken"); | |||||
| const token = localStorage.getItem(ACCESS_TOKEN_KEY); | |||||
| if (token) setAccessToken(token); | if (token) setAccessToken(token); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.warn("localStorage unavailable", 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(() => { | useEffect(() => { | ||||
| if (!isHydrated || typeof window === "undefined") return; | if (!isHydrated || typeof window === "undefined") return; | ||||
| @@ -46,37 +118,47 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre | |||||
| let cancelled = false; | let cancelled = false; | ||||
| (async () => { | (async () => { | ||||
| try { | 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) { | 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; | window.location.href = LOGIN_SESSION_EXPIRED_HREF; | ||||
| return; | return; | ||||
| } | } | ||||
| if (isBackendJwtExpired(token)) { | |||||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||||
| } | |||||
| setAccessToken(token); | |||||
| scheduleProactiveRefresh(token); | |||||
| } catch (e) { | } catch (e) { | ||||
| // Do not redirect on unexpected errors (network / getSession failures) | |||||
| console.warn("Auth token check failed", e); | console.warn("Auth token check failed", e); | ||||
| } | } | ||||
| })(); | })(); | ||||
| return () => { | return () => { | ||||
| cancelled = true; | cancelled = true; | ||||
| if (refreshTimerRef.current) { | |||||
| clearTimeout(refreshTimerRef.current); | |||||
| refreshTimerRef.current = null; | |||||
| } | |||||
| }; | }; | ||||
| }, [isHydrated]); | |||||
| }, [isHydrated, scheduleProactiveRefresh]); | |||||
| // Apply token + interceptors | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (accessToken) { | if (accessToken) { | ||||
| axiosInstance.defaults.headers.Authorization = `Bearer ${accessToken}`; | axiosInstance.defaults.headers.Authorization = `Bearer ${accessToken}`; | ||||
| @@ -86,24 +168,26 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre | |||||
| } | } | ||||
| }, [accessToken]); | }, [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 ( | return ( | ||||
| <AxiosContext.Provider value={axiosInstance}> | <AxiosContext.Provider value={axiosInstance}> | ||||
| <TokenContext.Provider value={{ setAccessToken: handleSetAccessToken }}> | <TokenContext.Provider value={{ setAccessToken: handleSetAccessToken }}> | ||||
| {/* Render children immediately – they will just not have the token for 1-2ms */} | |||||
| {children} | {children} | ||||
| </TokenContext.Provider> | </TokenContext.Provider> | ||||
| </AxiosContext.Provider> | </AxiosContext.Provider> | ||||
| @@ -111,4 +195,4 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre | |||||
| }; | }; | ||||
| export const useAxios = () => useContext(AxiosContext); | export const useAxios = () => useContext(AxiosContext); | ||||
| export const useToken = () => useContext(TokenContext); | |||||
| export const useToken = () => useContext(TokenContext); | |||||
| @@ -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({ | 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(); | clearInterceptors(); | ||||
| // Request interceptor | |||||
| axiosInstance.interceptors.request.use( | |||||
| requestInterceptorId = axiosInstance.interceptors.request.use( | |||||
| (config) => { | (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; | 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); | console.error("API Error:", error.response?.data || error); | ||||
| return Promise.reject(error); | return Promise.reject(error); | ||||
| }, | }, | ||||
| @@ -1,6 +1,8 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useMemo, useState } from "react"; | import React, { useCallback, useMemo, useState } from "react"; | ||||
| import { useSession } from "next-auth/react"; | |||||
| import { AUTH, hasAbility } from "@/authorities"; | |||||
| import { | import { | ||||
| Box, | Box, | ||||
| Typography, | Typography, | ||||
| @@ -68,6 +70,9 @@ const defaultCriteria: Criteria = { | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ | /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ | ||||
| export default function DeliveryChartPage() { | 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<Criteria>(defaultCriteria); | const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | ||||
| const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | ||||
| const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]); | const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]); | ||||
| @@ -132,6 +137,10 @@ export default function DeliveryChartPage() { | |||||
| }, [criteria.topItems, topItemsSelected, setChartLoading]); | }, [criteria.topItems, topItemsSelected, setChartLoading]); | ||||
| React.useEffect(() => { | React.useEffect(() => { | ||||
| if (!canSeeStaffPerformance) { | |||||
| setChartData((prev) => ({ ...prev, staffPerf: [] })); | |||||
| return; | |||||
| } | |||||
| const s = criteria.staffPerf.startDate; | const s = criteria.staffPerf.startDate; | ||||
| const e = criteria.staffPerf.endDate; | const e = criteria.staffPerf.endDate; | ||||
| if (!s || !e) { | if (!s || !e) { | ||||
| @@ -161,13 +170,17 @@ export default function DeliveryChartPage() { | |||||
| ) | ) | ||||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | ||||
| .finally(() => setChartLoading("staffPerf", false)); | .finally(() => setChartLoading("staffPerf", false)); | ||||
| }, [criteria.staffPerf, staffSelected, setChartLoading]); | |||||
| }, [canSeeStaffPerformance, criteria.staffPerf, staffSelected, setChartLoading]); | |||||
| React.useEffect(() => { | React.useEffect(() => { | ||||
| if (!canSeeStaffPerformance) { | |||||
| setStaffOptions([]); | |||||
| return; | |||||
| } | |||||
| fetchStaffDeliveryPerformanceHandlers() | fetchStaffDeliveryPerformanceHandlers() | ||||
| .then(setStaffOptions) | .then(setStaffOptions) | ||||
| .catch(() => setStaffOptions([])); | .catch(() => setStaffOptions([])); | ||||
| }, []); | |||||
| }, [canSeeStaffPerformance]); | |||||
| React.useEffect(() => { | React.useEffect(() => { | ||||
| const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); | const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); | ||||
| fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([])); | fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([])); | ||||
| @@ -317,6 +330,7 @@ export default function DeliveryChartPage() { | |||||
| )} | )} | ||||
| </ChartCard> | </ChartCard> | ||||
| {canSeeStaffPerformance && ( | |||||
| <ChartCard | <ChartCard | ||||
| title="員工發貨績效(每日揀貨數量與耗時)" | title="員工發貨績效(每日揀貨數量與耗時)" | ||||
| exportFilename="員工發貨績效" | exportFilename="員工發貨績效" | ||||
| @@ -496,6 +510,7 @@ export default function DeliveryChartPage() { | |||||
| </> | </> | ||||
| )} | )} | ||||
| </ChartCard> | </ChartCard> | ||||
| )} | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| } | } | ||||
| @@ -39,6 +39,7 @@ export default function M18SynPage() { | |||||
| type ParsedSyncResult = | type ParsedSyncResult = | ||||
| | { kind: "exists"; message: string } | | { kind: "exists"; message: string } | ||||
| | { kind: "skipped_not_pending"; message: string } | |||||
| | { kind: "not_found"; message: string } | | { kind: "not_found"; message: string } | ||||
| | { kind: "success"; message: string; totalSuccess: number } | | { kind: "success"; message: string; totalSuccess: number } | ||||
| | { kind: "fail"; message: string; totalFail: number } | | { kind: "fail"; message: string; totalFail: number } | ||||
| @@ -60,15 +61,45 @@ export default function M18SynPage() { | |||||
| return { kind: "exists", message: `${docName}已存在系統` }; | 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 ( | if ( | ||||
| docName === "送貨訂單" && | |||||
| totalProcessed === 1 && | totalProcessed === 1 && | ||||
| totalSuccess === 0 && | totalSuccess === 0 && | ||||
| totalFail === 1 && | totalFail === 1 && | ||||
| query.includes("code=equal=") | 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) { | if (totalSuccess > 0 && totalFail === 0) { | ||||
| @@ -108,7 +139,8 @@ export default function M18SynPage() { | |||||
| const handleSyncM18PoByCode = async () => { | const handleSyncM18PoByCode = async () => { | ||||
| if (m18PoInFlightRef.current) return; | if (m18PoInFlightRef.current) return; | ||||
| if (!m18PoCode.trim()) { | |||||
| const code = m18PoCode.trim(); | |||||
| if (!code) { | |||||
| alert("Please enter PO code."); | alert("Please enter PO code."); | ||||
| return; | return; | ||||
| } | } | ||||
| @@ -116,15 +148,58 @@ export default function M18SynPage() { | |||||
| setIsSyncingM18Po(true); | setIsSyncingM18Po(true); | ||||
| setM18PoSyncResult(""); | setM18PoSyncResult(""); | ||||
| try { | 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" }, | { 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) { | } catch (e) { | ||||
| console.error("M18 PO Sync By Code Error:", e); | console.error("M18 PO Sync By Code Error:", e); | ||||
| @@ -158,6 +233,7 @@ export default function M18SynPage() { | |||||
| const outputs: string[] = []; | const outputs: string[] = []; | ||||
| const okCodes: string[] = []; | const okCodes: string[] = []; | ||||
| const notFoundCodes: string[] = []; | const notFoundCodes: string[] = []; | ||||
| const skippedReleasedCodes: string[] = []; | |||||
| const otherFailCodes: string[] = []; | const otherFailCodes: string[] = []; | ||||
| for (const code of codes) { | for (const code of codes) { | ||||
| const response = await clientAuthFetch( | const response = await clientAuthFetch( | ||||
| @@ -169,6 +245,7 @@ export default function M18SynPage() { | |||||
| const parsed = parseSyncResult("送貨訂單", text || ""); | const parsed = parseSyncResult("送貨訂單", text || ""); | ||||
| if (parsed.kind === "success") okCodes.push(code); | if (parsed.kind === "success") okCodes.push(code); | ||||
| else if (parsed.kind === "not_found") notFoundCodes.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); | else otherFailCodes.push(code); | ||||
| const perCodeMsg = | const perCodeMsg = | ||||
| @@ -180,7 +257,13 @@ export default function M18SynPage() { | |||||
| outputs.push(""); | outputs.push(""); | ||||
| outputs.push(`共${okCodes.length}張送貨訂單成功`); | 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 (skippedReleasedCodes.length > 0) { | |||||
| outputs.push(`共${skippedReleasedCodes.length}張已放單/已完成,無法再同步 (${skippedReleasedCodes.join(", ")})`); | |||||
| } | |||||
| if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`); | if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`); | ||||
| setM18DoSyncResult(outputs.join("\n")); | setM18DoSyncResult(outputs.join("\n")); | ||||
| @@ -217,6 +300,7 @@ export default function M18SynPage() { | |||||
| const okCodes: string[] = []; | const okCodes: string[] = []; | ||||
| const existsCodes: string[] = []; | const existsCodes: string[] = []; | ||||
| const notFoundCodes: string[] = []; | const notFoundCodes: string[] = []; | ||||
| const skippedReleasedCodes: string[] = []; | |||||
| const otherFailCodes: string[] = []; | const otherFailCodes: string[] = []; | ||||
| for (const code of codes) { | for (const code of codes) { | ||||
| const response = await clientAuthFetch( | const response = await clientAuthFetch( | ||||
| @@ -229,6 +313,7 @@ export default function M18SynPage() { | |||||
| if (parsed.kind === "success") okCodes.push(code); | if (parsed.kind === "success") okCodes.push(code); | ||||
| else if (parsed.kind === "exists") existsCodes.push(code); | else if (parsed.kind === "exists") existsCodes.push(code); | ||||
| else if (parsed.kind === "not_found") notFoundCodes.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); | else otherFailCodes.push(code); | ||||
| const perCodeMsg = | const perCodeMsg = | ||||
| @@ -242,8 +327,14 @@ export default function M18SynPage() { | |||||
| // Summary | // Summary | ||||
| outputs.push(""); | outputs.push(""); | ||||
| outputs.push(`共${okCodes.length}張送貨訂單成功`); | 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 (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(", ")})`); | if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`); | ||||
| setM18DoExtraSyncResult(outputs.join("\n")); | setM18DoExtraSyncResult(outputs.join("\n")); | ||||
| @@ -301,6 +392,7 @@ export default function M18SynPage() { | |||||
| </Typography> | </Typography> | ||||
| <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | <Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}> | ||||
| Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. | ||||
| 「採購單」找不到時會自動改試「送貨訂單」;「送貨訂單」找不到時請改到「採購單」分頁。 | |||||
| </Typography> | </Typography> | ||||
| <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | ||||
| @@ -192,7 +192,6 @@ const AsyncItemCodeAutocomplete: React.FC<Props> = ({ | |||||
| }, | }, | ||||
| }} | }} | ||||
| onInputChange={(_, next, reason) => { | onInputChange={(_, next, reason) => { | ||||
| if (reason === "blur") return; | |||||
| if (reason === "reset") { | if (reason === "reset") { | ||||
| setInputValue(""); | setInputValue(""); | ||||
| onInputValueChange?.(""); | onInputValueChange?.(""); | ||||
| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useState, useEffect } from 'react'; | |||||
| import React, { useEffect, useRef, useState } from 'react'; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { | import { | ||||
| Dialog, | Dialog, | ||||
| @@ -19,15 +19,18 @@ import { | |||||
| Typography, | Typography, | ||||
| } from '@mui/material'; | } from '@mui/material'; | ||||
| import DownloadIcon from '@mui/icons-material/Download'; | import DownloadIcon from '@mui/icons-material/Download'; | ||||
| import dayjs from 'dayjs'; | |||||
| import { | import { | ||||
| fetchSemiFGItemCodes, | |||||
| fetchSemiFGItemCodesWithCategory, | fetchSemiFGItemCodesWithCategory, | ||||
| generateSemiFGProductionAnalysisReport, | generateSemiFGProductionAnalysisReport, | ||||
| generateSemiFGProductionAnalysisReportExcel, | generateSemiFGProductionAnalysisReportExcel, | ||||
| generateSemiFGProductionAnalysisReportExcelDaily, | |||||
| ItemCodeWithCategory, | ItemCodeWithCategory, | ||||
| } from './semiFGProductionAnalysisApi'; | } from './semiFGProductionAnalysisApi'; | ||||
| import { parseItemCodeTokens } from './parseItemCodeTokens'; | import { parseItemCodeTokens } from './parseItemCodeTokens'; | ||||
| type ExportFormat = 'pdf' | 'excel' | 'excel-daily'; | |||||
| interface SemiFGProductionAnalysisReportProps { | interface SemiFGProductionAnalysisReportProps { | ||||
| criteria: Record<string, string>; | criteria: Record<string, string>; | ||||
| prepareCriteria?: () => Record<string, string>; | prepareCriteria?: () => Record<string, string>; | ||||
| @@ -51,7 +54,8 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | const [showConfirmDialog, setShowConfirmDialog] = useState(false); | ||||
| const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); | const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<ItemCodeWithCategory[]>([]); | ||||
| const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | const [itemCodesWithCategory, setItemCodesWithCategory] = useState<Record<string, ItemCodeWithCategory>>({}); | ||||
| const [exportFormat, setExportFormat] = useState<'pdf' | 'excel'>('pdf'); | |||||
| const [exportFormat, setExportFormat] = useState<ExportFormat>('pdf'); | |||||
| const inFlightRef = useRef(false); | |||||
| // Fetch item codes with category when stockCategory changes | // Fetch item codes with category when stockCategory changes | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -71,14 +75,33 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| } | } | ||||
| }, [criteria.stockCategory]); | }, [criteria.stockCategory]); | ||||
| const handleExportClick = async (format: 'pdf' | 'excel') => { | |||||
| const dailyRangeError = (activeCriteria: Record<string, string>): 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; | const activeCriteria = prepareCriteria?.() ?? criteria; | ||||
| setExportFormat(format); | setExportFormat(format); | ||||
| // Validate required fields | |||||
| if (requiredFieldLabels.length > 0) { | if (requiredFieldLabels.length > 0) { | ||||
| alert(t('missingRequired', { fields: requiredFieldLabels.join('\n- ') })); | alert(t('missingRequired', { fields: requiredFieldLabels.join('\n- ') })); | ||||
| return; | return; | ||||
| } | } | ||||
| if (format === 'excel-daily') { | |||||
| const rangeError = dailyRangeError(activeCriteria); | |||||
| if (rangeError) { | |||||
| alert(rangeError); | |||||
| return; | |||||
| } | |||||
| } | |||||
| const selectedCodes = parseItemCodeTokens( | const selectedCodes = parseItemCodeTokens( | ||||
| [activeCriteria.itemCode, activeCriteria.itemCodePaste].filter(Boolean).join(' '), | [activeCriteria.itemCode, activeCriteria.itemCodePaste].filter(Boolean).join(' '), | ||||
| @@ -101,26 +124,46 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| }; | }; | ||||
| const executeExport = async ( | const executeExport = async ( | ||||
| format: 'pdf' | 'excel' = exportFormat, | |||||
| format: ExportFormat = exportFormat, | |||||
| activeCriteria: Record<string, string> = prepareCriteria?.() ?? criteria, | activeCriteria: Record<string, string> = prepareCriteria?.() ?? criteria, | ||||
| ) => { | ) => { | ||||
| if (inFlightRef.current) return; | |||||
| inFlightRef.current = true; | |||||
| setLoading(true); | setLoading(true); | ||||
| try { | try { | ||||
| if (format === 'excel') { | |||||
| if (format === 'excel-daily') { | |||||
| await generateSemiFGProductionAnalysisReportExcelDaily(activeCriteria, reportTitle); | |||||
| onExportSuccess?.('excel'); | |||||
| } else if (format === 'excel') { | |||||
| await generateSemiFGProductionAnalysisReportExcel(activeCriteria, reportTitle); | await generateSemiFGProductionAnalysisReportExcel(activeCriteria, reportTitle); | ||||
| onExportSuccess?.('excel'); | |||||
| } else { | } else { | ||||
| await generateSemiFGProductionAnalysisReport(activeCriteria, reportTitle); | await generateSemiFGProductionAnalysisReport(activeCriteria, reportTitle); | ||||
| onExportSuccess?.('pdf'); | |||||
| } | } | ||||
| onExportSuccess?.(format); | |||||
| setShowConfirmDialog(false); | setShowConfirmDialog(false); | ||||
| } catch (error) { | } catch (error) { | ||||
| console.error('Failed to generate report:', 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 { | } finally { | ||||
| setLoading(false); | 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 ( | return ( | ||||
| <> | <> | ||||
| <div style={{ display: 'flex', gap: 16 }}> | <div style={{ display: 'flex', gap: 16 }}> | ||||
| @@ -132,7 +175,7 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? t('generatingPdf') : t('downloadPdf')} | |||||
| {loading && exportFormat === 'pdf' ? t('generatingPdf') : t('downloadPdf')} | |||||
| </Button> | </Button> | ||||
| <Button | <Button | ||||
| variant="outlined" | variant="outlined" | ||||
| @@ -142,7 +185,20 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| disabled={loading} | disabled={loading} | ||||
| sx={{ px: 4 }} | sx={{ px: 4 }} | ||||
| > | > | ||||
| {loading ? t('generatingExcel') : t('downloadExcel')} | |||||
| {loading && exportFormat === 'excel' ? t('generatingExcel') : t('downloadExcel')} | |||||
| </Button> | |||||
| <Button | |||||
| variant="outlined" | |||||
| color="success" | |||||
| size="large" | |||||
| startIcon={<DownloadIcon />} | |||||
| onClick={() => handleExportClick('excel-daily')} | |||||
| disabled={loading} | |||||
| sx={{ px: 4 }} | |||||
| > | |||||
| {loading && exportFormat === 'excel-daily' | |||||
| ? t('semiFgGeneratingDailyExcel') | |||||
| : t('semiFgDownloadDailyExcel')} | |||||
| </Button> | </Button> | ||||
| </div> | </div> | ||||
| @@ -202,13 +258,7 @@ export default function SemiFGProductionAnalysisReport({ | |||||
| disabled={loading} | disabled={loading} | ||||
| startIcon={<DownloadIcon />} | startIcon={<DownloadIcon />} | ||||
| > | > | ||||
| {loading | |||||
| ? exportFormat === 'excel' | |||||
| ? t('generatingExcel') | |||||
| : t('generatingPdf') | |||||
| : exportFormat === 'excel' | |||||
| ? t('confirmDownloadExcel') | |||||
| : t('confirmDownloadPdf')} | |||||
| {confirmLabel} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| </Dialog> | </Dialog> | ||||
| @@ -790,7 +790,6 @@ export default function ReportPage() { | |||||
| pendingItemCodeInputRef.current[field.name] = ""; | pendingItemCodeInputRef.current[field.name] = ""; | ||||
| return; | return; | ||||
| } | } | ||||
| if (reason === "blur") return; | |||||
| pendingItemCodeInputRef.current[field.name] = input; | pendingItemCodeInputRef.current[field.name] = input; | ||||
| }} | }} | ||||
| onChange={(event, newValue, reason) => { | onChange={(event, newValue, reason) => { | ||||
| @@ -146,3 +146,53 @@ export const generateSemiFGProductionAnalysisReportExcel = async ( | |||||
| link.remove(); | link.remove(); | ||||
| window.URL.revokeObjectURL(downloadUrl); | window.URL.revokeObjectURL(downloadUrl); | ||||
| }; | }; | ||||
| /** | |||||
| * Excel with one column per day in the selected production date range. | |||||
| */ | |||||
| export const generateSemiFGProductionAnalysisReportExcelDaily = async ( | |||||
| criteria: Record<string, string>, | |||||
| reportTitle: string = '成品/半成品生產分析報告' | |||||
| ): Promise<void> => { | |||||
| 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<string> { | |||||
| 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(); | |||||
| } | |||||
| @@ -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 ( | |||||
| <I18nProvider namespaces={["shop", "navigation", "common"]}> | |||||
| {children} | |||||
| </I18nProvider> | |||||
| ); | |||||
| } | |||||
| @@ -53,6 +53,8 @@ export interface InventoryLotDetailResponse { | |||||
| stockTakeEndTime?: string | string[] | null; | stockTakeEndTime?: string | string[] | null; | ||||
| /** ISO string or backend LocalDateTime array */ | /** ISO string or backend LocalDateTime array */ | ||||
| approverTime?: string | string[] | null; | approverTime?: string | string[] | null; | ||||
| /** Times this saved first count has been changed. */ | |||||
| pickerReviseCount?: number | null; | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -145,6 +147,8 @@ export interface SaveStockTakeRecordRequest { | |||||
| badQty: number; | badQty: number; | ||||
| //stockTakerName: string; | //stockTakerName: string; | ||||
| remark?: string | null; | remark?: string | null; | ||||
| /** Overwrite the saved first count while the section is still stock-taking. */ | |||||
| reviseFirstQty?: boolean; | |||||
| } | } | ||||
| export interface AllPickedStockTakeListReponse { | export interface AllPickedStockTakeListReponse { | ||||
| id: number; | id: number; | ||||
| @@ -1,11 +1,19 @@ | |||||
| /** | /** | ||||
| * Client-side helpers for the backend JWT stored in localStorage (`accessToken`). | * 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 */ | /** Must match clientAuthFetch redirect target */ | ||||
| export const LOGIN_SESSION_EXPIRED_HREF = "/login?session=expired"; | 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). | * 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). | * True if `exp` claim is reached or passed. False if no `exp` (let API decide). | ||||
| */ | */ | ||||
| export function isBackendJwtExpired(token: string): boolean { | 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<string | null> | 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<string | null> { | |||||
| 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<string | null> { | |||||
| 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; | |||||
| } | } | ||||
| @@ -1,28 +1,55 @@ | |||||
| "use client"; | "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<string | null> { | |||||
| 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( | export async function clientAuthFetch( | ||||
| input: RequestInfo | URL, | input: RequestInfo | URL, | ||||
| init?: RequestInit | init?: RequestInit | ||||
| ): Promise<Response> { | ): Promise<Response> { | ||||
| 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); | const headers = new Headers(init?.headers); | ||||
| if (token) { | if (token) { | ||||
| headers.set("Authorization", `Bearer ${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; | window.location.href = LOGIN_SESSION_EXPIRED_HREF; | ||||
| } | } | ||||
| } | } | ||||
| @@ -27,6 +27,10 @@ export const AUTH = { | |||||
| REPORT_MGMT: "REPORT_MGMT", | 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 */ | /** 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", | 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; | } as const; | ||||
| /** | /** | ||||
| @@ -10,6 +10,7 @@ import Divider from "@mui/material/Divider"; | |||||
| import Typography from "@mui/material/Typography"; | import Typography from "@mui/material/Typography"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { signOut } from "next-auth/react"; | import { signOut } from "next-auth/react"; | ||||
| import { clearBackendTokens } from "@/app/utils/authToken"; | |||||
| type Props = Pick<AppBarProps, "avatarImageSrc" | "profileName">; | type Props = Pick<AppBarProps, "avatarImageSrc" | "profileName">; | ||||
| @@ -51,7 +52,13 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => { | |||||
| {profileName} | {profileName} | ||||
| </Typography> | </Typography> | ||||
| <Divider /> | <Divider /> | ||||
| <MenuItem onClick={() => signOut()} sx={{ py: 1.5, fontSize: "0.875rem" }}> | |||||
| <MenuItem | |||||
| onClick={() => { | |||||
| clearBackendTokens(); | |||||
| void signOut(); | |||||
| }} | |||||
| sx={{ py: 1.5, fontSize: "0.875rem" }} | |||||
| > | |||||
| {t("Sign out")} | {t("Sign out")} | ||||
| </MenuItem> | </MenuItem> | ||||
| </Menu> | </Menu> | ||||
| @@ -67,6 +67,8 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; | const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| const [isPrinting, setIsPrinting] = useState(false); | |||||
| const printInFlightRef = useRef(false); | |||||
| const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]); | const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]); | ||||
| const initialSearchTargetDate = useMemo( | const initialSearchTargetDate = useMemo( | ||||
| () => resolveWorkbenchRecordTargetDate(initialTargetDate), | () => resolveWorkbenchRecordTargetDate(initialTargetDate), | ||||
| @@ -183,6 +185,65 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| return t("All dates"); | return t("All dates"); | ||||
| }, [searchQuery.targetDate, t]); | }, [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<void>((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 askNumOfCarton = useCallback(async () => { | ||||
| const result = await Swal.fire({ | const result = await Swal.fire({ | ||||
| title: t("Enter the number of cartons: "), | title: t("Enter the number of cartons: "), | ||||
| @@ -210,6 +271,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| const handlePrintDN = useCallback( | const handlePrintDN = useCallback( | ||||
| async (recordId: number) => { | async (recordId: number) => { | ||||
| if (printInFlightRef.current) return; | |||||
| if (!a4Printer) { | if (!a4Printer) { | ||||
| await Swal.fire({ | await Swal.fire({ | ||||
| position: "bottom-end", | position: "bottom-end", | ||||
| @@ -220,31 +282,32 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| }); | }); | ||||
| return; | 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( | const handlePrintLabel = useCallback( | ||||
| async (recordId: number) => { | async (recordId: number) => { | ||||
| if (printInFlightRef.current) return; | |||||
| if (!labelPrinter) { | if (!labelPrinter) { | ||||
| await Swal.fire({ | await Swal.fire({ | ||||
| position: "bottom-end", | position: "bottom-end", | ||||
| @@ -255,30 +318,31 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| }); | }); | ||||
| return; | 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( | const handlePrintBoth = useCallback( | ||||
| async (recordId: number) => { | async (recordId: number) => { | ||||
| if (printInFlightRef.current) return; | |||||
| if (!a4Printer || !labelPrinter) { | if (!a4Printer || !labelPrinter) { | ||||
| await Swal.fire({ | await Swal.fire({ | ||||
| position: "bottom-end", | position: "bottom-end", | ||||
| @@ -289,37 +353,39 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| }); | }); | ||||
| return; | 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) => { | const handleLabelReprint = useCallback(async (doPickOrder: CompletedDoPickOrderResponse) => { | ||||
| if (printInFlightRef.current) return; | |||||
| if (!labelPrinter) { | if (!labelPrinter) { | ||||
| Swal.fire({ | Swal.fire({ | ||||
| position: "bottom-end", | position: "bottom-end", | ||||
| @@ -331,6 +397,9 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| return; | return; | ||||
| } | } | ||||
| printInFlightRef.current = true; | |||||
| setIsPrinting(true); | |||||
| try { | |||||
| const defaultTotalCartons = Math.max(1, doPickOrder.numberOfCartons || 1); | const defaultTotalCartons = Math.max(1, doPickOrder.numberOfCartons || 1); | ||||
| const result = await Swal.fire({ | const result = await Swal.fire({ | ||||
| title: t("Reprint DN Label"), | title: t("Reprint DN Label"), | ||||
| @@ -390,31 +459,22 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| return; | 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( | const handleDetailClick = useCallback( | ||||
| async (record: CompletedDoPickOrderResponse) => { | async (record: CompletedDoPickOrderResponse) => { | ||||
| @@ -735,16 +795,16 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||||
| <Button variant="outlined" onClick={() => void handleDetailClick(row)}> | <Button variant="outlined" onClick={() => void handleDetailClick(row)}> | ||||
| {t("View Details")} | {t("View Details")} | ||||
| </Button> | </Button> | ||||
| <Button variant="contained" onClick={() => void handlePrintDN(row.doPickOrderRecordId)}> | |||||
| <Button variant="contained" disabled={isPrinting} onClick={() => void handlePrintDN(row.doPickOrderRecordId)}> | |||||
| {t("Print Pick Order")} | {t("Print Pick Order")} | ||||
| </Button> | </Button> | ||||
| <Button variant="contained" onClick={() => void handlePrintBoth(row.doPickOrderRecordId)}> | |||||
| <Button variant="contained" disabled={isPrinting} onClick={() => void handlePrintBoth(row.doPickOrderRecordId)}> | |||||
| {t("Print DN & Label")} | {t("Print DN & Label")} | ||||
| </Button> | </Button> | ||||
| <Button variant="contained" onClick={() => void handlePrintLabel(row.doPickOrderRecordId)}> | |||||
| <Button variant="contained" disabled={isPrinting} onClick={() => void handlePrintLabel(row.doPickOrderRecordId)}> | |||||
| {t("Print Label")} | {t("Print Label")} | ||||
| </Button> | </Button> | ||||
| <Button variant="contained" onClick={() => void handleLabelReprint(row)}> | |||||
| <Button variant="contained" disabled={isPrinting} onClick={() => void handleLabelReprint(row)}> | |||||
| {t("Reprint Label(s)")} | {t("Reprint Label(s)")} | ||||
| </Button> | </Button> | ||||
| </CardActions> | </CardActions> | ||||
| @@ -13,7 +13,7 @@ import { useEffect, useState } from "react"; | |||||
| import { SubmitHandler, useForm } from "react-hook-form"; | import { SubmitHandler, useForm } from "react-hook-form"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | import { SetupAxiosInterceptors } from "@/app/(main)/axios/axiosInstance"; | ||||
| import { useToken } from "@/app/(main)/axios/AxiosProvider"; | |||||
| import { persistBackendTokens } from "@/app/utils/authToken"; | |||||
| import { Visibility, VisibilityOff } from "@mui/icons-material"; | import { Visibility, VisibilityOff } from "@mui/icons-material"; | ||||
| type LoginFields = { | type LoginFields = { | ||||
| @@ -25,6 +25,7 @@ type SessionWithAbilities = | |||||
| | ({ | | ({ | ||||
| abilities: string[]; | abilities: string[]; | ||||
| accessToken?: string; | accessToken?: string; | ||||
| refreshToken?: string; | |||||
| } & Session) | } & Session) | ||||
| | null; | | null; | ||||
| @@ -53,7 +54,6 @@ const LoginForm: React.FC = () => { | |||||
| const [serverError, setServerError] = useState<string>(); | const [serverError, setServerError] = useState<string>(); | ||||
| const router = useRouter(); | const router = useRouter(); | ||||
| const { setAccessToken } = useToken(); | |||||
| const [showPassword, setShowPassword] = useState(false); | const [showPassword, setShowPassword] = useState(false); | ||||
| const handleClickShowPassword = () => setShowPassword(!showPassword); | const handleClickShowPassword = () => setShowPassword(!showPassword); | ||||
| @@ -71,13 +71,13 @@ const LoginForm: React.FC = () => { | |||||
| return; | return; | ||||
| } | } | ||||
| // set auth to local storage | |||||
| // Persist access + refresh in localStorage (Login is outside AxiosProvider) | |||||
| const session = (await getSession()) as SessionWithAbilities; | const session = (await getSession()) as SessionWithAbilities; | ||||
| // @ts-ignore | |||||
| window.localStorage.setItem("accessToken", session?.accessToken ?? ""); | |||||
| setAccessToken(session?.accessToken ?? null); | |||||
| SetupAxiosInterceptors(session?.accessToken ?? null); | |||||
| // console.log(session) | |||||
| const access = session?.accessToken ?? ""; | |||||
| if (access) { | |||||
| persistBackendTokens(access, session?.refreshToken); | |||||
| SetupAxiosInterceptors(access); | |||||
| } | |||||
| window.localStorage.setItem( | window.localStorage.setItem( | ||||
| "abilities", | "abilities", | ||||
| JSON.stringify(session?.abilities), | JSON.stringify(session?.abilities), | ||||
| @@ -3,11 +3,13 @@ | |||||
| import { signOut } from "next-auth/react"; | import { signOut } from "next-auth/react"; | ||||
| import { useSearchParams } from "next/navigation"; | import { useSearchParams } from "next/navigation"; | ||||
| import { useEffect } from "react"; | import { useEffect } from "react"; | ||||
| import { clearBackendTokens } from "@/app/utils/authToken"; | |||||
| const LogoutPage = () => { | const LogoutPage = () => { | ||||
| const params = useSearchParams(); | const params = useSearchParams(); | ||||
| const callbackUrl = params.get("callbackUrl"); | const callbackUrl = params.get("callbackUrl"); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| clearBackendTokens(); | |||||
| signOut({ redirect: true, callbackUrl: callbackUrl || "/" }); | signOut({ redirect: true, callbackUrl: callbackUrl || "/" }); | ||||
| }); | }); | ||||
| @@ -251,7 +251,7 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <ShowChart />, | icon: <ShowChart />, | ||||
| labelKey: "nav.chartReports", | labelKey: "nav.chartReports", | ||||
| path: "", | path: "", | ||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE], | |||||
| isHidden: false, | isHidden: false, | ||||
| children: [ | children: [ | ||||
| { | { | ||||
| @@ -280,7 +280,7 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <LocalShipping />, | icon: <LocalShipping />, | ||||
| labelKey: "nav.chart.delivery", | labelKey: "nav.chart.delivery", | ||||
| path: "/chart/delivery", | path: "/chart/delivery", | ||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.chart.warehouse", | id: "nav.chart.warehouse", | ||||
| @@ -303,7 +303,7 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <Settings />, | icon: <Settings />, | ||||
| labelKey: "nav.settings", | labelKey: "nav.settings", | ||||
| path: "", | path: "", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN, AUTH.TRUCK_LANE], | |||||
| children: [ | children: [ | ||||
| { | { | ||||
| id: "nav.settings.user", | id: "nav.settings.user", | ||||
| @@ -325,78 +325,91 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <Category />, | icon: <Category />, | ||||
| labelKey: "nav.settings.items", | labelKey: "nav.settings.items", | ||||
| path: "/settings/items", | path: "/settings/items", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.itemDefaultShelfLife", | id: "nav.settings.itemDefaultShelfLife", | ||||
| icon: <EventAvailable />, | icon: <EventAvailable />, | ||||
| labelKey: "nav.settings.itemDefaultShelfLife", | labelKey: "nav.settings.itemDefaultShelfLife", | ||||
| path: "/settings/itemDefaultShelfLife", | path: "/settings/itemDefaultShelfLife", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.equipment", | id: "nav.settings.equipment", | ||||
| icon: <Build />, | icon: <Build />, | ||||
| labelKey: "nav.settings.equipment", | labelKey: "nav.settings.equipment", | ||||
| path: "/settings/equipment", | path: "/settings/equipment", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.warehouse", | id: "nav.settings.warehouse", | ||||
| icon: <Warehouse />, | icon: <Warehouse />, | ||||
| labelKey: "nav.settings.warehouse", | labelKey: "nav.settings.warehouse", | ||||
| path: "/settings/warehouse", | path: "/settings/warehouse", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.printer", | id: "nav.settings.printer", | ||||
| icon: <Print />, | icon: <Print />, | ||||
| labelKey: "nav.settings.printer", | labelKey: "nav.settings.printer", | ||||
| path: "/settings/printer", | path: "/settings/printer", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.priceInquiry", | id: "nav.settings.priceInquiry", | ||||
| icon: <Assessment />, | icon: <Assessment />, | ||||
| labelKey: "nav.settings.priceInquiry", | labelKey: "nav.settings.priceInquiry", | ||||
| path: "/settings/itemPrice", | path: "/settings/itemPrice", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.qcItem", | id: "nav.settings.qcItem", | ||||
| icon: <VerifiedUser />, | icon: <VerifiedUser />, | ||||
| labelKey: "nav.settings.qcItem", | labelKey: "nav.settings.qcItem", | ||||
| path: "/settings/qcItem", | path: "/settings/qcItem", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.qcCategory", | id: "nav.settings.qcCategory", | ||||
| icon: <Label />, | icon: <Label />, | ||||
| labelKey: "nav.settings.qcCategory", | labelKey: "nav.settings.qcCategory", | ||||
| path: "/settings/qcCategory", | path: "/settings/qcCategory", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.qcItemAll", | id: "nav.settings.qcItemAll", | ||||
| icon: <Checklist />, | icon: <Checklist />, | ||||
| labelKey: "nav.settings.qcItemAll", | labelKey: "nav.settings.qcItemAll", | ||||
| path: "/settings/qcItemAll", | path: "/settings/qcItemAll", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.shopAndTruck", | id: "nav.settings.shopAndTruck", | ||||
| icon: <Storefront />, | icon: <Storefront />, | ||||
| labelKey: "nav.settings.shopAndTruck", | labelKey: "nav.settings.shopAndTruck", | ||||
| path: "/settings/shop/board", | path: "/settings/shop/board", | ||||
| requiredAbility: [AUTH.TRUCK_LANE, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.deliveryOrderFloor", | id: "nav.settings.deliveryOrderFloor", | ||||
| icon: <Layers />, | icon: <Layers />, | ||||
| labelKey: "nav.settings.deliveryOrderFloor", | labelKey: "nav.settings.deliveryOrderFloor", | ||||
| path: "/settings/deliveryOrderFloor", | path: "/settings/deliveryOrderFloor", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.demandForecast", | id: "nav.settings.demandForecast", | ||||
| icon: <TrendingUp />, | icon: <TrendingUp />, | ||||
| labelKey: "nav.settings.demandForecast", | labelKey: "nav.settings.demandForecast", | ||||
| path: "/settings/rss", | path: "/settings/rss", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.bomWeighting", | id: "nav.settings.bomWeighting", | ||||
| icon: <ViewModule />, | icon: <ViewModule />, | ||||
| labelKey: "nav.settings.bomWeighting", | labelKey: "nav.settings.bomWeighting", | ||||
| path: "/settings/bomWeighting", | path: "/settings/bomWeighting", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.masterDataIssues", | id: "nav.settings.masterDataIssues", | ||||
| @@ -410,24 +423,28 @@ const NavigationContent: React.FC = () => { | |||||
| icon: <QrCodeIcon />, | icon: <QrCodeIcon />, | ||||
| labelKey: "nav.settings.qrCodeHandle", | labelKey: "nav.settings.qrCodeHandle", | ||||
| path: "/settings/qrCodeHandle", | path: "/settings/qrCodeHandle", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.importTesting", | id: "nav.settings.importTesting", | ||||
| icon: <Science />, | icon: <Science />, | ||||
| labelKey: "nav.settings.importTesting", | labelKey: "nav.settings.importTesting", | ||||
| path: "/settings/m18ImportTesting", | path: "/settings/m18ImportTesting", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.importExcel", | id: "nav.settings.importExcel", | ||||
| icon: <UploadFile />, | icon: <UploadFile />, | ||||
| labelKey: "nav.settings.importExcel", | labelKey: "nav.settings.importExcel", | ||||
| path: "/settings/importExcel", | path: "/settings/importExcel", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| { | { | ||||
| id: "nav.settings.importBom", | id: "nav.settings.importBom", | ||||
| icon: <UploadFile />, | icon: <UploadFile />, | ||||
| labelKey: "nav.settings.importBom", | labelKey: "nav.settings.importBom", | ||||
| path: "/settings/importBom", | path: "/settings/importBom", | ||||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||||
| }, | }, | ||||
| ], | ], | ||||
| }, | }, | ||||
| @@ -49,16 +49,25 @@ const PoSearch: React.FC<Props> = ({ | |||||
| const searchCriteria: Criterion<SearchParamNames>[] = [ | const searchCriteria: Criterion<SearchParamNames>[] = [ | ||||
| { label: t("Supplier"), paramName: "supplier", type: "text" }, | { label: t("Supplier"), paramName: "supplier", type: "text" }, | ||||
| { label: t("PO No."), paramName: "code", type: "text" }, | { label: t("PO No."), paramName: "code", type: "text" }, | ||||
| { label: t("Item Code"), paramName: "itemCode", type: "text" }, | |||||
| { label: t("Item Name"), paramName: "itemName", type: "text" }, | |||||
| { | { | ||||
| label: t("Escalated"), | label: t("Escalated"), | ||||
| paramName: "escalated", | paramName: "escalated", | ||||
| type: "select-labelled", | type: "select-labelled", | ||||
| advanced: true, | |||||
| options: [ | options: [ | ||||
| { label: t("Escalated"), value: "true" }, | { label: t("Escalated"), value: "true" }, | ||||
| { label: t("NotEscalated"), value: "false" }, | { label: t("NotEscalated"), value: "false" }, | ||||
| ], | ], | ||||
| }, | }, | ||||
| { label: t("Order Date"), label2: t("Order Date To"), paramName: "orderDate", type: "dateRange" }, | |||||
| { | |||||
| label: t("Order Date"), | |||||
| label2: t("Order Date To"), | |||||
| paramName: "orderDate", | |||||
| type: "dateRange", | |||||
| advanced: true, | |||||
| }, | |||||
| { | { | ||||
| label: t("Status"), | label: t("Status"), | ||||
| paramName: "status", | paramName: "status", | ||||
| @@ -374,6 +383,8 @@ const PoSearch: React.FC<Props> = ({ | |||||
| setFilterArgs({ | setFilterArgs({ | ||||
| code: query.code, | code: query.code, | ||||
| supplier: query.supplier, | supplier: query.supplier, | ||||
| itemCode: query.itemCode, | |||||
| itemName: query.itemName, | |||||
| status: query.status === "All" ? "" : query.status, | status: query.status === "All" ? "" : query.status, | ||||
| escalated: | escalated: | ||||
| query.escalated === "All" | query.escalated === "All" | ||||
| @@ -29,6 +29,7 @@ import { | |||||
| import ChevronLeft from "@mui/icons-material/ChevronLeft"; | import ChevronLeft from "@mui/icons-material/ChevronLeft"; | ||||
| import ChevronRight from "@mui/icons-material/ChevronRight"; | import ChevronRight from "@mui/icons-material/ChevronRight"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import type { StockInLine } from "@/app/api/stockIn"; | |||||
| import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | ||||
| import QcStockInModal from "../Qc/QcStockInModal"; | import QcStockInModal from "../Qc/QcStockInModal"; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| @@ -178,7 +179,7 @@ function isWaitingQcPutAway( | |||||
| return s !== "completed" && s !== "rejected"; | return s !== "completed" && s !== "rejected"; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.9 | 2026-08-10 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.10 | 2026-10-15 */ | |||||
| const ProductProcessList: React.FC<ProductProcessListProps> = ({ | const ProductProcessList: React.FC<ProductProcessListProps> = ({ | ||||
| onSelectProcess, | onSelectProcess, | ||||
| printerCombo, | printerCombo, | ||||
| @@ -191,6 +192,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | const { data: session } = useSession() as { data: SessionWithTokens | null }; | ||||
| const sessionToken = session as SessionWithTokens | null; | const sessionToken = session as SessionWithTokens | null; | ||||
| const [loading, setLoading] = useState(false); | const [loading, setLoading] = useState(false); | ||||
| /** Bumps on Search/Reset so the same criteria still refetch. */ | |||||
| const [listReloadKey, setListReloadKey] = useState(0); | |||||
| const [productionCache, setProductionCache] = useState< | const [productionCache, setProductionCache] = useState< | ||||
| AllJoborderProductProcessInfoResponse[] | AllJoborderProductProcessInfoResponse[] | ||||
| >([]); | >([]); | ||||
| @@ -234,6 +237,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| () => printerOptions[0] ?? null, | () => printerOptions[0] ?? null, | ||||
| ); | ); | ||||
| const printInFlightRef = useRef(false); | const printInFlightRef = useRef(false); | ||||
| const loadSeqRef = useRef(0); | |||||
| const hasLoadedOnceRef = useRef(false); | |||||
| const [printingSilId, setPrintingSilId] = useState<number | null>(null); | const [printingSilId, setPrintingSilId] = useState<number | null>(null); | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -328,6 +333,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| const handleApplySearch = useCallback( | const handleApplySearch = useCallback( | ||||
| (inputs: Record<SearchParam | `${SearchParam}To`, string>) => { | (inputs: Record<SearchParam | `${SearchParam}To`, string>) => { | ||||
| const selectedProcessType = (inputs.processType || "all") as ProcessFilter; | const selectedProcessType = (inputs.processType || "all") as ProcessFilter; | ||||
| setListReloadKey((k) => k + 1); | |||||
| onListPersistedStateChange((prev) => ({ | onListPersistedStateChange((prev) => ({ | ||||
| ...prev, | ...prev, | ||||
| filter: selectedProcessType, | filter: selectedProcessType, | ||||
| @@ -342,6 +348,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| ); | ); | ||||
| const handleResetSearch = useCallback(() => { | const handleResetSearch = useCallback(() => { | ||||
| setListReloadKey((k) => k + 1); | |||||
| onListPersistedStateChange((prev) => ({ | onListPersistedStateChange((prev) => ({ | ||||
| ...prev, | ...prev, | ||||
| filter: "all", | filter: "all", | ||||
| @@ -355,8 +362,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| }, [disableDateFilter, onListPersistedStateChange]); | }, [disableDateFilter, onListPersistedStateChange]); | ||||
| /** Load all tab datasets once per search; tab switches use cache only. */ | /** Load all tab datasets once per search; tab switches use cache only. */ | ||||
| const loadAllCaches = useCallback(async () => { | |||||
| setLoading(true); | |||||
| const loadAllCaches = useCallback(async (options?: { silent?: boolean }) => { | |||||
| const silent = options?.silent === true; | |||||
| const seq = ++loadSeqRef.current; | |||||
| if (!silent) setLoading(true); | |||||
| try { | try { | ||||
| const typeParam = filter === "all" ? undefined : filter; | const typeParam = filter === "all" ? undefined : filter; | ||||
| const base = { | const base = { | ||||
| @@ -391,6 +400,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| }), | }), | ||||
| ]); | ]); | ||||
| if (seq !== loadSeqRef.current) return; | |||||
| const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p)); | const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p)); | ||||
| const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p)); | const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p)); | ||||
| const putawayContent = putawayed?.content || []; | const putawayContent = putawayed?.content || []; | ||||
| @@ -405,6 +416,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| setCarriedOverCount(prod?.carriedOverCount ?? 0); | setCarriedOverCount(prod?.carriedOverCount ?? 0); | ||||
| } catch (e) { | } catch (e) { | ||||
| console.error(e); | console.error(e); | ||||
| if (seq !== loadSeqRef.current || silent) return; | |||||
| setProductionCache([]); | setProductionCache([]); | ||||
| setPendingQcCache([]); | setPendingQcCache([]); | ||||
| setPutawayedCache([]); | setPutawayedCache([]); | ||||
| @@ -413,12 +425,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| setPutawayedCount(0); | setPutawayedCount(0); | ||||
| setCarriedOverCount(0); | setCarriedOverCount(0); | ||||
| } finally { | } finally { | ||||
| setLoading(false); | |||||
| if (seq === loadSeqRef.current) setLoading(false); | |||||
| } | } | ||||
| }, [appliedSearch, disableDateFilter, filter]); | |||||
| }, [appliedSearch, disableDateFilter, filter, listReloadKey]); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| void loadAllCaches(); | |||||
| const silent = hasLoadedOnceRef.current; | |||||
| hasLoadedOnceRef.current = true; | |||||
| void loadAllCaches(silent ? { silent: true } : undefined); | |||||
| }, [loadAllCaches]); | }, [loadAllCaches]); | ||||
| const offPlanRows = useMemo(() => { | const offPlanRows = useMemo(() => { | ||||
| @@ -670,9 +684,12 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||||
| closeConfirm(); | closeConfirm(); | ||||
| } | } | ||||
| }, [pendingConfirmAction, closeConfirm]); | }, [pendingConfirmAction, closeConfirm]); | ||||
| const closeNewModal = useCallback(() => { | |||||
| const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => { | |||||
| setOpenModal(false); | setOpenModal(false); | ||||
| }, []); | |||||
| if (updatedStockInLine) { | |||||
| void loadAllCaches({ silent: true }); | |||||
| } | |||||
| }, [loadAllCaches]); | |||||
| const handlePrintLabel = useCallback( | const handlePrintLabel = useCallback( | ||||
| async (process: AllJoborderProductProcessInfoResponse) => { | async (process: AllJoborderProductProcessInfoResponse) => { | ||||
| @@ -544,9 +544,10 @@ useEffect(() => { | |||||
| <RadioGroup | <RadioGroup | ||||
| row | row | ||||
| aria-labelledby="demo-radio-buttons-group-label" | aria-labelledby="demo-radio-buttons-group-label" | ||||
| {...field} | |||||
| value={field.value} | |||||
| // value={field.value?.toString() || "true"} | |||||
| name={field.name} | |||||
| ref={field.ref} | |||||
| onBlur={field.onBlur} | |||||
| value={field.value == null ? "" : String(field.value)} | |||||
| onChange={(e) => { | onChange={(e) => { | ||||
| const value = e.target.value.toString();// === 'true'; | const value = e.target.value.toString();// === 'true'; | ||||
| @@ -4,8 +4,10 @@ import { | |||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| Divider, | Divider, | ||||
| Fab, | |||||
| Grid, | Grid, | ||||
| Modal, | Modal, | ||||
| Tooltip, | |||||
| ModalProps, | ModalProps, | ||||
| Stack, | Stack, | ||||
| Tab, | Tab, | ||||
| @@ -47,6 +49,7 @@ import LoadingComponent from "../General/LoadingComponent"; | |||||
| import { printFGStockInLabel, PrintFGStockInLabelRequest, fetchFGStockInLabel } from "@/app/api/jo/actions"; | import { printFGStockInLabel, PrintFGStockInLabelRequest, fetchFGStockInLabel } from "@/app/api/jo/actions"; | ||||
| import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | ||||
| import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; | import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; | ||||
| import Check from "@mui/icons-material/Check"; | |||||
| const style = { | const style = { | ||||
| position: "absolute", | position: "absolute", | ||||
| top: "50%", | top: "50%", | ||||
| @@ -74,7 +77,7 @@ interface Props extends CommonProps { | |||||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | ||||
| } | } | ||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 */ | |||||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.7 | 2026-10-15 */ | |||||
| const QcStockInModal: React.FC<Props> = ({ | const QcStockInModal: React.FC<Props> = ({ | ||||
| open, | open, | ||||
| onClose, | onClose, | ||||
| @@ -312,6 +315,37 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| return true; | return true; | ||||
| }, [stockInLineInfo]) | }, [stockInLineInfo]) | ||||
| /** PO stock-in only: mark every editable QC row 合格 and select 接受來貨. */ | |||||
| const showMarkAllQcOk = | |||||
| !viewOnly && | |||||
| !showPutaway && | |||||
| skipQc === false && | |||||
| stockInLineInfo?.jobOrderId == null; | |||||
| const markAllQcOkAndAccept = useCallback(() => { | |||||
| const current = formProps.getValues("qcResult") ?? []; | |||||
| formProps.setValue( | |||||
| "qcResult", | |||||
| current.map((item) => | |||||
| item.escalationLogId != null | |||||
| ? item | |||||
| : { ...item, qcPassed: true, failQty: 0 }, | |||||
| ), | |||||
| { shouldDirty: true, shouldValidate: true }, | |||||
| ); | |||||
| formProps.setValue("qcAccept", true, { shouldDirty: true }); | |||||
| // RadioGroup compares with === against value="1" (接受來貨). | |||||
| formProps.setValue("qcDecision", "1" as unknown as number, { | |||||
| shouldDirty: true, | |||||
| shouldValidate: true, | |||||
| }); | |||||
| const acceptQty = formProps.getValues("acceptQty"); | |||||
| const input = document.getElementById("accQty") as HTMLInputElement | null; | |||||
| if (input && acceptQty != null && !Number.isNaN(Number(acceptQty))) { | |||||
| input.value = Number(acceptQty).toString(); | |||||
| } | |||||
| }, [formProps]); | |||||
| const [openPutaway, setOpenPutaway] = useState(false); | const [openPutaway, setOpenPutaway] = useState(false); | ||||
| const onOpenPutaway = useCallback(() => { | const onOpenPutaway = useCallback(() => { | ||||
| @@ -585,10 +619,10 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| } | } | ||||
| closeWithResult(qcRes); | |||||
| closeWithResult(qcRes ?? stockInLineInfo); | |||||
| // setTabIndex(1); // Need to go Putaway tab? | // setTabIndex(1); // Need to go Putaway tab? | ||||
| } else { | } else { | ||||
| closeWithResult(qcRes); | |||||
| closeWithResult(qcRes ?? stockInLineInfo); | |||||
| } | } | ||||
| setIsSubmitting(false); | setIsSubmitting(false); | ||||
| qcSubmitInFlightRef.current = false; | qcSubmitInFlightRef.current = false; | ||||
| @@ -672,16 +706,15 @@ const QcStockInModal: React.FC<Props> = ({ | |||||
| if (res?.type === "putaway_held") { | if (res?.type === "putaway_held") { | ||||
| alert(res.message || "盤點進行中,自動上架將於盤點結束後入庫"); | alert(res.message || "盤點進行中,自動上架將於盤點結束後入庫"); | ||||
| } | } | ||||
| const updated = Array.isArray(res?.entity) ? res.entity[0] : res?.entity; | |||||
| closeWithResult(updated ?? stockInLineInfo); | |||||
| } catch (error) { | } catch (error) { | ||||
| const message = error instanceof Error ? error.message : "上架失敗"; | const message = error instanceof Error ? error.message : "上架失敗"; | ||||
| alert(message); | alert(message); | ||||
| return; | return; | ||||
| } | } | ||||
| // Close modal after successful putaway | |||||
| closeHandler({}, "backdropClick"); | |||||
| }, | }, | ||||
| [closeHandler], | |||||
| [closeWithResult, postStockInLine, stockInLineInfo], | |||||
| ); | ); | ||||
| // Print handler | // Print handler | ||||
| useEffect(() => { | useEffect(() => { | ||||
| @@ -854,13 +887,25 @@ const printQrcode = useCallback( | |||||
| denseLayout={useDenseQcLayout} | denseLayout={useDenseQcLayout} | ||||
| />) | />) | ||||
| } | } | ||||
| <Stack direction="row" justifyContent="flex-end" gap={1} sx={{pt:2}}> | |||||
| <Stack direction="row" justifyContent="flex-end" alignItems="center" gap={1} sx={{ pt: 2 }}> | |||||
| {showMarkAllQcOk && ( | |||||
| <Tooltip title={t("Mark all QC OK and accept")}> | |||||
| <Fab | |||||
| color="success" | |||||
| size="medium" | |||||
| aria-label={t("Mark all QC OK and accept")} | |||||
| onClick={markAllQcOkAndAccept} | |||||
| disabled={isSubmitting || isLoading} | |||||
| > | |||||
| <Check /> | |||||
| </Fab> | |||||
| </Tooltip> | |||||
| )} | |||||
| {(!viewOnly && !showPutaway) && (<Button | {(!viewOnly && !showPutaway) && (<Button | ||||
| id="Submit" | id="Submit" | ||||
| type="button" | type="button" | ||||
| variant="contained" | variant="contained" | ||||
| color="primary" | color="primary" | ||||
| sx={{ mt: 1 }} | |||||
| onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} | onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} | ||||
| disabled={isSubmitting || isLoading || roundChoiceRequired} | disabled={isSubmitting || isLoading || roundChoiceRequired} | ||||
| > | > | ||||
| @@ -15,6 +15,8 @@ import CardActions from "@mui/material/CardActions"; | |||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | import RestartAlt from "@mui/icons-material/RestartAlt"; | ||||
| import Search from "@mui/icons-material/Search"; | import Search from "@mui/icons-material/Search"; | ||||
| import ExpandMore from "@mui/icons-material/ExpandMore"; | |||||
| import ExpandLess from "@mui/icons-material/ExpandLess"; | |||||
| import dayjs, { Dayjs } from "dayjs"; | import dayjs, { Dayjs } from "dayjs"; | ||||
| import "dayjs/locale/zh-hk"; | import "dayjs/locale/zh-hk"; | ||||
| import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | ||||
| @@ -45,6 +47,8 @@ interface BaseCriterion<T extends string> { | |||||
| preFilledValue?: string | { from?: string; to?: string }; | preFilledValue?: string | { from?: string; to?: string }; | ||||
| filterObj?: T; | filterObj?: T; | ||||
| handleSelectionChange?: (selectedOptions: T[]) => void; | handleSelectionChange?: (selectedOptions: T[]) => void; | ||||
| /** Hidden until the user clicks more criteria. */ | |||||
| advanced?: boolean; | |||||
| } | } | ||||
| interface OptionWithLabel<T extends string> { | interface OptionWithLabel<T extends string> { | ||||
| @@ -201,6 +205,9 @@ function SearchBox<T extends string>({ | |||||
| const [inputs, setInputs] = useState(preFilledInputs); | const [inputs, setInputs] = useState(preFilledInputs); | ||||
| const [isReset, setIsReset] = useState(false); | const [isReset, setIsReset] = useState(false); | ||||
| const [showAdvanced, setShowAdvanced] = useState(false); | |||||
| const hasAdvanced = criteria.some((c) => c.advanced); | |||||
| const visibleCriteria = showAdvanced ? criteria : criteria.filter((c) => !c.advanced); | |||||
| const makeInputChangeHandler = useCallback( | const makeInputChangeHandler = useCallback( | ||||
| (paramName: T): React.ChangeEventHandler<HTMLInputElement> => { | (paramName: T): React.ChangeEventHandler<HTMLInputElement> => { | ||||
| @@ -297,7 +304,7 @@ function SearchBox<T extends string>({ | |||||
| {t("Search Criteria")} | {t("Search Criteria")} | ||||
| </Typography> | </Typography> | ||||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | ||||
| {criteria.map((c) => { | |||||
| {visibleCriteria.map((c) => { | |||||
| return ( | return ( | ||||
| <Grid key={c.paramName} item xs={6}> | <Grid key={c.paramName} item xs={6}> | ||||
| {c.type === "text" && ( | {c.type === "text" && ( | ||||
| @@ -615,6 +622,16 @@ function SearchBox<T extends string>({ | |||||
| > | > | ||||
| {t("Search")} | {t("Search")} | ||||
| </Button> | </Button> | ||||
| {hasAdvanced && ( | |||||
| <Button | |||||
| variant="text" | |||||
| startIcon={showAdvanced ? <ExpandLess /> : <ExpandMore />} | |||||
| onClick={() => setShowAdvanced((open) => !open)} | |||||
| disabled={disabled} | |||||
| > | |||||
| {showAdvanced ? t("Fewer criteria") : t("More criteria")} | |||||
| </Button> | |||||
| )} | |||||
| {extraActions} | {extraActions} | ||||
| </CardActions> | </CardActions> | ||||
| </CardContent> | </CardContent> | ||||
| @@ -605,6 +605,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| )} | )} | ||||
| {hasSecond && ( | {hasSecond && ( | ||||
| <> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <Radio | <Radio | ||||
| size="small" | size="small" | ||||
| @@ -666,6 +667,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||||
| currentQty={stockTakeHiddenOnHand(detail)} | currentQty={stockTakeHiddenOnHand(detail)} | ||||
| threshold={qtyGapWarnPercent} | threshold={qtyGapWarnPercent} | ||||
| /> | /> | ||||
| </> | |||||
| )} | )} | ||||
| {(() => { | {(() => { | ||||
| @@ -94,6 +94,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| /** Qty fields the user has left, or just saved. Warning stays hidden while typing. */ | /** Qty fields the user has left, or just saved. Warning stays hidden while typing. */ | ||||
| const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | ||||
| const [batchSaving, setBatchSaving] = useState(false); | const [batchSaving, setBatchSaving] = useState(false); | ||||
| /** 已盤點列:按「更改」後才打開數量欄,再按「保存」。 */ | |||||
| const [editingFirstIds, setEditingFirstIds] = useState<Record<number, boolean>>({}); | |||||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | const [shortcutInput, setShortcutInput] = useState<string>(""); | ||||
| const [page, setPage] = useState(0); | const [page, setPage] = useState(0); | ||||
| const [pageSize, setPageSize] = useState<number | string>("all"); | const [pageSize, setPageSize] = useState<number | string>("all"); | ||||
| @@ -104,6 +106,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | const currentUserId = session?.id ? parseInt(session.id) : undefined; | ||||
| const handleBatchTestAllRef = useRef<() => Promise<void>>(); | const handleBatchTestAllRef = useRef<() => Promise<void>>(); | ||||
| const batchInFlightRef = useRef(false); | const batchInFlightRef = useRef(false); | ||||
| const saveInFlightRef = useRef(false); | |||||
| const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; | const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; | ||||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | const handleChangePage = useCallback((event: unknown, newPage: number) => { | ||||
| setPage(newPage); | setPage(newPage); | ||||
| @@ -211,15 +214,42 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| }); | }); | ||||
| }; | }; | ||||
| const canReviseFirstCount = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||||
| const sessionStatus = selectedSession?.status?.toLowerCase() ?? ""; | |||||
| if (sessionStatus === "completed") { | |||||
| return false; | |||||
| } | |||||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||||
| if (recordStatus === "completed" || recordStatus !== "pass") { | |||||
| return false; | |||||
| } | |||||
| if (detail.approverQty != null) { | |||||
| return false; | |||||
| } | |||||
| if (detail.stockTakeRecordId == null) { | |||||
| return false; | |||||
| } | |||||
| if (detail.firstStockTakeQty == null || detail.secondStockTakeQty != null) { | |||||
| return false; | |||||
| } | |||||
| if ((detail.pickerReviseCount ?? 0) >= 2) { | |||||
| return false; | |||||
| } | |||||
| return true; | |||||
| }, [selectedSession?.status]); | |||||
| const handleSaveStockTake = useCallback( | const handleSaveStockTake = useCallback( | ||||
| async (detail: InventoryLotDetailResponse) => { | async (detail: InventoryLotDetailResponse) => { | ||||
| if (!selectedSession || !currentUserId) { | |||||
| if (!selectedSession || !currentUserId || saveInFlightRef.current) { | |||||
| return; | return; | ||||
| } | } | ||||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||||
| const revisingFirst = canReviseFirstCount(detail); | |||||
| const isFirstSubmit = detail.firstStockTakeQty == null || revisingFirst; | |||||
| const isSecondSubmit = | const isSecondSubmit = | ||||
| detail.firstStockTakeQty != null && detail.secondStockTakeQty == null; | |||||
| !revisingFirst && | |||||
| detail.firstStockTakeQty != null && | |||||
| detail.secondStockTakeQty == null; | |||||
| // 现在用户输入的是 total 和 bad,需要算 available = total - bad | // 现在用户输入的是 total 和 bad,需要算 available = total - bad | ||||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | ||||
| @@ -250,6 +280,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| return; | return; | ||||
| } | } | ||||
| saveInFlightRef.current = true; | |||||
| setSaving(true); | setSaving(true); | ||||
| try { | try { | ||||
| const request: SaveStockTakeRecordRequest = { | const request: SaveStockTakeRecordRequest = { | ||||
| @@ -258,6 +289,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| qty: availableValidated.qty, | qty: availableValidated.qty, | ||||
| badQty: badValidated.qty, | badQty: badValidated.qty, | ||||
| remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, | remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, | ||||
| reviseFirstQty: revisingFirst, | |||||
| }; | }; | ||||
| console.log("handleSaveStockTake: request:", request); | console.log("handleSaveStockTake: request:", request); | ||||
| console.log("handleSaveStockTake: selectedSession.stockTakeId:", selectedSession.stockTakeId); | console.log("handleSaveStockTake: selectedSession.stockTakeId:", selectedSession.stockTakeId); | ||||
| @@ -272,16 +304,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| qtyGapWarnPercent, | qtyGapWarnPercent, | ||||
| ); | ); | ||||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:${isFirstSubmit ? "first" : "second"}`]: true })); | setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:${isFirstSubmit ? "first" : "second"}`]: true })); | ||||
| onSnackbar( | |||||
| gapText | |||||
| ? `${t("Stock take record saved successfully")} ${gapText}` | |||||
| : t("Stock take record saved successfully"), | |||||
| gapText ? "warning" : "success", | |||||
| ); | |||||
| const oneChangeLeftText = | |||||
| revisingFirst && (detail.pickerReviseCount ?? 0) === 0 ? t("oneChangeLeft") : ""; | |||||
| const saveMessage = [t("Stock take record saved successfully"), gapText, oneChangeLeftText] | |||||
| .filter(Boolean) | |||||
| .join(" "); | |||||
| onSnackbar(saveMessage, gapText || oneChangeLeftText ? "warning" : "success"); | |||||
| //await loadDetails(page, pageSize, { silent: true }); | //await loadDetails(page, pageSize, { silent: true }); | ||||
| const nextQtyId = editableQtyIdsRef.current[editableQtyIdsRef.current.indexOf(detail.id) + 1]; | const nextQtyId = editableQtyIdsRef.current[editableQtyIdsRef.current.indexOf(detail.id) + 1]; | ||||
| if (nextQtyId != null) focusQtyInput(nextQtyId); | if (nextQtyId != null) focusQtyInput(nextQtyId); | ||||
| if (revisingFirst) { | |||||
| setEditingFirstIds((prev) => ({ ...prev, [detail.id]: false })); | |||||
| } | |||||
| setInventoryLotDetails((prev) => | setInventoryLotDetails((prev) => | ||||
| prev.map((d) => | prev.map((d) => | ||||
| d.id === detail.id | d.id === detail.id | ||||
| @@ -290,10 +325,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| stockTakeRecordId: d.stockTakeRecordId ?? null, // 首次儲存後可從 response 取得,此處先保留 | stockTakeRecordId: d.stockTakeRecordId ?? null, // 首次儲存後可從 response 取得,此處先保留 | ||||
| firstStockTakeQty: isFirstSubmit ? availableValidated.qty : d.firstStockTakeQty, | firstStockTakeQty: isFirstSubmit ? availableValidated.qty : d.firstStockTakeQty, | ||||
| firstBadQty: isFirstSubmit ? badValidated.qty : d.firstBadQty ?? null, | firstBadQty: isFirstSubmit ? badValidated.qty : d.firstBadQty ?? null, | ||||
| secondStockTakeQty: isSecondSubmit ? availableValidated.qty : d.secondStockTakeQty, | |||||
| secondBadQty: isSecondSubmit ? badValidated.qty : d.secondBadQty ?? null, | |||||
| secondStockTakeQty: revisingFirst ? null : (isSecondSubmit ? availableValidated.qty : d.secondStockTakeQty), | |||||
| secondBadQty: revisingFirst ? null : (isSecondSubmit ? badValidated.qty : d.secondBadQty ?? null), | |||||
| remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, | remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, | ||||
| stockTakeRecordStatus: "pass", | stockTakeRecordStatus: "pass", | ||||
| pickerReviseCount: revisingFirst ? (d.pickerReviseCount ?? 0) + 1 : d.pickerReviseCount, | |||||
| } | } | ||||
| : d | : d | ||||
| ) | ) | ||||
| @@ -316,6 +352,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| onSnackbar(errorMessage, "error"); | onSnackbar(errorMessage, "error"); | ||||
| } finally { | } finally { | ||||
| setSaving(false); | setSaving(false); | ||||
| saveInFlightRef.current = false; | |||||
| } | } | ||||
| }, | }, | ||||
| [ | [ | ||||
| @@ -326,6 +363,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| currentUserId, | currentUserId, | ||||
| onSnackbar, | onSnackbar, | ||||
| qtyGapWarnPercent, | qtyGapWarnPercent, | ||||
| canReviseFirstCount, | |||||
| ] | ] | ||||
| ); | ); | ||||
| @@ -637,10 +675,28 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| ) : ( | ) : ( | ||||
| inventoryLotDetails.map((detail) => { | inventoryLotDetails.map((detail) => { | ||||
| const submitDisabled = isSubmitDisabled(detail); | const submitDisabled = isSubmitDisabled(detail); | ||||
| const revisingFirst = canReviseFirstCount(detail); | |||||
| const editingFirst = revisingFirst && !!editingFirstIds[detail.id]; | |||||
| const sessionOpen = selectedSession?.status?.toLowerCase() !== "completed"; | |||||
| const changeUsedUp = | |||||
| sessionOpen && | |||||
| detail.stockTakeRecordStatus?.toLowerCase() === "pass" && | |||||
| detail.approverQty == null && | |||||
| detail.firstStockTakeQty != null && | |||||
| detail.secondStockTakeQty == null && | |||||
| (detail.pickerReviseCount ?? 0) >= 2; | |||||
| const showChangeButton = (revisingFirst || changeUsedUp) && !editingFirst; | |||||
| const oneChanceLeft = revisingFirst && (detail.pickerReviseCount ?? 0) === 1; | |||||
| const isFirstSubmit = detail.firstStockTakeQty == null; | const isFirstSubmit = detail.firstStockTakeQty == null; | ||||
| const isSecondSubmit = | const isSecondSubmit = | ||||
| detail.firstStockTakeQty != null && | detail.firstStockTakeQty != null && | ||||
| detail.secondStockTakeQty == null; | detail.secondStockTakeQty == null; | ||||
| const showFirstInput = (!submitDisabled && isFirstSubmit) || editingFirst; | |||||
| const savedFirstQty = sanitizeStockTakeQtyInput( | |||||
| String(Math.trunc(Number(detail.firstStockTakeQty ?? 0))) | |||||
| ); | |||||
| const editedFirstQty = sanitizeStockTakeQtyInput(recordInputs[detail.id]?.firstQty || ""); | |||||
| const firstQtyChanged = editedFirstQty !== "" && editedFirstQty !== savedFirstQty; | |||||
| const canEditQty = !submitDisabled && (isFirstSubmit || isSecondSubmit); | const canEditQty = !submitDisabled && (isFirstSubmit || isSecondSubmit); | ||||
| @@ -697,7 +753,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <TableCell sx={{ width: 250, minWidth: 250 }}> | <TableCell sx={{ width: 250, minWidth: 250 }}> | ||||
| <Stack spacing={1}> | <Stack spacing={1}> | ||||
| {/* First */} | {/* First */} | ||||
| {!submitDisabled && isFirstSubmit ? ( | |||||
| {showFirstInput ? ( | |||||
| <Stack spacing={0.5} alignItems="flex-start"> | <Stack spacing={0.5} alignItems="flex-start"> | ||||
| <Stack direction="row" spacing={1} alignItems="center"> | <Stack direction="row" spacing={1} alignItems="center"> | ||||
| <TextField | <TextField | ||||
| @@ -772,13 +828,21 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| /> | /> | ||||
| </Stack> | </Stack> | ||||
| ) : detail.firstStockTakeQty != null ? ( | ) : detail.firstStockTakeQty != null ? ( | ||||
| <Typography variant="body2"> | |||||
| {t("First")}:{" "} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| <Stack spacing={0.5} alignItems="flex-start"> | |||||
| <Typography variant="body2"> | |||||
| {t("First")}:{" "} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber(detail.firstStockTakeQty ?? 0)} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| /> | |||||
| </Typography> | |||||
| <StockTakeQtyGapHint | |||||
| open | |||||
| entered={String(Math.trunc(Number(detail.firstStockTakeQty)))} | |||||
| currentQty={stockTakeHiddenOnHand(detail)} | |||||
| threshold={qtyGapWarnPercent} | |||||
| /> | /> | ||||
| </Typography> | |||||
| </Stack> | |||||
| ) : null} | ) : null} | ||||
| {/* Second */} | {/* Second */} | ||||
| @@ -853,13 +917,21 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| /> | /> | ||||
| </Stack> | </Stack> | ||||
| ) : detail.secondStockTakeQty != null ? ( | ) : detail.secondStockTakeQty != null ? ( | ||||
| <Typography variant="body2"> | |||||
| {t("Second")}:{" "} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| <Stack spacing={0.5} alignItems="flex-start"> | |||||
| <Typography variant="body2"> | |||||
| {t("Second")}:{" "} | |||||
| <StockTakeQtyWithUnit | |||||
| qty={formatNumber(detail.secondStockTakeQty ?? 0)} | |||||
| uomShortDesc={detail.uomShortDesc} | |||||
| /> | |||||
| </Typography> | |||||
| <StockTakeQtyGapHint | |||||
| open | |||||
| entered={String(Math.trunc(Number(detail.secondStockTakeQty)))} | |||||
| currentQty={stockTakeHiddenOnHand(detail)} | |||||
| threshold={qtyGapWarnPercent} | |||||
| /> | /> | ||||
| </Typography> | |||||
| </Stack> | |||||
| ) : null} | ) : null} | ||||
| </Stack> | </Stack> | ||||
| </TableCell> | </TableCell> | ||||
| @@ -869,13 +941,39 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||||
| <Button | <Button | ||||
| variant="contained" | variant="contained" | ||||
| sx={pickerTouchButtonSx} | sx={pickerTouchButtonSx} | ||||
| onClick={() => handleSaveStockTake(detail)} | |||||
| disabled={saving || submitDisabled} | |||||
| color={showChangeButton ? "warning" : "primary"} | |||||
| onClick={() => { | |||||
| if (changeUsedUp) return; | |||||
| if (revisingFirst && !editingFirst) { | |||||
| if ((detail.pickerReviseCount ?? 0) === 1) { | |||||
| onSnackbar(t("oneChangeLeft"), "warning"); | |||||
| } | |||||
| setEditingFirstIds((prev) => ({ ...prev, [detail.id]: true })); | |||||
| return; | |||||
| } | |||||
| handleSaveStockTake(detail); | |||||
| }} | |||||
| disabled={ | |||||
| saving || | |||||
| batchSaving || | |||||
| changeUsedUp || | |||||
| (submitDisabled && !revisingFirst) || | |||||
| (editingFirst && !firstQtyChanged) | |||||
| } | |||||
| > | > | ||||
| {t("Save")} | |||||
| {showChangeButton ? t("Change") : t("Save")} | |||||
| </Button> | </Button> | ||||
| </Stack> | </Stack> | ||||
| {oneChanceLeft && ( | |||||
| <Typography | |||||
| variant="caption" | |||||
| component="div" | |||||
| role="status" | |||||
| sx={{ color: "warning.dark", fontWeight: 700, mt: 0.5, maxWidth: 140, lineHeight: 1.35 }} | |||||
| > | |||||
| {t("oneChangeLeft")} | |||||
| </Typography> | |||||
| )} | |||||
| </TableCell> | </TableCell> | ||||
| {/* Remark */} | {/* Remark */} | ||||
| @@ -468,9 +468,15 @@ const StockTakeTab: React.FC = () => { | |||||
| <Snackbar | <Snackbar | ||||
| open={snackbar.open} | open={snackbar.open} | ||||
| autoHideDuration={6000} | autoHideDuration={6000} | ||||
| onClose={() => setSnackbar({ ...snackbar, open: false })} | |||||
| onClose={(_event, reason) => { | |||||
| if (reason === "clickaway") return; | |||||
| setSnackbar((prev) => ({ ...prev, open: false })); | |||||
| }} | |||||
| > | > | ||||
| <Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}> | |||||
| <Alert | |||||
| onClose={() => setSnackbar((prev) => ({ ...prev, open: false }))} | |||||
| severity={snackbar.severity} | |||||
| > | |||||
| {snackbar.message} | {snackbar.message} | ||||
| </Alert> | </Alert> | ||||
| </Snackbar> | </Snackbar> | ||||
| @@ -1,3 +1,4 @@ | |||||
| export const BASE_API_URL = `${process.env.API_URL}`; | |||||
| export const BASE_API_URL = `${process.env.API_URL || process.env.NEXT_PUBLIC_API_URL || ""}`; | |||||
| export const LOGIN_API_PATH = `${BASE_API_URL}/login`; | export const LOGIN_API_PATH = `${BASE_API_URL}/login`; | ||||
| export const NEXT_PUBLIC_API_URL = `${process.env.NEXT_PUBLIC_API_URL}`; | |||||
| export const REFRESH_TOKEN_API_PATH = `${BASE_API_URL}/refresh-token`; | |||||
| export const NEXT_PUBLIC_API_URL = `${process.env.NEXT_PUBLIC_API_URL || process.env.API_URL || ""}`; | |||||
| @@ -1,8 +1,80 @@ | |||||
| // config/authConfig.ts (or wherever your authOptions live) | // config/authConfig.ts (or wherever your authOptions live) | ||||
| import { AuthOptions } from "next-auth"; | import { AuthOptions } from "next-auth"; | ||||
| import CredentialsProvider from "next-auth/providers/credentials"; | import CredentialsProvider from "next-auth/providers/credentials"; | ||||
| import { LOGIN_API_PATH } from "./api"; | |||||
| import { LOGIN_API_PATH, REFRESH_TOKEN_API_PATH } from "./api"; | |||||
| import { Session } from "next-auth"; | import { Session } from "next-auth"; | ||||
| import { JWT } from "next-auth/jwt"; | |||||
| /** Refresh access token this many ms before backend JWT `exp`. */ | |||||
| const ACCESS_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000; | |||||
| /** After a failed refresh, wait this long before trying again (avoids spam on session polls). */ | |||||
| const REFRESH_FAILURE_COOLDOWN_MS = 60 * 1000; | |||||
| function backendAccessTokenExpiresAtMs(accessToken: string | null | undefined): number | null { | |||||
| if (!accessToken) return null; | |||||
| try { | |||||
| const parts = accessToken.split("."); | |||||
| if (parts.length < 2) return null; | |||||
| const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); | |||||
| const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="); | |||||
| const json = Buffer.from(padded, "base64").toString("utf8"); | |||||
| const payload = JSON.parse(json) as { exp?: number }; | |||||
| return typeof payload.exp === "number" ? payload.exp * 1000 : null; | |||||
| } catch { | |||||
| return null; | |||||
| } | |||||
| } | |||||
| async function refreshAccessToken(token: JWT): Promise<JWT> { | |||||
| const refreshToken = token.refreshToken; | |||||
| if (!refreshToken || !REFRESH_TOKEN_API_PATH || REFRESH_TOKEN_API_PATH === "undefined/refresh-token") { | |||||
| return { | |||||
| ...token, | |||||
| error: "RefreshAccessTokenError", | |||||
| refreshFailedAt: Date.now(), | |||||
| }; | |||||
| } | |||||
| try { | |||||
| const res = await fetch(REFRESH_TOKEN_API_PATH, { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify({ refreshToken }), | |||||
| }); | |||||
| if (!res.ok) { | |||||
| return { | |||||
| ...token, | |||||
| error: "RefreshAccessTokenError", | |||||
| refreshFailedAt: Date.now(), | |||||
| }; | |||||
| } | |||||
| const data = (await res.json()) as { | |||||
| accessToken?: string; | |||||
| refreshToken?: string; | |||||
| }; | |||||
| if (!data.accessToken) { | |||||
| return { | |||||
| ...token, | |||||
| error: "RefreshAccessTokenError", | |||||
| refreshFailedAt: Date.now(), | |||||
| }; | |||||
| } | |||||
| return { | |||||
| ...token, | |||||
| accessToken: data.accessToken, | |||||
| refreshToken: data.refreshToken ?? refreshToken, | |||||
| error: undefined, | |||||
| refreshFailedAt: undefined, | |||||
| }; | |||||
| } catch { | |||||
| return { | |||||
| ...token, | |||||
| error: "RefreshAccessTokenError", | |||||
| refreshFailedAt: Date.now(), | |||||
| }; | |||||
| } | |||||
| } | |||||
| // Extend the built-in types | // Extend the built-in types | ||||
| declare module "next-auth" { | declare module "next-auth" { | ||||
| interface Session { | interface Session { | ||||
| @@ -13,6 +85,7 @@ declare module "next-auth" { | |||||
| /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ | /** JWT expiry (seconds since epoch); used to avoid redirecting to dashboard when token is expired */ | ||||
| exp?: number; | exp?: number; | ||||
| locale?: string; | locale?: string; | ||||
| error?: string; | |||||
| } | } | ||||
| interface User { | interface User { | ||||
| @@ -31,6 +104,9 @@ declare module "next-auth/jwt" { | |||||
| refreshToken?: string; | refreshToken?: string; | ||||
| abilities: string[]; | abilities: string[]; | ||||
| locale?: string; | locale?: string; | ||||
| error?: string; | |||||
| /** Epoch ms of last failed refresh — used as cooldown to avoid hammering /refresh-token */ | |||||
| refreshFailedAt?: number; | |||||
| } | } | ||||
| } | } | ||||
| @@ -72,7 +148,7 @@ export const authOptions: AuthOptions = { | |||||
| signIn: "/login", | signIn: "/login", | ||||
| }, | }, | ||||
| callbacks: { | callbacks: { | ||||
| // Persist custom fields into the JWT token | |||||
| // Persist custom fields into the JWT token; refresh backend access JWT near expiry | |||||
| async jwt({ token, user, trigger, session }) { | async jwt({ token, user, trigger, session }) { | ||||
| // First sign-in: `user` is available | // First sign-in: `user` is available | ||||
| if (user) { | if (user) { | ||||
| @@ -80,21 +156,49 @@ export const authOptions: AuthOptions = { | |||||
| token.accessToken = user.accessToken; | token.accessToken = user.accessToken; | ||||
| token.refreshToken = user.refreshToken; | token.refreshToken = user.refreshToken; | ||||
| token.abilities = user.abilities ?? []; | token.abilities = user.abilities ?? []; | ||||
| token.error = undefined; | |||||
| token.refreshFailedAt = undefined; | |||||
| const loginLocale = (user as { locale?: string }).locale; | const loginLocale = (user as { locale?: string }).locale; | ||||
| if (loginLocale) { | if (loginLocale) { | ||||
| token.locale = loginLocale; | token.locale = loginLocale; | ||||
| } | } | ||||
| return token; | |||||
| } | } | ||||
| if (trigger === "update" && session && typeof session === "object" && "locale" in session) { | |||||
| const next = (session as { locale?: string }).locale; | |||||
| if (next === "zh" || next === "en") { | |||||
| token.locale = next; | |||||
| if (trigger === "update" && session && typeof session === "object") { | |||||
| if ("locale" in session) { | |||||
| const next = (session as { locale?: string }).locale; | |||||
| if (next === "zh" || next === "en") { | |||||
| token.locale = next; | |||||
| } | |||||
| } | |||||
| if ("accessToken" in session && typeof (session as { accessToken?: string }).accessToken === "string") { | |||||
| token.accessToken = (session as { accessToken: string }).accessToken; | |||||
| } | |||||
| if ("refreshToken" in session && typeof (session as { refreshToken?: string }).refreshToken === "string") { | |||||
| token.refreshToken = (session as { refreshToken: string }).refreshToken; | |||||
| } | } | ||||
| } | } | ||||
| // On subsequent calls (token refresh, session access), user is not present | |||||
| // so we just return the existing token with custom fields preserved | |||||
| const expiresAt = backendAccessTokenExpiresAtMs(token.accessToken); | |||||
| // Can't parse exp → leave as-is (avoid refresh thrash). Still-valid → keep. | |||||
| if (expiresAt === null || Date.now() < expiresAt - ACCESS_TOKEN_REFRESH_SKEW_MS) { | |||||
| return token; | |||||
| } | |||||
| // Cooldown after a failed refresh so session polls don't hammer /refresh-token | |||||
| if ( | |||||
| token.refreshFailedAt && | |||||
| Date.now() - token.refreshFailedAt < REFRESH_FAILURE_COOLDOWN_MS | |||||
| ) { | |||||
| return token; | |||||
| } | |||||
| // Access token expired or near expiry — refresh using refresh token | |||||
| if (token.refreshToken) { | |||||
| return refreshAccessToken(token); | |||||
| } | |||||
| return token; | return token; | ||||
| }, | }, | ||||
| @@ -106,6 +210,7 @@ export const authOptions: AuthOptions = { | |||||
| session.abilities = token.abilities as string[]; | session.abilities = token.abilities as string[]; | ||||
| session.exp = token.exp as number | undefined; | session.exp = token.exp as number | undefined; | ||||
| session.locale = token.locale as string | undefined; | session.locale = token.locale as string | undefined; | ||||
| session.error = token.error as string | undefined; | |||||
| // Also add abilities to session.user for easier client-side access | // Also add abilities to session.user for easier client-side access | ||||
| if (session.user) { | if (session.user) { | ||||
| @@ -83,6 +83,8 @@ | |||||
| "Save": "Save", | "Save": "Save", | ||||
| "Saving": "Saving", | "Saving": "Saving", | ||||
| "Search": "Search", | "Search": "Search", | ||||
| "More criteria": "More criteria", | |||||
| "Fewer criteria": "Fewer criteria", | |||||
| "Search Criteria": "Search Criteria", | "Search Criteria": "Search Criteria", | ||||
| "Select Date": "Select Date", | "Select Date": "Select Date", | ||||
| "Session expired or unauthorized.": "Session expired or unauthorized.", | "Session expired or unauthorized.": "Session expired or unauthorized.", | ||||
| @@ -145,6 +145,7 @@ | |||||
| "email supplier": "Email Supplier", | "email supplier": "Email Supplier", | ||||
| "confirm putaway": "Confirm & Complete Put Away", | "confirm putaway": "Confirm & Complete Put Away", | ||||
| "confirm qc result": "Confirm QC Result", | "confirm qc result": "Confirm QC Result", | ||||
| "Mark all QC OK and accept": "Mark all QC OK and accept", | |||||
| "warehouse": "Warehouse", | "warehouse": "Warehouse", | ||||
| "qcItem": "QC Item", | "qcItem": "QC Item", | ||||
| "passed": "Passed", | "passed": "Passed", | ||||
| @@ -25,6 +25,12 @@ | |||||
| "cancel": "Cancel", | "cancel": "Cancel", | ||||
| "confirmDownloadPdf": "Confirm download PDF", | "confirmDownloadPdf": "Confirm download PDF", | ||||
| "confirmDownloadExcel": "Confirm download Excel", | "confirmDownloadExcel": "Confirm download Excel", | ||||
| "semiFgDownloadDailyExcel": "Download daily columns (Excel)", | |||||
| "semiFgGeneratingDailyExcel": "Generating daily Excel...", | |||||
| "semiFgConfirmDailyExcel": "Confirm download daily Excel", | |||||
| "semiFgDailyRangeRequired": "Enter the production date from and to. The daily Excel only includes that range.", | |||||
| "semiFgDailyRangeInvalid": "The start date cannot be after the end date.", | |||||
| "semiFgDailyRangeTooLong": "The date range cannot be longer than 366 days.", | |||||
| "semiFgConfirmTitle": "Selected item codes — FG / Semi-FG Production Analysis Report", | "semiFgConfirmTitle": "Selected item codes — FG / Semi-FG Production Analysis Report", | ||||
| "semiFgConfirmHint": "Please confirm the selected item codes and their categories:", | "semiFgConfirmHint": "Please confirm the selected item codes and their categories:", | ||||
| "semiFgColItem": "Item code and name", | "semiFgColItem": "Item code and name", | ||||
| @@ -124,6 +124,8 @@ | |||||
| "Return": "Return", | "Return": "Return", | ||||
| "Rows per page": "Rows per page", | "Rows per page": "Rows per page", | ||||
| "Save": "Save", | "Save": "Save", | ||||
| "Change": "Change", | |||||
| "oneChangeLeft": "Only 1 change left", | |||||
| "Search": "Search", | "Search": "Search", | ||||
| "Search Criteria": "Search Criteria", | "Search Criteria": "Search Criteria", | ||||
| "Search by location": "Search by location", | "Search by location": "Search by location", | ||||
| @@ -86,6 +86,8 @@ | |||||
| "Save": "儲存", | "Save": "儲存", | ||||
| "Saving": "儲存中", | "Saving": "儲存中", | ||||
| "Search": "搜索", | "Search": "搜索", | ||||
| "More criteria": "更多條件", | |||||
| "Fewer criteria": "收起條件", | |||||
| "Search Criteria": "搜索條件", | "Search Criteria": "搜索條件", | ||||
| "Select Date": "選擇日期", | "Select Date": "選擇日期", | ||||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | "Session expired or unauthorized.": "工作階段已過期或未經授權。", | ||||
| @@ -145,6 +145,7 @@ | |||||
| "email supplier": "電郵供應商", | "email supplier": "電郵供應商", | ||||
| "confirm putaway": "確定及完成上架", | "confirm putaway": "確定及完成上架", | ||||
| "confirm qc result": "確定品檢結果", | "confirm qc result": "確定品檢結果", | ||||
| "Mark all QC OK and accept": "全部合格並接受", | |||||
| "warehouse": "倉庫", | "warehouse": "倉庫", | ||||
| "qcItem": "品檢項目", | "qcItem": "品檢項目", | ||||
| "passed": "接受", | "passed": "接受", | ||||
| @@ -25,6 +25,12 @@ | |||||
| "cancel": "取消", | "cancel": "取消", | ||||
| "confirmDownloadPdf": "確認下載 PDF", | "confirmDownloadPdf": "確認下載 PDF", | ||||
| "confirmDownloadExcel": "確認下載 Excel", | "confirmDownloadExcel": "確認下載 Excel", | ||||
| "semiFgDownloadDailyExcel": "下載每日欄位 Excel", | |||||
| "semiFgGeneratingDailyExcel": "生成每日 Excel...", | |||||
| "semiFgConfirmDailyExcel": "確認下載每日 Excel", | |||||
| "semiFgDailyRangeRequired": "請先輸入完成生產日期(由、至),每日欄位 Excel 只會輸出這段日期。", | |||||
| "semiFgDailyRangeInvalid": "完成生產日期「由」不可晚於「至」。", | |||||
| "semiFgDailyRangeTooLong": "日期範圍不可超過 366 日。", | |||||
| "semiFgConfirmTitle": "已選擇的物料編號以及列印成品/半成品生產分析報告", | "semiFgConfirmTitle": "已選擇的物料編號以及列印成品/半成品生產分析報告", | ||||
| "semiFgConfirmHint": "請確認以下已選擇的物料編號及其類別:", | "semiFgConfirmHint": "請確認以下已選擇的物料編號及其類別:", | ||||
| "semiFgColItem": "物料編號及名稱", | "semiFgColItem": "物料編號及名稱", | ||||
| @@ -25,6 +25,8 @@ | |||||
| "Remark": "備註", | "Remark": "備註", | ||||
| "Action": "操作", | "Action": "操作", | ||||
| "Save": "保存", | "Save": "保存", | ||||
| "Change": "更改", | |||||
| "oneChangeLeft": "只剩 1 次更改機會", | |||||
| "Selected Qty": "選擇數量", | "Selected Qty": "選擇數量", | ||||
| "Not Match": "要求重點", | "Not Match": "要求重點", | ||||
| "Search Criteria": "搜索條件", | "Search Criteria": "搜索條件", | ||||