Handle user put DO (m18 PO) sync in m18 sync page PO tab; make the fg/semi fg report count by date rather than by month, created a button to make qc accept faster, added item code/name in search criteria of PO, make 2 new version of PO Item label printout (can set 1 to print the old version in setting)production
| @@ -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); | ||||
| }, | }, | ||||
| @@ -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"> | ||||
| @@ -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> | ||||
| @@ -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(); | |||||
| } | |||||
| @@ -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; | ||||
| } | } | ||||
| } | } | ||||
| @@ -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> | ||||
| @@ -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 || "/" }); | ||||
| }); | }); | ||||
| @@ -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" | ||||
| @@ -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%", | ||||
| @@ -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(() => { | ||||
| @@ -843,13 +877,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> | ||||
| @@ -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", | ||||
| @@ -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": "物料編號及名稱", | ||||