瀏覽代碼

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)
production
PC-20260115JRSN\Administrator 2 小時之前
父節點
當前提交
0ead5c6a12
共有 22 個文件被更改,包括 813 次插入 和 139 次删除
  1. +120
    -36
      src/app/(main)/axios/AxiosProvider.tsx
  2. +55
    -31
      src/app/(main)/axios/axiosInstance.ts
  3. +105
    -13
      src/app/(main)/m18Syn/page.tsx
  4. +68
    -18
      src/app/(main)/report/SemiFGProductionAnalysisReport.tsx
  5. +50
    -0
      src/app/(main)/report/semiFGProductionAnalysisApi.ts
  6. +144
    -5
      src/app/utils/authToken.ts
  7. +37
    -10
      src/app/utils/clientAuthFetch.ts
  8. +8
    -1
      src/components/AppBar/Profile.tsx
  9. +8
    -8
      src/components/LoginPage/LoginForm.tsx
  10. +2
    -0
      src/components/LogoutPage/LogoutPage.tsx
  11. +12
    -1
      src/components/PoSearch/PoSearch.tsx
  12. +4
    -3
      src/components/Qc/QcComponent.tsx
  13. +48
    -2
      src/components/Qc/QcStockInModal.tsx
  14. +18
    -1
      src/components/SearchBox/SearchBox.tsx
  15. +3
    -2
      src/config/api.ts
  16. +113
    -8
      src/config/authConfig.ts
  17. +2
    -0
      src/i18n/en/common.json
  18. +1
    -0
      src/i18n/en/purchaseOrder.json
  19. +6
    -0
      src/i18n/en/report.json
  20. +2
    -0
      src/i18n/zh/common.json
  21. +1
    -0
      src/i18n/zh/purchaseOrder.json
  22. +6
    -0
      src/i18n/zh/report.json

+ 120
- 36
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<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);

+ 55
- 31
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);
},


+ 105
- 13
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() {
</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">


+ 68
- 18
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<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>


+ 50
- 0
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<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();
}

+ 144
- 5
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<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;
}

+ 37
- 10
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<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;
}
}


+ 8
- 1
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<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>


+ 8
- 8
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<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),


+ 2
- 0
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 || "/" });
});



+ 12
- 1
src/components/PoSearch/PoSearch.tsx 查看文件

@@ -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"


+ 4
- 3
src/components/Qc/QcComponent.tsx 查看文件

@@ -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';



+ 48
- 2
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<Props> = ({
return true;
}, [stockInLineInfo])

/** PO stock-in only: mark every editable QC row 合格 and select 接受來貨. */
const showMarkAllQcOk =
!viewOnly &&
!showPutaway &&
skipQc === false &&
stockInLineInfo?.jobOrderId == null;

const markAllQcOkAndAccept = useCallback(() => {
const current = formProps.getValues("qcResult") ?? [];
formProps.setValue(
"qcResult",
current.map((item) =>
item.escalationLogId != null
? item
: { ...item, qcPassed: true, failQty: 0 },
),
{ shouldDirty: true, shouldValidate: true },
);
formProps.setValue("qcAccept", true, { shouldDirty: true });
// RadioGroup compares with === against value="1" (接受來貨).
formProps.setValue("qcDecision", "1" as unknown as number, {
shouldDirty: true,
shouldValidate: true,
});
const acceptQty = formProps.getValues("acceptQty");
const input = document.getElementById("accQty") as HTMLInputElement | null;
if (input && acceptQty != null && !Number.isNaN(Number(acceptQty))) {
input.value = Number(acceptQty).toString();
}
}, [formProps]);

const [openPutaway, setOpenPutaway] = useState(false);
const onOpenPutaway = useCallback(() => {
@@ -843,13 +877,25 @@ const printQrcode = useCallback(
denseLayout={useDenseQcLayout}
/>)
}
<Stack direction="row" justifyContent="flex-end" gap={1} sx={{pt:2}}>
<Stack direction="row" justifyContent="flex-end" alignItems="center" gap={1} sx={{ pt: 2 }}>
{showMarkAllQcOk && (
<Tooltip title={t("Mark all QC OK and accept")}>
<Fab
color="success"
size="medium"
aria-label={t("Mark all QC OK and accept")}
onClick={markAllQcOkAndAccept}
disabled={isSubmitting || isLoading}
>
<Check />
</Fab>
</Tooltip>
)}
{(!viewOnly && !showPutaway) && (<Button
id="Submit"
type="button"
variant="contained"
color="primary"
sx={{ mt: 1 }}
onClick={formProps.handleSubmit(onSubmitQc, onSubmitErrorQc)}
disabled={isSubmitting || isLoading || roundChoiceRequired}
>


+ 18
- 1
src/components/SearchBox/SearchBox.tsx 查看文件

@@ -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>


+ 3
- 2
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 || ""}`;

+ 113
- 8
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<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) {


+ 2
- 0
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.",


+ 1
- 0
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",


+ 6
- 0
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",


+ 2
- 0
src/i18n/zh/common.json 查看文件

@@ -86,6 +86,8 @@
"Save": "儲存",
"Saving": "儲存中",
"Search": "搜索",
"More criteria": "更多條件",
"Fewer criteria": "收起條件",
"Search Criteria": "搜索條件",
"Select Date": "選擇日期",
"Session expired or unauthorized.": "工作階段已過期或未經授權。",


+ 1
- 0
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": "接受",


+ 6
- 0
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": "物料編號及名稱",


Loading…
取消
儲存