From 0ead5c6a12dcd80f365f93f1d9ff07871c8ad0db Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Thu, 1 Oct 2026 20:15:13 +0800 Subject: [PATCH] fixing the around 16:30 logout problem; Handle user put DO (m18 PO) sync in m18 sync page PO tab; make the fg/semi fg report count by date rather than by month, created a button to make qc accept faster, added item code/name in search criteria of PO, make 2 new version of PO Item label printout (can set 1 to print the old version in setting) --- src/app/(main)/axios/AxiosProvider.tsx | 156 ++++++++++++++---- src/app/(main)/axios/axiosInstance.ts | 86 ++++++---- src/app/(main)/m18Syn/page.tsx | 118 +++++++++++-- .../report/SemiFGProductionAnalysisReport.tsx | 86 ++++++++-- .../report/semiFGProductionAnalysisApi.ts | 50 ++++++ src/app/utils/authToken.ts | 149 ++++++++++++++++- src/app/utils/clientAuthFetch.ts | 47 ++++-- src/components/AppBar/Profile.tsx | 9 +- src/components/LoginPage/LoginForm.tsx | 16 +- src/components/LogoutPage/LogoutPage.tsx | 2 + src/components/PoSearch/PoSearch.tsx | 13 +- src/components/Qc/QcComponent.tsx | 7 +- src/components/Qc/QcStockInModal.tsx | 50 +++++- src/components/SearchBox/SearchBox.tsx | 19 ++- src/config/api.ts | 5 +- src/config/authConfig.ts | 121 +++++++++++++- src/i18n/en/common.json | 2 + src/i18n/en/purchaseOrder.json | 1 + src/i18n/en/report.json | 6 + src/i18n/zh/common.json | 2 + src/i18n/zh/purchaseOrder.json | 1 + src/i18n/zh/report.json | 6 + 22 files changed, 813 insertions(+), 139 deletions(-) diff --git a/src/app/(main)/axios/AxiosProvider.tsx b/src/app/(main)/axios/AxiosProvider.tsx index e2f5f2f2..26bd3808 100644 --- a/src/app/(main)/axios/AxiosProvider.tsx +++ b/src/app/(main)/axios/AxiosProvider.tsx @@ -2,12 +2,26 @@ "use client"; -import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; +import React, { + createContext, + useContext, + useEffect, + useState, + useCallback, + useRef, +} from "react"; import { getSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { + ACCESS_TOKEN_KEY, + ACCESS_TOKEN_REFRESH_SKEW_SECONDS, + clearBackendTokens, + ensureFreshAccessToken, + getBackendJwtSecondsRemaining, isBackendJwtExpired, LOGIN_SESSION_EXPIRED_HREF, + persistBackendTokens, + readStoredRefreshToken, } from "@/app/utils/authToken"; import axiosInstance, { SetupAxiosInterceptors } from "./axiosInstance"; @@ -19,14 +33,48 @@ const TokenContext = createContext<{ setAccessToken: () => {}, }); -export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { +/** + * Merge next-auth tokens into localStorage without clobbering a newer + * localStorage access token (e.g. after a client-side refresh). + */ +function syncSessionTokensToStorage(session: SessionWithTokens | null): void { + if (!session) return; + + let localAccess = ""; + try { + localAccess = localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ""; + } catch { + localAccess = ""; + } + const localRefresh = readStoredRefreshToken(); + const sessionAccess = session.accessToken?.trim() || ""; + const sessionRefresh = session.refreshToken?.trim() || ""; + + // local missing, or local expired while session already has a fresher JWT + if ( + sessionAccess && + (!localAccess || isBackendJwtExpired(localAccess)) + ) { + persistBackendTokens(sessionAccess, sessionRefresh || localRefresh); + return; + } + + // Keep existing access token; only backfill missing refresh token + if (localAccess && sessionRefresh && !localRefresh) { + persistBackendTokens(localAccess, sessionRefresh); + } +} + +export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { const [accessToken, setAccessToken] = useState(null); const [isHydrated, setIsHydrated] = useState(false); + const refreshTimerRef = useRef | null>(null); - // Hydrate token only on client useEffect(() => { try { - const token = localStorage.getItem("accessToken"); + const token = localStorage.getItem(ACCESS_TOKEN_KEY); if (token) setAccessToken(token); } catch (e) { console.warn("localStorage unavailable", e); @@ -35,10 +83,34 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre } }, []); + const scheduleProactiveRefresh = useCallback((token: string) => { + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } + const remaining = getBackendJwtSecondsRemaining(token); + if (remaining === null) return; + + // Refresh a few minutes before expiry (clamp delay to a safe range for setTimeout) + const delayMs = Math.min( + Math.max(30_000, (remaining - ACCESS_TOKEN_REFRESH_SKEW_SECONDS) * 1000), + 2_147_483_647 + ); + refreshTimerRef.current = setTimeout(async () => { + const session = (await getSession()) as SessionWithTokens | null; + const next = await ensureFreshAccessToken( + readStoredRefreshToken() || session?.refreshToken + ); + if (next) { + setAccessToken(next); + scheduleProactiveRefresh(next); + } + }, delayMs); + }, []); + /** - * Detect expired/missing backend JWT before user actions (e.g. /report search). - * Sync accessToken from next-auth session into localStorage if missing, then - * redirect to login when the Bearer token is absent or past `exp`. + * Keep backend JWT fresh. Only force login redirect when we have neither a + * usable access token nor a successful refresh (avoid false logout on blips). */ useEffect(() => { if (!isHydrated || typeof window === "undefined") return; @@ -46,37 +118,47 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre let cancelled = false; (async () => { try { - let token = localStorage.getItem("accessToken")?.trim() ?? ""; + const session = (await getSession()) as SessionWithTokens | null; + if (cancelled) return; - if (!token) { - const session = (await getSession()) as SessionWithTokens | null; - if (cancelled) return; - if (session?.accessToken) { - token = session.accessToken; - localStorage.setItem("accessToken", token); - setAccessToken(token); - } - } + syncSessionTokensToStorage(session); + + const token = await ensureFreshAccessToken( + readStoredRefreshToken() || session?.refreshToken + ); + if (cancelled) return; if (!token) { + // Soft-fail if next-auth still has a session — avoid kicking users on + // transient refresh failures when an access token simply isn't readable yet. + if (session?.accessToken && !session.error) { + setAccessToken(session.accessToken); + persistBackendTokens(session.accessToken, session.refreshToken); + scheduleProactiveRefresh(session.accessToken); + return; + } + clearBackendTokens(); window.location.href = LOGIN_SESSION_EXPIRED_HREF; return; } - if (isBackendJwtExpired(token)) { - window.location.href = LOGIN_SESSION_EXPIRED_HREF; - } + setAccessToken(token); + scheduleProactiveRefresh(token); } catch (e) { + // Do not redirect on unexpected errors (network / getSession failures) console.warn("Auth token check failed", e); } })(); return () => { cancelled = true; + if (refreshTimerRef.current) { + clearTimeout(refreshTimerRef.current); + refreshTimerRef.current = null; + } }; - }, [isHydrated]); + }, [isHydrated, scheduleProactiveRefresh]); - // Apply token + interceptors useEffect(() => { if (accessToken) { axiosInstance.defaults.headers.Authorization = `Bearer ${accessToken}`; @@ -86,24 +168,26 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre } }, [accessToken]); - const handleSetAccessToken = useCallback((token: string | null) => { - setAccessToken(token); - try { - if (token) { - localStorage.setItem("accessToken", token); - } else { - localStorage.removeItem("accessToken"); + const handleSetAccessToken = useCallback( + (token: string | null) => { + setAccessToken(token); + try { + if (token) { + localStorage.setItem(ACCESS_TOKEN_KEY, token); + scheduleProactiveRefresh(token); + } else { + clearBackendTokens(); + } + } catch { + // ignore (e.g. private mode) } - } catch (e) { - // ignore (e.g. private mode) - } - }, []); + }, + [scheduleProactiveRefresh] + ); - // Critical fix: never return null → always return children wrapped in fragment return ( - {/* Render children immediately – they will just not have the token for 1-2ms */} {children} @@ -111,4 +195,4 @@ export const AxiosProvider: React.FC<{ children: React.ReactNode }> = ({ childre }; export const useAxios = () => useContext(AxiosContext); -export const useToken = () => useContext(TokenContext); \ No newline at end of file +export const useToken = () => useContext(TokenContext); diff --git a/src/app/(main)/axios/axiosInstance.ts b/src/app/(main)/axios/axiosInstance.ts index 1d24e5d9..2641a062 100644 --- a/src/app/(main)/axios/axiosInstance.ts +++ b/src/app/(main)/axios/axiosInstance.ts @@ -1,52 +1,76 @@ -import axios from "axios"; +import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; +import { + LOGIN_SESSION_EXPIRED_HREF, + readStoredRefreshToken, + refreshBackendAccessToken, +} from "@/app/utils/authToken"; const axiosInstance = axios.create({ - baseURL: process.env.API_URL, + baseURL: process.env.API_URL || process.env.NEXT_PUBLIC_API_URL, }); -// Clear existing interceptors to prevent multiple registrations -const clearInterceptors = () => { - const requestInterceptor = axiosInstance.interceptors.request.use(); - const responseInterceptor = axiosInstance.interceptors.response.use(); +type RetriableConfig = InternalAxiosRequestConfig & { _retry?: boolean }; - if (requestInterceptor) { - axiosInstance.interceptors.request.eject(requestInterceptor); - } +let requestInterceptorId: number | null = null; +let responseInterceptorId: number | null = null; - if (responseInterceptor) { - axiosInstance.interceptors.response.eject(responseInterceptor); +const clearInterceptors = () => { + if (requestInterceptorId !== null) { + axiosInstance.interceptors.request.eject(requestInterceptorId); + requestInterceptorId = null; + } + if (responseInterceptorId !== null) { + axiosInstance.interceptors.response.eject(responseInterceptorId); + responseInterceptorId = null; } }; -export const SetupAxiosInterceptors = (inputToken: string | null) => { - console.log("[debug] set up interceptors", inputToken); - - // Clear existing interceptors +/** + * Always prefer localStorage accessToken (may have been refreshed) over any + * closed-over token passed into SetupAxiosInterceptors. + */ +export const SetupAxiosInterceptors = (_inputToken: string | null) => { clearInterceptors(); - // Request interceptor - axiosInstance.interceptors.request.use( + requestInterceptorId = axiosInstance.interceptors.request.use( (config) => { - // Use the token passed in or retrieve from localStorage - const token = inputToken ?? localStorage.getItem("accessToken"); - if (token) { - config.headers.Authorization = `Bearer ${token}`; + try { + const token = + (typeof window !== "undefined" + ? localStorage.getItem("accessToken") + : null) ?? _inputToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + } catch { + if (_inputToken) { + config.headers.Authorization = `Bearer ${_inputToken}`; + } } return config; }, - (error) => { - return Promise.reject(error); - }, + (error) => Promise.reject(error), ); - console.log("[debug] set up interceptors2", inputToken); + responseInterceptorId = axiosInstance.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + const status = error.response?.status; + const original = error.config as RetriableConfig | undefined; + + // Only 401 = unauthenticated. Do not treat 403 (forbidden) as session expiry. + if (status === 401 && original && !original._retry && typeof window !== "undefined") { + original._retry = true; + const refreshed = await refreshBackendAccessToken(readStoredRefreshToken()); + if (refreshed) { + original.headers = original.headers ?? {}; + original.headers.Authorization = `Bearer ${refreshed}`; + axiosInstance.defaults.headers.Authorization = `Bearer ${refreshed}`; + return axiosInstance(original); + } + window.location.href = LOGIN_SESSION_EXPIRED_HREF; + } - // Response interceptor - axiosInstance.interceptors.response.use( - (response) => { - return response; - }, - (error) => { console.error("API Error:", error.response?.data || error); return Promise.reject(error); }, diff --git a/src/app/(main)/m18Syn/page.tsx b/src/app/(main)/m18Syn/page.tsx index 8a84520d..4c7f6b74 100644 --- a/src/app/(main)/m18Syn/page.tsx +++ b/src/app/(main)/m18Syn/page.tsx @@ -39,6 +39,7 @@ export default function M18SynPage() { type ParsedSyncResult = | { kind: "exists"; message: string } + | { kind: "skipped_not_pending"; message: string } | { kind: "not_found"; message: string } | { kind: "success"; message: string; totalSuccess: number } | { kind: "fail"; message: string; totalFail: number } @@ -60,15 +61,45 @@ export default function M18SynPage() { return { kind: "exists", message: `${docName}已存在系統` }; } - // "not found in M18" (sync-by-code returns processed=1, fail=1) + // Local PO/DO exists but status is not pending — sync intentionally skipped + if (query.includes("skipped (not pending")) { + const statusMatch = query.match(/status=([^\s;|]+)/); + const statusRaw = (statusMatch?.[1] ?? "").toLowerCase(); + const statusLabel = (() => { + if (statusRaw === "receiving") { + // DO uses receiving = 已放單; PO uses receiving = 收貨中 + return docName === "送貨訂單" ? "已放單" : "收貨中"; + } + if (statusRaw === "completed") return "已完成"; + if (statusRaw === "pending") return "待處理"; + return statusRaw || "非待處理"; + })(); + return { + kind: "skipped_not_pending", + message: `無法同步:此${docName}目前為「${statusLabel}」,不能再從 M18 覆寫(僅「待處理」可同步)。`, + }; + } + + // "not found in M18" (sync-by-code returns processed=1, fail=1) — may be wrong tab (PO vs DO) if ( - docName === "送貨訂單" && totalProcessed === 1 && totalSuccess === 0 && totalFail === 1 && query.includes("code=equal=") ) { - return { kind: "not_found", message: `在M18找不到${docName}` }; + if (docName === "採購單") { + return { + kind: "not_found", + message: "在M18找不到採購單", + }; + } + if (docName === "送貨訂單") { + return { + kind: "not_found", + message: + "在M18找不到送貨訂單。若此單為物料採購單,請改到「採購單」分頁同步。", + }; + } } if (totalSuccess > 0 && totalFail === 0) { @@ -108,7 +139,8 @@ export default function M18SynPage() { const handleSyncM18PoByCode = async () => { if (m18PoInFlightRef.current) return; - if (!m18PoCode.trim()) { + const code = m18PoCode.trim(); + if (!code) { alert("Please enter PO code."); return; } @@ -116,15 +148,58 @@ export default function M18SynPage() { setIsSyncingM18Po(true); setM18PoSyncResult(""); try { - const response = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(m18PoCode.trim())}`, + const poResponse = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/po-by-code?code=${encodeURIComponent(code)}`, { method: "GET" }, ); - if (response.status === 401 || response.status === 403) return; - const text = await response.text(); - setM18PoSyncResult(formatSyncResultText("採購單", text)); - if (!response.ok) { - alert(`Sync failed: ${response.status}`); + if (poResponse.status === 401 || poResponse.status === 403) return; + const poText = await poResponse.text(); + const poParsed = parseSyncResult("採購單", poText); + + if (poParsed.kind !== "not_found") { + setM18PoSyncResult(poParsed.message); + if (!poResponse.ok) { + alert(`Sync failed: ${poResponse.status}`); + } + return; + } + + // PO not found under material-PO scope — auto-try DO (shop PO) with same code + setM18PoSyncResult("採購單找不到,改嘗試同步送貨訂單…"); + const doResponse = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(code)}`, + { method: "GET" }, + ); + if (doResponse.status === 401 || doResponse.status === 403) return; + const doText = await doResponse.text(); + const doParsed = parseSyncResult("送貨訂單", doText); + + if (doParsed.kind === "success") { + setM18PoSyncResult( + [ + `採購單找不到(code=${code})`, + `已自動改以送貨訂單同步:成功`, + ].join("\n"), + ); + } else if (doParsed.kind === "not_found") { + setM18PoSyncResult( + [ + `採購單找不到(code=${code})`, + `已自動改試送貨訂單:亦找不到`, + "請確認單號是否正確。", + ].join("\n"), + ); + } else { + setM18PoSyncResult( + [ + `採購單找不到(code=${code})`, + `已自動改試送貨訂單:${doParsed.message}`, + ].join("\n"), + ); + } + + if (!doResponse.ok) { + alert(`DO fallback sync failed: ${doResponse.status}`); } } catch (e) { console.error("M18 PO Sync By Code Error:", e); @@ -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() { Requires 行政 (ADMIN) or M18同步 (M18_SYNC). Sync Purchase Order, Delivery Order, or product/material from M18 using document or item code. + 「採購單」找不到時會自動改試「送貨訂單」;「送貨訂單」找不到時請改到「採購單」分頁。 setTabValue(v)} aria-label="M18 sync by code" centered variant="fullWidth"> diff --git a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx index 9a55d1cf..af09bff2 100644 --- a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx +++ b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { useTranslation } from "react-i18next"; import { Dialog, @@ -19,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; prepareCriteria?: () => Record; @@ -51,7 +54,8 @@ export default function SemiFGProductionAnalysisReport({ const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [selectedItemCodesInfo, setSelectedItemCodesInfo] = useState([]); const [itemCodesWithCategory, setItemCodesWithCategory] = useState>({}); - const [exportFormat, setExportFormat] = useState<'pdf' | 'excel'>('pdf'); + const [exportFormat, setExportFormat] = useState('pdf'); + const inFlightRef = useRef(false); // Fetch item codes with category when stockCategory changes useEffect(() => { @@ -71,14 +75,33 @@ export default function SemiFGProductionAnalysisReport({ } }, [criteria.stockCategory]); - const handleExportClick = async (format: 'pdf' | 'excel') => { + const dailyRangeError = (activeCriteria: Record): string | null => { + const start = (activeCriteria.lastOutDateStart || '').trim(); + const end = (activeCriteria.lastOutDateEnd || '').trim(); + if (!start || !end) return t('semiFgDailyRangeRequired'); + const startDate = dayjs(start); + const endDate = dayjs(end); + if (!startDate.isValid() || !endDate.isValid()) return t('semiFgDailyRangeRequired'); + if (endDate.isBefore(startDate, 'day')) return t('semiFgDailyRangeInvalid'); + if (endDate.diff(startDate, 'day') + 1 > 366) return t('semiFgDailyRangeTooLong'); + return null; + }; + + const handleExportClick = async (format: ExportFormat) => { + if (inFlightRef.current) return; const activeCriteria = prepareCriteria?.() ?? criteria; setExportFormat(format); - // Validate required fields if (requiredFieldLabels.length > 0) { alert(t('missingRequired', { fields: requiredFieldLabels.join('\n- ') })); return; } + if (format === 'excel-daily') { + const rangeError = dailyRangeError(activeCriteria); + if (rangeError) { + alert(rangeError); + return; + } + } 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 = 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 ( <>
@@ -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')} +
@@ -202,13 +258,7 @@ export default function SemiFGProductionAnalysisReport({ disabled={loading} startIcon={} > - {loading - ? exportFormat === 'excel' - ? t('generatingExcel') - : t('generatingPdf') - : exportFormat === 'excel' - ? t('confirmDownloadExcel') - : t('confirmDownloadPdf')} + {confirmLabel} diff --git a/src/app/(main)/report/semiFGProductionAnalysisApi.ts b/src/app/(main)/report/semiFGProductionAnalysisApi.ts index e77636e5..0f7f845a 100644 --- a/src/app/(main)/report/semiFGProductionAnalysisApi.ts +++ b/src/app/(main)/report/semiFGProductionAnalysisApi.ts @@ -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, + reportTitle: string = '成品/半成品生產分析報告' +): Promise => { + const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis-excel-daily`; + + const response = await clientAuthFetch(url, { + method: 'POST', + headers: { + Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteria, 'itemCode')), + }); + + if (response.status === 401 || response.status === 403) throw new Error('Unauthorized'); + if (!response.ok) throw new Error(await readReportErrorMessage(response)); + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + + const contentDisposition = response.headers.get('Content-Disposition'); + let fileName = `${reportTitle}_每日.xlsx`; + if (contentDisposition?.includes('filename=')) { + fileName = contentDisposition.split('filename=')[1].split(';')[0].replace(/"/g, ''); + } + + link.setAttribute('download', fileName); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(downloadUrl); +}; + +async function readReportErrorMessage(response: Response): Promise { + const text = await response.text(); + try { + const parsed = JSON.parse(text) as { message?: string }; + if (parsed.message) return parsed.message; + } catch { + // Response body is not JSON. + } + return text.trim(); +} diff --git a/src/app/utils/authToken.ts b/src/app/utils/authToken.ts index 7dc3a48b..8f00cf5b 100644 --- a/src/app/utils/authToken.ts +++ b/src/app/utils/authToken.ts @@ -1,11 +1,19 @@ /** * Client-side helpers for the backend JWT stored in localStorage (`accessToken`). - * Used to detect expiry before API calls so pages like /report can redirect early. + * Used to detect expiry before API calls and to refresh via /refresh-token. */ +import { NEXT_PUBLIC_API_URL } from "@/config/api"; + /** Must match clientAuthFetch redirect target */ export const LOGIN_SESSION_EXPIRED_HREF = "/login?session=expired"; +export const ACCESS_TOKEN_KEY = "accessToken"; +export const REFRESH_TOKEN_KEY = "refreshToken"; + +/** Refresh when fewer than this many seconds remain on the access token. */ +export const ACCESS_TOKEN_REFRESH_SKEW_SECONDS = 5 * 60; + /** * Decode JWT payload (no signature verification — UX only; server still validates). */ @@ -30,12 +38,143 @@ export function decodeJwtPayload( } } +/** Seconds until JWT `exp`, or null if missing/unreadable. Negative if already expired. */ +export function getBackendJwtSecondsRemaining(token: string): number | null { + const payload = decodeJwtPayload(token); + const exp = payload?.exp; + if (typeof exp !== "number") return null; + return exp - Date.now() / 1000; +} + /** * True if `exp` claim is reached or passed. False if no `exp` (let API decide). */ export function isBackendJwtExpired(token: string): boolean { - const payload = decodeJwtPayload(token); - const exp = payload?.exp; - if (typeof exp !== "number") return false; - return Date.now() / 1000 >= exp; + const remaining = getBackendJwtSecondsRemaining(token); + if (remaining === null) return false; + return remaining <= 0; +} + +/** True when token should be refreshed soon (or is already expired). */ +export function isBackendJwtExpiringSoon( + token: string, + skewSeconds: number = ACCESS_TOKEN_REFRESH_SKEW_SECONDS +): boolean { + const remaining = getBackendJwtSecondsRemaining(token); + if (remaining === null) return false; + return remaining <= skewSeconds; +} + +export function persistBackendTokens( + accessToken: string, + refreshToken?: string | null +): void { + if (typeof window === "undefined") return; + try { + localStorage.setItem(ACCESS_TOKEN_KEY, accessToken); + if (refreshToken) { + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + } + } catch { + // ignore (e.g. private mode) + } +} + +export function clearBackendTokens(): void { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(ACCESS_TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + } catch { + // ignore + } +} + +export function readStoredRefreshToken(): string | null { + if (typeof window === "undefined") return null; + try { + return localStorage.getItem(REFRESH_TOKEN_KEY)?.trim() || null; + } catch { + return null; + } +} + +type RefreshResponse = { + accessToken?: string; + refreshToken?: string; +}; + +let refreshInFlight: Promise | null = null; + +/** + * Call backend `/refresh-token`. Returns new access token, or null if refresh failed. + * Single-flight: concurrent callers share one request. + */ +export async function refreshBackendAccessToken( + refreshTokenOverride?: string | null +): Promise { + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + const refreshToken = + refreshTokenOverride?.trim() || readStoredRefreshToken(); + if (!refreshToken) return null; + + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + if (!base) { + console.warn("refreshBackendAccessToken: NEXT_PUBLIC_API_URL missing"); + return null; + } + + try { + const res = await fetch(`${base}/refresh-token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refreshToken }), + }); + if (!res.ok) { + console.warn("refresh-token failed", res.status); + return null; + } + const data = (await res.json()) as RefreshResponse; + if (!data.accessToken) return null; + persistBackendTokens(data.accessToken, data.refreshToken ?? refreshToken); + return data.accessToken; + } catch (e) { + console.warn("refresh-token error", e); + return null; + } + })().finally(() => { + refreshInFlight = null; + }); + + return refreshInFlight; +} + +/** + * Ensure a non-expired access token is in localStorage. + * Tries refresh when missing/expired/near expiry. Returns the token to use, or null. + */ +export async function ensureFreshAccessToken( + refreshTokenFallback?: string | null +): Promise { + if (typeof window === "undefined") return null; + + let token = ""; + try { + token = localStorage.getItem(ACCESS_TOKEN_KEY)?.trim() ?? ""; + } catch { + token = ""; + } + + if (token && !isBackendJwtExpiringSoon(token)) { + return token; + } + + const refreshed = await refreshBackendAccessToken(refreshTokenFallback); + if (refreshed) return refreshed; + + // Refresh failed: if current token still valid (not yet expired), keep using it + if (token && !isBackendJwtExpired(token)) return token; + return null; } diff --git a/src/app/utils/clientAuthFetch.ts b/src/app/utils/clientAuthFetch.ts index 67ed99a9..659442ce 100644 --- a/src/app/utils/clientAuthFetch.ts +++ b/src/app/utils/clientAuthFetch.ts @@ -1,28 +1,55 @@ "use client"; -import { LOGIN_SESSION_EXPIRED_HREF } from "@/app/utils/authToken"; +import { + clearBackendTokens, + ensureFreshAccessToken, + LOGIN_SESSION_EXPIRED_HREF, + readStoredRefreshToken, + refreshBackendAccessToken, +} from "@/app/utils/authToken"; +import { getSession } from "next-auth/react"; +import { SessionWithTokens } from "@/config/authConfig"; + +async function resolveRefreshTokenFallback(): Promise { + const stored = readStoredRefreshToken(); + if (stored) return stored; + const session = (await getSession()) as SessionWithTokens | null; + return session?.refreshToken ?? null; +} /** - * Client-side fetch that adds Bearer token from localStorage and redirects - * to /login?session=expired on 401 or 403 (session timeout / unauthorized). - * Use this for all authenticated API requests so session expiry is handled consistently. + * Client-side fetch that adds Bearer token from localStorage. + * On 401, tries one refresh + retry; only then redirects to login. + * 403 (forbidden) is NOT treated as session expiry. */ export async function clientAuthFetch( input: RequestInfo | URL, init?: RequestInit ): Promise { - const token = - typeof window !== "undefined" ? localStorage.getItem("accessToken") : null; + let token = await ensureFreshAccessToken(readStoredRefreshToken()); + if (!token) { + token = await ensureFreshAccessToken(await resolveRefreshTokenFallback()); + } + const headers = new Headers(init?.headers); if (token) { headers.set("Authorization", `Bearer ${token}`); } - const response = await fetch(input, { ...init, headers }); + let response = await fetch(input, { ...init, headers }); + + if (response.status === 401) { + const refreshed = await refreshBackendAccessToken( + await resolveRefreshTokenFallback() + ); + if (refreshed) { + headers.set("Authorization", `Bearer ${refreshed}`); + response = await fetch(input, { ...init, headers }); + } - if (response.status === 401 || response.status === 403) { - if (typeof window !== "undefined") { - console.warn(`Auth error ${response.status} → redirecting to login`); + if (response.status === 401 && typeof window !== "undefined") { + console.warn("Auth error 401 → redirecting to login"); + clearBackendTokens(); window.location.href = LOGIN_SESSION_EXPIRED_HREF; } } diff --git a/src/components/AppBar/Profile.tsx b/src/components/AppBar/Profile.tsx index f5d7fdde..921d6101 100644 --- a/src/components/AppBar/Profile.tsx +++ b/src/components/AppBar/Profile.tsx @@ -10,6 +10,7 @@ import Divider from "@mui/material/Divider"; import Typography from "@mui/material/Typography"; import { useTranslation } from "react-i18next"; import { signOut } from "next-auth/react"; +import { clearBackendTokens } from "@/app/utils/authToken"; type Props = Pick; @@ -51,7 +52,13 @@ const Profile: React.FC = ({ avatarImageSrc, profileName }) => { {profileName} - signOut()} sx={{ py: 1.5, fontSize: "0.875rem" }}> + { + clearBackendTokens(); + void signOut(); + }} + sx={{ py: 1.5, fontSize: "0.875rem" }} + > {t("Sign out")} diff --git a/src/components/LoginPage/LoginForm.tsx b/src/components/LoginPage/LoginForm.tsx index 0e7ac53c..93831d3d 100644 --- a/src/components/LoginPage/LoginForm.tsx +++ b/src/components/LoginPage/LoginForm.tsx @@ -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(); 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), diff --git a/src/components/LogoutPage/LogoutPage.tsx b/src/components/LogoutPage/LogoutPage.tsx index 7cc220db..b9ff82f2 100644 --- a/src/components/LogoutPage/LogoutPage.tsx +++ b/src/components/LogoutPage/LogoutPage.tsx @@ -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 || "/" }); }); diff --git a/src/components/PoSearch/PoSearch.tsx b/src/components/PoSearch/PoSearch.tsx index 0d82819c..7e4df36e 100644 --- a/src/components/PoSearch/PoSearch.tsx +++ b/src/components/PoSearch/PoSearch.tsx @@ -49,16 +49,25 @@ const PoSearch: React.FC = ({ const searchCriteria: Criterion[] = [ { 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 = ({ setFilterArgs({ code: query.code, supplier: query.supplier, + itemCode: query.itemCode, + itemName: query.itemName, status: query.status === "All" ? "" : query.status, escalated: query.escalated === "All" diff --git a/src/components/Qc/QcComponent.tsx b/src/components/Qc/QcComponent.tsx index 3c111f7d..72f5d04e 100644 --- a/src/components/Qc/QcComponent.tsx +++ b/src/components/Qc/QcComponent.tsx @@ -544,9 +544,10 @@ useEffect(() => { { const value = e.target.value.toString();// === 'true'; diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index b38d37dd..5e9e201e 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -4,8 +4,10 @@ import { Box, Button, Divider, + Fab, Grid, Modal, + Tooltip, ModalProps, Stack, Tab, @@ -47,6 +49,7 @@ import LoadingComponent from "../General/LoadingComponent"; import { printFGStockInLabel, PrintFGStockInLabelRequest, fetchFGStockInLabel } from "@/app/api/jo/actions"; import { fetchItemForPutAway } from "@/app/api/stockIn/actions"; import { fetchWarehouseListClient } from "@/app/api/warehouse/client"; +import Check from "@mui/icons-material/Check"; const style = { position: "absolute", top: "50%", @@ -312,6 +315,37 @@ const QcStockInModal: React.FC = ({ return true; }, [stockInLineInfo]) + /** PO stock-in only: mark every editable QC row 合格 and select 接受來貨. */ + const showMarkAllQcOk = + !viewOnly && + !showPutaway && + skipQc === false && + stockInLineInfo?.jobOrderId == null; + + const markAllQcOkAndAccept = useCallback(() => { + const current = formProps.getValues("qcResult") ?? []; + formProps.setValue( + "qcResult", + current.map((item) => + item.escalationLogId != null + ? item + : { ...item, qcPassed: true, failQty: 0 }, + ), + { shouldDirty: true, shouldValidate: true }, + ); + formProps.setValue("qcAccept", true, { shouldDirty: true }); + // RadioGroup compares with === against value="1" (接受來貨). + formProps.setValue("qcDecision", "1" as unknown as number, { + shouldDirty: true, + shouldValidate: true, + }); + const acceptQty = formProps.getValues("acceptQty"); + const input = document.getElementById("accQty") as HTMLInputElement | null; + if (input && acceptQty != null && !Number.isNaN(Number(acceptQty))) { + input.value = Number(acceptQty).toString(); + } + }, [formProps]); + const [openPutaway, setOpenPutaway] = useState(false); const onOpenPutaway = useCallback(() => { @@ -843,13 +877,25 @@ const printQrcode = useCallback( denseLayout={useDenseQcLayout} />) } - + + {showMarkAllQcOk && ( + + + + + + )} {(!viewOnly && !showPutaway) && ( + {hasAdvanced && ( + + )} {extraActions} diff --git a/src/config/api.ts b/src/config/api.ts index e9a8c275..7313647b 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -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 || ""}`; diff --git a/src/config/authConfig.ts b/src/config/authConfig.ts index d55433c5..ef3ce648 100644 --- a/src/config/authConfig.ts +++ b/src/config/authConfig.ts @@ -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 { + 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) { diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index 795915fc..e07fb3ca 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -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.", diff --git a/src/i18n/en/purchaseOrder.json b/src/i18n/en/purchaseOrder.json index f75266b5..d7865538 100644 --- a/src/i18n/en/purchaseOrder.json +++ b/src/i18n/en/purchaseOrder.json @@ -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", diff --git a/src/i18n/en/report.json b/src/i18n/en/report.json index a9558ab7..dde42687 100644 --- a/src/i18n/en/report.json +++ b/src/i18n/en/report.json @@ -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", diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 6753daf4..4bafa529 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -86,6 +86,8 @@ "Save": "儲存", "Saving": "儲存中", "Search": "搜索", + "More criteria": "更多條件", + "Fewer criteria": "收起條件", "Search Criteria": "搜索條件", "Select Date": "選擇日期", "Session expired or unauthorized.": "工作階段已過期或未經授權。", diff --git a/src/i18n/zh/purchaseOrder.json b/src/i18n/zh/purchaseOrder.json index 99664477..ac82c0f3 100644 --- a/src/i18n/zh/purchaseOrder.json +++ b/src/i18n/zh/purchaseOrder.json @@ -145,6 +145,7 @@ "email supplier": "電郵供應商", "confirm putaway": "確定及完成上架", "confirm qc result": "確定品檢結果", + "Mark all QC OK and accept": "全部合格並接受", "warehouse": "倉庫", "qcItem": "品檢項目", "passed": "接受", diff --git a/src/i18n/zh/report.json b/src/i18n/zh/report.json index 86517558..b76eea46 100644 --- a/src/i18n/zh/report.json +++ b/src/i18n/zh/report.json @@ -25,6 +25,12 @@ "cancel": "取消", "confirmDownloadPdf": "確認下載 PDF", "confirmDownloadExcel": "確認下載 Excel", + "semiFgDownloadDailyExcel": "下載每日欄位 Excel", + "semiFgGeneratingDailyExcel": "生成每日 Excel...", + "semiFgConfirmDailyExcel": "確認下載每日 Excel", + "semiFgDailyRangeRequired": "請先輸入完成生產日期(由、至),每日欄位 Excel 只會輸出這段日期。", + "semiFgDailyRangeInvalid": "完成生產日期「由」不可晚於「至」。", + "semiFgDailyRangeTooLong": "日期範圍不可超過 366 日。", "semiFgConfirmTitle": "已選擇的物料編號以及列印成品/半成品生產分析報告", "semiFgConfirmHint": "請確認以下已選擇的物料編號及其類別:", "semiFgColItem": "物料編號及名稱",