Co-authored-by: Cursor <[email protected]> # Conflicts: # src/components/Qc/QcStockInModal.tsx # src/components/StockTakeManagement/PickerStockTake.tsx # src/components/StockTakeManagement/StockTakeTab.tsxstockTakeUpdate
| @@ -2,12 +2,26 @@ | |||
| "use client"; | |||
| 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); | |||
| }, | |||
| @@ -1,6 +1,8 @@ | |||
| "use client"; | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import { useSession } from "next-auth/react"; | |||
| import { AUTH, hasAbility } from "@/authorities"; | |||
| import { | |||
| Box, | |||
| Typography, | |||
| @@ -68,6 +70,9 @@ const defaultCriteria: Criteria = { | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ | |||
| export default function DeliveryChartPage() { | |||
| const { data: session } = useSession(); | |||
| const abilities = session?.abilities ?? session?.user?.abilities ?? []; | |||
| const canSeeStaffPerformance = hasAbility(abilities, AUTH.PERFORMANCE); | |||
| const [criteria, setCriteria] = useState<Criteria>(defaultCriteria); | |||
| const [topItemsSelected, setTopItemsSelected] = useState<TopDeliveryItemOption[]>([]); | |||
| const [topItemOptions, setTopItemOptions] = useState<TopDeliveryItemOption[]>([]); | |||
| @@ -132,6 +137,10 @@ export default function DeliveryChartPage() { | |||
| }, [criteria.topItems, topItemsSelected, setChartLoading]); | |||
| React.useEffect(() => { | |||
| if (!canSeeStaffPerformance) { | |||
| setChartData((prev) => ({ ...prev, staffPerf: [] })); | |||
| return; | |||
| } | |||
| const s = criteria.staffPerf.startDate; | |||
| const e = criteria.staffPerf.endDate; | |||
| if (!s || !e) { | |||
| @@ -161,13 +170,17 @@ export default function DeliveryChartPage() { | |||
| ) | |||
| .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) | |||
| .finally(() => setChartLoading("staffPerf", false)); | |||
| }, [criteria.staffPerf, staffSelected, setChartLoading]); | |||
| }, [canSeeStaffPerformance, criteria.staffPerf, staffSelected, setChartLoading]); | |||
| React.useEffect(() => { | |||
| if (!canSeeStaffPerformance) { | |||
| setStaffOptions([]); | |||
| return; | |||
| } | |||
| fetchStaffDeliveryPerformanceHandlers() | |||
| .then(setStaffOptions) | |||
| .catch(() => setStaffOptions([])); | |||
| }, []); | |||
| }, [canSeeStaffPerformance]); | |||
| React.useEffect(() => { | |||
| const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); | |||
| fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([])); | |||
| @@ -317,6 +330,7 @@ export default function DeliveryChartPage() { | |||
| )} | |||
| </ChartCard> | |||
| {canSeeStaffPerformance && ( | |||
| <ChartCard | |||
| title="員工發貨績效(每日揀貨數量與耗時)" | |||
| exportFilename="員工發貨績效" | |||
| @@ -496,6 +510,7 @@ export default function DeliveryChartPage() { | |||
| </> | |||
| )} | |||
| </ChartCard> | |||
| )} | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -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"> | |||
| @@ -192,7 +192,6 @@ const AsyncItemCodeAutocomplete: React.FC<Props> = ({ | |||
| }, | |||
| }} | |||
| onInputChange={(_, next, reason) => { | |||
| if (reason === "blur") return; | |||
| if (reason === "reset") { | |||
| setInputValue(""); | |||
| onInputValueChange?.(""); | |||
| @@ -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> | |||
| @@ -790,7 +790,6 @@ export default function ReportPage() { | |||
| pendingItemCodeInputRef.current[field.name] = ""; | |||
| return; | |||
| } | |||
| if (reason === "blur") return; | |||
| pendingItemCodeInputRef.current[field.name] = input; | |||
| }} | |||
| onChange={(event, newValue, reason) => { | |||
| @@ -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(); | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| import { I18nProvider } from "@/i18n"; | |||
| import { authOptions } from "@/config/authConfig"; | |||
| import { AUTH, hasAbility } from "@/authorities"; | |||
| import { getServerSession } from "next-auth"; | |||
| import { redirect } from "next/navigation"; | |||
| /** Route board and shop/truck lane pages: ADMIN or 車線 (TRUCK_LANE). */ | |||
| export default async function ShopSettingsLayout({ | |||
| children, | |||
| }: { | |||
| children: React.ReactNode; | |||
| }) { | |||
| const session = await getServerSession(authOptions); | |||
| const abilities = session?.user?.abilities ?? []; | |||
| const canAccess = | |||
| hasAbility(abilities, AUTH.TRUCK_LANE) || hasAbility(abilities, AUTH.ADMIN); | |||
| if (!canAccess) { | |||
| redirect("/dashboard"); | |||
| } | |||
| return ( | |||
| <I18nProvider namespaces={["shop", "navigation", "common"]}> | |||
| {children} | |||
| </I18nProvider> | |||
| ); | |||
| } | |||
| @@ -53,6 +53,8 @@ export interface InventoryLotDetailResponse { | |||
| stockTakeEndTime?: string | string[] | null; | |||
| /** ISO string or backend LocalDateTime array */ | |||
| approverTime?: string | string[] | null; | |||
| /** Times this saved first count has been changed. */ | |||
| pickerReviseCount?: number | null; | |||
| } | |||
| /** | |||
| @@ -145,6 +147,8 @@ export interface SaveStockTakeRecordRequest { | |||
| badQty: number; | |||
| //stockTakerName: string; | |||
| remark?: string | null; | |||
| /** Overwrite the saved first count while the section is still stock-taking. */ | |||
| reviseFirstQty?: boolean; | |||
| } | |||
| export interface AllPickedStockTakeListReponse { | |||
| id: number; | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -27,6 +27,10 @@ export const AUTH = { | |||
| REPORT_MGMT: "REPORT_MGMT", | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 — Manual M18 sync page (/m18Syn): ADMIN or this ability */ | |||
| M18_SYNC: "M18_SYNC", | |||
| /** Route board (/settings/shop/board): ADMIN or this ability (DB name: 車線) */ | |||
| TRUCK_LANE: "TRUCK_LANE", | |||
| /** 員工發貨績效 on /chart/delivery (DB name: 績效) */ | |||
| PERFORMANCE: "PERFORMANCE", | |||
| } as const; | |||
| /** | |||
| @@ -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> | |||
| @@ -67,6 +67,8 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; | |||
| const [loading, setLoading] = useState(false); | |||
| const [isPrinting, setIsPrinting] = useState(false); | |||
| const printInFlightRef = useRef(false); | |||
| const [records, setRecords] = useState<CompletedDoPickOrderResponse[]>([]); | |||
| const initialSearchTargetDate = useMemo( | |||
| () => resolveWorkbenchRecordTargetDate(initialTargetDate), | |||
| @@ -183,6 +185,65 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| return t("All dates"); | |||
| }, [searchQuery.targetDate, t]); | |||
| const rememberCartonQty = useCallback((recordId: number, cartonQty: number) => { | |||
| setRecords((prev) => | |||
| prev.map((row) => | |||
| row.doPickOrderRecordId === recordId ? { ...row, numberOfCartons: cartonQty } : row, | |||
| ), | |||
| ); | |||
| }, []); | |||
| const submitPrint = useCallback( | |||
| async (job: () => Promise<{ success: boolean }>) => { | |||
| await new Promise<void>((resolve) => { | |||
| void Swal.fire({ | |||
| title: t("Printing..."), | |||
| allowOutsideClick: false, | |||
| allowEscapeKey: false, | |||
| showConfirmButton: false, | |||
| didOpen: () => { | |||
| Swal.showLoading(); | |||
| resolve(); | |||
| }, | |||
| }); | |||
| }); | |||
| try { | |||
| const response = await job(); | |||
| Swal.close(); | |||
| if (response.success) { | |||
| await Swal.fire({ | |||
| position: "bottom-end", | |||
| icon: "success", | |||
| text: t("Printed Successfully."), | |||
| showConfirmButton: false, | |||
| timer: 1500, | |||
| }); | |||
| return true; | |||
| } | |||
| await Swal.fire({ | |||
| position: "bottom-end", | |||
| icon: "error", | |||
| text: t("Print failed"), | |||
| showConfirmButton: false, | |||
| timer: 2000, | |||
| }); | |||
| return false; | |||
| } catch (error) { | |||
| console.error("print error:", error); | |||
| Swal.close(); | |||
| await Swal.fire({ | |||
| position: "bottom-end", | |||
| icon: "error", | |||
| text: t("Print failed"), | |||
| showConfirmButton: false, | |||
| timer: 2000, | |||
| }); | |||
| return false; | |||
| } | |||
| }, | |||
| [t], | |||
| ); | |||
| const askNumOfCarton = useCallback(async () => { | |||
| const result = await Swal.fire({ | |||
| title: t("Enter the number of cartons: "), | |||
| @@ -210,6 +271,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| const handlePrintDN = useCallback( | |||
| async (recordId: number) => { | |||
| if (printInFlightRef.current) return; | |||
| if (!a4Printer) { | |||
| await Swal.fire({ | |||
| position: "bottom-end", | |||
| @@ -220,31 +282,32 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| }); | |||
| return; | |||
| } | |||
| const cartonQty = await askNumOfCarton(); | |||
| if (!cartonQty) return; | |||
| const response = await printDNWorkbench({ | |||
| deliveryOrderPickOrderId: recordId, | |||
| printerId: a4Printer.id, | |||
| printQty: 1, | |||
| isDraft: false, | |||
| numOfCarton: cartonQty, | |||
| }); | |||
| if (response.success) { | |||
| await Swal.fire({ | |||
| position: "bottom-end", | |||
| icon: "success", | |||
| text: t("Printed Successfully."), | |||
| showConfirmButton: false, | |||
| timer: 1500, | |||
| }); | |||
| await loadData(); | |||
| printInFlightRef.current = true; | |||
| setIsPrinting(true); | |||
| try { | |||
| const cartonQty = await askNumOfCarton(); | |||
| if (!cartonQty) return; | |||
| const ok = await submitPrint(() => | |||
| printDNWorkbench({ | |||
| deliveryOrderPickOrderId: recordId, | |||
| printerId: a4Printer.id, | |||
| printQty: 1, | |||
| isDraft: false, | |||
| numOfCarton: cartonQty, | |||
| }), | |||
| ); | |||
| if (ok) rememberCartonQty(recordId, cartonQty); | |||
| } finally { | |||
| setIsPrinting(false); | |||
| printInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [a4Printer, askNumOfCarton, loadData, t], | |||
| [a4Printer, askNumOfCarton, rememberCartonQty, submitPrint, t], | |||
| ); | |||
| const handlePrintLabel = useCallback( | |||
| async (recordId: number) => { | |||
| if (printInFlightRef.current) return; | |||
| if (!labelPrinter) { | |||
| await Swal.fire({ | |||
| position: "bottom-end", | |||
| @@ -255,30 +318,31 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| }); | |||
| return; | |||
| } | |||
| const cartonQty = await askNumOfCarton(); | |||
| if (!cartonQty) return; | |||
| const response = await printDNLabelsWorkbench({ | |||
| deliveryOrderPickOrderId: recordId, | |||
| printerId: labelPrinter.id, | |||
| printQty: 1, | |||
| numOfCarton: cartonQty, | |||
| }); | |||
| if (response.success) { | |||
| await Swal.fire({ | |||
| position: "bottom-end", | |||
| icon: "success", | |||
| text: t("Printed Successfully."), | |||
| showConfirmButton: false, | |||
| timer: 1500, | |||
| }); | |||
| await loadData(); | |||
| printInFlightRef.current = true; | |||
| setIsPrinting(true); | |||
| try { | |||
| const cartonQty = await askNumOfCarton(); | |||
| if (!cartonQty) return; | |||
| const ok = await submitPrint(() => | |||
| printDNLabelsWorkbench({ | |||
| deliveryOrderPickOrderId: recordId, | |||
| printerId: labelPrinter.id, | |||
| printQty: 1, | |||
| numOfCarton: cartonQty, | |||
| }), | |||
| ); | |||
| if (ok) rememberCartonQty(recordId, cartonQty); | |||
| } finally { | |||
| setIsPrinting(false); | |||
| printInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [askNumOfCarton, labelPrinter, loadData, t], | |||
| [askNumOfCarton, labelPrinter, rememberCartonQty, submitPrint, t], | |||
| ); | |||
| const handlePrintBoth = useCallback( | |||
| async (recordId: number) => { | |||
| if (printInFlightRef.current) return; | |||
| if (!a4Printer || !labelPrinter) { | |||
| await Swal.fire({ | |||
| position: "bottom-end", | |||
| @@ -289,37 +353,39 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| }); | |||
| return; | |||
| } | |||
| const cartonQty = await askNumOfCarton(); | |||
| if (!cartonQty) return; | |||
| const [labelRes, dnRes] = await Promise.all([ | |||
| printDNLabelsWorkbench({ | |||
| deliveryOrderPickOrderId: recordId, | |||
| printerId: labelPrinter.id, | |||
| printQty: 1, | |||
| numOfCarton: cartonQty, | |||
| }), | |||
| printDNWorkbench({ | |||
| deliveryOrderPickOrderId: recordId, | |||
| printerId: a4Printer.id, | |||
| printQty: 1, | |||
| isDraft: false, | |||
| numOfCarton: cartonQty, | |||
| }), | |||
| ]); | |||
| if (labelRes.success && dnRes.success) { | |||
| await Swal.fire({ | |||
| position: "bottom-end", | |||
| icon: "success", | |||
| text: t("Printed Successfully."), | |||
| showConfirmButton: false, | |||
| timer: 1500, | |||
| printInFlightRef.current = true; | |||
| setIsPrinting(true); | |||
| try { | |||
| const cartonQty = await askNumOfCarton(); | |||
| if (!cartonQty) return; | |||
| const ok = await submitPrint(async () => { | |||
| const [labelRes, dnRes] = await Promise.all([ | |||
| printDNLabelsWorkbench({ | |||
| deliveryOrderPickOrderId: recordId, | |||
| printerId: labelPrinter.id, | |||
| printQty: 1, | |||
| numOfCarton: cartonQty, | |||
| }), | |||
| printDNWorkbench({ | |||
| deliveryOrderPickOrderId: recordId, | |||
| printerId: a4Printer.id, | |||
| printQty: 1, | |||
| isDraft: false, | |||
| numOfCarton: cartonQty, | |||
| }), | |||
| ]); | |||
| return { success: labelRes.success && dnRes.success }; | |||
| }); | |||
| await loadData(); | |||
| if (ok) rememberCartonQty(recordId, cartonQty); | |||
| } finally { | |||
| setIsPrinting(false); | |||
| printInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [a4Printer, askNumOfCarton, labelPrinter, loadData, t], | |||
| [a4Printer, askNumOfCarton, labelPrinter, rememberCartonQty, submitPrint, t], | |||
| ); | |||
| const handleLabelReprint = useCallback(async (doPickOrder: CompletedDoPickOrderResponse) => { | |||
| if (printInFlightRef.current) return; | |||
| if (!labelPrinter) { | |||
| Swal.fire({ | |||
| position: "bottom-end", | |||
| @@ -331,6 +397,9 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| return; | |||
| } | |||
| printInFlightRef.current = true; | |||
| setIsPrinting(true); | |||
| try { | |||
| const defaultTotalCartons = Math.max(1, doPickOrder.numberOfCartons || 1); | |||
| const result = await Swal.fire({ | |||
| title: t("Reprint DN Label"), | |||
| @@ -390,31 +459,22 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| return; | |||
| } | |||
| try { | |||
| const response = await printDNLabelsReprintWorkbench({ | |||
| deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId, | |||
| printerId: labelPrinter.id, | |||
| printQty: 1, | |||
| fromCarton: result.value.fromCarton, | |||
| toCarton: result.value.toCarton, | |||
| totalCartonsOnShipment: result.value.totalCartonsOnShipment, | |||
| }); | |||
| if (response.success) { | |||
| Swal.fire({ | |||
| position: "bottom-end", | |||
| icon: "success", | |||
| text: t("Printed Successfully."), | |||
| showConfirmButton: false, | |||
| timer: 1500 | |||
| }); | |||
| } else { | |||
| console.error("Reprint failed:", response.message); | |||
| } | |||
| } catch (error) { | |||
| console.error("reprint error: ", error); | |||
| const ok = await submitPrint(() => | |||
| printDNLabelsReprintWorkbench({ | |||
| deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId, | |||
| printerId: labelPrinter.id, | |||
| printQty: 1, | |||
| fromCarton: result.value.fromCarton, | |||
| toCarton: result.value.toCarton, | |||
| totalCartonsOnShipment: result.value.totalCartonsOnShipment, | |||
| }), | |||
| ); | |||
| if (ok) rememberCartonQty(doPickOrder.doPickOrderRecordId, result.value.totalCartonsOnShipment); | |||
| } finally { | |||
| setIsPrinting(false); | |||
| printInFlightRef.current = false; | |||
| } | |||
| }, [labelPrinter, t]); | |||
| }, [labelPrinter, rememberCartonQty, submitPrint, t]); | |||
| const handleDetailClick = useCallback( | |||
| async (record: CompletedDoPickOrderResponse) => { | |||
| @@ -735,16 +795,16 @@ const GoodPickExecutionWorkbenchRecord: React.FC<Props> = ({ | |||
| <Button variant="outlined" onClick={() => void handleDetailClick(row)}> | |||
| {t("View Details")} | |||
| </Button> | |||
| <Button variant="contained" onClick={() => void handlePrintDN(row.doPickOrderRecordId)}> | |||
| <Button variant="contained" disabled={isPrinting} onClick={() => void handlePrintDN(row.doPickOrderRecordId)}> | |||
| {t("Print Pick Order")} | |||
| </Button> | |||
| <Button variant="contained" onClick={() => void handlePrintBoth(row.doPickOrderRecordId)}> | |||
| <Button variant="contained" disabled={isPrinting} onClick={() => void handlePrintBoth(row.doPickOrderRecordId)}> | |||
| {t("Print DN & Label")} | |||
| </Button> | |||
| <Button variant="contained" onClick={() => void handlePrintLabel(row.doPickOrderRecordId)}> | |||
| <Button variant="contained" disabled={isPrinting} onClick={() => void handlePrintLabel(row.doPickOrderRecordId)}> | |||
| {t("Print Label")} | |||
| </Button> | |||
| <Button variant="contained" onClick={() => void handleLabelReprint(row)}> | |||
| <Button variant="contained" disabled={isPrinting} onClick={() => void handleLabelReprint(row)}> | |||
| {t("Reprint Label(s)")} | |||
| </Button> | |||
| </CardActions> | |||
| @@ -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 || "/" }); | |||
| }); | |||
| @@ -251,7 +251,7 @@ const NavigationContent: React.FC = () => { | |||
| icon: <ShowChart />, | |||
| labelKey: "nav.chartReports", | |||
| path: "", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE], | |||
| isHidden: false, | |||
| children: [ | |||
| { | |||
| @@ -280,7 +280,7 @@ const NavigationContent: React.FC = () => { | |||
| icon: <LocalShipping />, | |||
| labelKey: "nav.chart.delivery", | |||
| path: "/chart/delivery", | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN], | |||
| requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE], | |||
| }, | |||
| { | |||
| id: "nav.chart.warehouse", | |||
| @@ -303,7 +303,7 @@ const NavigationContent: React.FC = () => { | |||
| icon: <Settings />, | |||
| labelKey: "nav.settings", | |||
| path: "", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN, AUTH.TRUCK_LANE], | |||
| children: [ | |||
| { | |||
| id: "nav.settings.user", | |||
| @@ -325,78 +325,91 @@ const NavigationContent: React.FC = () => { | |||
| icon: <Category />, | |||
| labelKey: "nav.settings.items", | |||
| path: "/settings/items", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.itemDefaultShelfLife", | |||
| icon: <EventAvailable />, | |||
| labelKey: "nav.settings.itemDefaultShelfLife", | |||
| path: "/settings/itemDefaultShelfLife", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.equipment", | |||
| icon: <Build />, | |||
| labelKey: "nav.settings.equipment", | |||
| path: "/settings/equipment", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.warehouse", | |||
| icon: <Warehouse />, | |||
| labelKey: "nav.settings.warehouse", | |||
| path: "/settings/warehouse", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.printer", | |||
| icon: <Print />, | |||
| labelKey: "nav.settings.printer", | |||
| path: "/settings/printer", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.priceInquiry", | |||
| icon: <Assessment />, | |||
| labelKey: "nav.settings.priceInquiry", | |||
| path: "/settings/itemPrice", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.qcItem", | |||
| icon: <VerifiedUser />, | |||
| labelKey: "nav.settings.qcItem", | |||
| path: "/settings/qcItem", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.qcCategory", | |||
| icon: <Label />, | |||
| labelKey: "nav.settings.qcCategory", | |||
| path: "/settings/qcCategory", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.qcItemAll", | |||
| icon: <Checklist />, | |||
| labelKey: "nav.settings.qcItemAll", | |||
| path: "/settings/qcItemAll", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.shopAndTruck", | |||
| icon: <Storefront />, | |||
| labelKey: "nav.settings.shopAndTruck", | |||
| path: "/settings/shop/board", | |||
| requiredAbility: [AUTH.TRUCK_LANE, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.deliveryOrderFloor", | |||
| icon: <Layers />, | |||
| labelKey: "nav.settings.deliveryOrderFloor", | |||
| path: "/settings/deliveryOrderFloor", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.demandForecast", | |||
| icon: <TrendingUp />, | |||
| labelKey: "nav.settings.demandForecast", | |||
| path: "/settings/rss", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.bomWeighting", | |||
| icon: <ViewModule />, | |||
| labelKey: "nav.settings.bomWeighting", | |||
| path: "/settings/bomWeighting", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.masterDataIssues", | |||
| @@ -410,24 +423,28 @@ const NavigationContent: React.FC = () => { | |||
| icon: <QrCodeIcon />, | |||
| labelKey: "nav.settings.qrCodeHandle", | |||
| path: "/settings/qrCodeHandle", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.importTesting", | |||
| icon: <Science />, | |||
| labelKey: "nav.settings.importTesting", | |||
| path: "/settings/m18ImportTesting", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.importExcel", | |||
| icon: <UploadFile />, | |||
| labelKey: "nav.settings.importExcel", | |||
| path: "/settings/importExcel", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| { | |||
| id: "nav.settings.importBom", | |||
| icon: <UploadFile />, | |||
| labelKey: "nav.settings.importBom", | |||
| path: "/settings/importBom", | |||
| requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], | |||
| }, | |||
| ], | |||
| }, | |||
| @@ -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" | |||
| @@ -29,6 +29,7 @@ import { | |||
| import ChevronLeft from "@mui/icons-material/ChevronLeft"; | |||
| import ChevronRight from "@mui/icons-material/ChevronRight"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import type { StockInLine } from "@/app/api/stockIn"; | |||
| import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; | |||
| import QcStockInModal from "../Qc/QcStockInModal"; | |||
| import { useSession } from "next-auth/react"; | |||
| @@ -178,7 +179,7 @@ function isWaitingQcPutAway( | |||
| return s !== "completed" && s !== "rejected"; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.9 | 2026-08-10 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.10 | 2026-10-15 */ | |||
| const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| onSelectProcess, | |||
| printerCombo, | |||
| @@ -191,6 +192,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| const { data: session } = useSession() as { data: SessionWithTokens | null }; | |||
| const sessionToken = session as SessionWithTokens | null; | |||
| const [loading, setLoading] = useState(false); | |||
| /** Bumps on Search/Reset so the same criteria still refetch. */ | |||
| const [listReloadKey, setListReloadKey] = useState(0); | |||
| const [productionCache, setProductionCache] = useState< | |||
| AllJoborderProductProcessInfoResponse[] | |||
| >([]); | |||
| @@ -234,6 +237,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| () => printerOptions[0] ?? null, | |||
| ); | |||
| const printInFlightRef = useRef(false); | |||
| const loadSeqRef = useRef(0); | |||
| const hasLoadedOnceRef = useRef(false); | |||
| const [printingSilId, setPrintingSilId] = useState<number | null>(null); | |||
| useEffect(() => { | |||
| @@ -328,6 +333,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| const handleApplySearch = useCallback( | |||
| (inputs: Record<SearchParam | `${SearchParam}To`, string>) => { | |||
| const selectedProcessType = (inputs.processType || "all") as ProcessFilter; | |||
| setListReloadKey((k) => k + 1); | |||
| onListPersistedStateChange((prev) => ({ | |||
| ...prev, | |||
| filter: selectedProcessType, | |||
| @@ -342,6 +348,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| ); | |||
| const handleResetSearch = useCallback(() => { | |||
| setListReloadKey((k) => k + 1); | |||
| onListPersistedStateChange((prev) => ({ | |||
| ...prev, | |||
| filter: "all", | |||
| @@ -355,8 +362,10 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| }, [disableDateFilter, onListPersistedStateChange]); | |||
| /** Load all tab datasets once per search; tab switches use cache only. */ | |||
| const loadAllCaches = useCallback(async () => { | |||
| setLoading(true); | |||
| const loadAllCaches = useCallback(async (options?: { silent?: boolean }) => { | |||
| const silent = options?.silent === true; | |||
| const seq = ++loadSeqRef.current; | |||
| if (!silent) setLoading(true); | |||
| try { | |||
| const typeParam = filter === "all" ? undefined : filter; | |||
| const base = { | |||
| @@ -391,6 +400,8 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| }), | |||
| ]); | |||
| if (seq !== loadSeqRef.current) return; | |||
| const prodContent = (prod?.content || []).filter((p) => !isPutawayCompleted(p)); | |||
| const qcContent = (pendingQc?.content || []).filter((p) => !isPutawayCompleted(p)); | |||
| const putawayContent = putawayed?.content || []; | |||
| @@ -405,6 +416,7 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| setCarriedOverCount(prod?.carriedOverCount ?? 0); | |||
| } catch (e) { | |||
| console.error(e); | |||
| if (seq !== loadSeqRef.current || silent) return; | |||
| setProductionCache([]); | |||
| setPendingQcCache([]); | |||
| setPutawayedCache([]); | |||
| @@ -413,12 +425,14 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| setPutawayedCount(0); | |||
| setCarriedOverCount(0); | |||
| } finally { | |||
| setLoading(false); | |||
| if (seq === loadSeqRef.current) setLoading(false); | |||
| } | |||
| }, [appliedSearch, disableDateFilter, filter]); | |||
| }, [appliedSearch, disableDateFilter, filter, listReloadKey]); | |||
| useEffect(() => { | |||
| void loadAllCaches(); | |||
| const silent = hasLoadedOnceRef.current; | |||
| hasLoadedOnceRef.current = true; | |||
| void loadAllCaches(silent ? { silent: true } : undefined); | |||
| }, [loadAllCaches]); | |||
| const offPlanRows = useMemo(() => { | |||
| @@ -670,9 +684,12 @@ const ProductProcessList: React.FC<ProductProcessListProps> = ({ | |||
| closeConfirm(); | |||
| } | |||
| }, [pendingConfirmAction, closeConfirm]); | |||
| const closeNewModal = useCallback(() => { | |||
| const closeNewModal = useCallback((updatedStockInLine?: StockInLine) => { | |||
| setOpenModal(false); | |||
| }, []); | |||
| if (updatedStockInLine) { | |||
| void loadAllCaches({ silent: true }); | |||
| } | |||
| }, [loadAllCaches]); | |||
| const handlePrintLabel = useCallback( | |||
| async (process: AllJoborderProductProcessInfoResponse) => { | |||
| @@ -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%", | |||
| @@ -74,7 +77,7 @@ interface Props extends CommonProps { | |||
| // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; | |||
| } | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.5 | 2026-09-18 */ | |||
| /** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.7 | 2026-10-15 */ | |||
| const QcStockInModal: React.FC<Props> = ({ | |||
| open, | |||
| onClose, | |||
| @@ -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(() => { | |||
| @@ -585,10 +619,10 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| } | |||
| closeWithResult(qcRes); | |||
| closeWithResult(qcRes ?? stockInLineInfo); | |||
| // setTabIndex(1); // Need to go Putaway tab? | |||
| } else { | |||
| closeWithResult(qcRes); | |||
| closeWithResult(qcRes ?? stockInLineInfo); | |||
| } | |||
| setIsSubmitting(false); | |||
| qcSubmitInFlightRef.current = false; | |||
| @@ -672,16 +706,15 @@ const QcStockInModal: React.FC<Props> = ({ | |||
| if (res?.type === "putaway_held") { | |||
| alert(res.message || "盤點進行中,自動上架將於盤點結束後入庫"); | |||
| } | |||
| const updated = Array.isArray(res?.entity) ? res.entity[0] : res?.entity; | |||
| closeWithResult(updated ?? stockInLineInfo); | |||
| } catch (error) { | |||
| const message = error instanceof Error ? error.message : "上架失敗"; | |||
| alert(message); | |||
| return; | |||
| } | |||
| // Close modal after successful putaway | |||
| closeHandler({}, "backdropClick"); | |||
| }, | |||
| [closeHandler], | |||
| [closeWithResult, postStockInLine, stockInLineInfo], | |||
| ); | |||
| // Print handler | |||
| useEffect(() => { | |||
| @@ -854,13 +887,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> | |||
| @@ -605,6 +605,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| )} | |||
| {hasSecond && ( | |||
| <> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <Radio | |||
| size="small" | |||
| @@ -666,6 +667,7 @@ const ApproverStockTake: React.FC<ApproverStockTakeProps> = ({ | |||
| currentQty={stockTakeHiddenOnHand(detail)} | |||
| threshold={qtyGapWarnPercent} | |||
| /> | |||
| </> | |||
| )} | |||
| {(() => { | |||
| @@ -94,6 +94,8 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| /** Qty fields the user has left, or just saved. Warning stays hidden while typing. */ | |||
| const [gapCheckOpen, setGapCheckOpen] = useState<Record<string, boolean>>({}); | |||
| const [batchSaving, setBatchSaving] = useState(false); | |||
| /** 已盤點列:按「更改」後才打開數量欄,再按「保存」。 */ | |||
| const [editingFirstIds, setEditingFirstIds] = useState<Record<number, boolean>>({}); | |||
| const [shortcutInput, setShortcutInput] = useState<string>(""); | |||
| const [page, setPage] = useState(0); | |||
| const [pageSize, setPageSize] = useState<number | string>("all"); | |||
| @@ -104,6 +106,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| const currentUserId = session?.id ? parseInt(session.id) : undefined; | |||
| const handleBatchTestAllRef = useRef<() => Promise<void>>(); | |||
| const batchInFlightRef = useRef(false); | |||
| const saveInFlightRef = useRef(false); | |||
| const isSessionCompleted = selectedSession?.status?.toLowerCase() === "completed"; | |||
| const handleChangePage = useCallback((event: unknown, newPage: number) => { | |||
| setPage(newPage); | |||
| @@ -211,15 +214,42 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| }); | |||
| }; | |||
| const canReviseFirstCount = useCallback((detail: InventoryLotDetailResponse): boolean => { | |||
| const sessionStatus = selectedSession?.status?.toLowerCase() ?? ""; | |||
| if (sessionStatus === "completed") { | |||
| return false; | |||
| } | |||
| const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); | |||
| if (recordStatus === "completed" || recordStatus !== "pass") { | |||
| return false; | |||
| } | |||
| if (detail.approverQty != null) { | |||
| return false; | |||
| } | |||
| if (detail.stockTakeRecordId == null) { | |||
| return false; | |||
| } | |||
| if (detail.firstStockTakeQty == null || detail.secondStockTakeQty != null) { | |||
| return false; | |||
| } | |||
| if ((detail.pickerReviseCount ?? 0) >= 2) { | |||
| return false; | |||
| } | |||
| return true; | |||
| }, [selectedSession?.status]); | |||
| const handleSaveStockTake = useCallback( | |||
| async (detail: InventoryLotDetailResponse) => { | |||
| if (!selectedSession || !currentUserId) { | |||
| if (!selectedSession || !currentUserId || saveInFlightRef.current) { | |||
| return; | |||
| } | |||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||
| const revisingFirst = canReviseFirstCount(detail); | |||
| const isFirstSubmit = detail.firstStockTakeQty == null || revisingFirst; | |||
| const isSecondSubmit = | |||
| detail.firstStockTakeQty != null && detail.secondStockTakeQty == null; | |||
| !revisingFirst && | |||
| detail.firstStockTakeQty != null && | |||
| detail.secondStockTakeQty == null; | |||
| // 现在用户输入的是 total 和 bad,需要算 available = total - bad | |||
| const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; | |||
| @@ -250,6 +280,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| return; | |||
| } | |||
| saveInFlightRef.current = true; | |||
| setSaving(true); | |||
| try { | |||
| const request: SaveStockTakeRecordRequest = { | |||
| @@ -258,6 +289,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| qty: availableValidated.qty, | |||
| badQty: badValidated.qty, | |||
| remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, | |||
| reviseFirstQty: revisingFirst, | |||
| }; | |||
| console.log("handleSaveStockTake: request:", request); | |||
| console.log("handleSaveStockTake: selectedSession.stockTakeId:", selectedSession.stockTakeId); | |||
| @@ -272,16 +304,19 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| qtyGapWarnPercent, | |||
| ); | |||
| setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:${isFirstSubmit ? "first" : "second"}`]: true })); | |||
| onSnackbar( | |||
| gapText | |||
| ? `${t("Stock take record saved successfully")} ${gapText}` | |||
| : t("Stock take record saved successfully"), | |||
| gapText ? "warning" : "success", | |||
| ); | |||
| const oneChangeLeftText = | |||
| revisingFirst && (detail.pickerReviseCount ?? 0) === 0 ? t("oneChangeLeft") : ""; | |||
| const saveMessage = [t("Stock take record saved successfully"), gapText, oneChangeLeftText] | |||
| .filter(Boolean) | |||
| .join(" "); | |||
| onSnackbar(saveMessage, gapText || oneChangeLeftText ? "warning" : "success"); | |||
| //await loadDetails(page, pageSize, { silent: true }); | |||
| const nextQtyId = editableQtyIdsRef.current[editableQtyIdsRef.current.indexOf(detail.id) + 1]; | |||
| if (nextQtyId != null) focusQtyInput(nextQtyId); | |||
| if (revisingFirst) { | |||
| setEditingFirstIds((prev) => ({ ...prev, [detail.id]: false })); | |||
| } | |||
| setInventoryLotDetails((prev) => | |||
| prev.map((d) => | |||
| d.id === detail.id | |||
| @@ -290,10 +325,11 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| stockTakeRecordId: d.stockTakeRecordId ?? null, // 首次儲存後可從 response 取得,此處先保留 | |||
| firstStockTakeQty: isFirstSubmit ? availableValidated.qty : d.firstStockTakeQty, | |||
| firstBadQty: isFirstSubmit ? badValidated.qty : d.firstBadQty ?? null, | |||
| secondStockTakeQty: isSecondSubmit ? availableValidated.qty : d.secondStockTakeQty, | |||
| secondBadQty: isSecondSubmit ? badValidated.qty : d.secondBadQty ?? null, | |||
| secondStockTakeQty: revisingFirst ? null : (isSecondSubmit ? availableValidated.qty : d.secondStockTakeQty), | |||
| secondBadQty: revisingFirst ? null : (isSecondSubmit ? badValidated.qty : d.secondBadQty ?? null), | |||
| remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, | |||
| stockTakeRecordStatus: "pass", | |||
| pickerReviseCount: revisingFirst ? (d.pickerReviseCount ?? 0) + 1 : d.pickerReviseCount, | |||
| } | |||
| : d | |||
| ) | |||
| @@ -316,6 +352,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| onSnackbar(errorMessage, "error"); | |||
| } finally { | |||
| setSaving(false); | |||
| saveInFlightRef.current = false; | |||
| } | |||
| }, | |||
| [ | |||
| @@ -326,6 +363,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| currentUserId, | |||
| onSnackbar, | |||
| qtyGapWarnPercent, | |||
| canReviseFirstCount, | |||
| ] | |||
| ); | |||
| @@ -637,10 +675,28 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| ) : ( | |||
| inventoryLotDetails.map((detail) => { | |||
| const submitDisabled = isSubmitDisabled(detail); | |||
| const revisingFirst = canReviseFirstCount(detail); | |||
| const editingFirst = revisingFirst && !!editingFirstIds[detail.id]; | |||
| const sessionOpen = selectedSession?.status?.toLowerCase() !== "completed"; | |||
| const changeUsedUp = | |||
| sessionOpen && | |||
| detail.stockTakeRecordStatus?.toLowerCase() === "pass" && | |||
| detail.approverQty == null && | |||
| detail.firstStockTakeQty != null && | |||
| detail.secondStockTakeQty == null && | |||
| (detail.pickerReviseCount ?? 0) >= 2; | |||
| const showChangeButton = (revisingFirst || changeUsedUp) && !editingFirst; | |||
| const oneChanceLeft = revisingFirst && (detail.pickerReviseCount ?? 0) === 1; | |||
| const isFirstSubmit = detail.firstStockTakeQty == null; | |||
| const isSecondSubmit = | |||
| detail.firstStockTakeQty != null && | |||
| detail.secondStockTakeQty == null; | |||
| const showFirstInput = (!submitDisabled && isFirstSubmit) || editingFirst; | |||
| const savedFirstQty = sanitizeStockTakeQtyInput( | |||
| String(Math.trunc(Number(detail.firstStockTakeQty ?? 0))) | |||
| ); | |||
| const editedFirstQty = sanitizeStockTakeQtyInput(recordInputs[detail.id]?.firstQty || ""); | |||
| const firstQtyChanged = editedFirstQty !== "" && editedFirstQty !== savedFirstQty; | |||
| const canEditQty = !submitDisabled && (isFirstSubmit || isSecondSubmit); | |||
| @@ -697,7 +753,7 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <TableCell sx={{ width: 250, minWidth: 250 }}> | |||
| <Stack spacing={1}> | |||
| {/* First */} | |||
| {!submitDisabled && isFirstSubmit ? ( | |||
| {showFirstInput ? ( | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Stack direction="row" spacing={1} alignItems="center"> | |||
| <TextField | |||
| @@ -772,13 +828,21 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| /> | |||
| </Stack> | |||
| ) : detail.firstStockTakeQty != null ? ( | |||
| <Typography variant="body2"> | |||
| {t("First")}:{" "} | |||
| <StockTakeQtyWithUnit | |||
| qty={formatNumber(detail.firstStockTakeQty ?? 0)} | |||
| uomShortDesc={detail.uomShortDesc} | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Typography variant="body2"> | |||
| {t("First")}:{" "} | |||
| <StockTakeQtyWithUnit | |||
| qty={formatNumber(detail.firstStockTakeQty ?? 0)} | |||
| uomShortDesc={detail.uomShortDesc} | |||
| /> | |||
| </Typography> | |||
| <StockTakeQtyGapHint | |||
| open | |||
| entered={String(Math.trunc(Number(detail.firstStockTakeQty)))} | |||
| currentQty={stockTakeHiddenOnHand(detail)} | |||
| threshold={qtyGapWarnPercent} | |||
| /> | |||
| </Typography> | |||
| </Stack> | |||
| ) : null} | |||
| {/* Second */} | |||
| @@ -853,13 +917,21 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| /> | |||
| </Stack> | |||
| ) : detail.secondStockTakeQty != null ? ( | |||
| <Typography variant="body2"> | |||
| {t("Second")}:{" "} | |||
| <StockTakeQtyWithUnit | |||
| qty={formatNumber(detail.secondStockTakeQty ?? 0)} | |||
| uomShortDesc={detail.uomShortDesc} | |||
| <Stack spacing={0.5} alignItems="flex-start"> | |||
| <Typography variant="body2"> | |||
| {t("Second")}:{" "} | |||
| <StockTakeQtyWithUnit | |||
| qty={formatNumber(detail.secondStockTakeQty ?? 0)} | |||
| uomShortDesc={detail.uomShortDesc} | |||
| /> | |||
| </Typography> | |||
| <StockTakeQtyGapHint | |||
| open | |||
| entered={String(Math.trunc(Number(detail.secondStockTakeQty)))} | |||
| currentQty={stockTakeHiddenOnHand(detail)} | |||
| threshold={qtyGapWarnPercent} | |||
| /> | |||
| </Typography> | |||
| </Stack> | |||
| ) : null} | |||
| </Stack> | |||
| </TableCell> | |||
| @@ -869,13 +941,39 @@ const PickerStockTake: React.FC<PickerStockTakeProps> = ({ | |||
| <Button | |||
| variant="contained" | |||
| sx={pickerTouchButtonSx} | |||
| onClick={() => handleSaveStockTake(detail)} | |||
| disabled={saving || submitDisabled} | |||
| color={showChangeButton ? "warning" : "primary"} | |||
| onClick={() => { | |||
| if (changeUsedUp) return; | |||
| if (revisingFirst && !editingFirst) { | |||
| if ((detail.pickerReviseCount ?? 0) === 1) { | |||
| onSnackbar(t("oneChangeLeft"), "warning"); | |||
| } | |||
| setEditingFirstIds((prev) => ({ ...prev, [detail.id]: true })); | |||
| return; | |||
| } | |||
| handleSaveStockTake(detail); | |||
| }} | |||
| disabled={ | |||
| saving || | |||
| batchSaving || | |||
| changeUsedUp || | |||
| (submitDisabled && !revisingFirst) || | |||
| (editingFirst && !firstQtyChanged) | |||
| } | |||
| > | |||
| {t("Save")} | |||
| {showChangeButton ? t("Change") : t("Save")} | |||
| </Button> | |||
| </Stack> | |||
| {oneChanceLeft && ( | |||
| <Typography | |||
| variant="caption" | |||
| component="div" | |||
| role="status" | |||
| sx={{ color: "warning.dark", fontWeight: 700, mt: 0.5, maxWidth: 140, lineHeight: 1.35 }} | |||
| > | |||
| {t("oneChangeLeft")} | |||
| </Typography> | |||
| )} | |||
| </TableCell> | |||
| {/* Remark */} | |||
| @@ -468,9 +468,15 @@ const StockTakeTab: React.FC = () => { | |||
| <Snackbar | |||
| open={snackbar.open} | |||
| autoHideDuration={6000} | |||
| onClose={() => setSnackbar({ ...snackbar, open: false })} | |||
| onClose={(_event, reason) => { | |||
| if (reason === "clickaway") return; | |||
| setSnackbar((prev) => ({ ...prev, open: false })); | |||
| }} | |||
| > | |||
| <Alert onClose={() => setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}> | |||
| <Alert | |||
| onClose={() => setSnackbar((prev) => ({ ...prev, open: false }))} | |||
| severity={snackbar.severity} | |||
| > | |||
| {snackbar.message} | |||
| </Alert> | |||
| </Snackbar> | |||
| @@ -1,3 +1,4 @@ | |||
| export const BASE_API_URL = `${process.env.API_URL}`; | |||
| export const BASE_API_URL = `${process.env.API_URL || process.env.NEXT_PUBLIC_API_URL || ""}`; | |||
| export const LOGIN_API_PATH = `${BASE_API_URL}/login`; | |||
| export const 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", | |||
| @@ -124,6 +124,8 @@ | |||
| "Return": "Return", | |||
| "Rows per page": "Rows per page", | |||
| "Save": "Save", | |||
| "Change": "Change", | |||
| "oneChangeLeft": "Only 1 change left", | |||
| "Search": "Search", | |||
| "Search Criteria": "Search Criteria", | |||
| "Search by location": "Search by location", | |||
| @@ -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": "物料編號及名稱", | |||
| @@ -25,6 +25,8 @@ | |||
| "Remark": "備註", | |||
| "Action": "操作", | |||
| "Save": "保存", | |||
| "Change": "更改", | |||
| "oneChangeLeft": "只剩 1 次更改機會", | |||
| "Selected Qty": "選擇數量", | |||
| "Not Match": "要求重點", | |||
| "Search Criteria": "搜索條件", | |||