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"; | |||
| import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; | |||
| import React, { | |||
| createContext, | |||
| useContext, | |||
| useEffect, | |||
| useState, | |||
| useCallback, | |||
| useRef, | |||
| } from "react"; | |||
| import { getSession } from "next-auth/react"; | |||
| import { SessionWithTokens } from "@/config/authConfig"; | |||
| import { | |||
| ACCESS_TOKEN_KEY, | |||
| ACCESS_TOKEN_REFRESH_SKEW_SECONDS, | |||
| clearBackendTokens, | |||
| ensureFreshAccessToken, | |||
| getBackendJwtSecondsRemaining, | |||
| isBackendJwtExpired, | |||
| LOGIN_SESSION_EXPIRED_HREF, | |||
| persistBackendTokens, | |||
| readStoredRefreshToken, | |||
| } from "@/app/utils/authToken"; | |||
| import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance"; | |||
| @@ -19,14 +33,48 @@ const TokenContext = createContext<{ | |||
| setAccessToken: () => {}, | |||
| }); | |||
| export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { | |||
| /** | |||
| * Merge next-auth tokens into localStorage without clobbering a newer | |||
| * localStorage access token (e.g. after a client-side refresh). | |||
| */ | |||
| function syncSessionTokensToStorage(session: SessionWithTokens | null): void { | |||
| if (!session) return; | |||
| let localAccess = ""; | |||
| try { | |||
| localAccess = localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ""; | |||
| } catch { | |||
| localAccess = ""; | |||
| } | |||
| const localRefresh = readStoredRefreshToken(); | |||
| const sessionAccess = session.accessToken?.trim() || ""; | |||
| const sessionRefresh = session.refreshToken?.trim() || ""; | |||
| // local missing, or local expired while session already has a fresher JWT | |||
| if ( | |||
| sessionAccess && | |||
| (!localAccess || isBackendJwtExpired(localAccess)) | |||
| ) { | |||
| persistBackendTokens(sessionAccess, sessionRefresh || localRefresh); | |||
| return; | |||
| } | |||
| // Keep existing access token; only backfill missing refresh token | |||
| if (localAccess && sessionRefresh && !localRefresh) { | |||
| persistBackendTokens(localAccess, sessionRefresh); | |||
| } | |||
| } | |||
| export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ | |||
| children, | |||
| }) => { | |||
| const [accessToken, setAccessToken] = useState<string | null>(null); | |||
| const [isHydrated, setIsHydrated] = useState(false); | |||
| const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |||
| // Hydrate token only on client | |||
| useEffect(() => { | |||
| try { | |||
| const token = localStorage.getItem("accessToken"); | |||
| const token = localStorage.getItem(ACCESS_TOKEN_KEY); | |||
| if (token) setAccessToken(token); | |||
| } catch (e) { | |||
| console.warn("localStorage unavailable", e); | |||
| @@ -35,10 +83,34 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre | |||
| } | |||
| }, []); | |||
| const scheduleProactiveRefresh = useCallback((token: string) => { | |||
| if (refreshTimerRef.current) { | |||
| clearTimeout(refreshTimerRef.current); | |||
| refreshTimerRef.current = null; | |||
| } | |||
| const remaining = getBackendJwtSecondsRemaining(token); | |||
| if (remaining === null) return; | |||
| // Refresh a few minutes before expiry (clamp delay to a safe range for setTimeout) | |||
| const delayMs = Math.min( | |||
| Math.max(30_000, (remaining - ACCESS_TOKEN_REFRESH_SKEW_SECONDS) * 1000), | |||
| 2_147_483_647 | |||
| ); | |||
| refreshTimerRef.current = setTimeout(async () => { | |||
| const session = (await getSession()) as SessionWithTokens | null; | |||
| const next = await ensureFreshAccessToken( | |||
| readStoredRefreshToken() || session?.refreshToken | |||
| ); | |||
| if (next) { | |||
| setAccessToken(next); | |||
| scheduleProactiveRefresh(next); | |||
| } | |||
| }, delayMs); | |||
| }, []); | |||
| /** | |||
| * Detect expired/missing backend JWT before user actions (e.g. /report search). | |||
| * Sync accessToken from next-auth session into localStorage if missing, then | |||
| * redirect to login when the Bearer token is absent or past `exp`. | |||
| * Keep backend JWT fresh. Only force login redirect when we have neither a | |||
| * usable access token nor a successful refresh (avoid false logout on blips). | |||
| */ | |||
| useEffect(() => { | |||
| if (!isHydrated || typeof window === "undefined") return; | |||
| @@ -46,37 +118,47 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre | |||
| let cancelled = false; | |||
| (async () => { | |||
| try { | |||
| let token = localStorage.getItem("accessToken")?.trim() ?? ""; | |||
| const session = (await getSession()) as SessionWithTokens | null; | |||
| if (cancelled) return; | |||
| if (!token) { | |||
| const session = (await getSession()) as SessionWithTokens | null; | |||
| if (cancelled) return; | |||
| if (session?.accessToken) { | |||
| token = session.accessToken; | |||
| localStorage.setItem("accessToken", token); | |||
| setAccessToken(token); | |||
| } | |||
| } | |||
| syncSessionTokensToStorage(session); | |||
| const token = await ensureFreshAccessToken( | |||
| readStoredRefreshToken() || session?.refreshToken | |||
| ); | |||
| if (cancelled) return; | |||
| if (!token) { | |||
| // Soft-fail if next-auth still has a session — avoid kicking users on | |||
| // transient refresh failures when an access token simply isn't readable yet. | |||
| if (session?.accessToken && !session.error) { | |||
| setAccessToken(session.accessToken); | |||
| persistBackendTokens(session.accessToken, session.refreshToken); | |||
| scheduleProactiveRefresh(session.accessToken); | |||
| return; | |||
| } | |||
| clearBackendTokens(); | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| return; | |||
| } | |||
| if (isBackendJwtExpired(token)) { | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| } | |||
| setAccessToken(token); | |||
| scheduleProactiveRefresh(token); | |||
| } catch (e) { | |||
| // Do not redirect on unexpected errors (network / getSession failures) | |||
| console.warn("Auth token check failed", e); | |||
| } | |||
| })(); | |||
| return () => { | |||
| cancelled = true; | |||
| if (refreshTimerRef.current) { | |||
| clearTimeout(refreshTimerRef.current); | |||
| refreshTimerRef.current = null; | |||
| } | |||
| }; | |||
| }, [isHydrated]); | |||
| }, [isHydrated, scheduleProactiveRefresh]); | |||
| // Apply token + interceptors | |||
| useEffect(() => { | |||
| if (accessToken) { | |||
| axiosInstance.defaults.headers.Authorization = `Bearer ${accessToken}`; | |||
| @@ -86,24 +168,26 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre | |||
| } | |||
| }, [accessToken]); | |||
| const handleSetAccessToken = useCallback((token: string | null) => { | |||
| setAccessToken(token); | |||
| try { | |||
| if (token) { | |||
| localStorage.setItem("accessToken", token); | |||
| } else { | |||
| localStorage.removeItem("accessToken"); | |||
| const handleSetAccessToken = useCallback( | |||
| (token: string | null) => { | |||
| setAccessToken(token); | |||
| try { | |||
| if (token) { | |||
| localStorage.setItem(ACCESS_TOKEN_KEY, token); | |||
| scheduleProactiveRefresh(token); | |||
| } else { | |||
| clearBackendTokens(); | |||
| } | |||
| } catch { | |||
| // ignore (e.g. private mode) | |||
| } | |||
| } catch (e) { | |||
| // ignore (e.g. private mode) | |||
| } | |||
| }, []); | |||
| }, | |||
| [scheduleProactiveRefresh] | |||
| ); | |||
| // Critical fix: never return null → always return children wrapped in fragment | |||
| return ( | |||
| <AxiosContext.Provider value={axiosInstance}> | |||
| <TokenContext.Provider value={{ setAccessToken: handleSetAccessToken }}> | |||
| {/* Render children immediately – they will just not have the token for 1-2ms */} | |||
| {children} | |||
| </TokenContext.Provider> | |||
| </AxiosContext.Provider> | |||
| @@ -111,4 +195,4 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre | |||
| }; | |||
| 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({ | |||
| baseURL: process.env.API_URL, | |||
| baseURL: process.env.API_URL || process.env.NEXT_PUBLIC_API_URL, | |||
| }); | |||
| // Clear existing interceptors to prevent multiple registrations | |||
| const clearInterceptors = () => { | |||
| const requestInterceptor = axiosInstance.interceptors.request.use(); | |||
| const responseInterceptor = axiosInstance.interceptors.response.use(); | |||
| type RetriableConfig = InternalAxiosRequestConfig & { _retry?: boolean }; | |||
| if (requestInterceptor) { | |||
| axiosInstance.interceptors.request.eject(requestInterceptor); | |||
| } | |||
| let requestInterceptorId: number | null = null; | |||
| let responseInterceptorId: number | null = null; | |||
| if (responseInterceptor) { | |||
| axiosInstance.interceptors.response.eject(responseInterceptor); | |||
| const clearInterceptors = () => { | |||
| if (requestInterceptorId !== null) { | |||
| axiosInstance.interceptors.request.eject(requestInterceptorId); | |||
| requestInterceptorId = null; | |||
| } | |||
| if (responseInterceptorId !== null) { | |||
| axiosInstance.interceptors.response.eject(responseInterceptorId); | |||
| responseInterceptorId = null; | |||
| } | |||
| }; | |||
| export const SetupAxiosInterceptors = (inputToken: string | null) => { | |||
| console.log("[debug] set up interceptors", inputToken); | |||
| // Clear existing interceptors | |||
| /** | |||
| * Always prefer localStorage accessToken (may have been refreshed) over any | |||
| * closed-over token passed into SetupAxiosInterceptors. | |||
| */ | |||
| export const SetupAxiosInterceptors = (_inputToken: string | null) => { | |||
| clearInterceptors(); | |||
| // Request interceptor | |||
| axiosInstance.interceptors.request.use( | |||
| requestInterceptorId = axiosInstance.interceptors.request.use( | |||
| (config) => { | |||
| // Use the token passed in or retrieve from localStorage | |||
| const token = inputToken ?? localStorage.getItem("accessToken"); | |||
| if (token) { | |||
| config.headers.Authorization = `Bearer ${token}`; | |||
| try { | |||
| const token = | |||
| (typeof window !== "undefined" | |||
| ? localStorage.getItem("accessToken") | |||
| : null) ?? _inputToken; | |||
| if (token) { | |||
| config.headers.Authorization = `Bearer ${token}`; | |||
| } | |||
| } catch { | |||
| if (_inputToken) { | |||
| config.headers.Authorization = `Bearer ${_inputToken}`; | |||
| } | |||
| } | |||
| return config; | |||
| }, | |||
| (error) => { | |||
| return Promise.reject(error); | |||
| }, | |||
| (error) => Promise.reject(error), | |||
| ); | |||
| console.log("[debug] set up interceptors2", inputToken); | |||
| responseInterceptorId = axiosInstance.interceptors.response.use( | |||
| (response) => response, | |||
| async (error: AxiosError) => { | |||
| const status = error.response?.status; | |||
| const original = error.config as RetriableConfig | undefined; | |||
| // Only 401 = unauthenticated. Do not treat 403 (forbidden) as session expiry. | |||
| if (status === 401 && original && !original._retry && typeof window !== "undefined") { | |||
| original._retry = true; | |||
| const refreshed = await refreshBackendAccessToken(readStoredRefreshToken()); | |||
| if (refreshed) { | |||
| original.headers = original.headers ?? {}; | |||
| original.headers.Authorization = `Bearer ${refreshed}`; | |||
| axiosInstance.defaults.headers.Authorization = `Bearer ${refreshed}`; | |||
| return axiosInstance(original); | |||
| } | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| } | |||
| // Response interceptor | |||
| axiosInstance.interceptors.response.use( | |||
| (response) => { | |||
| return response; | |||
| }, | |||
| (error) => { | |||
| console.error("API Error:", error.response?.data || error); | |||
| return Promise.reject(error); | |||
| }, | |||
| @@ -39,6 +39,7 @@ export default function M18SynPage() { | |||
| type ParsedSyncResult = | |||
| | { kind: "exists"; message: string } | |||
| | { kind: "skipped_not_pending"; message: string } | |||
| | { kind: "not_found"; message: string } | |||
| | { kind: "success"; message: string; totalSuccess: number } | |||
| | { kind: "fail"; message: string; totalFail: number } | |||
| @@ -60,15 +61,45 @@ export default function M18SynPage() { | |||
| return { kind: "exists", message: `${docName}已存在系統` }; | |||
| } | |||
| // "not found in M18" (sync-by-code returns processed=1, fail=1) | |||
| // Local PO/DO exists but status is not pending — sync intentionally skipped | |||
| if (query.includes("skipped (not pending")) { | |||
| const statusMatch = query.match(/status=([^\s;|]+)/); | |||
| const statusRaw = (statusMatch?.[1] ?? "").toLowerCase(); | |||
| const statusLabel = (() => { | |||
| if (statusRaw === "receiving") { | |||
| // DO uses receiving = 已放單; PO uses receiving = 收貨中 | |||
| return docName === "送貨訂單" ? "已放單" : "收貨中"; | |||
| } | |||
| if (statusRaw === "completed") return "已完成"; | |||
| if (statusRaw === "pending") return "待處理"; | |||
| return statusRaw || "非待處理"; | |||
| })(); | |||
| return { | |||
| kind: "skipped_not_pending", | |||
| message: `無法同步:此${docName}目前為「${statusLabel}」,不能再從 M18 覆寫(僅「待處理」可同步)。`, | |||
| }; | |||
| } | |||
| // "not found in M18" (sync-by-code returns processed=1, fail=1) — may be wrong tab (PO vs DO) | |||
| if ( | |||
| docName === "送貨訂單" && | |||
| totalProcessed === 1 && | |||
| totalSuccess === 0 && | |||
| totalFail === 1 && | |||
| query.includes("code=equal=") | |||
| ) { | |||
| return { kind: "not_found", message: `在M18找不到${docName}` }; | |||
| if (docName === "採購單") { | |||
| return { | |||
| kind: "not_found", | |||
| message: "在M18找不到採購單", | |||
| }; | |||
| } | |||
| if (docName === "送貨訂單") { | |||
| return { | |||
| kind: "not_found", | |||
| message: | |||
| "在M18找不到送貨訂單。若此單為物料採購單,請改到「採購單」分頁同步。", | |||
| }; | |||
| } | |||
| } | |||
| if (totalSuccess > 0 && totalFail === 0) { | |||
| @@ -108,7 +139,8 @@ export default function M18SynPage() { | |||
| const handleSyncM18PoByCode = async () => { | |||
| if (m18PoInFlightRef.current) return; | |||
| if (!m18PoCode.trim()) { | |||
| const code = m18PoCode.trim(); | |||
| if (!code) { | |||
| alert("Please enter PO code."); | |||
| return; | |||
| } | |||
| @@ -116,15 +148,58 @@ export default function M18SynPage() { | |||
| setIsSyncingM18Po(true); | |||
| setM18PoSyncResult(""); | |||
| try { | |||
| const response = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`, | |||
| const poResponse = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(code)}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (response.status === 401 || response.status === 403) return; | |||
| const text = await response.text(); | |||
| setM18PoSyncResult(formatSyncResultText("採購單", text)); | |||
| if (!response.ok) { | |||
| alert(`Sync failed: ${response.status}`); | |||
| if (poResponse.status === 401 || poResponse.status === 403) return; | |||
| const poText = await poResponse.text(); | |||
| const poParsed = parseSyncResult("採購單", poText); | |||
| if (poParsed.kind !== "not_found") { | |||
| setM18PoSyncResult(poParsed.message); | |||
| if (!poResponse.ok) { | |||
| alert(`Sync failed: ${poResponse.status}`); | |||
| } | |||
| return; | |||
| } | |||
| // PO not found under material-PO scope — auto-try DO (shop PO) with same code | |||
| setM18PoSyncResult("採購單找不到,改嘗試同步送貨訂單…"); | |||
| const doResponse = await clientAuthFetch( | |||
| `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(code)}`, | |||
| { method: "GET" }, | |||
| ); | |||
| if (doResponse.status === 401 || doResponse.status === 403) return; | |||
| const doText = await doResponse.text(); | |||
| const doParsed = parseSyncResult("送貨訂單", doText); | |||
| if (doParsed.kind === "success") { | |||
| setM18PoSyncResult( | |||
| [ | |||
| `採購單找不到(code=${code})`, | |||
| `已自動改以送貨訂單同步:成功`, | |||
| ].join("\n"), | |||
| ); | |||
| } else if (doParsed.kind === "not_found") { | |||
| setM18PoSyncResult( | |||
| [ | |||
| `採購單找不到(code=${code})`, | |||
| `已自動改試送貨訂單:亦找不到`, | |||
| "請確認單號是否正確。", | |||
| ].join("\n"), | |||
| ); | |||
| } else { | |||
| setM18PoSyncResult( | |||
| [ | |||
| `採購單找不到(code=${code})`, | |||
| `已自動改試送貨訂單:${doParsed.message}`, | |||
| ].join("\n"), | |||
| ); | |||
| } | |||
| if (!doResponse.ok) { | |||
| alert(`DO fallback sync failed: ${doResponse.status}`); | |||
| } | |||
| } catch (e) { | |||
| console.error("M18 PO Sync By Code Error:", e); | |||
| @@ -158,6 +233,7 @@ export default function M18SynPage() { | |||
| const outputs: string[] = []; | |||
| const okCodes: string[] = []; | |||
| const notFoundCodes: string[] = []; | |||
| const skippedReleasedCodes: string[] = []; | |||
| const otherFailCodes: string[] = []; | |||
| for (const code of codes) { | |||
| const response = await clientAuthFetch( | |||
| @@ -169,6 +245,7 @@ export default function M18SynPage() { | |||
| const parsed = parseSyncResult("送貨訂單", text || ""); | |||
| if (parsed.kind === "success") okCodes.push(code); | |||
| else if (parsed.kind === "not_found") notFoundCodes.push(code); | |||
| else if (parsed.kind === "skipped_not_pending") skippedReleasedCodes.push(code); | |||
| else otherFailCodes.push(code); | |||
| const perCodeMsg = | |||
| @@ -180,7 +257,13 @@ export default function M18SynPage() { | |||
| outputs.push(""); | |||
| outputs.push(`共${okCodes.length}張送貨訂單成功`); | |||
| if (notFoundCodes.length > 0) outputs.push(`共${notFoundCodes.length}張在M18找不到訂單 (${notFoundCodes.join(", ")})`); | |||
| if (notFoundCodes.length > 0) { | |||
| outputs.push(`共${notFoundCodes.length}張在M18找不到送貨訂單 (${notFoundCodes.join(", ")})`); | |||
| outputs.push("提示:若為物料採購單,請改到「採購單」分頁同步。"); | |||
| } | |||
| if (skippedReleasedCodes.length > 0) { | |||
| outputs.push(`共${skippedReleasedCodes.length}張已放單/已完成,無法再同步 (${skippedReleasedCodes.join(", ")})`); | |||
| } | |||
| if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`); | |||
| setM18DoSyncResult(outputs.join("\n")); | |||
| @@ -217,6 +300,7 @@ export default function M18SynPage() { | |||
| const okCodes: string[] = []; | |||
| const existsCodes: string[] = []; | |||
| const notFoundCodes: string[] = []; | |||
| const skippedReleasedCodes: string[] = []; | |||
| const otherFailCodes: string[] = []; | |||
| for (const code of codes) { | |||
| const response = await clientAuthFetch( | |||
| @@ -229,6 +313,7 @@ export default function M18SynPage() { | |||
| if (parsed.kind === "success") okCodes.push(code); | |||
| else if (parsed.kind === "exists") existsCodes.push(code); | |||
| else if (parsed.kind === "not_found") notFoundCodes.push(code); | |||
| else if (parsed.kind === "skipped_not_pending") skippedReleasedCodes.push(code); | |||
| else otherFailCodes.push(code); | |||
| const perCodeMsg = | |||
| @@ -242,8 +327,14 @@ export default function M18SynPage() { | |||
| // Summary | |||
| outputs.push(""); | |||
| outputs.push(`共${okCodes.length}張送貨訂單成功`); | |||
| if (notFoundCodes.length > 0) outputs.push(`共${notFoundCodes.length}張在M18找不到訂單 (${notFoundCodes.join(", ")})`); | |||
| if (notFoundCodes.length > 0) { | |||
| outputs.push(`共${notFoundCodes.length}張在M18找不到送貨訂單 (${notFoundCodes.join(", ")})`); | |||
| outputs.push("提示:若為物料採購單,請改到「採購單」分頁同步。"); | |||
| } | |||
| if (existsCodes.length > 0) outputs.push(`共${existsCodes.length}張訂單已存在系統 (${existsCodes.join(", ")})`); | |||
| if (skippedReleasedCodes.length > 0) { | |||
| outputs.push(`共${skippedReleasedCodes.length}張已放單/已完成,無法再同步 (${skippedReleasedCodes.join(", ")})`); | |||
| } | |||
| if (otherFailCodes.length > 0) outputs.push(`共${otherFailCodes.length}張同步失敗 (${otherFailCodes.join(", ")})`); | |||
| setM18DoExtraSyncResult(outputs.join("\n")); | |||
| @@ -301,6 +392,7 @@ export default function M18SynPage() { | |||
| </Typography> | |||
| <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. | |||
| 「採購單」找不到時會自動改試「送貨訂單」;「送貨訂單」找不到時請改到「採購單」分頁。 | |||
| </Typography> | |||
| <Tabs value={tabValue} onChange={(_, v) => setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> | |||
| @@ -1,6 +1,6 @@ | |||
| "use client"; | |||
| import React, { useState, useEffect } from 'react'; | |||
| import React, { useEffect, useRef, useState } from 'react'; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { | |||
| Dialog, | |||
| @@ -19,15 +19,18 @@ import { | |||
| Typography, | |||
| } from '@mui/material'; | |||
| import DownloadIcon from '@mui/icons-material/Download'; | |||
| import dayjs from 'dayjs'; | |||
| import { | |||
| fetchSemiFGItemCodes, | |||
| fetchSemiFGItemCodesWithCategory, | |||
| generateSemiFGProductionAnalysisReport, | |||
| generateSemiFGProductionAnalysisReportExcel, | |||
| generateSemiFGProductionAnalysisReportExcelDaily, | |||
| ItemCodeWithCategory, | |||
| } from './semiFGProductionAnalysisApi'; | |||
| import { parseItemCodeTokens } from './parseItemCodeTokens'; | |||
| type ExportFormat = 'pdf' | 'excel' | 'excel-daily'; | |||
| interface SemiFGProductionAnalysisReportProps { | |||
| criteria: Record<string, string>; | |||
| prepareCriteria?: () => Record<string, string>; | |||
| @@ -51,7 +54,8 @@ export default function SemiFGProductionAnalysisReport({ | |||
| const [showConfirmDialog, setShowConfirmDialog] = useState(false); | |||
| const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState<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 | |||
| useEffect(() => { | |||
| @@ -71,14 +75,33 @@ export default function SemiFGProductionAnalysisReport({ | |||
| } | |||
| }, [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; | |||
| setExportFormat(format); | |||
| // Validate required fields | |||
| if (requiredFieldLabels.length > 0) { | |||
| alert(t('missingRequired', { fields: requiredFieldLabels.join('\n- ') })); | |||
| return; | |||
| } | |||
| if (format === 'excel-daily') { | |||
| const rangeError = dailyRangeError(activeCriteria); | |||
| if (rangeError) { | |||
| alert(rangeError); | |||
| return; | |||
| } | |||
| } | |||
| const selectedCodes = parseItemCodeTokens( | |||
| [activeCriteria.itemCode, activeCriteria.itemCodePaste].filter(Boolean).join(' '), | |||
| @@ -101,26 +124,46 @@ export default function SemiFGProductionAnalysisReport({ | |||
| }; | |||
| const executeExport = async ( | |||
| format: 'pdf' | 'excel' = exportFormat, | |||
| format: ExportFormat = exportFormat, | |||
| activeCriteria: Record<string, string> = prepareCriteria?.() ?? criteria, | |||
| ) => { | |||
| if (inFlightRef.current) return; | |||
| inFlightRef.current = true; | |||
| setLoading(true); | |||
| try { | |||
| if (format === 'excel') { | |||
| if (format === 'excel-daily') { | |||
| await generateSemiFGProductionAnalysisReportExcelDaily(activeCriteria, reportTitle); | |||
| onExportSuccess?.('excel'); | |||
| } else if (format === 'excel') { | |||
| await generateSemiFGProductionAnalysisReportExcel(activeCriteria, reportTitle); | |||
| onExportSuccess?.('excel'); | |||
| } else { | |||
| await generateSemiFGProductionAnalysisReport(activeCriteria, reportTitle); | |||
| onExportSuccess?.('pdf'); | |||
| } | |||
| onExportSuccess?.(format); | |||
| setShowConfirmDialog(false); | |||
| } catch (error) { | |||
| console.error('Failed to generate report:', error); | |||
| alert(t('generateError')); | |||
| const message = error instanceof Error ? error.message.trim() : ''; | |||
| alert(message && !message.startsWith('HTTP error') ? message : t('generateError')); | |||
| } finally { | |||
| setLoading(false); | |||
| inFlightRef.current = false; | |||
| } | |||
| }; | |||
| const confirmLabel = loading | |||
| ? exportFormat === 'excel-daily' | |||
| ? t('semiFgGeneratingDailyExcel') | |||
| : exportFormat === 'excel' | |||
| ? t('generatingExcel') | |||
| : t('generatingPdf') | |||
| : exportFormat === 'excel-daily' | |||
| ? t('semiFgConfirmDailyExcel') | |||
| : exportFormat === 'excel' | |||
| ? t('confirmDownloadExcel') | |||
| : t('confirmDownloadPdf'); | |||
| return ( | |||
| <> | |||
| <div style={{ display: 'flex', gap: 16 }}> | |||
| @@ -132,7 +175,7 @@ export default function SemiFGProductionAnalysisReport({ | |||
| disabled={loading} | |||
| sx={{ px: 4 }} | |||
| > | |||
| {loading ? t('generatingPdf') : t('downloadPdf')} | |||
| {loading && exportFormat === 'pdf' ? t('generatingPdf') : t('downloadPdf')} | |||
| </Button> | |||
| <Button | |||
| variant="outlined" | |||
| @@ -142,7 +185,20 @@ export default function SemiFGProductionAnalysisReport({ | |||
| disabled={loading} | |||
| 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> | |||
| </div> | |||
| @@ -202,13 +258,7 @@ export default function SemiFGProductionAnalysisReport({ | |||
| disabled={loading} | |||
| startIcon={<DownloadIcon />} | |||
| > | |||
| {loading | |||
| ? exportFormat === 'excel' | |||
| ? t('generatingExcel') | |||
| : t('generatingPdf') | |||
| : exportFormat === 'excel' | |||
| ? t('confirmDownloadExcel') | |||
| : t('confirmDownloadPdf')} | |||
| {confirmLabel} | |||
| </Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| @@ -146,3 +146,53 @@ export const generateSemiFGProductionAnalysisReportExcel = async ( | |||
| link.remove(); | |||
| window.URL.revokeObjectURL(downloadUrl); | |||
| }; | |||
| /** | |||
| * Excel with one column per day in the selected production date range. | |||
| */ | |||
| export const generateSemiFGProductionAnalysisReportExcelDaily = async ( | |||
| criteria: Record<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`). | |||
| * Used to detect expiry before API calls so pages like /report can redirect early. | |||
| * Used to detect expiry before API calls and to refresh via /refresh-token. | |||
| */ | |||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | |||
| /** Must match clientAuthFetch redirect target */ | |||
| export const LOGIN_SESSION_EXPIRED_HREF = "/login?session=expired"; | |||
| export const ACCESS_TOKEN_KEY = "accessToken"; | |||
| export const REFRESH_TOKEN_KEY = "refreshToken"; | |||
| /** Refresh when fewer than this many seconds remain on the access token. */ | |||
| export const ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 5 * 60; | |||
| /** | |||
| * Decode JWT payload (no signature verification — UX only; server still validates). | |||
| */ | |||
| @@ -30,12 +38,143 @@ export function decodeJwtPayload( | |||
| } | |||
| } | |||
| /** Seconds until JWT `exp`, or null if missing/unreadable. Negative if already expired. */ | |||
| export function getBackendJwtSecondsRemaining(token: string): number | null { | |||
| const payload = decodeJwtPayload(token); | |||
| const exp = payload?.exp; | |||
| if (typeof exp !== "number") return null; | |||
| return exp - Date.now() / 1000; | |||
| } | |||
| /** | |||
| * True if `exp` claim is reached or passed. False if no `exp` (let API decide). | |||
| */ | |||
| export function isBackendJwtExpired(token: string): boolean { | |||
| const payload = decodeJwtPayload(token); | |||
| const exp = payload?.exp; | |||
| if (typeof exp !== "number") return false; | |||
| return Date.now() / 1000 >= exp; | |||
| const remaining = getBackendJwtSecondsRemaining(token); | |||
| if (remaining === null) return false; | |||
| return remaining <= 0; | |||
| } | |||
| /** True when token should be refreshed soon (or is already expired). */ | |||
| export function isBackendJwtExpiringSoon( | |||
| token: string, | |||
| skewSeconds: number = ACCESS_TOKEN_REFRESH_SKEW_SECONDS | |||
| ): boolean { | |||
| const remaining = getBackendJwtSecondsRemaining(token); | |||
| if (remaining === null) return false; | |||
| return remaining <= skewSeconds; | |||
| } | |||
| export function persistBackendTokens( | |||
| accessToken: string, | |||
| refreshToken?: string | null | |||
| ): void { | |||
| if (typeof window === "undefined") return; | |||
| try { | |||
| localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); | |||
| if (refreshToken) { | |||
| localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); | |||
| } | |||
| } catch { | |||
| // ignore (e.g. private mode) | |||
| } | |||
| } | |||
| export function clearBackendTokens(): void { | |||
| if (typeof window === "undefined") return; | |||
| try { | |||
| localStorage.removeItem(ACCESS_TOKEN_KEY); | |||
| localStorage.removeItem(REFRESH_TOKEN_KEY); | |||
| } catch { | |||
| // ignore | |||
| } | |||
| } | |||
| export function readStoredRefreshToken(): string | null { | |||
| if (typeof window === "undefined") return null; | |||
| try { | |||
| return localStorage.getItem(REFRESH_TOKEN_KEY)?.trim() || null; | |||
| } catch { | |||
| return null; | |||
| } | |||
| } | |||
| type RefreshResponse = { | |||
| accessToken?: string; | |||
| refreshToken?: string; | |||
| }; | |||
| let refreshInFlight: Promise<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"; | |||
| 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( | |||
| input: RequestInfo | URL, | |||
| init?: RequestInit | |||
| ): 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); | |||
| if (token) { | |||
| headers.set("Authorization", `Bearer ${token}`); | |||
| } | |||
| const response = await fetch(input, { ...init, headers }); | |||
| let response = await fetch(input, { ...init, headers }); | |||
| if (response.status === 401) { | |||
| const refreshed = await refreshBackendAccessToken( | |||
| await resolveRefreshTokenFallback() | |||
| ); | |||
| if (refreshed) { | |||
| headers.set("Authorization", `Bearer ${refreshed}`); | |||
| response = await fetch(input, { ...init, headers }); | |||
| } | |||
| if (response.status === 401 || response.status === 403) { | |||
| if (typeof window !== "undefined") { | |||
| console.warn(`Auth error ${response.status} → redirecting to login`); | |||
| if (response.status === 401 && typeof window !== "undefined") { | |||
| console.warn("Auth error 401 → redirecting to login"); | |||
| clearBackendTokens(); | |||
| window.location.href = LOGIN_SESSION_EXPIRED_HREF; | |||
| } | |||
| } | |||
| @@ -10,6 +10,7 @@ import Divider from "@mui/material/Divider"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { signOut } from "next-auth/react"; | |||
| import { clearBackendTokens } from "@/app/utils/authToken"; | |||
| type Props = Pick<AppBarProps, "avatarImageSrc" | "profileName">; | |||
| @@ -51,7 +52,13 @@ const Profile: React.FC<Props> = ({ avatarImageSrc, profileName }) => { | |||
| {profileName} | |||
| </Typography> | |||
| <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")} | |||
| </MenuItem> | |||
| </Menu> | |||
| @@ -13,7 +13,7 @@ import { useEffect, useState } from "react"; | |||
| import { SubmitHandler, useForm } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| 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"; | |||
| type LoginFields = { | |||
| @@ -25,6 +25,7 @@ type SessionWithAbilities = | |||
| | ({ | |||
| abilities: string[]; | |||
| accessToken?: string; | |||
| refreshToken?: string; | |||
| } & Session) | |||
| | null; | |||
| @@ -53,7 +54,6 @@ const LoginForm: React.FC = () => { | |||
| const [serverError, setServerError] = useState<string>(); | |||
| const router = useRouter(); | |||
| const { setAccessToken } = useToken(); | |||
| const [showPassword, setShowPassword] = useState(false); | |||
| const handleClickShowPassword = () => setShowPassword(!showPassword); | |||
| @@ -71,13 +71,13 @@ const LoginForm: React.FC = () => { | |||
| return; | |||
| } | |||
| // set auth to local storage | |||
| // Persist access + refresh in localStorage (Login is outside AxiosProvider) | |||
| 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( | |||
| "abilities", | |||
| JSON.stringify(session?.abilities), | |||
| @@ -3,11 +3,13 @@ | |||
| import { signOut } from "next-auth/react"; | |||
| import { useSearchParams } from "next/navigation"; | |||
| import { useEffect } from "react"; | |||
| import { clearBackendTokens } from "@/app/utils/authToken"; | |||
| const LogoutPage = () => { | |||
| const params = useSearchParams(); | |||
| const callbackUrl = params.get("callbackUrl"); | |||
| useEffect(() => { | |||
| clearBackendTokens(); | |||
| signOut({ redirect: true, callbackUrl: callbackUrl || "/" }); | |||
| }); | |||
| @@ -49,16 +49,25 @@ const PoSearch: React.FC<Props> = ({ | |||
| const searchCriteria: Criterion<SearchParamNames>[] = [ | |||
| { label: t("Supplier"), paramName: "supplier", 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"), | |||
| paramName: "escalated", | |||
| type: "select-labelled", | |||
| advanced: true, | |||
| options: [ | |||
| { label: t("Escalated"), value: "true" }, | |||
| { 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"), | |||
| paramName: "status", | |||
| @@ -374,6 +383,8 @@ const PoSearch: React.FC<Props> = ({ | |||
| setFilterArgs({ | |||
| code: query.code, | |||
| supplier: query.supplier, | |||
| itemCode: query.itemCode, | |||
| itemName: query.itemName, | |||
| status: query.status === "All" ? "" : query.status, | |||
| escalated: | |||
| query.escalated === "All" | |||
| @@ -544,9 +544,10 @@ useEffect(() => { | |||
| <RadioGroup | |||
| row | |||
| 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) => { | |||
| const value = e.target.value.toString();// === 'true'; | |||
| @@ -4,8 +4,10 @@ import { | |||
| Box, | |||
| Button, | |||
| Divider, | |||
| Fab, | |||
| Grid, | |||
| Modal, | |||
| Tooltip, | |||
| ModalProps, | |||
| Stack, | |||
| Tab, | |||
| @@ -47,6 +49,7 @@ import LoadingComponent from "../General/LoadingComponent"; | |||
| import { printFGStockInLabel, PrintFGStockInLabelRequest, fetchFGStockInLabel } from "@/app/api/jo/actions"; | |||
| import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | |||
| import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; | |||
| import Check from "@mui/icons-material/Check"; | |||
| const style = { | |||
| position: "absolute", | |||
| top: "50%", | |||
| @@ -312,6 +315,37 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| return true; | |||
| }, [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 onOpenPutaway = useCallback(() => { | |||
| @@ -843,13 +877,25 @@ const printQrcode = useCallback( | |||
| 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 | |||
| id="Submit" | |||
| type="button" | |||
| variant="contained" | |||
| color="primary" | |||
| sx={{ mt: 1 }} | |||
| onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)} | |||
| disabled={isSubmitting || isLoading || roundChoiceRequired} | |||
| > | |||
| @@ -15,6 +15,8 @@ import CardActions from "@mui/material/CardActions"; | |||
| import Button from "@mui/material/Button"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| 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/locale/zh-hk"; | |||
| import { DatePicker } from "@mui/x-date-pickers/DatePicker"; | |||
| @@ -45,6 +47,8 @@ interface BaseCriterion<T extends string> { | |||
| preFilledValue?: string | { from?: string; to?: string }; | |||
| filterObj?: T; | |||
| handleSelectionChange?: (selectedOptions: T[]) => void; | |||
| /** Hidden until the user clicks more criteria. */ | |||
| advanced?: boolean; | |||
| } | |||
| interface OptionWithLabel<T extends string> { | |||
| @@ -201,6 +205,9 @@ function SearchBox<T extends string>({ | |||
| const [inputs, setInputs] = useState(preFilledInputs); | |||
| 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( | |||
| (paramName: T): React.ChangeEventHandler<HTMLInputElement> => { | |||
| @@ -297,7 +304,7 @@ function SearchBox<T extends string>({ | |||
| {t("Search Criteria")} | |||
| </Typography> | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| {criteria.map((c) => { | |||
| {visibleCriteria.map((c) => { | |||
| return ( | |||
| <Grid key={c.paramName} item xs={6}> | |||
| {c.type === "text" && ( | |||
| @@ -615,6 +622,16 @@ function SearchBox<T extends string>({ | |||
| > | |||
| {t("Search")} | |||
| </Button> | |||
| {hasAdvanced && ( | |||
| <Button | |||
| variant="text" | |||
| startIcon={showAdvanced ? <ExpandLess /> : <ExpandMore />} | |||
| onClick={() => setShowAdvanced((open) => !open)} | |||
| disabled={disabled} | |||
| > | |||
| {showAdvanced ? t("Fewer criteria") : t("More criteria")} | |||
| </Button> | |||
| )} | |||
| {extraActions} | |||
| </CardActions> | |||
| </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 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) | |||
| import { AuthOptions } from "next-auth"; | |||
| 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 { 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 | |||
| declare module "next-auth" { | |||
| 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 */ | |||
| exp?: number; | |||
| locale?: string; | |||
| error?: string; | |||
| } | |||
| interface User { | |||
| @@ -31,6 +104,9 @@ declare module "next-auth/jwt" { | |||
| refreshToken?: string; | |||
| abilities: 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", | |||
| }, | |||
| 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 }) { | |||
| // First sign-in: `user` is available | |||
| if (user) { | |||
| @@ -80,21 +156,49 @@ export const authOptions: AuthOptions = { | |||
| token.accessToken = user.accessToken; | |||
| token.refreshToken = user.refreshToken; | |||
| token.abilities = user.abilities ?? []; | |||
| token.error = undefined; | |||
| token.refreshFailedAt = undefined; | |||
| const loginLocale = (user as { locale?: string }).locale; | |||
| if (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; | |||
| }, | |||
| @@ -106,6 +210,7 @@ export const authOptions: AuthOptions = { | |||
| session.abilities = token.abilities as string[]; | |||
| session.exp = token.exp as number | 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 | |||
| if (session.user) { | |||
| @@ -83,6 +83,8 @@ | |||
| "Save": "Save", | |||
| "Saving": "Saving", | |||
| "Search": "Search", | |||
| "More criteria": "More criteria", | |||
| "Fewer 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 & Complete Put Away", | |||
| "confirm qc result": "Confirm QC Result", | |||
| "Mark all QC OK and accept": "Mark all QC OK and accept", | |||
| "warehouse": "Warehouse", | |||
| "qcItem": "QC Item", | |||
| "passed": "Passed", | |||
| @@ -25,6 +25,12 @@ | |||
| "cancel": "Cancel", | |||
| "confirmDownloadPdf": "Confirm download PDF", | |||
| "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", | |||
| "semiFgConfirmHint": "Please confirm the selected item codes and their categories:", | |||
| "semiFgColItem": "Item code and name", | |||
| @@ -86,6 +86,8 @@ | |||
| "Save": "儲存", | |||
| "Saving": "儲存中", | |||
| "Search": "搜索", | |||
| "More criteria": "更多條件", | |||
| "Fewer criteria": "收起條件", | |||
| "Search Criteria": "搜索條件", | |||
| "Select Date": "選擇日期", | |||
| "Session expired or unauthorized.": "工作階段已過期或未經授權。", | |||
| @@ -145,6 +145,7 @@ | |||
| "email supplier": "電郵供應商", | |||
| "confirm putaway": "確定及完成上架", | |||
| "confirm qc result": "確定品檢結果", | |||
| "Mark all QC OK and accept": "全部合格並接受", | |||
| "warehouse": "倉庫", | |||
| "qcItem": "品檢項目", | |||
| "passed": "接受", | |||
| @@ -25,6 +25,12 @@ | |||
| "cancel": "取消", | |||
| "confirmDownloadPdf": "確認下載 PDF", | |||
| "confirmDownloadExcel": "確認下載 Excel", | |||
| "semiFgDownloadDailyExcel": "下載每日欄位 Excel", | |||
| "semiFgGeneratingDailyExcel": "生成每日 Excel...", | |||
| "semiFgConfirmDailyExcel": "確認下載每日 Excel", | |||
| "semiFgDailyRangeRequired": "請先輸入完成生產日期(由、至),每日欄位 Excel 只會輸出這段日期。", | |||
| "semiFgDailyRangeInvalid": "完成生產日期「由」不可晚於「至」。", | |||
| "semiFgDailyRangeTooLong": "日期範圍不可超過 366 日。", | |||
| "semiFgConfirmTitle": "已選擇的物料編號以及列印成品/半成品生產分析報告", | |||
| "semiFgConfirmHint": "請確認以下已選擇的物料編號及其類別:", | |||
| "semiFgColItem": "物料編號及名稱", | |||