From 43edd62256f5c7f73b15a5ad89d8a265d257cd61 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 1 Sep 2026 13:20:22 +0800 Subject: [PATCH 01/38] update --- .../(main)/settings/stockLedgerFix/page.tsx | 18 + src/app/api/stockLedgerFix/client.ts | 311 +++++ .../StockLedgerFixPageClient.tsx | 1220 +++++++++++++++++ 3 files changed, 1549 insertions(+) create mode 100644 src/app/(main)/settings/stockLedgerFix/page.tsx create mode 100644 src/app/api/stockLedgerFix/client.ts create mode 100644 src/components/StockLedgerFix/StockLedgerFixPageClient.tsx diff --git a/src/app/(main)/settings/stockLedgerFix/page.tsx b/src/app/(main)/settings/stockLedgerFix/page.tsx new file mode 100644 index 00000000..1602d68c --- /dev/null +++ b/src/app/(main)/settings/stockLedgerFix/page.tsx @@ -0,0 +1,18 @@ +import { Metadata } from "next"; +import PageTitleBar from "@/components/PageTitleBar"; +import StockLedgerFixPageClient from "@/components/StockLedgerFix/StockLedgerFixPageClient"; + +export const metadata: Metadata = { + title: "Stock Ledger Fix", +}; + +const StockLedgerFixPage: React.FC = () => { + return ( + <> + + + + ); +}; + +export default StockLedgerFixPage; diff --git a/src/app/api/stockLedgerFix/client.ts b/src/app/api/stockLedgerFix/client.ts new file mode 100644 index 00000000..5c72dd55 --- /dev/null +++ b/src/app/api/stockLedgerFix/client.ts @@ -0,0 +1,311 @@ +"use client"; + +import axiosInstance from "@/app/(main)/axios/axiosInstance"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; + +export type StockLedgerFixDayStatus = { + date: string; + cnt: number; + missLot: number; + missUom: number; + missInventoryId: number; + missLotQty: number; + dayTableLots: number; +}; + +export type StockLedgerFixCalendarResponse = { + from: string; + to: string; + days: StockLedgerFixDayStatus[]; +}; + +export type StockLedgerFixCheckPart = { + key: string; + label: string; + ok: number; + miss: number; + incorrect: number; + group?: string; +}; + +export type StockLedgerFixDayDetail = { + date: string; + cnt: number; + parts: StockLedgerFixCheckPart[]; +}; + +export type StockLedgerFixRunResponse = { + date: string; + filledLotLineId: number; + filledUomId: number; + filledInventoryId: number; + filledLotQty: number; + filledBalance: number; + dayRowsWritten: number; + stillMissLot: number; + stillMissUom: number; + stillMissInventoryId: number; + stillMissLotQty: number; +}; + +export type StockLedgerFixInventoryPreview = { + inventoryRows: number; + lotUomPairs: number; + missingUomPairs: number; +}; + +export type StockLedgerFixInventoryResponse = { + inserted: number; + updated: number; + missingUomPairsAfter: number; +}; + +export type StockLedgerFixSearchInventoryHit = { + inventoryId: number; + itemId: number | null; + itemCode: string | null; + uomId: number | null; + ledgerCnt: number; +}; + +export type StockLedgerFixSearchLotHit = { + inventoryLotLineId: number; + lotNo: string | null; + itemCode: string | null; + inventoryId: number | null; + ledgerCnt: number; +}; + +export type StockLedgerFixScopeDetail = { + kind: string; + id: number; + itemCode: string | null; + lotNo: string | null; + inventoryId: number | null; + uomId: number | null; + firstDate: string | null; + lastDate: string | null; + cnt: number; + lastBalance: string | null; + lastLotQtyAfter: string | null; + parts: StockLedgerFixCheckPart[]; +}; + +export type StockLedgerFixAdjRow = { + lotLineId: number; + inventoryId: number | null; + itemCode: string | null; + lineIn: string; + lineOut: string; + ledgerIn: string; + ledgerOut: string; + missIn: string; + missOut: string; +}; + +export type StockLedgerFixAdjPreview = { + adjDate: string; + lotCount: number; + adjInCount: number; + adjOutCount: number; + skippedNegCount: number; + sumMissIn: string; + sumMissOut: string; + skuNet: string; + rows: StockLedgerFixAdjRow[]; +}; + +export type StockLedgerFixAdjResponse = { + adjDate: string; + insertedIn: number; + insertedOut: number; + filledLotQty: number; + filledBalance: number; + dayRowsWritten: number; +}; + +export async function fetchStockLedgerFixAdjPreview( + adjDate?: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/adj`, + { + params: { + rowLimit: 20, + ...(adjDate ? { adjDate } : {}), + }, + timeout: 600000, + }, + ); + return response.data; +} + +export async function runStockLedgerFixAdj( + adjDate?: string, +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/adj`, + adjDate ? { adjDate } : {}, + { timeout: 1200000 }, + ); + return response.data; +} + +export async function fetchStockLedgerFixCalendar( + from: string, + to: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/calendar`, + { params: { from, to } }, + ); + return response.data; +} + +export async function fetchStockLedgerFixDay( + date: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/day`, + { params: { date } }, + ); + return response.data; +} + +export async function runStockLedgerFixDay( + date: string, + steps?: string[], +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`, + { date, ...(steps && steps.length > 0 ? { steps } : {}) }, + ); + return response.data; +} + +export async function runStockLedgerFixRange( + from: string, + to: string, + steps?: string[], +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`, + { + mode: "range", + from, + to, + ...(steps && steps.length > 0 ? { steps } : {}), + }, + { timeout: 1200000 }, + ); + return response.data; +} + +export async function fetchStockLedgerFixInventory(): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/inventory`, + ); + return response.data; +} + +export async function runStockLedgerFixInventory(): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/inventory`, + ); + return response.data; +} + +export async function searchStockLedgerFixInventory( + q: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/lookup/inventory`, + { params: { q } }, + ); + return response.data; +} + +export async function searchStockLedgerFixLot( + q: string, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/lookup/lot`, + { params: { q } }, + ); + return response.data; +} + +export async function fetchStockLedgerFixInventoryScope( + id: number, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/scope/inventory`, + { params: { id } }, + ); + return response.data; +} + +export async function fetchStockLedgerFixLotScope( + id: number, +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/scope/lot`, + { params: { id } }, + ); + return response.data; +} + +export async function runStockLedgerFixInventoryScope( + inventoryId: number, +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`, + { mode: "inventory", inventoryId }, + ); + return response.data; +} + +export async function runStockLedgerFixLotScope( + lotLineId: number, +): Promise { + const response = await axiosInstance.post( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/run`, + { mode: "lot", lotLineId }, + ); + return response.data; +} + +export async function downloadStockLedgerFixSql( + from: string, + to: string, + parts?: string[], +): Promise { + const response = await axiosInstance.get( + `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/export`, + { + params: { + from, + to, + ...(parts && parts.length > 0 ? { parts } : {}), + }, + paramsSerializer: { + indexes: null, + }, + responseType: "blob", + timeout: 600000, + }, + ); + const blob = new Blob([response.data], { type: "application/sql;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const partTag = + parts && parts.length > 0 && parts.length < 5 + ? `_${parts.join("-").replaceAll(".", "")}` + : ""; + a.download = `stock_ledger_fix_${from.replaceAll("-", "")}_${to.replaceAll("-", "")}${partTag}.sql`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx new file mode 100644 index 00000000..60d5f9e7 --- /dev/null +++ b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx @@ -0,0 +1,1220 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { + Alert, + Box, + Button, + Checkbox, + Chip, + CircularProgress, + FormControlLabel, + Paper, + Stack, + Tab, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tabs, + TextField, + Typography, +} from "@mui/material"; +import { DateCalendar } from "@mui/x-date-pickers/DateCalendar"; +import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import dayjs, { type Dayjs } from "dayjs"; +import "dayjs/locale/zh-hk"; +import { + fetchStockLedgerFixAdjPreview, + runStockLedgerFixAdj, + fetchStockLedgerFixDay, + fetchStockLedgerFixInventory, + fetchStockLedgerFixInventoryScope, + fetchStockLedgerFixLotScope, + runStockLedgerFixDay, + runStockLedgerFixRange, + runStockLedgerFixInventory, + runStockLedgerFixInventoryScope, + runStockLedgerFixLotScope, + searchStockLedgerFixInventory, + searchStockLedgerFixLot, + downloadStockLedgerFixSql, + type StockLedgerFixAdjPreview, + type StockLedgerFixCheckPart, + type StockLedgerFixDayDetail, + type StockLedgerFixInventoryPreview, + type StockLedgerFixScopeDetail, + type StockLedgerFixSearchInventoryHit, + type StockLedgerFixSearchLotHit, +} from "@/app/api/stockLedgerFix/client"; + +const FIRST_LEDGER_DAY = dayjs("2026-03-01"); + +const DAY_FIX_STEPS = ["2.1", "2.2", "2.3", "2.4", "2.5", "2.6"] as const; +type DayFixStep = (typeof DAY_FIX_STEPS)[number]; +const ALL_DAY_STEPS: Record = { + "2.1": true, + "2.2": true, + "2.3": true, + "2.4": true, + "2.5": true, + "2.6": true, +}; +const DAY_STEP_LABEL: Record = { + "2.1": "2.1 lot", + "2.2": "2.2 uom", + "2.3": "2.3 inventoryId", + "2.4": "2.4 lotQty", + "2.5": "2.5 balance", + "2.6": "2.6 日結", +}; + +/** Export SQL parts (aligned with fix steps + 1.0 / 2.7). */ +const EXPORT_PARTS = ["1.0", "2.3", "ledger", "2.6", "2.7"] as const; +type ExportPart = (typeof EXPORT_PARTS)[number]; +const DEFAULT_EXPORT_PARTS: Record = { + "1.0": false, + "2.3": false, + ledger: true, + "2.6": true, + "2.7": false, +}; +const FULL_EXPORT_PARTS: Record = { + "1.0": true, + "2.3": true, + ledger: true, + "2.6": true, + "2.7": true, +}; +const EXPORT_PART_LABEL: Record = { + "1.0": "1.0 inventory", + "2.3": "2.3 inventoryId", + ledger: "ledger lot/uom/lotQty/balance", + "2.6": "2.6 日結", + "2.7": "2.7 ADJ INSERT", +}; + +function selectedExportParts(flags: Record): ExportPart[] { + return EXPORT_PARTS.filter((p) => flags[p]); +} + +function exportPartsPayload(flags: Record): string[] | undefined { + const selected = selectedExportParts(flags); + if (selected.length === 0) return undefined; + // Always send explicit list so backend does not fall back to legacy default alone + return selected; +} + +function selectedDaySteps(flags: Record): DayFixStep[] { + return DAY_FIX_STEPS.filter((s) => flags[s]); +} + +function stepsPayload(flags: Record): string[] | undefined { + const selected = selectedDaySteps(flags); + if (selected.length === 0 || selected.length === DAY_FIX_STEPS.length) return undefined; + return selected; +} + +function apiErrorMessage(e: unknown, fallback: string): string { + if (e && typeof e === "object" && "response" in e) { + const data = (e as { response?: { data?: unknown } }).response?.data; + if (typeof data === "string" && data.trim()) { + return data.trim().slice(0, 400); + } + if (data && typeof data === "object") { + const msg = (data as { message?: unknown }).message; + if (typeof msg === "string" && msg.trim()) { + return msg.trim().slice(0, 400); + } + } + } + if (e instanceof Error && e.message) return e.message; + return fallback; +} + +function partVerdict( + part: StockLedgerFixCheckPart, +): "correct" | "miss" | "incorrect" | "over-issue" | "can-fix" | "cannot-fix" { + if (part.group === "canFix") { + return part.miss > 0 || part.incorrect > 0 ? "can-fix" : "correct"; + } + if (part.group === "cannotFix") { + return part.miss > 0 || part.incorrect > 0 ? "cannot-fix" : "correct"; + } + if (part.key === "overIssue") { + if (part.incorrect > 0 || part.miss > 0) return "over-issue"; + return "correct"; + } + if (part.incorrect > 0) return "incorrect"; + if (part.miss > 0) return "miss"; + if (part.key === "dayTable" && part.ok === 0) return "miss"; + return "correct"; +} + +function CheckPartsTable({ parts }: { parts: StockLedgerFixCheckPart[] }) { + const field = parts.filter((p) => !p.group || p.group === "field"); + const canFix = parts.filter( + (p) => p.group === "canFix" && p.miss + p.incorrect > 0, + ); + const cannotFix = parts.filter( + (p) => p.group === "cannotFix" && p.miss + p.incorrect > 0, + ); + const renderRows = (rows: StockLedgerFixCheckPart[]) => + rows.map((p) => { + const v = partVerdict(p); + return ( + + {p.label} + + + + {p.ok} + {p.miss} + {p.incorrect} + + ); + }); + return ( + + + + + 項目 + 狀態 + 正確 + 缺 + 不正確 + + + {renderRows(field)} +
+ {canFix.length > 0 && ( + <> + 可自動修(按 Fix 會處理) + + + + 原因 + 狀態 + 正確 + 列數 + — + + + {renderRows(canFix)} +
+ + )} + {cannotFix.length > 0 && ( + <> + + 不能自動修(Fix 不會消;舊 TKE 開新批/真超發/沒有 lot 來源) + + + + + 原因 + 狀態 + 正確 + — + 列數 + + + {renderRows(cannotFix)} +
+ + )} +
+ ); +} + +const StockLedgerFixPageClient: React.FC = () => { + const [tab, setTab] = useState<"day" | "inventory" | "lot">("day"); + const [selected, setSelected] = useState(() => + dayjs().subtract(1, "day"), + ); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [fixing, setFixing] = useState(false); + const [fixError, setFixError] = useState(null); + const [fixMessage, setFixMessage] = useState(null); + const detailInFlight = useRef(false); + const fixInFlight = useRef(false); + const inventoryLoadInFlight = useRef(false); + const inventoryRunInFlight = useRef(false); + const searchInFlight = useRef(false); + const scopeInFlight = useRef(false); + const exportInFlight = useRef(false); + const [chainFrom, setChainFrom] = useState("2026-03-15"); + const [chainTo, setChainTo] = useState(() => + dayjs().subtract(1, "day").format("YYYY-MM-DD"), + ); + const [chainRunning, setChainRunning] = useState(false); + const [chainProgress, setChainProgress] = useState(null); + const [daySteps, setDaySteps] = useState>(ALL_DAY_STEPS); + const [exportFrom, setExportFrom] = useState("2026-03-15"); + const [exportTo, setExportTo] = useState(() => + dayjs().subtract(1, "day").format("YYYY-MM-DD"), + ); + const [exportParts, setExportParts] = + useState>(DEFAULT_EXPORT_PARTS); + const [exporting, setExporting] = useState(false); + const [exportError, setExportError] = useState(null); + const [inventoryPreview, setInventoryPreview] = + useState(null); + const [inventoryLoading, setInventoryLoading] = useState(false); + const [inventoryRunning, setInventoryRunning] = useState(false); + const [inventoryError, setInventoryError] = useState(null); + const [inventoryMessage, setInventoryMessage] = useState(null); + const adjLoadInFlight = useRef(false); + const adjRunInFlight = useRef(false); + const [adjPreview, setAdjPreview] = useState(null); + const [adjLoading, setAdjLoading] = useState(false); + const [adjRunning, setAdjRunning] = useState(false); + const [adjError, setAdjError] = useState(null); + const [adjMessage, setAdjMessage] = useState(null); + /** Default yesterday; freeze-night dump set to today so ADJ lands on dump day. */ + const [adjDate, setAdjDate] = useState(() => + dayjs().subtract(1, "day").format("YYYY-MM-DD"), + ); + const [invQuery, setInvQuery] = useState(""); + const [lotQuery, setLotQuery] = useState(""); + const [invHits, setInvHits] = useState([]); + const [lotHits, setLotHits] = useState([]); + const [searchError, setSearchError] = useState(null); + const [searching, setSearching] = useState(false); + const [scope, setScope] = useState(null); + const [scopeLoading, setScopeLoading] = useState(false); + + const loadInventory = useCallback(async () => { + if (inventoryLoadInFlight.current) return; + inventoryLoadInFlight.current = true; + setInventoryLoading(true); + setInventoryError(null); + try { + const data = await fetchStockLedgerFixInventory(); + setInventoryPreview(data); + } catch (e) { + console.error(e); + setInventoryError("無法載入 inventory 1.0 預覽(需要 ADMIN / TESTING)"); + setInventoryPreview(null); + } finally { + setInventoryLoading(false); + inventoryLoadInFlight.current = false; + } + }, []); + + const loadDay = useCallback(async (date: string) => { + if (detailInFlight.current) return; + detailInFlight.current = true; + setDetailLoading(true); + setDetailError(null); + setFixMessage(null); + try { + const data = await fetchStockLedgerFixDay(date); + setDetail(data); + } catch (e) { + console.error(e); + setDetailError("無法載入當日檢查"); + setDetail(null); + } finally { + setDetailLoading(false); + detailInFlight.current = false; + } + }, []); + + const loadAdjPreview = useCallback(async () => { + if (adjLoadInFlight.current) return; + const d = adjDate.trim(); + if (!d) { + setAdjError("請填 ADJ 日期"); + return; + } + if (d > dayjs().format("YYYY-MM-DD")) { + setAdjError("ADJ 日期不能是未來"); + return; + } + adjLoadInFlight.current = true; + setAdjLoading(true); + setAdjError(null); + try { + setAdjPreview(await fetchStockLedgerFixAdjPreview(d)); + } catch (e) { + console.error(e); + setAdjError(apiErrorMessage(e, "無法載入 ADJ 預覽(需要 ADMIN / TESTING)")); + setAdjPreview(null); + } finally { + setAdjLoading(false); + adjLoadInFlight.current = false; + } + }, [adjDate]); + + useEffect(() => { + void loadInventory(); + }, [loadInventory]); + + useEffect(() => { + if (tab === "day" && selected) { + void loadDay(selected.format("YYYY-MM-DD")); + } + }, [selected, loadDay, tab]); + + const onFixDay = async () => { + if (!selected || fixInFlight.current) return; + const date = selected.format("YYYY-MM-DD"); + if (selected.isAfter(dayjs(), "day")) { + setFixError("不能修未來日期"); + return; + } + const picked = selectedDaySteps(daySteps); + if (picked.length === 0) { + setFixError("請至少勾一個步驟(預設全跑 2.1–2.6)"); + return; + } + const steps = stepsPayload(daySteps); + const stepLabel = steps?.join("、") ?? "全部 2.1–2.6"; + if (steps) { + const ok = window.confirm( + `只跑 ${stepLabel}(${date})。未勾的步驟不會重算。確定?`, + ); + if (!ok) return; + } + fixInFlight.current = true; + setFixing(true); + setFixError(null); + setFixMessage(null); + try { + const res = await runStockLedgerFixDay(date, steps); + setFixMessage( + `已修 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + ); + await loadDay(date); + } catch (e) { + console.error(e); + setFixError(apiErrorMessage(e, "修復失敗")); + } finally { + setFixing(false); + fixInFlight.current = false; + } + }; + + const onInventory = async () => { + if (inventoryRunInFlight.current) return; + const ok = window.confirm( + "方案 A:保留現有 inventory.id,補缺 (itemId, uomId),並用 lot 的 in−out 重算全部數量。沒有 lot 的 UOM 會變成 0。不會改 inventory_lot_line。確定執行?", + ); + if (!ok) return; + inventoryRunInFlight.current = true; + setInventoryRunning(true); + setInventoryError(null); + setInventoryMessage(null); + try { + const res = await runStockLedgerFixInventory(); + setInventoryMessage( + `inventory 1.0 完成:新增 ${res.inserted} 列、重算 ${res.updated} 列、之後仍缺 UOM ${res.missingUomPairsAfter}`, + ); + await loadInventory(); + } catch (e) { + console.error(e); + setInventoryError(apiErrorMessage(e, "inventory 1.0 失敗")); + } finally { + setInventoryRunning(false); + inventoryRunInFlight.current = false; + } + }; + + const onAdjApply = async () => { + if (adjRunInFlight.current) return; + const d = adjDate.trim() || adjPreview?.adjDate; + if (!d) { + setAdjError("請填 ADJ 日期並先預覽"); + return; + } + const ok = window.confirm( + `會在 ${d} 新增 ADJ:入向 ${adjPreview?.adjInCount ?? 0} 筆、出向 ${adjPreview?.adjOutCount ?? 0} 筆(含反向調整)。缺入合計 ${adjPreview?.sumMissIn ?? "?"}、缺出合計 ${adjPreview?.sumMissOut ?? "?"}。目標:每 lot 最後 lotQtyAfter = line remain。已對齊的 lot 不會再動。確定?`, + ); + if (!ok) return; + adjRunInFlight.current = true; + setAdjRunning(true); + setAdjError(null); + setAdjMessage(null); + try { + const res = await runStockLedgerFixAdj(d); + setAdjMessage( + `ADJ ${res.adjDate}:入 ${res.insertedIn}、出 ${res.insertedOut}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + ); + await loadAdjPreview(); + } catch (e) { + console.error(e); + setAdjError(apiErrorMessage(e, "ADJ 對齊失敗")); + } finally { + setAdjRunning(false); + adjRunInFlight.current = false; + } + }; + + const onSearchInventory = async () => { + if (searchInFlight.current || !invQuery.trim()) return; + searchInFlight.current = true; + setSearching(true); + setSearchError(null); + try { + const hits = await searchStockLedgerFixInventory(invQuery.trim()); + setInvHits(hits); + setScope(null); + } catch (e) { + console.error(e); + setSearchError(apiErrorMessage(e, "搜尋失敗")); + } finally { + setSearching(false); + searchInFlight.current = false; + } + }; + + const onSearchLot = async () => { + if (searchInFlight.current || !lotQuery.trim()) return; + searchInFlight.current = true; + setSearching(true); + setSearchError(null); + try { + const hits = await searchStockLedgerFixLot(lotQuery.trim()); + setLotHits(hits); + setScope(null); + } catch (e) { + console.error(e); + setSearchError(apiErrorMessage(e, "搜尋失敗")); + } finally { + setSearching(false); + searchInFlight.current = false; + } + }; + + const loadInventoryScope = async (id: number) => { + if (scopeInFlight.current) return; + scopeInFlight.current = true; + setScopeLoading(true); + setFixError(null); + setFixMessage(null); + try { + setScope(await fetchStockLedgerFixInventoryScope(id)); + } catch (e) { + console.error(e); + setFixError(apiErrorMessage(e, "無法載入 inventory")); + setScope(null); + } finally { + setScopeLoading(false); + scopeInFlight.current = false; + } + }; + + const loadLotScope = async (id: number) => { + if (scopeInFlight.current) return; + scopeInFlight.current = true; + setScopeLoading(true); + setFixError(null); + setFixMessage(null); + try { + setScope(await fetchStockLedgerFixLotScope(id)); + } catch (e) { + console.error(e); + setFixError(apiErrorMessage(e, "無法載入 lot")); + setScope(null); + } finally { + setScopeLoading(false); + scopeInFlight.current = false; + } + }; + + const onFixInventoryScope = async () => { + if (!scope || scope.kind !== "inventory" || fixInFlight.current) return; + const ok = window.confirm( + "會重算這顆 inventory 今天以前全部 ledger 的 lotQty、日結,以及這顆料的 balance。確定?", + ); + if (!ok) return; + fixInFlight.current = true; + setFixing(true); + setFixError(null); + setFixMessage(null); + try { + const res = await runStockLedgerFixInventoryScope(scope.id); + setFixMessage( + `已修 inventory ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + ); + await loadInventoryScope(scope.id); + } catch (e) { + console.error(e); + setFixError(apiErrorMessage(e, "修復失敗")); + } finally { + setFixing(false); + fixInFlight.current = false; + } + }; + + const onFixLotScope = async () => { + if (!scope || scope.kind !== "lot" || fixInFlight.current) return; + const ok = window.confirm( + "只修這張 lot 的 lotQty 與日結,不會改整顆料的 balance。確定?", + ); + if (!ok) return; + fixInFlight.current = true; + setFixing(true); + setFixError(null); + setFixMessage(null); + try { + const res = await runStockLedgerFixLotScope(scope.id); + setFixMessage( + `已修 lot ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、日結 ${res.dayRowsWritten}`, + ); + await loadLotScope(scope.id); + } catch (e) { + console.error(e); + setFixError(apiErrorMessage(e, "修復失敗")); + } finally { + setFixing(false); + fixInFlight.current = false; + } + }; + + const canFixDay = Boolean(selected && !selected.isAfter(dayjs(), "day")); + + const onFixDayRange = async () => { + if (fixInFlight.current) return; + const from = chainFrom.trim(); + const to = chainTo.trim(); + const today = dayjs().format("YYYY-MM-DD"); + if (!from || !to) { + setFixError("請填區間修 from / to"); + return; + } + if (to < from) { + setFixError("to 必須 ≥ from"); + return; + } + if (from < FIRST_LEDGER_DAY.format("YYYY-MM-DD")) { + setFixError(`from 不能早於 ${FIRST_LEDGER_DAY.format("YYYY-MM-DD")}`); + return; + } + if (to > today) { + setFixError("不能修未來日期(今天可以,給 freeze dump 夜修)"); + return; + } + const picked = selectedDaySteps(daySteps); + if (picked.length === 0) { + setFixError("請至少勾一個步驟(預設全跑 2.1–2.6)"); + return; + } + const steps = stepsPayload(daySteps); + const stepLabel = steps?.join("、") ?? "全部 2.1–2.6"; + const ok = window.confirm( + `一次修 ${from} → ${to}(步驟:${stepLabel})。2.1–2.5 整段一次算、2.6 仍依日寫日結。過程中無法中停。確定?`, + ); + if (!ok) return; + + fixInFlight.current = true; + setChainRunning(true); + setFixing(true); + setFixError(null); + setFixMessage(null); + setChainProgress(`區間修 ${from} → ${to}(${stepLabel})進行中…`); + try { + const res = await runStockLedgerFixRange(from, to, steps); + setFixMessage( + `區間修完成 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + ); + setSelected(dayjs(to)); + setChainProgress(null); + } catch (e) { + console.error(e); + setFixError(apiErrorMessage(e, "區間修失敗")); + } finally { + setFixing(false); + setChainRunning(false); + fixInFlight.current = false; + } + }; + + const onExportSql = async () => { + if (exportInFlight.current) return; + const from = exportFrom.trim(); + const to = exportTo.trim(); + if (!from || !to) { + setExportError("請填 from / to"); + return; + } + if (to < from) { + setExportError("to 必須 ≥ from"); + return; + } + const picked = selectedExportParts(exportParts); + if (picked.length === 0) { + setExportError("請至少勾一個匯出項目"); + return; + } + if (exportParts["2.3"] && !exportParts["1.0"]) { + const ok = window.confirm( + "已勾 2.3 inventoryId 但未勾 1.0 inventory:正式庫若缺新 id,UPDATE 會指到空列。建議一併勾 1.0(完整檔)。仍要匯出?", + ); + if (!ok) return; + } + const parts = exportPartsPayload(exportParts); + exportInFlight.current = true; + setExporting(true); + setExportError(null); + try { + await downloadStockLedgerFixSql(from, to, parts); + } catch (e) { + console.error(e); + const data = (e as { response?: { data?: unknown } })?.response?.data; + if (data instanceof Blob) { + try { + const text = (await data.text()).trim().slice(0, 400); + setExportError(text || "匯出失敗"); + } catch { + setExportError(apiErrorMessage(e, "匯出失敗")); + } + } else { + setExportError(apiErrorMessage(e, "匯出失敗")); + } + } finally { + setExporting(false); + exportInFlight.current = false; + } + }; + + return ( + + + + 1.0 inventory(方案 A) + + 保留現有 id;lot 有、inventory 沒有的 UOM 會新增;onHand = Σ(in−out) 按 + item+UOM。沒有 lot 的列會變成 0。請在進出倉暫停時執行,再逐日 Fix。 + + {inventoryError && {inventoryError}} + {inventoryMessage && ( + {inventoryMessage} + )} + {inventoryLoading && !inventoryPreview && } + {inventoryPreview && ( + + 現有 inventory {inventoryPreview.inventoryRows} 列;lot 的 + item+UOM {inventoryPreview.lotUomPairs} 組;缺{" "} + {inventoryPreview.missingUomPairs} 組。 + + )} + + + + + + + + + 2.7 ADJ:line in/out 對齊 ledger + + 請先選 ADJ 日期再預覽。預設昨天;freeze dump 夜可選今天(例如 31/8 23:17 寫 ADJ 到 + 31/8)。比對每 lot 的 line in/out 與 ledger 加總;有差就 ADJ。目標:最後一筆 + lotQtyAfter = line remain。明細最多 20 列。 + + + { + setAdjDate(e.target.value); + setAdjPreview(null); + setAdjMessage(null); + }} + disabled={adjLoading || adjRunning} + InputLabelProps={{ shrink: true }} + inputProps={{ max: dayjs().format("YYYY-MM-DD") }} + /> + + + + {adjError && {adjError}} + {adjMessage && {adjMessage}} + {adjLoading && !adjPreview && } + {adjPreview && ( + + ADJ 日期 {adjPreview.adjDate};可補 {adjPreview.lotCount} 條 + lot(入 {adjPreview.adjInCount}、出 {adjPreview.adjOutCount});缺入合計{" "} + {adjPreview.sumMissIn}、缺出合計 {adjPreview.sumMissOut};SKU 淨額{" "} + {adjPreview.skuNet} + {adjPreview.skuNet !== "0" ? "(非 0 表示有缺入沒有對應缺出)" : ""} + {adjPreview.skippedNegCount > 0 + ? `;含反向 ADJ ${adjPreview.skippedNegCount} 條(ledger 多記)` + : ""} + 。下列最多 20 列。 + + )} + {adjPreview && adjPreview.rows.length > 0 && ( + + + + lotLineId + itemCode + line in/out + ledger in/out + 缺入 + 缺出 + + + + {adjPreview.rows.map((r) => ( + + {r.lotLineId} + {r.itemCode ?? "—"} + + {r.lineIn} / {r.lineOut} + + + {r.ledgerIn} / {r.ledgerOut} + + {r.missIn} + {r.missOut} + + ))} + +
+ )} + + + + +
+
+ + + { + setTab(v); + setFixError(null); + setFixMessage(null); + setSearchError(null); + }} + > + + + + + + + {tab === "day" && ( + + + + setSelected(v)} + views={["year", "month", "day"]} + openTo="day" + minDate={FIRST_LEDGER_DAY} + maxDate={dayjs()} + /> + + + 點左上標題可先選年再選月。換月不會查資料;點某一天才檢查當天。今天可修(freeze dump 夜)。 + + + + + + + {selected ? selected.format("YYYY-MM-DD") : "請選日期"} + + {detailError && {detailError}} + {fixError && {fixError}} + {fixMessage && {fixMessage}} + {chainProgress && {chainProgress}} + {detailLoading && } + {detail && !detailLoading && ( + <> + + 當天 ledger {detail.cnt} 列 + + + + )} + + + 步驟(預設全跑) + + + 套 SQL 後只改 inventoryId 時只勾 2.3。只跑部分時未勾的不會重算。 + + + {DAY_FIX_STEPS.map((step) => ( + + setDaySteps((prev) => ({ ...prev, [step]: checked })) + } + /> + } + label={DAY_STEP_LABEL[step]} + /> + ))} + + + + + + + + + 區間修(2.1–2.6 一次) + + + 使用上面勾的步驟,整段日期一次 API。2.1–2.5 用同一視窗算完;2.6 日結仍依日寫入。可含今天(freeze dump 夜)。最長約 20 分鐘。 + + + setChainFrom(e.target.value)} + disabled={chainRunning} + InputLabelProps={{ shrink: true }} + /> + setChainTo(e.target.value)} + disabled={chainRunning} + InputLabelProps={{ shrink: true }} + /> + + + + + + 匯出已修 SQL(新庫用) + + + 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → ledger(±2.3)→ 2.7 INSERT → + 2.6。完整檔(含 1.0+2.3)且正式庫 freeze/id 同源時,可跳過正式庫 1.0/2.3。預設只匯 + ledger+日結(舊行為)。 + + + {EXPORT_PARTS.map((part) => ( + + setExportParts((prev) => ({ ...prev, [part]: checked })) + } + /> + } + label={EXPORT_PART_LABEL[part]} + /> + ))} + + + + {exportError && {exportError}} + + setExportFrom(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + setExportTo(e.target.value)} + InputLabelProps={{ shrink: true }} + /> + + + + + + + )} + + {tab === "inventory" && ( + + + + 搜 itemCode 或 inventoryId。Fix 會重算這顆料今天以前全部流水的 lotQty、日結與 + balance,不必逐日點日曆。 + + + setInvQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void onSearchInventory(); + }} + /> + + + {searchError && {searchError}} + {invHits.length > 0 && ( + + + + inventoryId + itemCode + uomId + ledger 列 + + + + {invHits.map((h) => ( + void loadInventoryScope(h.inventoryId)} + sx={{ cursor: "pointer" }} + > + {h.inventoryId} + {h.itemCode ?? "—"} + {h.uomId ?? "—"} + {h.ledgerCnt} + + ))} + +
+ )} + {fixError && {fixError}} + {fixMessage && {fixMessage}} + {scopeLoading && } + {scope?.kind === "inventory" && !scopeLoading && ( + <> + + {scope.itemCode ?? "?"} / inventory {scope.id} / uom {scope.uomId ?? "?"} + ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後 + balance {scope.lastBalance ?? "—"} + + + + + + + )} +
+
+ )} + + {tab === "lot" && ( + + + + 搜 lotNo 或 inventoryLotLineId。Fix 只重算這張 lot 的 lotQty 與日結,不改 + balance。 + + + setLotQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void onSearchLot(); + }} + /> + + + {searchError && {searchError}} + {lotHits.length > 0 && ( + + + + lot line id + lotNo + itemCode + inventoryId + ledger 列 + + + + {lotHits.map((h) => ( + void loadLotScope(h.inventoryLotLineId)} + sx={{ cursor: "pointer" }} + > + {h.inventoryLotLineId} + {h.lotNo ?? "—"} + {h.itemCode ?? "—"} + {h.inventoryId ?? "—"} + {h.ledgerCnt} + + ))} + +
+ )} + {fixError && {fixError}} + {fixMessage && {fixMessage}} + {scopeLoading && } + {scope?.kind === "lot" && !scopeLoading && ( + <> + + {scope.lotNo ?? "?"} / lot {scope.id} / {scope.itemCode ?? "?"} + ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後 + lotQtyAfter {scope.lastLotQtyAfter ?? "—"} + + + + + + + )} +
+
+ )} +
+ ); +}; + +export default StockLedgerFixPageClient; From ca36da92f28deb766532b584b77378e29f792eee Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 14 Sep 2026 15:02:01 +0800 Subject: [PATCH 02/38] =?UTF-8?q?Liquibase=2020260910=EF=BC=9A#1=20stockUo?= =?UTF-8?q?mId=E3=80=81#2=E2=80=933=20Option=20A=20trigger=E3=80=81#35=20i?= =?UTF-8?q?ndex=20=E4=B8=8A=E7=B7=9A=20app=EF=BC=9A#5=20entity=E3=80=81#7?= =?UTF-8?q?=20available=20=3D=20onHand=20=E2=88=92=20unavailable=E3=80=81#?= =?UTF-8?q?8=20=E6=90=9C=E5=B0=8B=E5=85=A8=20bucket=E3=80=81#34=20Resolver?= =?UTF-8?q?=EF=BC=88#10=EF=BC=8F#11=20=E5=B7=B2=E6=8E=A5=EF=BC=89=20Admin?= =?UTF-8?q?=201.0=EF=BC=9A=E8=A3=9C=20stockUomId=E3=80=81uomId=3Dbase?= =?UTF-8?q?=EF=BC=88=E9=80=99=E5=B0=B1=E6=98=AF=20#1=20=E7=9A=84=20data=20?= =?UTF-8?q?patch=EF=BC=89=202.3=20=E6=8A=8A=20ledger=20=E7=B6=81=E5=88=B0?= =?UTF-8?q?=E8=A9=B2=20bucket=202.7=EF=BC=9A=E5=85=88=E8=B6=85=E7=99=BC=20?= =?UTF-8?q?remain=E2=86=920=EF=BC=88=E6=94=B9=20lot=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=86=8D=20miss=EF=BC=9B=E5=85=B1=E7=94=A8=E4=B8=80=E5=BC=B5?= =?UTF-8?q?=E5=85=A5=E5=80=89=E5=96=AE=E3=80=81=E4=B8=80=E5=BC=B5=E5=87=BA?= =?UTF-8?q?=E5=80=89=E5=96=AE=202.6=20=E6=97=A5=E7=B5=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/stockLedgerFix/client.ts | 7 ++++ .../StockLedgerFixPageClient.tsx | 36 +++++++++++-------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/app/api/stockLedgerFix/client.ts b/src/app/api/stockLedgerFix/client.ts index 5c72dd55..675ec990 100644 --- a/src/app/api/stockLedgerFix/client.ts +++ b/src/app/api/stockLedgerFix/client.ts @@ -52,12 +52,15 @@ export type StockLedgerFixInventoryPreview = { inventoryRows: number; lotUomPairs: number; missingUomPairs: number; + nullStockUomId: number; }; export type StockLedgerFixInventoryResponse = { inserted: number; updated: number; missingUomPairsAfter: number; + patchedStockUomId: number; + nullStockUomIdAfter: number; }; export type StockLedgerFixSearchInventoryHit = { @@ -101,6 +104,7 @@ export type StockLedgerFixAdjRow = { ledgerOut: string; missIn: string; missOut: string; + overIssue: string; }; export type StockLedgerFixAdjPreview = { @@ -112,6 +116,8 @@ export type StockLedgerFixAdjPreview = { sumMissIn: string; sumMissOut: string; skuNet: string; + overIssueCount: number; + sumOverIssue: string; rows: StockLedgerFixAdjRow[]; }; @@ -119,6 +125,7 @@ export type StockLedgerFixAdjResponse = { adjDate: string; insertedIn: number; insertedOut: number; + overIssuePatched: number; filledLotQty: number; filledBalance: number; dayRowsWritten: number; diff --git a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx index 60d5f9e7..78c15865 100644 --- a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx +++ b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx @@ -93,7 +93,7 @@ const EXPORT_PART_LABEL: Record = { "2.3": "2.3 inventoryId", ledger: "ledger lot/uom/lotQty/balance", "2.6": "2.6 日結", - "2.7": "2.7 ADJ INSERT", + "2.7": "2.7 ADJ+入出倉單", }; function selectedExportParts(flags: Record): ExportPart[] { @@ -426,7 +426,7 @@ const StockLedgerFixPageClient: React.FC = () => { const onInventory = async () => { if (inventoryRunInFlight.current) return; const ok = window.confirm( - "方案 A:保留現有 inventory.id,補缺 (itemId, uomId),並用 lot 的 in−out 重算全部數量。沒有 lot 的 UOM 會變成 0。不會改 inventory_lot_line。確定執行?", + "方案 A:保留現有 inventory.id;從 lot 回填 stockUomId(舊 uomId 是 stock 則改回 base);補缺 (itemId, stockUomId);用 lot 的 in−out 重算 onHand/unavailable。不寫 onHoldQty、不改 inventory_lot_line。確定執行?", ); if (!ok) return; inventoryRunInFlight.current = true; @@ -436,7 +436,7 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixInventory(); setInventoryMessage( - `inventory 1.0 完成:新增 ${res.inserted} 列、重算 ${res.updated} 列、之後仍缺 UOM ${res.missingUomPairsAfter}`, + `inventory 1.0 完成:回填 stockUomId ${res.patchedStockUomId} 列、新增 ${res.inserted} 列、重算 ${res.updated} 列、之後仍缺 UOM ${res.missingUomPairsAfter}、仍 NULL stockUomId ${res.nullStockUomIdAfter}`, ); await loadInventory(); } catch (e) { @@ -456,7 +456,7 @@ const StockLedgerFixPageClient: React.FC = () => { return; } const ok = window.confirm( - `會在 ${d} 新增 ADJ:入向 ${adjPreview?.adjInCount ?? 0} 筆、出向 ${adjPreview?.adjOutCount ?? 0} 筆(含反向調整)。缺入合計 ${adjPreview?.sumMissIn ?? "?"}、缺出合計 ${adjPreview?.sumMissOut ?? "?"}。目標:每 lot 最後 lotQtyAfter = line remain。已對齊的 lot 不會再動。確定?`, + `會在 ${d} 先修超發 remain→0(${adjPreview?.overIssueCount ?? 0} 條 lot,合計 ${adjPreview?.sumOverIssue ?? "?"},會改 lot inQty)。再補 miss ADJ:入向 ${adjPreview?.adjInCount ?? 0} 筆、出向 ${adjPreview?.adjOutCount ?? 0} 筆。入向全部掛同一張入倉單、出向全部掛同一張出倉單(miss 不改 lot 數量)。缺入合計 ${adjPreview?.sumMissIn ?? "?"}、缺出合計 ${adjPreview?.sumMissOut ?? "?"}。確定?`, ); if (!ok) return; adjRunInFlight.current = true; @@ -466,7 +466,7 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixAdj(d); setAdjMessage( - `ADJ ${res.adjDate}:入 ${res.insertedIn}、出 ${res.insertedOut}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + `ADJ ${res.adjDate}:超發 remain→0 ${res.overIssuePatched}、入 ${res.insertedIn}、出 ${res.insertedOut}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, ); await loadAdjPreview(); } catch (e) { @@ -631,7 +631,7 @@ const StockLedgerFixPageClient: React.FC = () => { const steps = stepsPayload(daySteps); const stepLabel = steps?.join("、") ?? "全部 2.1–2.6"; const ok = window.confirm( - `一次修 ${from} → ${to}(步驟:${stepLabel})。2.1–2.5 整段一次算、2.6 仍依日寫日結。過程中無法中停。確定?`, + `一次修 ${from} → ${to}(步驟:${stepLabel})。改為逐日跑(每天 commit)。中途失敗時已跑完的天會留下。確定?`, ); if (!ok) return; @@ -724,7 +724,8 @@ const StockLedgerFixPageClient: React.FC = () => { 現有 inventory {inventoryPreview.inventoryRows} 列;lot 的 item+UOM {inventoryPreview.lotUomPairs} 組;缺{" "} - {inventoryPreview.missingUomPairs} 組。 + {inventoryPreview.missingUomPairs} 組;stockUomId 仍 NULL{" "} + {inventoryPreview.nullStockUomId} 列。 )} @@ -742,11 +743,13 @@ const StockLedgerFixPageClient: React.FC = () => { - 2.7 ADJ:line in/out 對齊 ledger + 2.7 ADJ:超發 remain→0,再對齊 ledger 請先選 ADJ 日期再預覽。預設昨天;freeze dump 夜可選今天(例如 31/8 23:17 寫 ADJ 到 - 31/8)。比對每 lot 的 line in/out 與 ledger 加總;有差就 ADJ。目標:最後一筆 - lotQtyAfter = line remain。明細最多 20 列。 + 31/8)。先處理超發(line 入少於出):把該 lot 的 inQty 補到等於 outQty(remain=0,trigger + 重算 inventory)。再處理 miss:line 與 ledger 有差就 ADJ,掛既有 lot 的入/出倉行(不改 lot + 數量)。一次 Apply 共用一張入倉單、一張出倉單,下面多行;同一 ADJ 日期再跑會沿用這兩張單。 + 明細最多 20 列。 { {adjLoading && !adjPreview && } {adjPreview && ( - ADJ 日期 {adjPreview.adjDate};可補 {adjPreview.lotCount} 條 - lot(入 {adjPreview.adjInCount}、出 {adjPreview.adjOutCount});缺入合計{" "} + ADJ 日期 {adjPreview.adjDate};可處理 {adjPreview.lotCount} 條 + lot(超發 remain→0 {adjPreview.overIssueCount}、合計 {adjPreview.sumOverIssue};miss 入{" "} + {adjPreview.adjInCount}、出 {adjPreview.adjOutCount});缺入合計{" "} {adjPreview.sumMissIn}、缺出合計 {adjPreview.sumMissOut};SKU 淨額{" "} {adjPreview.skuNet} {adjPreview.skuNet !== "0" ? "(非 0 表示有缺入沒有對應缺出)" : ""} {adjPreview.skippedNegCount > 0 ? `;含反向 ADJ ${adjPreview.skippedNegCount} 條(ledger 多記)` : ""} - 。下列最多 20 列。 + 。下列最多 20 列(超發優先)。 )} {adjPreview && adjPreview.rows.length > 0 && ( @@ -810,6 +814,7 @@ const StockLedgerFixPageClient: React.FC = () => { ledger in/out 缺入 缺出 + 超發 @@ -825,6 +830,7 @@ const StockLedgerFixPageClient: React.FC = () => { {r.missIn} {r.missOut} + {r.overIssue} ))} @@ -949,7 +955,7 @@ const StockLedgerFixPageClient: React.FC = () => { 區間修(2.1–2.6 一次) - 使用上面勾的步驟,整段日期一次 API。2.1–2.5 用同一視窗算完;2.6 日結仍依日寫入。可含今天(freeze dump 夜)。最長約 20 分鐘。 + 使用上面勾的步驟,一次 API 但改為逐日(與單日修相同 SQL,每天 commit)。可含今天(freeze dump 夜)。前端最長約 20 分鐘;大區間仍建議匯出 SQL。 { 匯出已修 SQL(新庫用) - 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → ledger(±2.3)→ 2.7 INSERT → + 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → ledger(±2.3)→ 2.7 入出倉單+ADJ INSERT → 2.6。完整檔(含 1.0+2.3)且正式庫 freeze/id 同源時,可跳過正式庫 1.0/2.3。預設只匯 ledger+日結(舊行為)。 From 4df3a1544c66a77b286a82ec2dc955752e6de354 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 15 Sep 2026 12:31:14 +0800 Subject: [PATCH 03/38] fix stock leger balance part --- src/components/StockLedgerFix/StockLedgerFixPageClient.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx index 78c15865..fb9cfec2 100644 --- a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx +++ b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx @@ -553,7 +553,7 @@ const StockLedgerFixPageClient: React.FC = () => { const onFixInventoryScope = async () => { if (!scope || scope.kind !== "inventory" || fixInFlight.current) return; const ok = window.confirm( - "會重算這顆 inventory 今天以前全部 ledger 的 lotQty、日結,以及這顆料的 balance。確定?", + "會重算這顆 inventory 到今天為止全部 ledger 的 lotQty、日結,以及這顆料的 balance。確定?", ); if (!ok) return; fixInFlight.current = true; @@ -1062,8 +1062,8 @@ const StockLedgerFixPageClient: React.FC = () => { - 搜 itemCode 或 inventoryId。Fix 會重算這顆料今天以前全部流水的 lotQty、日結與 - balance,不必逐日點日曆。 + 搜 itemCode 或 inventoryId。Fix 會重算這顆料到今天為止全部流水的 lotQty、日結與 + balance,不必逐日點日曆。白天倉還在寫這顆料時,修完後新流水仍由 live writer 接。 Date: Tue, 15 Sep 2026 14:12:35 +0800 Subject: [PATCH 04/38] 1.0 fix --- src/app/api/stockLedgerFix/client.ts | 1 + src/components/StockLedgerFix/StockLedgerFixPageClient.tsx | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/app/api/stockLedgerFix/client.ts b/src/app/api/stockLedgerFix/client.ts index 675ec990..80577459 100644 --- a/src/app/api/stockLedgerFix/client.ts +++ b/src/app/api/stockLedgerFix/client.ts @@ -61,6 +61,7 @@ export type StockLedgerFixInventoryResponse = { missingUomPairsAfter: number; patchedStockUomId: number; nullStockUomIdAfter: number; + orphansDeleted?: number; }; export type StockLedgerFixSearchInventoryHit = { diff --git a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx index fb9cfec2..c9be243a 100644 --- a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx +++ b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx @@ -436,7 +436,7 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixInventory(); setInventoryMessage( - `inventory 1.0 完成:回填 stockUomId ${res.patchedStockUomId} 列、新增 ${res.inserted} 列、重算 ${res.updated} 列、之後仍缺 UOM ${res.missingUomPairsAfter}、仍 NULL stockUomId ${res.nullStockUomIdAfter}`, + `inventory 1.0 完成:回填 stockUomId ${res.patchedStockUomId} 列、新增 ${res.inserted} 列、殘列 deleted ${res.orphansDeleted ?? 0}、重算 ${res.updated} 列、之後仍缺 UOM ${res.missingUomPairsAfter}、仍 NULL stockUomId ${res.nullStockUomIdAfter}`, ); await loadInventory(); } catch (e) { @@ -713,7 +713,8 @@ const StockLedgerFixPageClient: React.FC = () => { 1.0 inventory(方案 A) 保留現有 id;lot 有、inventory 沒有的 UOM 會新增;onHand = Σ(in−out) 按 - item+UOM。沒有 lot 的列會變成 0。請在進出倉暫停時執行,再逐日 Fix。 + item+UOM。沒有 lot 的列會變成 0。同一料多顆舊列時只留一顆 stock UOM + 桶,其餘 deleted=1。請在進出倉暫停時執行,再逐日 Fix。 {inventoryError && {inventoryError}} {inventoryMessage && ( From e39365f4f8af830a7c235c26866f45218bb39724 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 15 Sep 2026 14:57:47 +0800 Subject: [PATCH 05/38] =?UTF-8?q?#24=20/=20#25=20/=20#26=EF=BC=89#27=20#22?= =?UTF-8?q?=20#4/#6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/inventory/actions.ts | 18 ++-- src/app/api/inventory/index.ts | 1 + src/app/api/inventory/inventoryBucket.ts | 82 +++++++++++++++++++ src/app/api/jo/actions.ts | 1 + src/app/api/jo/index.ts | 2 + .../InventorySearch/InventorySearch.tsx | 7 +- src/components/JoSave/JoRelease.tsx | 18 ++-- src/components/JoSave/PickTable.tsx | 18 ++-- src/components/JoSearch/JoSearch.tsx | 18 ++-- .../JoWorkbench/JoWorkbenchSearch.tsx | 18 ++-- .../ProductionProcessJobOrderDetail.tsx | 15 ++-- 11 files changed, 141 insertions(+), 57 deletions(-) create mode 100644 src/app/api/inventory/inventoryBucket.ts diff --git a/src/app/api/inventory/actions.ts b/src/app/api/inventory/actions.ts index fb7d8643..8cca8117 100644 --- a/src/app/api/inventory/actions.ts +++ b/src/app/api/inventory/actions.ts @@ -161,30 +161,22 @@ async function fetchInventoriesImpl(data: SearchInventory) { ); } -async function fetchInventoriesLatestImpl(data: SearchInventory) { - const queryStr = convertObjToURLSearchParams(data); - return serverFetchJson( - `${BASE_API_URL}/inventory/searchLatest/getRecordByPage?${queryStr}`, - { next: { tags: ["inventories"] } }, - ); -} - export const fetchInventories = cache(fetchInventoriesImpl); /** - * FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.3 | 2026-09-07 - * Inventory search page: latest inventory row per item (no baseUnit/uomId filter). + * ChangeList #24 — same as fetchInventories (all stock-UOM buckets). + * Backend `/inventory/searchLatest/getRecordByPage` is deprecated and aliases getRecordByPage. */ -export const fetchInventoriesLatest = cache(fetchInventoriesLatestImpl); +export const fetchInventoriesLatest = cache(fetchInventoriesImpl); /** Bypass React cache() after mutations so lists show fresh qty. */ export async function fetchInventoriesFresh(data: SearchInventory) { return fetchInventoriesImpl(data); } -/** Bypass React cache() for inventory search page latest-inventory search. */ +/** @deprecated Use fetchInventoriesFresh — same full-page search. */ export async function fetchInventoriesLatestFresh(data: SearchInventory) { - return fetchInventoriesLatestImpl(data); + return fetchInventoriesImpl(data); } async function fetchInventoryLotLinesImpl(data: SearchInventoryLotLine) { diff --git a/src/app/api/inventory/index.ts b/src/app/api/inventory/index.ts index 869bed2e..8002224c 100644 --- a/src/app/api/inventory/index.ts +++ b/src/app/api/inventory/index.ts @@ -14,6 +14,7 @@ export interface InventoryResult { onHoldQty: number; unavailableQty: number; availableQty: number; + stockUomId?: number | null; uomCode: string; uomUdfudesc: string; uomShortDesc: string; diff --git a/src/app/api/inventory/inventoryBucket.ts b/src/app/api/inventory/inventoryBucket.ts new file mode 100644 index 00000000..52b6199c --- /dev/null +++ b/src/app/api/inventory/inventoryBucket.ts @@ -0,0 +1,82 @@ +/** Keys used to pick one inventory row when an item has multiple stock-UOM buckets. */ +export type InventoryBucketPick = { + itemId?: number | null; + itemCode?: string | null; + itemName?: string | null; + uomId?: number | null; + stockUomId?: number | null; + uom?: string | null; + shortUom?: string | null; + stockUom?: string | null; +}; + +export type InventoryBucketRow = { + itemId?: number | null; + itemCode?: string | null; + itemName?: string | null; + stockUomId?: number | null; + uomCode?: string | null; + uomUdfudesc?: string | null; + uomShortDesc?: string | null; + availableQty?: number | null; + onHandQty?: number | null; + unavailableQty?: number | null; +}; + +const norm = (s?: string | null) => (s ?? "").trim().toLowerCase(); + +/** Available = onHand − unavailable. Do not treat 0 as missing (`||` is wrong). */ +export function inventoryAvailableQty(inv: InventoryBucketRow): number { + if (inv.availableQty != null && !Number.isNaN(Number(inv.availableQty))) { + return Number(inv.availableQty); + } + return Number(inv.onHandQty ?? 0) - Number(inv.unavailableQty ?? 0); +} + +function itemMatches(inv: InventoryBucketRow, pick: InventoryBucketPick): boolean { + if (pick.itemId != null && inv.itemId != null) { + return Number(inv.itemId) === Number(pick.itemId); + } + if (pick.itemCode && inv.itemCode) { + return inv.itemCode === pick.itemCode; + } + if (pick.itemName && inv.itemName) { + return inv.itemName === pick.itemName; + } + return false; +} + +function uomMatches(inv: InventoryBucketRow, pick: InventoryBucketPick): boolean { + const pickUomId = pick.stockUomId ?? pick.uomId; + if (pickUomId != null && inv.stockUomId != null) { + return Number(inv.stockUomId) === Number(pickUomId); + } + const labels = [pick.uom, pick.shortUom, pick.stockUom].map(norm).filter(Boolean); + if (labels.length === 0) return true; + const invLabels = [inv.uomUdfudesc, inv.uomShortDesc, inv.uomCode].map(norm); + return labels.some((l) => invLabels.includes(l)); +} + +export function matchInventoryBucket( + inventories: InventoryBucketRow[], + pick: InventoryBucketPick, +): InventoryBucketRow | undefined { + const itemHits = inventories.filter((inv) => itemMatches(inv, pick)); + if (itemHits.length === 0) return undefined; + const hasUomPick = + pick.stockUomId != null || + pick.uomId != null || + [pick.uom, pick.shortUom, pick.stockUom].some((s) => Boolean(norm(s))); + if (hasUomPick) { + return itemHits.find((inv) => uomMatches(inv, pick)); + } + return itemHits[0]; +} + +export function getStockAvailableFromInventories( + inventories: InventoryBucketRow[], + pick: InventoryBucketPick, +): number { + const inv = matchInventoryBucket(inventories, pick); + return inv ? inventoryAvailableQty(inv) : 0; +} diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 75efe315..87d6fcfb 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -533,6 +533,7 @@ export interface JobOrderLineInfo { stockUom: string, stockBaseUom: string, + stockUomId?: number | null, availableStatus: string, bomProcessId: number, diff --git a/src/app/api/jo/index.ts b/src/app/api/jo/index.ts index ed8b99f4..67bd46e7 100644 --- a/src/app/api/jo/index.ts +++ b/src/app/api/jo/index.ts @@ -67,6 +67,8 @@ export interface JoDetail { export interface JoDetailPickLine { id: number; + itemId?: number; + uomId?: number; code: string; name: string; type: string; diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index a59ff224..8d278b6a 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -12,7 +12,7 @@ import { analyzeQrCode, SearchInventory, SearchInventoryLotLine, - fetchInventoriesLatest, + fetchInventories, fetchInventoryLotLines, } from '@/app/api/inventory/actions'; import { PrinterCombo } from '@/app/api/settings/printer'; @@ -85,6 +85,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { uomCode: item.uom || uom, uomUdfudesc: uom, uomShortDesc: item.uom || uom, + stockUomId: undefined, qtyPerSmallestUnit: 1, baseUom: uom, price: 0, @@ -208,7 +209,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { pageSize: pagingController.pageSize, }; - const response = await fetchInventoriesLatest(params); + const response = await fetchInventories(params); if (response) { setInventoriesTotalCount(response.total); @@ -220,7 +221,7 @@ const InventorySearch: React.FC = ({ inventories, printerCombo }) => { break; case 'paging': setFilteredInventories((fi) => - uniqBy([...fi, ...response.records], 'itemId'), + uniqBy([...fi, ...response.records], 'id'), ); } } diff --git a/src/components/JoSave/JoRelease.tsx b/src/components/JoSave/JoRelease.tsx index fb704344..2b81b9d8 100644 --- a/src/components/JoSave/JoRelease.tsx +++ b/src/components/JoSave/JoRelease.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { JoDetailPickLine } from "@/app/api/jo"; import { fetchInventories } from "@/app/api/inventory/actions"; import { InventoryResult } from "@/app/api/inventory"; +import { getStockAvailableFromInventories } from "@/app/api/inventory/inventoryBucket"; import { useEffect, useState, useMemo } from "react"; import { useFormContext } from "react-hook-form"; import { JoDetail } from "@/app/api/jo"; @@ -50,15 +51,14 @@ const JoRelease: React.FC = ({ }, [pickLines]); const getStockAvailable = (pickLine: JoDetailPickLine) => { - const inventory = inventoryData.find(inventory => - inventory.itemCode === pickLine.code || inventory.itemName === pickLine.name - ); - - if (inventory) { - return inventory.availableQty || (inventory.onHandQty - inventory.onHoldQty - inventory.unavailableQty); - } - - return 0; + return getStockAvailableFromInventories(inventoryData, { + itemId: pickLine.itemId, + itemCode: pickLine.code, + itemName: pickLine.name, + uomId: pickLine.uomId, + uom: pickLine.uom, + shortUom: pickLine.shortUom, + }); }; const isStockSufficient = (pickLine: JoDetailPickLine) => { diff --git a/src/components/JoSave/PickTable.tsx b/src/components/JoSave/PickTable.tsx index 91237f8c..ebe63d3e 100644 --- a/src/components/JoSave/PickTable.tsx +++ b/src/components/JoSave/PickTable.tsx @@ -12,6 +12,7 @@ import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutli import HelpOutlineOutlinedIcon from '@mui/icons-material/HelpOutlineOutlined'; import { fetchInventories } from "@/app/api/inventory/actions"; import { InventoryResult } from "@/app/api/inventory"; +import { getStockAvailableFromInventories } from "@/app/api/inventory/inventoryBucket"; import { useEffect, useState } from "react"; import DoDisturbAltRoundedIcon from '@mui/icons-material/DoDisturbAltRounded'; @@ -54,15 +55,14 @@ const PickTable: React.FC = ({ }, [pickLines]); const getStockAvailable = (pickLine: JoDetailPickLine) => { - const inventory = inventoryData.find(inventory => - inventory.itemCode === pickLine.code || inventory.itemName === pickLine.name - ); - - if (inventory) { - return inventory.availableQty || (inventory.onHandQty - inventory.onHoldQty - inventory.unavailableQty); - } - - return 0; + return getStockAvailableFromInventories(inventoryData, { + itemId: pickLine.itemId, + itemCode: pickLine.code, + itemName: pickLine.name, + uomId: pickLine.uomId, + uom: pickLine.uom, + shortUom: pickLine.shortUom, + }); }; const isStockSufficient = (pickLine: JoDetailPickLine) => { diff --git a/src/components/JoSearch/JoSearch.tsx b/src/components/JoSearch/JoSearch.tsx index c6bbe1f9..c7ed6376 100644 --- a/src/components/JoSearch/JoSearch.tsx +++ b/src/components/JoSearch/JoSearch.tsx @@ -25,6 +25,7 @@ import { msg } from "../Swal/CustomAlerts"; import dayjs from "dayjs"; //import { fetchInventories } from "@/app/api/inventory/actions"; import { InventoryResult } from "@/app/api/inventory"; +import { getStockAvailableFromInventories } from "@/app/api/inventory/inventoryBucket"; import { PrinterCombo } from "@/app/api/settings/printer"; import { JobTypeResponse } from "@/app/api/jo/actions"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; @@ -132,15 +133,14 @@ const JoSearch: React.FC = ({ defaultInputs, bomCombo, printerCombo, jobT */ const getStockAvailable = (pickLine: JoDetailPickLine) => { - const inventory = inventoryData.find(inventory => - inventory.itemCode === pickLine.code || inventory.itemName === pickLine.name - ); - - if (inventory) { - return inventory.availableQty || (inventory.onHandQty - inventory.onHoldQty - inventory.unavailableQty); - } - - return 0; + return getStockAvailableFromInventories(inventoryData, { + itemId: pickLine.itemId, + itemCode: pickLine.code, + itemName: pickLine.name, + uomId: pickLine.uomId, + uom: pickLine.uom, + shortUom: pickLine.shortUom, + }); }; const isStockSufficient = (pickLine: JoDetailPickLine) => { diff --git a/src/components/JoWorkbench/JoWorkbenchSearch.tsx b/src/components/JoWorkbench/JoWorkbenchSearch.tsx index 54792aa8..def91204 100644 --- a/src/components/JoWorkbench/JoWorkbenchSearch.tsx +++ b/src/components/JoWorkbench/JoWorkbenchSearch.tsx @@ -26,6 +26,7 @@ import { msg } from "../Swal/CustomAlerts"; import dayjs from "dayjs"; //import { fetchInventories } from "@/app/api/inventory/actions"; import { InventoryResult } from "@/app/api/inventory"; +import { getStockAvailableFromInventories } from "@/app/api/inventory/inventoryBucket"; import { PrinterCombo } from "@/app/api/settings/printer"; import { JobTypeResponse } from "@/app/api/jo/actions"; import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; @@ -133,15 +134,14 @@ const JoWorkbenchSearch: React.FC = ({ defaultInputs, bomCombo, printerCo */ const getStockAvailable = (pickLine: JoDetailPickLine) => { - const inventory = inventoryData.find(inventory => - inventory.itemCode === pickLine.code || inventory.itemName === pickLine.name - ); - - if (inventory) { - return inventory.availableQty || (inventory.onHandQty - inventory.onHoldQty - inventory.unavailableQty); - } - - return 0; + return getStockAvailableFromInventories(inventoryData, { + itemId: pickLine.itemId, + itemCode: pickLine.code, + itemName: pickLine.name, + uomId: pickLine.uomId, + uom: pickLine.uom, + shortUom: pickLine.shortUom, + }); }; const isStockSufficient = (pickLine: JoDetailPickLine) => { diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index dbb73b76..78818a53 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -36,6 +36,7 @@ import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutli import DoDisturbAltRoundedIcon from '@mui/icons-material/DoDisturbAltRounded'; import { fetchInventories } from "@/app/api/inventory/actions"; import { InventoryResult } from "@/app/api/inventory"; +import { matchInventoryBucket, inventoryAvailableQty } from "@/app/api/inventory/inventoryBucket"; import { releaseJoForWorkbench } from "@/app/api/jo/workbenchActions"; import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; import ProcessSummaryHeader from "./ProcessSummaryHeader"; @@ -187,13 +188,17 @@ const getStockAvailable = (line: JobOrderLineInfo) => { if (line.type?.toLowerCase() === "consumables" || line.type?.toLowerCase() === "nm") { return line.stockQty || 0; } - const inventory = inventoryData.find(inv => - inv.itemCode === line.itemCode || inv.itemName === line.itemName - ); + const inventory = matchInventoryBucket(inventoryData, { + itemId: line.itemId, + itemCode: line.itemCode, + itemName: line.itemName, + stockUomId: line.stockUomId, + stockUom: line.stockUom, + }); if (inventory) { - return inventory.availableQty || (inventory.onHandQty - inventory.onHoldQty - inventory.unavailableQty); + return inventoryAvailableQty(inventory); } - return line.stockQty || 0; + return line.stockQty ?? 0; }; const handleOpenPlanStartDialog = useCallback(() => { // 将 processData.date 转换为 dayjs 对象 From 5eab59df8cb1be036c7351457594794fc1cc28fb Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Wed, 16 Sep 2026 11:40:51 +0800 Subject: [PATCH 06/38] inventory search fix --- src/app/api/settings/item/index.ts | 3 +++ src/app/api/stockAdjustment/actions.ts | 1 + src/components/InventorySearch/InventoryLotLineTable.tsx | 6 +++++- src/components/InventorySearch/InventorySearch.tsx | 6 ++++-- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/app/api/settings/item/index.ts b/src/app/api/settings/item/index.ts index e85933e0..f23c1145 100644 --- a/src/app/api/settings/item/index.ts +++ b/src/app/api/settings/item/index.ts @@ -66,6 +66,9 @@ export type ItemsResult = { latestMarketUnitPrice?: number; latestMupUpdatedDate?: string; purchaseUnit?: string; + uomId?: number; + uom?: string; + uomDesc?: string; }; export type Result = { diff --git a/src/app/api/stockAdjustment/actions.ts b/src/app/api/stockAdjustment/actions.ts index c42e0c09..f7742248 100644 --- a/src/app/api/stockAdjustment/actions.ts +++ b/src/app/api/stockAdjustment/actions.ts @@ -16,6 +16,7 @@ export interface StockAdjustmentLineRequest { expiryDate: string; warehouseId: number; uom?: string | null; + uomId?: number | null; remarks?: string | null; } diff --git a/src/components/InventorySearch/InventoryLotLineTable.tsx b/src/components/InventorySearch/InventoryLotLineTable.tsx index 237112dc..c3a886a5 100644 --- a/src/components/InventorySearch/InventoryLotLineTable.tsx +++ b/src/components/InventorySearch/InventoryLotLineTable.tsx @@ -44,6 +44,7 @@ type AdjustmentEntry = InventoryLotLineResult & { isNew?: boolean; isOpeningInventory?: boolean; remarks?: string; + uomId?: number; }; @@ -61,7 +62,7 @@ interface Props { onStockAdjustmentSuccess?: () => void | Promise; } -/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.8 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.9 | 2026-09-16 */ const InventoryLotLineTable: React.FC = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory, filterLotNo, @@ -157,6 +158,7 @@ const prevAdjustmentModalOpenRef = useRef(false); adjustedQty: line.availableQty ?? 0, originalQty: line.availableQty ?? 0, remarks: '', + uomId: inventory.stockUomId ?? inventory.uomId, })); setAdjustmentEntries(initial); originalAdjustmentLinesRef.current = initial; @@ -276,6 +278,7 @@ const prevAdjustmentModalOpenRef = useRef(false); expiryDate, warehouseId: line.warehouse?.id ?? 0, uom: line.uom ?? null, + uomId: line.uomId ?? null, remarks: line.remarks?.trim() || null, }; }, []); @@ -345,6 +348,7 @@ const prevAdjustmentModalOpenRef = useRef(false); status: 'available', availableQty: addEntryForm.qty, uom: inventory.uomUdfudesc || inventory.uomShortDesc || inventory.uomCode, + uomId: inventory.stockUomId ?? inventory.uomId, qtyPerSmallestUnit: inventory.qtyPerSmallestUnit ?? 1, baseUom: inventory.baseUom || '', stockInLineId: 0, diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index 170f051e..8e908e23 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -62,6 +62,7 @@ type ItemLookupRow = { type?: string; uom?: string; uomDesc?: string; + uomId?: number; purchaseUnit?: string; }; @@ -83,7 +84,7 @@ const inventoryPageRowKey = (row: InventoryResult, byLocation: boolean) => ? `${row.itemId}-${row.uomId ?? row.stockUomId ?? row.uomUdfudesc ?? ''}` : String(row.id); -/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.4 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 18 | v1.0.5 | 2026-09-16 */ const InventorySearch: React.FC = ({ inventories, printerCombo, @@ -108,10 +109,11 @@ const InventorySearch: React.FC = ({ onHoldQty: 0, unavailableQty: 0, availableQty: 0, + uomId: item.uomId != null ? Number(item.uomId) : undefined, uomCode: item.uom || uom, uomUdfudesc: uom, uomShortDesc: item.uom || uom, - stockUomId: undefined, + stockUomId: item.uomId != null ? Number(item.uomId) : undefined, qtyPerSmallestUnit: 1, baseUom: uom, price: 0, From cee2dc15f8f332dcce34c8d1327003e46179f822 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Wed, 16 Sep 2026 11:44:34 +0800 Subject: [PATCH 07/38] =?UTF-8?q?Merge=20branch=20'fix=E8=B2=A0=E6=95=B8?= =?UTF-8?q?=E5=80=89'=20of=20http://svn.2fi-solutions.com:8300/derek/FPSMS?= =?UTF-8?q?-frontend=20into=20fix=E8=B2=A0=E6=95=B8=E5=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/app/api/inventory/index.ts --- src/app/api/inventory/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/api/inventory/index.ts b/src/app/api/inventory/index.ts index 74a7bb52..f403d291 100644 --- a/src/app/api/inventory/index.ts +++ b/src/app/api/inventory/index.ts @@ -16,6 +16,7 @@ export interface InventoryResult { availableQty: number; /** Item Search / getRecordByPage (inventory bucket). */ stockUomId?: number | null; + stockUomCode?: string | null; /** Location Search / searchLatest (lot-line grouped row). Same UOM id space as stockUomId. */ uomId?: number; uomCode: string; From 533dd176c17f6bf458358e612773040dee1d2642 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Wed, 16 Sep 2026 16:57:00 +0800 Subject: [PATCH 08/38] [Fix] EN support on "Stock Ledger Fix" page --- .../(main)/settings/stockLedgerFix/page.tsx | 18 +- src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../StockLedgerFixPageClient.tsx | 496 +++++++++++------- src/i18n/en/navigation.json | 2 + src/i18n/en/stockLedgerFix.json | 147 ++++++ src/i18n/zh/navigation.json | 2 + src/i18n/zh/stockLedgerFix.json | 147 ++++++ 7 files changed, 606 insertions(+), 207 deletions(-) create mode 100644 src/i18n/en/stockLedgerFix.json create mode 100644 src/i18n/zh/stockLedgerFix.json diff --git a/src/app/(main)/settings/stockLedgerFix/page.tsx b/src/app/(main)/settings/stockLedgerFix/page.tsx index 1602d68c..d477b351 100644 --- a/src/app/(main)/settings/stockLedgerFix/page.tsx +++ b/src/app/(main)/settings/stockLedgerFix/page.tsx @@ -1,16 +1,22 @@ import { Metadata } from "next"; import PageTitleBar from "@/components/PageTitleBar"; import StockLedgerFixPageClient from "@/components/StockLedgerFix/StockLedgerFixPageClient"; +import { getServerI18n, I18nProvider } from "@/i18n"; -export const metadata: Metadata = { - title: "Stock Ledger Fix", -}; +export async function generateMetadata(): Promise { + const { t } = await getServerI18n("stockLedgerFix"); + return { title: t("pageTitle") }; +} + +const StockLedgerFixPage: React.FC = async () => { + const { t } = await getServerI18n("stockLedgerFix"); -const StockLedgerFixPage: React.FC = () => { return ( <> - - + + + + ); }; diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index a5804a95..cea41b5e 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -35,6 +35,7 @@ const pathToLabelKey: { [path: string]: string } = { "/settings/qrCodeHandle": "nav.breadcrumb.qrCodeHandle", "/settings/deliveryOrderFloor": "nav.breadcrumb.deliveryOrderFloor", "/settings/masterDataIssues": "nav.breadcrumb.masterDataIssues", + "/settings/stockLedgerFix": "nav.breadcrumb.stockLedgerFix", "/settings/rss": "nav.breadcrumb.demandForecast", "/settings/equipment": "nav.breadcrumb.equipment", "/settings/equipment/MaintenanceEdit": "nav.breadcrumb.equipmentMaintenanceEdit", diff --git a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx index c9be243a..ed79b094 100644 --- a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx +++ b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx @@ -24,8 +24,10 @@ import { import { DateCalendar } from "@mui/x-date-pickers/DateCalendar"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { useTranslation } from "react-i18next"; import dayjs, { type Dayjs } from "dayjs"; import "dayjs/locale/zh-hk"; +import "dayjs/locale/en"; import { fetchStockLedgerFixAdjPreview, runStockLedgerFixAdj, @@ -62,13 +64,13 @@ const ALL_DAY_STEPS: Record = { "2.5": true, "2.6": true, }; -const DAY_STEP_LABEL: Record = { - "2.1": "2.1 lot", - "2.2": "2.2 uom", - "2.3": "2.3 inventoryId", - "2.4": "2.4 lotQty", - "2.5": "2.5 balance", - "2.6": "2.6 日結", +const DAY_STEP_I18N: Record = { + "2.1": "step21", + "2.2": "step22", + "2.3": "step23", + "2.4": "step24", + "2.5": "step25", + "2.6": "step26", }; /** Export SQL parts (aligned with fix steps + 1.0 / 2.7). */ @@ -88,12 +90,12 @@ const FULL_EXPORT_PARTS: Record = { "2.6": true, "2.7": true, }; -const EXPORT_PART_LABEL: Record = { - "1.0": "1.0 inventory", - "2.3": "2.3 inventoryId", - ledger: "ledger lot/uom/lotQty/balance", - "2.6": "2.6 日結", - "2.7": "2.7 ADJ+入出倉單", +const EXPORT_PART_I18N: Record = { + "1.0": "export10", + "2.3": "export23", + ledger: "exportLedger", + "2.6": "export26", + "2.7": "export27", }; function selectedExportParts(flags: Record): ExportPart[] { @@ -153,7 +155,20 @@ function partVerdict( return "correct"; } +const VERDICT_LABEL: Record< + ReturnType, + "verdictCorrect" | "verdictMiss" | "verdictOverIssue" | "verdictCanFix" | "verdictCannotFix" | "verdictIncorrect" +> = { + correct: "verdictCorrect", + miss: "verdictMiss", + "over-issue": "verdictOverIssue", + "can-fix": "verdictCanFix", + "cannot-fix": "verdictCannotFix", + incorrect: "verdictIncorrect", +}; + function CheckPartsTable({ parts }: { parts: StockLedgerFixCheckPart[] }) { + const { t } = useTranslation("stockLedgerFix"); const field = parts.filter((p) => !p.group || p.group === "field"); const canFix = parts.filter( (p) => p.group === "canFix" && p.miss + p.incorrect > 0, @@ -166,23 +181,13 @@ function CheckPartsTable({ parts }: { parts: StockLedgerFixCheckPart[] }) { const v = partVerdict(p); return ( - {p.label} + + {t(`part.${p.key}`, { defaultValue: p.label })} + - +
- 項目 - 狀態 - 正確 - 缺 - 不正確 + {t("checkItem")} + {t("checkStatus")} + {t("checkCorrect")} + {t("checkMiss")} + {t("checkIncorrect")} {renderRows(field)}
{canFix.length > 0 && ( <> - 可自動修(按 Fix 會處理) + + {t("canAutoFix")} + - 原因 - 狀態 - 正確 - 列數 - — + {t("checkReason")} + {t("checkStatus")} + {t("checkCorrect")} + {t("checkRows")} + {t("checkDash")} {renderRows(canFix)} @@ -231,17 +238,17 @@ function CheckPartsTable({ parts }: { parts: StockLedgerFixCheckPart[] }) { )} {cannotFix.length > 0 && ( <> - - 不能自動修(Fix 不會消;舊 TKE 開新批/真超發/沒有 lot 來源) + + {t("cannotAutoFix")}
- 原因 - 狀態 - 正確 - — - 列數 + {t("checkReason")} + {t("checkStatus")} + {t("checkCorrect")} + {t("checkDash")} + {t("checkRows")} {renderRows(cannotFix)} @@ -253,6 +260,9 @@ function CheckPartsTable({ parts }: { parts: StockLedgerFixCheckPart[] }) { } const StockLedgerFixPageClient: React.FC = () => { + const { t, i18n } = useTranslation("stockLedgerFix"); + const isZh = (i18n.language || "zh").startsWith("zh"); + const listSep = isZh ? "、" : ", "; const [tab, setTab] = useState<"day" | "inventory" | "lot">("day"); const [selected, setSelected] = useState(() => dayjs().subtract(1, "day"), @@ -321,13 +331,13 @@ const StockLedgerFixPageClient: React.FC = () => { setInventoryPreview(data); } catch (e) { console.error(e); - setInventoryError("無法載入 inventory 1.0 預覽(需要 ADMIN / TESTING)"); + setInventoryError(t("inventory10LoadError")); setInventoryPreview(null); } finally { setInventoryLoading(false); inventoryLoadInFlight.current = false; } - }, []); + }, [t]); const loadDay = useCallback(async (date: string) => { if (detailInFlight.current) return; @@ -340,23 +350,23 @@ const StockLedgerFixPageClient: React.FC = () => { setDetail(data); } catch (e) { console.error(e); - setDetailError("無法載入當日檢查"); + setDetailError(t("dayLoadError")); setDetail(null); } finally { setDetailLoading(false); detailInFlight.current = false; } - }, []); + }, [t]); const loadAdjPreview = useCallback(async () => { if (adjLoadInFlight.current) return; const d = adjDate.trim(); if (!d) { - setAdjError("請填 ADJ 日期"); + setAdjError(t("adjDateRequired")); return; } if (d > dayjs().format("YYYY-MM-DD")) { - setAdjError("ADJ 日期不能是未來"); + setAdjError(t("adjDateFuture")); return; } adjLoadInFlight.current = true; @@ -366,13 +376,13 @@ const StockLedgerFixPageClient: React.FC = () => { setAdjPreview(await fetchStockLedgerFixAdjPreview(d)); } catch (e) { console.error(e); - setAdjError(apiErrorMessage(e, "無法載入 ADJ 預覽(需要 ADMIN / TESTING)")); + setAdjError(apiErrorMessage(e, t("adjLoadError"))); setAdjPreview(null); } finally { setAdjLoading(false); adjLoadInFlight.current = false; } - }, [adjDate]); + }, [adjDate, t]); useEffect(() => { void loadInventory(); @@ -388,19 +398,19 @@ const StockLedgerFixPageClient: React.FC = () => { if (!selected || fixInFlight.current) return; const date = selected.format("YYYY-MM-DD"); if (selected.isAfter(dayjs(), "day")) { - setFixError("不能修未來日期"); + setFixError(t("cannotFixFuture")); return; } const picked = selectedDaySteps(daySteps); if (picked.length === 0) { - setFixError("請至少勾一個步驟(預設全跑 2.1–2.6)"); + setFixError(t("pickAtLeastOneStep")); return; } const steps = stepsPayload(daySteps); - const stepLabel = steps?.join("、") ?? "全部 2.1–2.6"; + const stepLabel = steps?.join(listSep) ?? t("allSteps216"); if (steps) { const ok = window.confirm( - `只跑 ${stepLabel}(${date})。未勾的步驟不會重算。確定?`, + t("confirmPartialSteps", { steps: stepLabel, date }), ); if (!ok) return; } @@ -411,12 +421,21 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixDay(date, steps); setFixMessage( - `已修 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + t("fixDayDone", { + date: res.date, + steps: stepLabel, + lot: res.filledLotLineId, + uom: res.filledUomId, + inventory: res.filledInventoryId, + lotQty: res.filledLotQty, + balance: res.filledBalance, + dayRows: res.dayRowsWritten, + }), ); await loadDay(date); } catch (e) { console.error(e); - setFixError(apiErrorMessage(e, "修復失敗")); + setFixError(apiErrorMessage(e, t("fixFailed"))); } finally { setFixing(false); fixInFlight.current = false; @@ -425,9 +444,7 @@ const StockLedgerFixPageClient: React.FC = () => { const onInventory = async () => { if (inventoryRunInFlight.current) return; - const ok = window.confirm( - "方案 A:保留現有 inventory.id;從 lot 回填 stockUomId(舊 uomId 是 stock 則改回 base);補缺 (itemId, stockUomId);用 lot 的 in−out 重算 onHand/unavailable。不寫 onHoldQty、不改 inventory_lot_line。確定執行?", - ); + const ok = window.confirm(t("inventory10Confirm")); if (!ok) return; inventoryRunInFlight.current = true; setInventoryRunning(true); @@ -436,12 +453,19 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixInventory(); setInventoryMessage( - `inventory 1.0 完成:回填 stockUomId ${res.patchedStockUomId} 列、新增 ${res.inserted} 列、殘列 deleted ${res.orphansDeleted ?? 0}、重算 ${res.updated} 列、之後仍缺 UOM ${res.missingUomPairsAfter}、仍 NULL stockUomId ${res.nullStockUomIdAfter}`, + t("inventory10Done", { + patched: res.patchedStockUomId, + inserted: res.inserted, + orphans: res.orphansDeleted ?? 0, + updated: res.updated, + missingAfter: res.missingUomPairsAfter, + nullAfter: res.nullStockUomIdAfter, + }), ); await loadInventory(); } catch (e) { console.error(e); - setInventoryError(apiErrorMessage(e, "inventory 1.0 失敗")); + setInventoryError(apiErrorMessage(e, t("inventory10Fail"))); } finally { setInventoryRunning(false); inventoryRunInFlight.current = false; @@ -452,11 +476,19 @@ const StockLedgerFixPageClient: React.FC = () => { if (adjRunInFlight.current) return; const d = adjDate.trim() || adjPreview?.adjDate; if (!d) { - setAdjError("請填 ADJ 日期並先預覽"); + setAdjError(t("adjDateAndPreviewRequired")); return; } const ok = window.confirm( - `會在 ${d} 先修超發 remain→0(${adjPreview?.overIssueCount ?? 0} 條 lot,合計 ${adjPreview?.sumOverIssue ?? "?"},會改 lot inQty)。再補 miss ADJ:入向 ${adjPreview?.adjInCount ?? 0} 筆、出向 ${adjPreview?.adjOutCount ?? 0} 筆。入向全部掛同一張入倉單、出向全部掛同一張出倉單(miss 不改 lot 數量)。缺入合計 ${adjPreview?.sumMissIn ?? "?"}、缺出合計 ${adjPreview?.sumMissOut ?? "?"}。確定?`, + t("adjConfirm", { + date: d, + overIssueCount: adjPreview?.overIssueCount ?? 0, + sumOverIssue: adjPreview?.sumOverIssue ?? "?", + adjInCount: adjPreview?.adjInCount ?? 0, + adjOutCount: adjPreview?.adjOutCount ?? 0, + sumMissIn: adjPreview?.sumMissIn ?? "?", + sumMissOut: adjPreview?.sumMissOut ?? "?", + }), ); if (!ok) return; adjRunInFlight.current = true; @@ -466,12 +498,20 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixAdj(d); setAdjMessage( - `ADJ ${res.adjDate}:超發 remain→0 ${res.overIssuePatched}、入 ${res.insertedIn}、出 ${res.insertedOut}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + t("adjDone", { + date: res.adjDate, + overIssuePatched: res.overIssuePatched, + insertedIn: res.insertedIn, + insertedOut: res.insertedOut, + filledLotQty: res.filledLotQty, + filledBalance: res.filledBalance, + dayRowsWritten: res.dayRowsWritten, + }), ); await loadAdjPreview(); } catch (e) { console.error(e); - setAdjError(apiErrorMessage(e, "ADJ 對齊失敗")); + setAdjError(apiErrorMessage(e, t("adjFail"))); } finally { setAdjRunning(false); adjRunInFlight.current = false; @@ -489,7 +529,7 @@ const StockLedgerFixPageClient: React.FC = () => { setScope(null); } catch (e) { console.error(e); - setSearchError(apiErrorMessage(e, "搜尋失敗")); + setSearchError(apiErrorMessage(e, t("searchFailed"))); } finally { setSearching(false); searchInFlight.current = false; @@ -507,7 +547,7 @@ const StockLedgerFixPageClient: React.FC = () => { setScope(null); } catch (e) { console.error(e); - setSearchError(apiErrorMessage(e, "搜尋失敗")); + setSearchError(apiErrorMessage(e, t("searchFailed"))); } finally { setSearching(false); searchInFlight.current = false; @@ -524,7 +564,7 @@ const StockLedgerFixPageClient: React.FC = () => { setScope(await fetchStockLedgerFixInventoryScope(id)); } catch (e) { console.error(e); - setFixError(apiErrorMessage(e, "無法載入 inventory")); + setFixError(apiErrorMessage(e, t("invLoadError"))); setScope(null); } finally { setScopeLoading(false); @@ -542,7 +582,7 @@ const StockLedgerFixPageClient: React.FC = () => { setScope(await fetchStockLedgerFixLotScope(id)); } catch (e) { console.error(e); - setFixError(apiErrorMessage(e, "無法載入 lot")); + setFixError(apiErrorMessage(e, t("lotLoadError"))); setScope(null); } finally { setScopeLoading(false); @@ -552,9 +592,7 @@ const StockLedgerFixPageClient: React.FC = () => { const onFixInventoryScope = async () => { if (!scope || scope.kind !== "inventory" || fixInFlight.current) return; - const ok = window.confirm( - "會重算這顆 inventory 到今天為止全部 ledger 的 lotQty、日結,以及這顆料的 balance。確定?", - ); + const ok = window.confirm(t("invFixConfirm")); if (!ok) return; fixInFlight.current = true; setFixing(true); @@ -563,12 +601,20 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixInventoryScope(scope.id); setFixMessage( - `已修 inventory ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + t("invFixDone", { + id: scope.id, + lot: res.filledLotLineId, + uom: res.filledUomId, + inventory: res.filledInventoryId, + lotQty: res.filledLotQty, + balance: res.filledBalance, + dayRows: res.dayRowsWritten, + }), ); await loadInventoryScope(scope.id); } catch (e) { console.error(e); - setFixError(apiErrorMessage(e, "修復失敗")); + setFixError(apiErrorMessage(e, t("fixFailed"))); } finally { setFixing(false); fixInFlight.current = false; @@ -577,9 +623,7 @@ const StockLedgerFixPageClient: React.FC = () => { const onFixLotScope = async () => { if (!scope || scope.kind !== "lot" || fixInFlight.current) return; - const ok = window.confirm( - "只修這張 lot 的 lotQty 與日結,不會改整顆料的 balance。確定?", - ); + const ok = window.confirm(t("lotFixConfirm")); if (!ok) return; fixInFlight.current = true; setFixing(true); @@ -588,12 +632,19 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixLotScope(scope.id); setFixMessage( - `已修 lot ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、日結 ${res.dayRowsWritten}`, + t("lotFixDone", { + id: scope.id, + lot: res.filledLotLineId, + uom: res.filledUomId, + inventory: res.filledInventoryId, + lotQty: res.filledLotQty, + dayRows: res.dayRowsWritten, + }), ); await loadLotScope(scope.id); } catch (e) { console.error(e); - setFixError(apiErrorMessage(e, "修復失敗")); + setFixError(apiErrorMessage(e, t("fixFailed"))); } finally { setFixing(false); fixInFlight.current = false; @@ -608,30 +659,30 @@ const StockLedgerFixPageClient: React.FC = () => { const to = chainTo.trim(); const today = dayjs().format("YYYY-MM-DD"); if (!from || !to) { - setFixError("請填區間修 from / to"); + setFixError(t("rangeFromRequired")); return; } if (to < from) { - setFixError("to 必須 ≥ from"); + setFixError(t("toMustBeGteFrom")); return; } if (from < FIRST_LEDGER_DAY.format("YYYY-MM-DD")) { - setFixError(`from 不能早於 ${FIRST_LEDGER_DAY.format("YYYY-MM-DD")}`); + setFixError(t("fromTooEarly", { date: FIRST_LEDGER_DAY.format("YYYY-MM-DD") })); return; } if (to > today) { - setFixError("不能修未來日期(今天可以,給 freeze dump 夜修)"); + setFixError(t("cannotFixFutureRange")); return; } const picked = selectedDaySteps(daySteps); if (picked.length === 0) { - setFixError("請至少勾一個步驟(預設全跑 2.1–2.6)"); + setFixError(t("pickAtLeastOneStep")); return; } const steps = stepsPayload(daySteps); - const stepLabel = steps?.join("、") ?? "全部 2.1–2.6"; + const stepLabel = steps?.join(listSep) ?? t("allSteps216"); const ok = window.confirm( - `一次修 ${from} → ${to}(步驟:${stepLabel})。改為逐日跑(每天 commit)。中途失敗時已跑完的天會留下。確定?`, + t("rangeConfirm", { from, to, steps: stepLabel }), ); if (!ok) return; @@ -640,17 +691,26 @@ const StockLedgerFixPageClient: React.FC = () => { setFixing(true); setFixError(null); setFixMessage(null); - setChainProgress(`區間修 ${from} → ${to}(${stepLabel})進行中…`); + setChainProgress(t("rangeProgress", { from, to, steps: stepLabel })); try { const res = await runStockLedgerFixRange(from, to, steps); setFixMessage( - `區間修完成 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + t("rangeDone", { + date: res.date, + steps: stepLabel, + lot: res.filledLotLineId, + uom: res.filledUomId, + inventory: res.filledInventoryId, + lotQty: res.filledLotQty, + balance: res.filledBalance, + dayRows: res.dayRowsWritten, + }), ); setSelected(dayjs(to)); setChainProgress(null); } catch (e) { console.error(e); - setFixError(apiErrorMessage(e, "區間修失敗")); + setFixError(apiErrorMessage(e, t("rangeFail"))); } finally { setFixing(false); setChainRunning(false); @@ -663,22 +723,20 @@ const StockLedgerFixPageClient: React.FC = () => { const from = exportFrom.trim(); const to = exportTo.trim(); if (!from || !to) { - setExportError("請填 from / to"); + setExportError(t("exportFromToRequired")); return; } if (to < from) { - setExportError("to 必須 ≥ from"); + setExportError(t("toMustBeGteFrom")); return; } const picked = selectedExportParts(exportParts); if (picked.length === 0) { - setExportError("請至少勾一個匯出項目"); + setExportError(t("exportPickAtLeastOne")); return; } if (exportParts["2.3"] && !exportParts["1.0"]) { - const ok = window.confirm( - "已勾 2.3 inventoryId 但未勾 1.0 inventory:正式庫若缺新 id,UPDATE 會指到空列。建議一併勾 1.0(完整檔)。仍要匯出?", - ); + const ok = window.confirm(t("export23Without10")); if (!ok) return; } const parts = exportPartsPayload(exportParts); @@ -693,12 +751,12 @@ const StockLedgerFixPageClient: React.FC = () => { if (data instanceof Blob) { try { const text = (await data.text()).trim().slice(0, 400); - setExportError(text || "匯出失敗"); + setExportError(text || t("exportFail")); } catch { - setExportError(apiErrorMessage(e, "匯出失敗")); + setExportError(apiErrorMessage(e, t("exportFail"))); } } else { - setExportError(apiErrorMessage(e, "匯出失敗")); + setExportError(apiErrorMessage(e, t("exportFail"))); } } finally { setExporting(false); @@ -710,11 +768,9 @@ const StockLedgerFixPageClient: React.FC = () => { - 1.0 inventory(方案 A) + {t("inventory10Title")} - 保留現有 id;lot 有、inventory 沒有的 UOM 會新增;onHand = Σ(in−out) 按 - item+UOM。沒有 lot 的列會變成 0。同一料多顆舊列時只留一顆 stock UOM - 桶,其餘 deleted=1。請在進出倉暫停時執行,再逐日 Fix。 + {t("inventory10Description")} {inventoryError && {inventoryError}} {inventoryMessage && ( @@ -723,10 +779,12 @@ const StockLedgerFixPageClient: React.FC = () => { {inventoryLoading && !inventoryPreview && } {inventoryPreview && ( - 現有 inventory {inventoryPreview.inventoryRows} 列;lot 的 - item+UOM {inventoryPreview.lotUomPairs} 組;缺{" "} - {inventoryPreview.missingUomPairs} 組;stockUomId 仍 NULL{" "} - {inventoryPreview.nullStockUomId} 列。 + {t("inventory10Preview", { + rows: inventoryPreview.inventoryRows, + lotPairs: inventoryPreview.lotUomPairs, + missing: inventoryPreview.missingUomPairs, + nullUom: inventoryPreview.nullStockUomId, + })} )} @@ -736,7 +794,7 @@ const StockLedgerFixPageClient: React.FC = () => { disabled={inventoryRunning || inventoryLoading} onClick={() => void onInventory()} > - {inventoryRunning ? "重算中…" : "Run inventory 1.0 (A)"} + {inventoryRunning ? t("inventory10Running") : t("inventory10Run")} @@ -744,19 +802,15 @@ const StockLedgerFixPageClient: React.FC = () => { - 2.7 ADJ:超發 remain→0,再對齊 ledger + {t("adjTitle")} - 請先選 ADJ 日期再預覽。預設昨天;freeze dump 夜可選今天(例如 31/8 23:17 寫 ADJ 到 - 31/8)。先處理超發(line 入少於出):把該 lot 的 inQty 補到等於 outQty(remain=0,trigger - 重算 inventory)。再處理 miss:line 與 ledger 有差就 ADJ,掛既有 lot 的入/出倉行(不改 lot - 數量)。一次 Apply 共用一張入倉單、一張出倉單,下面多行;同一 ADJ 日期再跑會沿用這兩張單。 - 明細最多 20 列。 + {t("adjDescription")} { setAdjDate(e.target.value); @@ -775,7 +829,7 @@ const StockLedgerFixPageClient: React.FC = () => { setAdjPreview(null); }} > - 今天(freeze 夜) + {t("adjTodayFreeze")} {adjError && {adjError}} @@ -793,36 +847,43 @@ const StockLedgerFixPageClient: React.FC = () => { {adjLoading && !adjPreview && } {adjPreview && ( - ADJ 日期 {adjPreview.adjDate};可處理 {adjPreview.lotCount} 條 - lot(超發 remain→0 {adjPreview.overIssueCount}、合計 {adjPreview.sumOverIssue};miss 入{" "} - {adjPreview.adjInCount}、出 {adjPreview.adjOutCount});缺入合計{" "} - {adjPreview.sumMissIn}、缺出合計 {adjPreview.sumMissOut};SKU 淨額{" "} - {adjPreview.skuNet} - {adjPreview.skuNet !== "0" ? "(非 0 表示有缺入沒有對應缺出)" : ""} - {adjPreview.skippedNegCount > 0 - ? `;含反向 ADJ ${adjPreview.skippedNegCount} 條(ledger 多記)` - : ""} - 。下列最多 20 列(超發優先)。 + {t("adjPreviewSummary", { + date: adjPreview.adjDate, + lotCount: adjPreview.lotCount, + overIssueCount: adjPreview.overIssueCount, + sumOverIssue: adjPreview.sumOverIssue, + adjInCount: adjPreview.adjInCount, + adjOutCount: adjPreview.adjOutCount, + sumMissIn: adjPreview.sumMissIn, + sumMissOut: adjPreview.sumMissOut, + skuNet: adjPreview.skuNet, + skuNetNote: + adjPreview.skuNet !== "0" ? t("adjSkuNetNote") : "", + revNote: + adjPreview.skippedNegCount > 0 + ? t("adjRevNote", { count: adjPreview.skippedNegCount }) + : "", + })} )} {adjPreview && adjPreview.rows.length > 0 && (
- lotLineId - itemCode - line in/out - ledger in/out - 缺入 - 缺出 - 超發 + {t("adjColLotLineId")} + {t("adjColItemCode")} + {t("adjColLineInOut")} + {t("adjColLedgerInOut")} + {t("adjColMissIn")} + {t("adjColMissOut")} + {t("adjColOverIssue")} {adjPreview.rows.map((r) => ( {r.lotLineId} - {r.itemCode ?? "—"} + {r.itemCode ?? t("checkDash")} {r.lineIn} / {r.lineOut} @@ -843,7 +904,11 @@ const StockLedgerFixPageClient: React.FC = () => { disabled={adjLoading || adjRunning} onClick={() => void loadAdjPreview()} > - {adjLoading ? "預覽中…" : adjPreview ? "重新預覽" : "預覽"} + {adjLoading + ? t("adjPreviewing") + : adjPreview + ? t("adjPreviewAgain") + : t("adjPreview")} @@ -867,16 +932,24 @@ const StockLedgerFixPageClient: React.FC = () => { setSearchError(null); }} > - - - + + + {tab === "day" && ( - - - + + + setSelected(v)} @@ -886,15 +959,19 @@ const StockLedgerFixPageClient: React.FC = () => { maxDate={dayjs()} /> - - 點左上標題可先選年再選月。換月不會查資料;點某一天才檢查當天。今天可修(freeze dump 夜)。 + + {t("calendarHint")} - + - {selected ? selected.format("YYYY-MM-DD") : "請選日期"} + {selected ? selected.format("YYYY-MM-DD") : t("selectDate")} {detailError && {detailError}} {fixError && {fixError}} @@ -904,22 +981,26 @@ const StockLedgerFixPageClient: React.FC = () => { {detail && !detailLoading && ( <> - 當天 ledger {detail.cnt} 列 + {t("dayLedgerCount", { cnt: detail.cnt })} )} - 步驟(預設全跑) + {t("stepsTitle")} - 套 SQL 後只改 inventoryId 時只勾 2.3。只跑部分時未勾的不會重算。 + {t("stepsHint")} {DAY_FIX_STEPS.map((step) => ( { } /> } - label={DAY_STEP_LABEL[step]} + label={t(DAY_STEP_I18N[step])} /> ))} @@ -948,21 +1029,21 @@ const StockLedgerFixPageClient: React.FC = () => { disabled={!canFixDay || fixing || chainRunning} onClick={() => void onFixDay()} > - {fixing && !chainRunning ? "修復中…" : "Fix this day"} + {fixing && !chainRunning ? t("fixing") : t("fixThisDay")} - 區間修(2.1–2.6 一次) + {t("rangeTitle")} - 使用上面勾的步驟,一次 API 但改為逐日(與單日修相同 SQL,每天 commit)。可含今天(freeze dump 夜)。前端最長約 20 分鐘;大區間仍建議匯出 SQL。 + {t("rangeHint")} setChainFrom(e.target.value)} disabled={chainRunning} @@ -971,7 +1052,7 @@ const StockLedgerFixPageClient: React.FC = () => { setChainTo(e.target.value)} disabled={chainRunning} @@ -982,23 +1063,26 @@ const StockLedgerFixPageClient: React.FC = () => { disabled={fixing || chainRunning} onClick={() => void onFixDayRange()} > - {chainRunning ? "區間修中…" : "區間修"} + {chainRunning ? t("rangeRunning") : t("rangeRun")} - 匯出已修 SQL(新庫用) + {t("exportTitle")} - 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → ledger(±2.3)→ 2.7 入出倉單+ADJ INSERT → - 2.6。完整檔(含 1.0+2.3)且正式庫 freeze/id 同源時,可跳過正式庫 1.0/2.3。預設只匯 - ledger+日結(舊行為)。 + {t("exportHint")} {EXPORT_PARTS.map((part) => ( { } /> } - label={EXPORT_PART_LABEL[part]} + label={t(EXPORT_PART_I18N[part])} /> ))} {exportError && {exportError}} @@ -1032,7 +1116,7 @@ const StockLedgerFixPageClient: React.FC = () => { setExportFrom(e.target.value)} InputLabelProps={{ shrink: true }} @@ -1040,7 +1124,7 @@ const StockLedgerFixPageClient: React.FC = () => { setExportTo(e.target.value)} InputLabelProps={{ shrink: true }} @@ -1050,7 +1134,7 @@ const StockLedgerFixPageClient: React.FC = () => { disabled={exporting} onClick={() => void onExportSql()} > - {exporting ? "匯出中…" : "匯出 .sql"} + {exporting ? t("exporting") : t("exportSql")} @@ -1063,13 +1147,12 @@ const StockLedgerFixPageClient: React.FC = () => { - 搜 itemCode 或 inventoryId。Fix 會重算這顆料到今天為止全部流水的 lotQty、日結與 - balance,不必逐日點日曆。白天倉還在寫這顆料時,修完後新流水仍由 live writer 接。 + {t("invTabHint")} setInvQuery(e.target.value)} onKeyDown={(e) => { @@ -1081,7 +1164,7 @@ const StockLedgerFixPageClient: React.FC = () => { disabled={searching || !invQuery.trim()} onClick={() => void onSearchInventory()} > - {searching ? "搜尋中…" : "搜尋"} + {searching ? t("searching") : t("search")} {searchError && {searchError}} @@ -1089,10 +1172,10 @@ const StockLedgerFixPageClient: React.FC = () => {
- inventoryId - itemCode - uomId - ledger 列 + {t("colInventoryId")} + {t("colItemCode")} + {t("colUomId")} + {t("colLedgerRows")} @@ -1105,8 +1188,8 @@ const StockLedgerFixPageClient: React.FC = () => { sx={{ cursor: "pointer" }} > {h.inventoryId} - {h.itemCode ?? "—"} - {h.uomId ?? "—"} + {h.itemCode ?? t("checkDash")} + {h.uomId ?? t("checkDash")} {h.ledgerCnt} ))} @@ -1119,9 +1202,15 @@ const StockLedgerFixPageClient: React.FC = () => { {scope?.kind === "inventory" && !scopeLoading && ( <> - {scope.itemCode ?? "?"} / inventory {scope.id} / uom {scope.uomId ?? "?"} - ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後 - balance {scope.lastBalance ?? "—"} + {t("invScopeSummary", { + itemCode: scope.itemCode ?? "?", + id: scope.id, + uomId: scope.uomId ?? "?", + firstDate: scope.firstDate, + lastDate: scope.lastDate, + cnt: scope.cnt, + lastBalance: scope.lastBalance ?? t("checkDash"), + })} @@ -1130,7 +1219,7 @@ const StockLedgerFixPageClient: React.FC = () => { disabled={fixing || scope.cnt === 0} onClick={() => void onFixInventoryScope()} > - {fixing ? "修復中…" : "Fix this inventory"} + {fixing ? t("fixing") : t("fixThisInventory")} @@ -1143,13 +1232,12 @@ const StockLedgerFixPageClient: React.FC = () => { - 搜 lotNo 或 inventoryLotLineId。Fix 只重算這張 lot 的 lotQty 與日結,不改 - balance。 + {t("lotTabHint")} setLotQuery(e.target.value)} onKeyDown={(e) => { @@ -1161,7 +1249,7 @@ const StockLedgerFixPageClient: React.FC = () => { disabled={searching || !lotQuery.trim()} onClick={() => void onSearchLot()} > - {searching ? "搜尋中…" : "搜尋"} + {searching ? t("searching") : t("search")} {searchError && {searchError}} @@ -1169,11 +1257,11 @@ const StockLedgerFixPageClient: React.FC = () => {
- lot line id - lotNo - itemCode - inventoryId - ledger 列 + {t("colLotLineId")} + {t("colLotNo")} + {t("colItemCode")} + {t("colInventoryId")} + {t("colLedgerRows")} @@ -1186,9 +1274,9 @@ const StockLedgerFixPageClient: React.FC = () => { sx={{ cursor: "pointer" }} > {h.inventoryLotLineId} - {h.lotNo ?? "—"} - {h.itemCode ?? "—"} - {h.inventoryId ?? "—"} + {h.lotNo ?? t("checkDash")} + {h.itemCode ?? t("checkDash")} + {h.inventoryId ?? t("checkDash")} {h.ledgerCnt} ))} @@ -1201,9 +1289,15 @@ const StockLedgerFixPageClient: React.FC = () => { {scope?.kind === "lot" && !scopeLoading && ( <> - {scope.lotNo ?? "?"} / lot {scope.id} / {scope.itemCode ?? "?"} - ;{scope.firstDate} → {scope.lastDate};{scope.cnt} 列;最後 - lotQtyAfter {scope.lastLotQtyAfter ?? "—"} + {t("lotScopeSummary", { + lotNo: scope.lotNo ?? "?", + id: scope.id, + itemCode: scope.itemCode ?? "?", + firstDate: scope.firstDate, + lastDate: scope.lastDate, + cnt: scope.cnt, + lastLotQtyAfter: scope.lastLotQtyAfter ?? t("checkDash"), + })} @@ -1212,7 +1306,7 @@ const StockLedgerFixPageClient: React.FC = () => { disabled={fixing || scope.cnt === 0} onClick={() => void onFixLotScope()} > - {fixing ? "修復中…" : "Fix this lot"} + {fixing ? t("fixing") : t("fixThisLot")} diff --git a/src/i18n/en/navigation.json b/src/i18n/en/navigation.json index cb72caca..be5b4659 100644 --- a/src/i18n/en/navigation.json +++ b/src/i18n/en/navigation.json @@ -49,6 +49,8 @@ "nav.settings.demandForecast": "Demand Forecast Setting", "nav.settings.bomWeighting": "BOM Weighting Score List", "nav.settings.masterDataIssues": "BOM / Item UOM Issues", + "nav.settings.stockLedgerFix": "Stock Ledger Fix", + "nav.breadcrumb.stockLedgerFix": "Stock Ledger Fix", "nav.settings.qrCodeHandle": "QR Code Handle", "nav.settings.importTesting": "Import Testing", "nav.settings.importExcel": "Import Excel", diff --git a/src/i18n/en/stockLedgerFix.json b/src/i18n/en/stockLedgerFix.json new file mode 100644 index 00000000..7ec6dc0d --- /dev/null +++ b/src/i18n/en/stockLedgerFix.json @@ -0,0 +1,147 @@ +{ + "pageTitle": "Stock Ledger Fix", + "inventory10Title": "1.0 Inventory (Plan A)", + "inventory10Description": "Keeps existing IDs. Adds UOMs that exist on lots but not in inventory. onHand = Σ(in−out) by item+UOM. Rows with no lots become 0. When an item has multiple old rows, only one stock-UOM bucket is kept; the rest are marked deleted=1. Run this while inbound/outbound is paused, then Fix day by day.", + "inventory10LoadError": "Could not load inventory 1.0 preview (ADMIN / TESTING required)", + "inventory10Preview": "Existing inventory: {{rows}} rows; lot item+UOM pairs: {{lotPairs}}; missing: {{missing}}; stockUomId still NULL: {{nullUom}} rows.", + "inventory10Running": "Recalculating…", + "inventory10Run": "Run inventory 1.0 (A)", + "inventory10Confirm": "Plan A: keep existing inventory.id; backfill stockUomId from lots (if the old uomId is stock, revert it to base); insert missing (itemId, stockUomId) rows; recalculate onHand/unavailable from lot in−out. Does not write onHoldQty or change inventory_lot_line. Continue?", + "inventory10Done": "Inventory 1.0 complete: patched stockUomId {{patched}} rows, inserted {{inserted}}, orphans deleted {{orphans}}, recalculated {{updated}}, still missing UOM {{missingAfter}}, still NULL stockUomId {{nullAfter}}", + "inventory10Fail": "Inventory 1.0 failed", + "adjTitle": "2.7 ADJ: set over-issue remain to 0, then align ledger", + "adjDescription": "Choose an ADJ date, then preview. Default is yesterday. For a freeze-dump night you can pick today (e.g. write 31/8 23:17 ADJ onto 31/8). First handle over-issue (line in < out): raise that lot’s inQty to equal outQty (remain=0; trigger recalculates inventory). Then handle miss: if line and ledger differ, write ADJ against the existing lot’s inbound/outbound lines (lot quantities are not changed). One Apply shares one inbound document and one outbound document, with multiple lines under them. Running again on the same ADJ date reuses those two documents. Detail shows at most 20 rows.", + "adjDateLabel": "ADJ date", + "adjDateRequired": "Please enter an ADJ date", + "adjDateFuture": "ADJ date cannot be in the future", + "adjDateAndPreviewRequired": "Please enter an ADJ date and preview first", + "adjTodayFreeze": "Today (freeze night)", + "adjYesterday": "Yesterday", + "adjLoadError": "Could not load ADJ preview (ADMIN / TESTING required)", + "adjPreviewSummary": "ADJ date {{date}}; {{lotCount}} lots can be processed (over-issue remain→0: {{overIssueCount}}, total {{sumOverIssue}}; miss in {{adjInCount}}, out {{adjOutCount}}); missing in total {{sumMissIn}}, missing out total {{sumMissOut}}; SKU net {{skuNet}}{{skuNetNote}}{{revNote}}. Table shows at most 20 rows (over-issue first).", + "adjSkuNetNote": " (non-zero means missing in has no matching missing out)", + "adjRevNote": "; includes reverse ADJ {{count}} lots (ledger over-recorded)", + "adjColLotLineId": "lotLineId", + "adjColItemCode": "itemCode", + "adjColLineInOut": "line in/out", + "adjColLedgerInOut": "ledger in/out", + "adjColMissIn": "Missing in", + "adjColMissOut": "Missing out", + "adjColOverIssue": "Over-issue", + "adjPreviewing": "Previewing…", + "adjPreviewAgain": "Refresh preview", + "adjPreview": "Preview", + "adjApplying": "Writing ADJ…", + "adjApply": "Apply ADJ", + "adjConfirm": "On {{date}}, first patch over-issue remain→0 ({{overIssueCount}} lots, total {{sumOverIssue}}; this changes lot inQty). Then fill miss ADJ: inbound {{adjInCount}}, outbound {{adjOutCount}}. All inbound lines share one inbound document; all outbound lines share one outbound document (miss does not change lot qty). Missing in total {{sumMissIn}}, missing out total {{sumMissOut}}. Continue?", + "adjDone": "ADJ {{date}}: over-issue remain→0 {{overIssuePatched}}, in {{insertedIn}}, out {{insertedOut}}, lotQty {{filledLotQty}}, balance {{filledBalance}}, day close {{dayRowsWritten}}", + "adjFail": "ADJ align failed", + "tabCalendar": "Calendar", + "tabInventory": "Inventory", + "tabLot": "Lot line", + "calendarHint": "Click the title at the top-left to pick year, then month.\nChanging month does not load data; click a day to inspect that day.\nToday can be fixed (freeze-dump night).", + "selectDate": "Select a date", + "dayLoadError": "Could not load the day’s check", + "dayLedgerCount": "{{cnt}} ledger rows that day", + "cannotFixFuture": "Cannot fix a future date", + "cannotFixFutureRange": "Cannot fix a future date (today is allowed for freeze-dump night)", + "pickAtLeastOneStep": "Select at least one step (default runs all of 2.1–2.6)", + "allSteps216": "all 2.1–2.6", + "confirmPartialSteps": "Run only {{steps}} ({{date}}). Unchecked steps will not be recalculated. Continue?", + "fixDayDone": "Fixed {{date}} ({{steps}}): lot {{lot}}, uom {{uom}}, inventory {{inventory}}, lotQty {{lotQty}}, balance {{balance}}, day close {{dayRows}}", + "fixFailed": "Fix failed", + "fixing": "Fixing…", + "fixThisDay": "Fix this day", + "stepsTitle": "Steps (all selected by default)", + "stepsHint": "After applying SQL, if you only need to change inventoryId, tick 2.3 only. Unchecked steps are not recalculated.", + "selectAll": "Select all", + "step21": "2.1 lot", + "step22": "2.2 uom", + "step23": "2.3 inventoryId", + "step24": "2.4 lotQty", + "step25": "2.5 balance", + "step26": "2.6 day close", + "rangeTitle": "Range fix (2.1–2.6 in one run)", + "rangeHint": "Uses the steps ticked above. One API call, but runs day by day (same SQL as a single-day fix, commit per day). Today is allowed (freeze-dump night). Frontend timeout is about 20 minutes; for a large range, export SQL instead.", + "rangeFromRequired": "Please fill range from / to", + "toMustBeGteFrom": "to must be ≥ from", + "fromTooEarly": "from cannot be earlier than {{date}}", + "rangeConfirm": "Fix {{from}} → {{to}} (steps: {{steps}}). Runs day by day (commit each day). Days already finished are kept if it fails mid-way. Continue?", + "rangeProgress": "Range fix {{from}} → {{to}} ({{steps}}) in progress…", + "rangeDone": "Range fix complete {{date}} ({{steps}}): lot {{lot}}, uom {{uom}}, inventory {{inventory}}, lotQty {{lotQty}}, balance {{balance}}, day close {{dayRows}}", + "rangeFail": "Range fix failed", + "rangeRunning": "Range fix in progress…", + "rangeRun": "Range fix", + "from": "from", + "to": "to", + "exportTitle": "Export patched SQL (for the new database)", + "exportHint": "Tick the sections to export (same as day-fix steps). Apply order: 1.0 → ledger (±2.3) → 2.7 inbound/outbound documents + ADJ INSERT → 2.6. A full file (1.0 + 2.3) can skip production 1.0/2.3 when production is frozen and IDs match. Default exports only ledger + day close (legacy behaviour).", + "exportPickAtLeastOne": "Select at least one export section", + "export23Without10": "2.3 inventoryId is ticked but 1.0 inventory is not: if production is missing the new IDs, UPDATE will point at empty rows. Tick 1.0 as well (full file). Export anyway?", + "exportFail": "Export failed", + "exporting": "Exporting…", + "exportSql": "Export .sql", + "exportDefault": "Default", + "exportFull": "Full file", + "exportFromToRequired": "Please fill from / to", + "export10": "1.0 inventory", + "export23": "2.3 inventoryId", + "exportLedger": "ledger lot/uom/lotQty/balance", + "export26": "2.6 day close", + "export27": "2.7 ADJ + inbound/outbound documents", + "invTabHint": "Search by itemCode or inventoryId. Fix recalculates lotQty, day close, and balance for this item’s ledger up to today — no need to click the calendar day by day. If the warehouse is still writing this item during the day, new ledger rows after the fix are still handled by the live writer.", + "invSearchLabel": "itemCode / inventoryId", + "searching": "Searching…", + "search": "Search", + "searchFailed": "Search failed", + "colInventoryId": "inventoryId", + "colItemCode": "itemCode", + "colUomId": "uomId", + "colLedgerRows": "ledger rows", + "invLoadError": "Could not load inventory", + "invScopeSummary": "{{itemCode}} / inventory {{id}} / uom {{uomId}}; {{firstDate}} → {{lastDate}}; {{cnt}} rows; last balance {{lastBalance}}", + "invFixConfirm": "This will recalculate lotQty and day close for all ledger rows of this inventory up to today, plus this item’s balance. Continue?", + "invFixDone": "Fixed inventory {{id}}: lot {{lot}}, uom {{uom}}, inventory {{inventory}}, lotQty {{lotQty}}, balance {{balance}}, day close {{dayRows}}", + "fixThisInventory": "Fix this inventory", + "lotTabHint": "Search by lotNo or inventoryLotLineId. Fix only recalculates this lot’s lotQty and day close; it does not change balance.", + "lotSearchLabel": "lotNo / lot line id", + "colLotLineId": "lot line id", + "colLotNo": "lotNo", + "lotLoadError": "Could not load lot", + "lotScopeSummary": "{{lotNo}} / lot {{id}} / {{itemCode}}; {{firstDate}} → {{lastDate}}; {{cnt}} rows; last lotQtyAfter {{lastLotQtyAfter}}", + "lotFixConfirm": "This only fixes this lot’s lotQty and day close; it will not change the item’s balance. Continue?", + "lotFixDone": "Fixed lot {{id}}: lot {{lot}}, uom {{uom}}, inventory {{inventory}}, lotQty {{lotQty}}, day close {{dayRows}}", + "fixThisLot": "Fix this lot", + "checkItem": "Item", + "checkStatus": "Status", + "checkCorrect": "Correct", + "checkMiss": "Missing", + "checkIncorrect": "Incorrect", + "checkReason": "Reason", + "checkRows": "Rows", + "checkDash": "—", + "canAutoFix": "Can auto-fix (handled by Fix)", + "cannotAutoFix": "Cannot auto-fix (Fix will not clear these: old TKE new lots / true over-issue / no lot source)", + "verdictCorrect": "correct", + "verdictMiss": "miss", + "verdictOverIssue": "over-issue", + "verdictCanFix": "can fix", + "verdictCannotFix": "cannot fix", + "verdictIncorrect": "incorrect", + "part": { + "lot": "inventoryLotLineId", + "uom": "uomId", + "inventory": "inventoryId", + "lotQty": "lotQtyBefore/After (lot already exists)", + "overIssue": "over-issue (lotQty < 0)", + "balance": "balance", + "dayTable": "stock_lot_day", + "fixable21a": "Can fix 2.1a: SIL/SOL already has a lot line", + "fixable21b": "Can fix 2.1b: inventoryLotId has only one line", + "fixableLotQty": "Can fix: lot exists but lotQty is missing", + "cannotNoSource": "Cannot fix: no SIL/SOL lot source", + "cannotMultiLine": "Cannot fix: same lot has multiple warehouse lines", + "earlyTke": "Cannot fix: old TKE opened a new lot (sibling surplus)", + "realOver": "Cannot fix: over-issue is not old TKE (true over-issue / ledger mismatch)" + } +} diff --git a/src/i18n/zh/navigation.json b/src/i18n/zh/navigation.json index afbc1d79..156c7c81 100644 --- a/src/i18n/zh/navigation.json +++ b/src/i18n/zh/navigation.json @@ -81,6 +81,8 @@ "nav.settings.items": "物品", "nav.settings.itemDefaultShelfLife": "物品預設保質期", "nav.settings.masterDataIssues": "BOM / 物料單位問題", + "nav.settings.stockLedgerFix": "庫存帳修復", + "nav.breadcrumb.stockLedgerFix": "庫存帳修復", "nav.settings.priceInquiry": "價格查詢", "nav.settings.printer": "列印機", "nav.settings.qcCategory": "QC 品檢模板", diff --git a/src/i18n/zh/stockLedgerFix.json b/src/i18n/zh/stockLedgerFix.json new file mode 100644 index 00000000..0ad5e825 --- /dev/null +++ b/src/i18n/zh/stockLedgerFix.json @@ -0,0 +1,147 @@ +{ + "pageTitle": "庫存帳修復", + "inventory10Title": "1.0 inventory(方案 A)", + "inventory10Description": "保留現有 id;lot 有、inventory 沒有的 UOM 會新增;onHand = Σ(in−out) 按 item+UOM。沒有 lot 的列會變成 0。同一料多顆舊列時只留一顆 stock UOM 桶,其餘 deleted=1。請在進出倉暫停時執行,再逐日 Fix。", + "inventory10LoadError": "無法載入 inventory 1.0 預覽(需要 ADMIN / TESTING)", + "inventory10Preview": "現有 inventory {{rows}} 列;lot 的 item+UOM {{lotPairs}} 組;缺 {{missing}} 組;stockUomId 仍 NULL {{nullUom}} 列。", + "inventory10Running": "重算中…", + "inventory10Run": "Run inventory 1.0 (A)", + "inventory10Confirm": "方案 A:保留現有 inventory.id;從 lot 回填 stockUomId(舊 uomId 是 stock 則改回 base);補缺 (itemId, stockUomId);用 lot 的 in−out 重算 onHand/unavailable。不寫 onHoldQty、不改 inventory_lot_line。確定執行?", + "inventory10Done": "inventory 1.0 完成:回填 stockUomId {{patched}} 列、新增 {{inserted}} 列、殘列 deleted {{orphans}}、重算 {{updated}} 列、之後仍缺 UOM {{missingAfter}}、仍 NULL stockUomId {{nullAfter}}", + "inventory10Fail": "inventory 1.0 失敗", + "adjTitle": "2.7 ADJ:超發 remain→0,再對齊 ledger", + "adjDescription": "請先選 ADJ 日期再預覽。預設昨天;freeze dump 夜可選今天(例如 31/8 23:17 寫 ADJ 到 31/8)。先處理超發(line 入少於出):把該 lot 的 inQty 補到等於 outQty(remain=0,trigger 重算 inventory)。再處理 miss:line 與 ledger 有差就 ADJ,掛既有 lot 的入/出倉行(不改 lot 數量)。一次 Apply 共用一張入倉單、一張出倉單,下面多行;同一 ADJ 日期再跑會沿用這兩張單。明細最多 20 列。", + "adjDateLabel": "ADJ 日期", + "adjDateRequired": "請填 ADJ 日期", + "adjDateFuture": "ADJ 日期不能是未來", + "adjDateAndPreviewRequired": "請填 ADJ 日期並先預覽", + "adjTodayFreeze": "今天(freeze 夜)", + "adjYesterday": "昨天", + "adjLoadError": "無法載入 ADJ 預覽(需要 ADMIN / TESTING)", + "adjPreviewSummary": "ADJ 日期 {{date}};可處理 {{lotCount}} 條 lot(超發 remain→0 {{overIssueCount}}、合計 {{sumOverIssue}};miss 入 {{adjInCount}}、出 {{adjOutCount}});缺入合計 {{sumMissIn}}、缺出合計 {{sumMissOut}};SKU 淨額 {{skuNet}}{{skuNetNote}}{{revNote}}。下列最多 20 列(超發優先)。", + "adjSkuNetNote": "(非 0 表示有缺入沒有對應缺出)", + "adjRevNote": ";含反向 ADJ {{count}} 條(ledger 多記)", + "adjColLotLineId": "lotLineId", + "adjColItemCode": "itemCode", + "adjColLineInOut": "line in/out", + "adjColLedgerInOut": "ledger in/out", + "adjColMissIn": "缺入", + "adjColMissOut": "缺出", + "adjColOverIssue": "超發", + "adjPreviewing": "預覽中…", + "adjPreviewAgain": "重新預覽", + "adjPreview": "預覽", + "adjApplying": "寫入 ADJ 中…", + "adjApply": "Apply ADJ", + "adjConfirm": "會在 {{date}} 先修超發 remain→0({{overIssueCount}} 條 lot,合計 {{sumOverIssue}},會改 lot inQty)。再補 miss ADJ:入向 {{adjInCount}} 筆、出向 {{adjOutCount}} 筆。入向全部掛同一張入倉單、出向全部掛同一張出倉單(miss 不改 lot 數量)。缺入合計 {{sumMissIn}}、缺出合計 {{sumMissOut}}。確定?", + "adjDone": "ADJ {{date}}:超發 remain→0 {{overIssuePatched}}、入 {{insertedIn}}、出 {{insertedOut}}、lotQty {{filledLotQty}}、balance {{filledBalance}}、日結 {{dayRowsWritten}}", + "adjFail": "ADJ 對齊失敗", + "tabCalendar": "日曆", + "tabInventory": "Inventory", + "tabLot": "Lot line", + "calendarHint": "點左上標題可先選年再選月。換月不會查資料;點某一天才檢查當天。今天可修(freeze dump 夜)。", + "selectDate": "請選日期", + "dayLoadError": "無法載入當日檢查", + "dayLedgerCount": "當天 ledger {{cnt}} 列", + "cannotFixFuture": "不能修未來日期", + "cannotFixFutureRange": "不能修未來日期(今天可以,給 freeze dump 夜修)", + "pickAtLeastOneStep": "請至少勾一個步驟(預設全跑 2.1–2.6)", + "allSteps216": "全部 2.1–2.6", + "confirmPartialSteps": "只跑 {{steps}}({{date}})。未勾的步驟不會重算。確定?", + "fixDayDone": "已修 {{date}}({{steps}}):lot {{lot}}、uom {{uom}}、inventory {{inventory}}、lotQty {{lotQty}}、balance {{balance}}、日結 {{dayRows}}", + "fixFailed": "修復失敗", + "fixing": "修復中…", + "fixThisDay": "Fix this day", + "stepsTitle": "步驟(預設全跑)", + "stepsHint": "套 SQL 後只改 inventoryId 時只勾 2.3。只跑部分時未勾的不會重算。", + "selectAll": "全選", + "step21": "2.1 lot", + "step22": "2.2 uom", + "step23": "2.3 inventoryId", + "step24": "2.4 lotQty", + "step25": "2.5 balance", + "step26": "2.6 日結", + "rangeTitle": "區間修(2.1–2.6 一次)", + "rangeHint": "使用上面勾的步驟,一次 API 但改為逐日(與單日修相同 SQL,每天 commit)。可含今天(freeze dump 夜)。前端最長約 20 分鐘;大區間仍建議匯出 SQL。", + "rangeFromRequired": "請填區間修 from / to", + "toMustBeGteFrom": "to 必須 ≥ from", + "fromTooEarly": "from 不能早於 {{date}}", + "rangeConfirm": "一次修 {{from}} → {{to}}(步驟:{{steps}})。改為逐日跑(每天 commit)。中途失敗時已跑完的天會留下。確定?", + "rangeProgress": "區間修 {{from}} → {{to}}({{steps}})進行中…", + "rangeDone": "區間修完成 {{date}}({{steps}}):lot {{lot}}、uom {{uom}}、inventory {{inventory}}、lotQty {{lotQty}}、balance {{balance}}、日結 {{dayRows}}", + "rangeFail": "區間修失敗", + "rangeRunning": "區間修中…", + "rangeRun": "區間修", + "from": "from", + "to": "to", + "exportTitle": "匯出已修 SQL(新庫用)", + "exportHint": "勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → ledger(±2.3)→ 2.7 入出倉單+ADJ INSERT → 2.6。完整檔(含 1.0+2.3)且正式庫 freeze/id 同源時,可跳過正式庫 1.0/2.3。預設只匯 ledger+日結(舊行為)。", + "exportPickAtLeastOne": "請至少勾一個匯出項目", + "export23Without10": "已勾 2.3 inventoryId 但未勾 1.0 inventory:正式庫若缺新 id,UPDATE 會指到空列。建議一併勾 1.0(完整檔)。仍要匯出?", + "exportFail": "匯出失敗", + "exporting": "匯出中…", + "exportSql": "匯出 .sql", + "exportDefault": "預設", + "exportFull": "完整檔", + "exportFromToRequired": "請填 from / to", + "export10": "1.0 inventory", + "export23": "2.3 inventoryId", + "exportLedger": "ledger lot/uom/lotQty/balance", + "export26": "2.6 日結", + "export27": "2.7 ADJ+入出倉單", + "invTabHint": "搜 itemCode 或 inventoryId。Fix 會重算這顆料到今天為止全部流水的 lotQty、日結與 balance,不必逐日點日曆。白天倉還在寫這顆料時,修完後新流水仍由 live writer 接。", + "invSearchLabel": "itemCode / inventoryId", + "searching": "搜尋中…", + "search": "搜尋", + "searchFailed": "搜尋失敗", + "colInventoryId": "inventoryId", + "colItemCode": "itemCode", + "colUomId": "uomId", + "colLedgerRows": "ledger 列", + "invLoadError": "無法載入 inventory", + "invScopeSummary": "{{itemCode}} / inventory {{id}} / uom {{uomId}};{{firstDate}} → {{lastDate}};{{cnt}} 列;最後 balance {{lastBalance}}", + "invFixConfirm": "會重算這顆 inventory 到今天為止全部 ledger 的 lotQty、日結,以及這顆料的 balance。確定?", + "invFixDone": "已修 inventory {{id}}:lot {{lot}}、uom {{uom}}、inventory {{inventory}}、lotQty {{lotQty}}、balance {{balance}}、日結 {{dayRows}}", + "fixThisInventory": "Fix this inventory", + "lotTabHint": "搜 lotNo 或 inventoryLotLineId。Fix 只重算這張 lot 的 lotQty 與日結,不改 balance。", + "lotSearchLabel": "lotNo / lot line id", + "colLotLineId": "lot line id", + "colLotNo": "lotNo", + "lotLoadError": "無法載入 lot", + "lotScopeSummary": "{{lotNo}} / lot {{id}} / {{itemCode}};{{firstDate}} → {{lastDate}};{{cnt}} 列;最後 lotQtyAfter {{lastLotQtyAfter}}", + "lotFixConfirm": "只修這張 lot 的 lotQty 與日結,不會改整顆料的 balance。確定?", + "lotFixDone": "已修 lot {{id}}:lot {{lot}}、uom {{uom}}、inventory {{inventory}}、lotQty {{lotQty}}、日結 {{dayRows}}", + "fixThisLot": "Fix this lot", + "checkItem": "項目", + "checkStatus": "狀態", + "checkCorrect": "正確", + "checkMiss": "缺", + "checkIncorrect": "不正確", + "checkReason": "原因", + "checkRows": "列數", + "checkDash": "—", + "canAutoFix": "可自動修(按 Fix 會處理)", + "cannotAutoFix": "不能自動修(Fix 不會消;舊 TKE 開新批/真超發/沒有 lot 來源)", + "verdictCorrect": "correct", + "verdictMiss": "miss", + "verdictOverIssue": "over-issue", + "verdictCanFix": "可修", + "verdictCannotFix": "不能修", + "verdictIncorrect": "incorrect", + "part": { + "lot": "inventoryLotLineId", + "uom": "uomId", + "inventory": "inventoryId", + "lotQty": "lotQtyBefore/After(lot 已有)", + "overIssue": "over-issue (lotQty < 0)", + "balance": "balance", + "dayTable": "stock_lot_day", + "fixable21a": "可修 2.1a:SIL/SOL 已有 lot line", + "fixable21b": "可修 2.1b:inventoryLotId 僅一條 line", + "fixableLotQty": "可修:lot 已有但缺 lotQty", + "cannotNoSource": "不能修:沒有 SIL/SOL lot 來源", + "cannotMultiLine": "不能修:同一 lot 有多條 warehouse line", + "earlyTke": "不能修:舊 TKE 開新批(sibling 盤盈)", + "realOver": "不能修:over-issue 非舊 TKE(真超發/帳不一致)" + } +} From 163b634c7e592e92903f4d20f5f9379034e7a415 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Mon, 21 Sep 2026 12:41:41 +0800 Subject: [PATCH 09/38] [Fix] DO Workbench EN language --- .../DoWorkbench/DoWorkbenchTabs.tsx | 34 ++- .../TruckRoutingSummaryTabWorkbench.tsx | 25 +- .../WorkbenchGoodPickExecutionDetail.tsx | 42 ++-- .../WorkbenchLotLabelPrintModal.tsx | 92 ++++---- .../FinishedGoodCartonDashboardTab.tsx | 101 ++++---- .../WorkbenchPickExecution.tsx | 8 +- src/i18n/en/common.json | 220 +++++++++--------- src/i18n/en/doWorkbench.json | 21 +- src/i18n/en/pickOrder.json | 92 +++++++- src/i18n/en/ticketReleaseTable.json | 6 +- src/i18n/zh/doWorkbench.json | 16 +- src/i18n/zh/pickOrder.json | 81 ++++++- src/utils/workbenchPickLotUtils.ts | 38 ++- 13 files changed, 518 insertions(+), 258 deletions(-) diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index 69015ee6..c247d87b 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -186,7 +186,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom const confirmResult = await Swal.fire({ title: t("Batch Print"), - text: `${t("Confirm print: (")}${releasedOrders.length}${t("piece(s))")}`, + text: t("Confirm print drafts", { count: releasedOrders.length }), icon: "question", showCancelButton: true, confirmButtonText: t("Confirm"), @@ -276,18 +276,29 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom @@ -297,7 +308,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom sx={{ overflow: "visible", /* 徽章在標籤右側外凸,預留空間避免與下一個 Tab 貼死 */ - pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2, + pr: etraIncompleteDopoCount > 99 ? 2.5 : etraIncompleteDopoCount > 0 ? 2 : 1, }} label={ = ({ defaultTabIndex = 0, printerCom 0 ? 1 : 0 }} + sx={{ + pr: etraIncompleteDopoCount > 0 ? 1 : 0, + whiteSpace: "normal", + lineHeight: 1.2, + textAlign: "center", + }} > {t("Etra Pick Order Detail")} @@ -341,8 +357,8 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom - - + + diff --git a/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx b/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx index 3325a539..c47689f1 100644 --- a/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx +++ b/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material"; import DownloadIcon from "@mui/icons-material/Download"; +import { useTranslation } from "react-i18next"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { @@ -18,6 +19,7 @@ import { } from "@/lib/featureUsageLog"; const TruckRoutingSummaryTabWorkbench: React.FC = () => { + const { t } = useTranslation(); const [storeOptions, setStoreOptions] = useState([]); const [laneOptions, setLaneOptions] = useState([]); const [storeId, setStoreId] = useState(""); @@ -45,6 +47,9 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { const canDownload = storeId && truckLanceCode && date && !loading; + const displayOptionLabel = (label: string) => + String(label).trim() === "車線-X" ? t("Truck X") : label; + const onDownload = async () => { if (!canDownload) return; try { @@ -55,7 +60,9 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { }); if (precheck.hasUnpickedOrders) { const confirmed = window.confirm( - `此車線仍有 ${precheck.unpickedOrderCount} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?` + t("Unpicked orders confirm download", { + count: precheck.unpickedOrderCount, + }), ); if (!confirmed) return; } @@ -92,7 +99,7 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { ); } catch (error) { console.error("Failed to download Workbench Truck Routing Summary", error); - alert("下載 Workbench 送貨路線摘要失敗,請稍後再試。"); + alert(t("Failed to download Workbench truck routing summary. Please try again later.")); } finally { setLoading(false); } @@ -101,39 +108,39 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { return ( - 送貨路線摘要 (Workbench) + {t("Truck Routing Summary (Workbench)")} onStoreChange(e.target.value)} > {storeOptions.map((opt) => ( - {opt.label} + {displayOptionLabel(opt.label)} ))} setTruckLanceCode(e.target.value)} disabled={!storeId} > {laneOptions.map((opt) => ( - {opt.label} + {displayOptionLabel(opt.label)} ))} { disabled={!canDownload} onClick={onDownload} > - {loading ? "生成中..." : "下載報告 (PDF)"} + {loading ? t("Generating...") : t("Download report (PDF)")} ); diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 29536591..91578a07 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -421,22 +421,30 @@ function isWorkbenchSourceLotExpired(lot: any): boolean { return false; } -function getWorkbenchSourceLotStatusSummary(lot: any): { +type PickOrderT = (key: string, options?: Record) => string; + +function getWorkbenchSourceLotStatusSummary(lot: any, t: PickOrderT): { severity: "success" | "warning" | "error"; text: string; } { if (!lot) { - return { severity: "warning", text: "無法判斷此批號狀態" }; + return { severity: "warning", text: t("Cannot determine this lot status") }; } if (isWorkbenchSourceLotExpired(lot)) { - return { severity: "error", text: "此批號狀態:已過期" }; + return { severity: "error", text: t("Lot status: expired") }; } const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); if (solSt === "rejected") { - return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" }; + return { + severity: "warning", + text: t("This pick line was rejected. Please scan another lot."), + }; } if (solSt === "completed" || solSt === "partially_completed") { - return { severity: "warning", text: "此出庫行:已完成,無需再提貨" }; + return { + severity: "warning", + text: t("This pick line is already completed. No further pick needed."), + }; } /** * 無批次列:後端仍標 insufficient_stock,語意是「尚無可出庫批號」而非「已用畢」。 @@ -449,28 +457,28 @@ function getWorkbenchSourceLotStatusSummary(lot: any): { if (isNoLotRow) { return { severity: "warning", - text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR", + text: t( + "No lot bound / no available stock line. Please scan nearby inbound or transfer QR.", + ), }; } const av = String(lot.lotAvailability || "").toLowerCase(); if (av === "insufficient_stock") { - return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" }; + return { severity: "warning", text: t("Lot status: depleted (no remaining stock)") }; } const avail = Number(lot.availableQty); if (lot.lotNo && Number.isFinite(avail) && avail <= 0) { - return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" }; + return { severity: "warning", text: t("Lot status: depleted (available qty is 0)") }; } if (isInventoryLotLineUnavailable(lot)) { return { severity: "warning", - text: "此批號狀態:庫存不可用(未上架或行狀態不可用)", + text: t("Lot status: unavailable (not put away or line unavailable)"), }; } - return { severity: "success", text: "此批號狀態:可提貨" }; + return { severity: "success", text: t("Lot status: ready to pick") }; } -type PickOrderT = (key: string, options?: Record) => string; - function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { const msg = raw.trim(); if (!msg) return msg; @@ -1334,9 +1342,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO severity: undefined as "success" | "warning" | "error" | undefined, }; } - const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot); + const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot, t); return { text: s.text, severity: s.severity }; - }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]); + }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot, t]); const workbenchLotLabelSubmitQty = useMemo(() => { if (!workbenchLotLabelContextLot) return 0; @@ -1811,7 +1819,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg( - `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` + t("This lot ({{lot}}) was rejected and cannot be used. Please scan another lot.", { + lot: scannedLot.lotNo || scannedStockInLineId, + }), ); }); // Mark this SOL as processed to prevent re-processing @@ -1864,7 +1874,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO startTransition(() => { setQrScanError(true); setQrScanSuccess(false); - setQrScanErrorMsg("当前订单中没有此物品的批次信息"); + setQrScanErrorMsg(t("No lot information for this item in the current order")); }); return; } diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx index 2863a5b9..1d477005 100644 --- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -37,6 +37,7 @@ import { printWorkbenchLotLabel, } from "@/app/api/doworkbench/actions"; import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; type ScanPayload = { itemId: number; @@ -167,6 +168,7 @@ const WorkbenchLotLabelPrintModal: React.FC = submitQty = null, onSubmitQtyChange, }) => { + const { t } = useTranslation(); const scanInputRef = useRef(null); const [scanInput, setScanInput] = useState(""); const [scanError, setScanError] = useState(null); @@ -210,8 +212,8 @@ const WorkbenchLotLabelPrintModal: React.FC = useEffect(() => { if (!open) return; resetAll(); - const t = setTimeout(() => scanInputRef.current?.focus(), 50); - return () => clearTimeout(t); + const focusTimer = setTimeout(() => scanInputRef.current?.focus(), 50); + return () => clearTimeout(focusTimer); }, [open, resetAll]); const loadPrinters = useCallback(async () => { @@ -224,13 +226,13 @@ const WorkbenchLotLabelPrintModal: React.FC = setPrinters([]); setSnackbar({ open: true, - message: e instanceof Error ? e.message : "載入印表機清單失敗", + message: e instanceof Error ? e.message : t("Failed to load printer list"), severity: "error", }); } finally { setPrintersLoading(false); } - }, []); + }, [t]); useEffect(() => { if (!open) return; @@ -283,23 +285,23 @@ const WorkbenchLotLabelPrintModal: React.FC = setAnalysis(data); setSnackbar({ open: true, - message: "已載入同品可用批號清單", + message: t("Loaded available lots for this item"), severity: "success", }); } catch (e) { setAnalysis(null); - setScanError(e instanceof Error ? e.message : "分析失敗"); + setScanError(e instanceof Error ? e.message : t("Analysis failed")); } finally { setAnalysisLoading(false); } }, - [resolveExpectedUomId], + [resolveExpectedUomId, t], ); const analyzeByItem = useCallback( async (itemId: number) => { if (!Number.isFinite(itemId) || itemId <= 0) { - setScanError("無效 itemId,無法載入批號清單。"); + setScanError(t("Invalid itemId, cannot load lot list.")); return; } setLastItemId(itemId); @@ -325,17 +327,17 @@ const WorkbenchLotLabelPrintModal: React.FC = }); setSnackbar({ open: true, - message: "已載入同品可用批號清單", + message: t("Loaded available lots for this item"), severity: "success", }); } catch (e) { setAnalysis(null); - setScanError(e instanceof Error ? e.message : "分析失敗"); + setScanError(e instanceof Error ? e.message : t("Analysis failed")); } finally { setAnalysisLoading(false); } }, - [resolveExpectedUomId], + [resolveExpectedUomId, t], ); const handleAnalyze = useCallback(async () => { @@ -343,13 +345,13 @@ const WorkbenchLotLabelPrintModal: React.FC = const payload = safeParseScanPayload(raw); if (!payload) { setScanError( - '掃碼內容格式錯誤,請重新掃碼', + t("Invalid scan format. Please scan again."), ); setAnalysis(null); return; } await analyzePayload(payload); - }, [scanInput, analyzePayload]); + }, [scanInput, analyzePayload, t]); const handleRefreshLots = useCallback(async () => { const payload = lastPayload ?? safeParseScanPayload(scanInput.trim()); @@ -368,12 +370,12 @@ const WorkbenchLotLabelPrintModal: React.FC = if (!payload) { setSnackbar({ open: true, - message: "請先掃碼或查詢一次,才可刷新批號清單。", + message: t("Scan or look up once before refreshing the lot list."), severity: "info", }); return; } - }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput]); + }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput, t]); useEffect(() => { if (!open) return; @@ -470,7 +472,7 @@ const WorkbenchLotLabelPrintModal: React.FC = if (selectedPrinterId === "") { setSnackbar({ open: true, - message: "請先選擇印表機", + message: t("Please select a printer first"), severity: "error", }); return; @@ -478,7 +480,7 @@ const WorkbenchLotLabelPrintModal: React.FC = if (printQty < 1 || !Number.isFinite(printQty)) { setSnackbar({ open: true, - message: "列印張數需為大於等於 1 的整數", + message: t("Print quantity must be an integer of 1 or more"), severity: "error", }); return; @@ -493,25 +495,25 @@ const WorkbenchLotLabelPrintModal: React.FC = }); setSnackbar({ open: true, - message: `已送出列印:Lot ${lotNo}`, + message: t("Print sent: Lot {{lotNo}}", { lotNo }), severity: "success", }); } catch (e) { setSnackbar({ open: true, - message: e instanceof Error ? e.message : "列印失敗", + message: e instanceof Error ? e.message : t("Print failed"), severity: "error", }); } finally { setPrintingLotLineId(null); } }, - [selectedPrinterId, printQty], + [selectedPrinterId, printQty, t], ); return ( - 批號標籤列印(提貨台) + {t("Lot label print (pick station)")} {statusTitleText ? ( @@ -548,13 +550,13 @@ const WorkbenchLotLabelPrintModal: React.FC = > setScanInput(e.target.value)} fullWidth size="small" error={!!scanError} - helperText={scanError || "掃描後按 Enter 或點「查詢」"} + helperText={scanError || t("Scan then press Enter or click Look up")} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -568,7 +570,7 @@ const WorkbenchLotLabelPrintModal: React.FC = onClick={() => void handleAnalyze()} disabled={analysisLoading || !scanInput.trim()} > - {analysisLoading ? : "查詢"} + {analysisLoading ? : t("Look up")} @@ -594,16 +596,16 @@ const WorkbenchLotLabelPrintModal: React.FC = sx={{ minWidth: 260 }} disabled={printersLoading} > - 印表機 + {t("Printer")}
- 2/F 出箱數 - {summary.floor2F.toLocaleString("zh-HK")} + {t("2/F carton qty")} + {summary.floor2F.toLocaleString(numberLocale)} - 4/F 出箱數 - {summary.floor4F.toLocaleString("zh-HK")} + {t("4/F carton qty")} + {summary.floor4F.toLocaleString(numberLocale)} - 車線-X 出箱數 - {summary.truckX.toLocaleString("zh-HK")} + {t("Truck X carton qty")} + {summary.truckX.toLocaleString(numberLocale)} - 總出箱數 - {summary.total.toLocaleString("zh-HK")} + {t("Total carton qty")} + {summary.total.toLocaleString(numberLocale)}
diff --git a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx index 03b91061..da9a2b0a 100644 --- a/src/components/PickOrderSearch/WorkbenchPickExecution.tsx +++ b/src/components/PickOrderSearch/WorkbenchPickExecution.tsx @@ -654,11 +654,11 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { } const reminder = workbenchLotLabelReminderText?.trim() ?? ""; if (reminder && isExpiredWorkbenchReminderMessage(reminder)) { - return { text: "此批號狀態:已過期", severity: "error" as const }; + return { text: t("Lot status: expired"), severity: "error" as const }; } - const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot); + const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot, t); return { text: s.text, severity: s.severity }; - }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot, workbenchLotLabelReminderText]); + }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot, workbenchLotLabelReminderText, t]); const handleJustComplete = useCallback( async (row: LotRow) => { @@ -1714,7 +1714,7 @@ const WorkbenchPickExecution: React.FC = ({ filterArgs }) => { onClick={() => openWorkbenchLotLabelModalForLot(r)} sx={{ flexShrink: 0, fontSize: "0.7rem", py: 0.25, minWidth: "auto", px: 1, whiteSpace: "nowrap" }} > - {t(" 批號 QR 碼")} + {t("lot QR code")} ) : null}
diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index a3b15c4e..795915fc 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -1,131 +1,131 @@ { - "Actions": "操作", - "Add Document": "新增文件", + "Actions": "Actions", + "Add Document": "Add Document", "All": "All", - "Allergic Substances": "過敏原", + "Allergic Substances": "Allergens", "An error has occurred. Please try again later.": "An error has occurred. Please try again later.", - "Are you sure you want to delete this item?": "您確定要刪除此項目嗎?", - "Back": "返回", - "Basic Info": "基本資訊", + "Are you sure you want to delete this item?": "Are you sure you want to delete this item?", + "Back": "Back", + "Basic Info": "Basic Info", "Bom Required Qty": "BOM Required Qty", "Bom UOM": "BOM UOM", - "Brand": "品牌", - "CMB": "消耗品", - "CO": "消耗品", - "Cancel": "取消", - "Column Name": "欄位名稱", - "Coming soon": "即將推出", - "Complexity": "複雜度", - "Confirm": "確認", - "Confirm Delete": "確認刪除", - "Cost (HKD)": "費用 (HKD)", - "Current total": "目前總和", - "Day Before Yesterday": "前天", - "Delete": "刪除", - "Delete Failed": "刪除失敗", + "Brand": "Brand", + "CMB": "Consumable", + "CO": "Consumable", + "Cancel": "Cancel", + "Column Name": "Column Name", + "Coming soon": "Coming soon", + "Complexity": "Complexity", + "Confirm": "Confirm", + "Confirm Delete": "Confirm Delete", + "Cost (HKD)": "Cost (HKD)", + "Current total": "Current total", + "Day Before Yesterday": "Day Before Yesterday", + "Delete": "Delete", + "Delete Failed": "Delete Failed", "Do you want to delete?": "Do you want to delete?", - "Density": "濃淡", - "Depth": "顔色深淺度 深1淺5", - "Description": "描述", - "Details": "詳情", - "Duration (Minutes)": "時間(分)", - "Edit": "編輯", - "Enter any additional observations or notes...": "輸入其他觀察或備註...", - "Enter or select remark": "輸入或選擇備註", + "Density": "Density", + "Depth": "Color depth (dark 1 / light 5)", + "Description": "Description", + "Details": "Details", + "Duration (Minutes)": "Duration (Minutes)", + "Edit": "Edit", + "Enter any additional observations or notes...": "Enter any additional observations or notes...", + "Enter or select remark": "Enter or select remark", "Error saving data": "Error saving data", - "Failed to fetch data": "無法取得資料", - "Filter": "過濾", - "Finished Good Detail": "成品出倉詳情", - "Finished Good Management": "成品出倉管理", - "Finished Good Order": "成品出倉", - "Float": "浮沉", - "General Data": "基本資料", - "Grade {{grade}}": "等級 {{grade}}", - "Invoice": "發票", - "Invoice Date": "發票日期", + "Failed to fetch data": "Failed to fetch data", + "Filter": "Filter", + "Finished Good Detail": "Finished Good Detail", + "Finished Good Management": "Finished Good Management", + "Finished Good Order": "Finished Good Order", + "Float": "Float", + "General Data": "General Data", + "Grade {{grade}}": "Grade {{grade}}", + "Invoice": "Invoice", + "Invoice Date": "Invoice Date", "IP": "IP", "Item Code": "Item Code", "Item Name": "Item Name", - "Loading": "載入中...", - "Loading order summary": "正在載入訂單摘要", - "Location": "位置", - "MA": "材料", - "MAT": "材料", - "MI": "雜項", - "Material Name": "材料清單", + "Loading": "Loading...", + "Loading order summary": "Loading order summary", + "Location": "Location", + "MA": "Material", + "MAT": "Material", + "MI": "Miscellaneous", + "Material Name": "Material List", "Name": "Name", - "Min": "最小值", - "NM": "雜項及非消耗品", - "No": "否", - "No Lot": "沒有批號", - "No data available": "沒有資料", - "No options": "沒有選項", - "Order": "順序", + "Min": "Min", + "NM": "Miscellaneous and non-consumables", + "No": "No", + "No Lot": "No Lot", + "No data available": "No data available", + "No options": "No options", + "Order": "Order", "Pending": "Pending", "Port": "Port", - "Please Select BOM": "請選擇 BOM", - "Please try again later.": "請稍後重試。", - "Project Code": "專案代碼", - "Project Code and Name": "專案代碼與名稱", - "QC Template not found": "找不到 QC 範本", - "Qty": "數量", - "RM": "原料", - "Range": "範圍", - "Refresh": "重新載入", - "Remarks": "備註", - "Remove Document": "移除文件", - "Report": "報告", + "Please Select BOM": "Please select BOM", + "Please try again later.": "Please try again later.", + "Project Code": "Project Code", + "Project Code and Name": "Project Code and Name", + "QC Template not found": "QC template not found", + "Qty": "Qty", + "RM": "Raw material", + "Range": "Range", + "Refresh": "Refresh", + "Remarks": "Remarks", + "Remove Document": "Remove Document", + "Report": "Report", "Reset": "Reset", - "Row per page": "每頁行數", - "Rows per page": "每頁行數", - "Sales Qty": "銷售數量", - "Sales UOM": "銷售單位", - "Save": "儲存", - "Saving": "儲存中", + "Row per page": "Rows per page", + "Rows per page": "Rows per page", + "Sales Qty": "Sales Qty", + "Sales UOM": "Sales UOM", + "Save": "Save", + "Saving": "Saving", "Search": "Search", "Search Criteria": "Search Criteria", - "Select Date": "選擇日期", - "Session expired or unauthorized.": "工作階段已過期或未經授權。", + "Select Date": "Select Date", + "Session expired or unauthorized.": "Session expired or unauthorized.", "Sign out": "Sign out", "Language": "Language", - "Status": "狀態", - "Stock Qty": "庫存數量", - "Supporting Document": "證明文件", - "Task": "任務", - "Time Sequence": "時段", + "Status": "Status", + "Stock Qty": "Stock Qty", + "Supporting Document": "Supporting Document", + "Task": "Task", + "Time Sequence": "Time Sequence", "Type": "Type", - "Today": "今天", - "Total weighting must equal 1": "權重總和必須等於 1", - "Unauthorized: Please log in again": "未經授權:請重新登入", - "Uom": "單位", - "Update Failed": "更新失敗", - "Update Success": "更新成功", - "Weighting must be a number": "權重必須為數字", - "Yes": "是", - "Yesterday": "昨天", - "all": "全部", + "Today": "Today", + "Total weighting must equal 1": "Total weighting must equal 1", + "Unauthorized: Please log in again": "Unauthorized: Please log in again", + "Uom": "UOM", + "Update Failed": "Update Failed", + "Update Success": "Update Success", + "Weighting must be a number": "Weighting must be a number", + "Yes": "Yes", + "Yesterday": "Yesterday", + "all": "All", "bomWeighting": "BOM Weighting Score", - "cmb": "消耗品", - "collapsible table": "可折疊表格", - "consumable": "消耗品", - "consumables": "消耗品", - "create": "新增", - "edit": "編輯", - "expand row": "展開行", - "group mode": "群組模式", - "item": "貨品", - "items": "物品", - "mat": "原料", - "menu": "選單", - "nm": "雜項及非消耗品", - "non-consumables": "非消耗品", - "other": "其他", - "profile": "個人資料", - "revert": "還原", - "settings": "設定", - "stockRecord": "盤點記錄", - "stocktakemanagement": "盤點管理", - "testing sections tabs": "測試區域分頁", - "warehouse": "倉庫", - "材料": "材料" + "cmb": "Consumable", + "collapsible table": "Collapsible table", + "consumable": "Consumable", + "consumables": "Consumables", + "create": "Create", + "edit": "Edit", + "expand row": "Expand row", + "group mode": "Group mode", + "item": "Item", + "items": "Items", + "mat": "Raw material", + "menu": "Menu", + "nm": "Miscellaneous and non-consumables", + "non-consumables": "Non-consumables", + "other": "Other", + "profile": "Profile", + "revert": "Revert", + "settings": "Settings", + "stockRecord": "Stock record", + "stocktakemanagement": "Stock take management", + "testing sections tabs": "Testing section tabs", + "warehouse": "Warehouse", + "材料": "Material" } diff --git a/src/i18n/en/doWorkbench.json b/src/i18n/en/doWorkbench.json index 321a91a6..108d183a 100644 --- a/src/i18n/en/doWorkbench.json +++ b/src/i18n/en/doWorkbench.json @@ -45,5 +45,24 @@ "DO Workbench Search": "DO Workbench Search", "completed": "completed", "items": "items", - "pending": "pending" + "pending": "pending", + "Pick Order Detail": "Pick Detail", + "Etra Pick Order Detail": "Etra", + "Finished Good Record": "FG Record", + "Finished Good Record (All)": "FG Record (All)", + "Ticket Release Table": "Ticket Release", + "FG Carton Qty": "Carton Qty", + "成品出倉出箱數量": "Carton Qty", + "Truck Routing Summary": "Routing Summary", + "送貨路線摘要": "Truck Routing Summary", + "車線-X": "Truck X", + "Confirm print drafts": "Print {{count}} draft(s)?", + "Truck Routing Summary (Workbench)": "Truck Routing Summary (Workbench)", + "2/F or 4/F": "2/F or 4/F", + "Lane": "Lane", + "Date": "Date", + "Generating...": "Generating...", + "Download report (PDF)": "Download report (PDF)", + "Unpicked orders confirm download": "This lane still has {{count}} unpicked order(s).\nPrint / download the truck routing summary anyway?", + "Failed to download Workbench truck routing summary. Please try again later.": "Failed to download Workbench truck routing summary. Please try again later." } diff --git a/src/i18n/en/pickOrder.json b/src/i18n/en/pickOrder.json index db082bee..7760686c 100644 --- a/src/i18n/en/pickOrder.json +++ b/src/i18n/en/pickOrder.json @@ -58,7 +58,7 @@ "Lines": "Lines", "Before Today": "Before Today", "Truck X": "Truck X", - "Finsihed good items": "Finsihed good items", + "Finsihed good items": "Finished good items", "kinds": "kinds", "Completed Date": "Completed Date", "Completed Time": "Completed Time", @@ -147,8 +147,8 @@ "Etra": "Etra", "Exit Etra view": "Exit Etra view", "Etra Pick Order Detail": "Etra Pick Order Detail", - "Etra incomplete badge tooltip": "Etra incomplete badge tooltip", - "Etra incomplete badge tooltip none": "Etra incomplete badge tooltip none", + "Etra incomplete badge tooltip": "Incomplete extra tickets today: {{count}} (pending/released, excluding completed)", + "Etra incomplete badge tooltip none": "No incomplete extra tickets", "Back to normal assign tab": "Back to normal assign tab", "Enter isExtra workbench view?": "Enter isExtra workbench view?", "Etra view groups all add-on tickets by shop and lane for the selected date.": "Etra view groups all add-on tickets by shop and lane for the selected date.", @@ -179,7 +179,6 @@ "Confirm Search": "Search", "Merge Etra ticket search prompt": "Enter shop (optional) and date, then click Search to load merge candidates.", "Merge Etra ticket search failed": "Failed to load merge candidates. Ensure the backend is updated and restarted.", - "Truck X": "Truck X", "Pick Order": "Pick Order", "Type": "Type", "Product Type": "Product Type", @@ -529,14 +528,14 @@ "Floor ticket": "Floor ticket", "2F ticket": "2F ticket", "4F ticket": "4F ticket", - "4F lane panel legend": "4F lane panel legend", - "Loading sequence n": "Loading sequence n", - "lot QR code": "lot QR code", + "4F lane panel legend": "Lane — loading sequence (unassigned/total)", + "Loading sequence n": "Board {{n}}", + "lot QR code": "Lot QR Code", "label Printer": "label Printer", "Loading Sequence": "Loading Sequence", "Ticket No": "Ticket No", "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.": "The scanned lot inventory line is unavailable. Cannot switch or bind; pick line was not updated.", - "is unavable. Please check around have available QR code or not.": "is unavable. Please check around have available QR code or not.", + "is unavable. Please check around have available QR code or not.": "This lot is unavailable. Please check around for an available QR code.", "Lot switch failed; pick line was not marked as checked.": "Lot switch failed; pick line was not marked as checked.", "Lot confirmation failed. Please try again.": "Lot confirmation failed. Please try again.", "Powder Mixture": "Powder Mixture", @@ -559,5 +558,80 @@ "passed": "Passed", "failed": "Failed", "confirm_accept_with_fail": "There are failed QC items. Confirm to accept stock out?", - "No pending pick line left for this item. It may already be completed or fully processed.": "No pending pick line left for this item. It may already be completed or fully processed." + "No pending pick line left for this item. It may already be completed or fully processed.": "No pending pick line left for this item. It may already be completed or fully processed.", + "FG Carton Qty": "FG Carton Qty", + "成品出倉出箱數量": "FG Carton Qty", + "Truck Routing Summary": "Truck Routing Summary", + "送貨路線摘要": "Truck Routing Summary", + "車線-X": "Truck X", + "Lot QR Code": "Lot QR Code", + " 批號 QR 碼": "Lot QR Code", + "Cannot determine this lot status": "Cannot determine this lot status", + "Lot status: expired": "Lot status: expired", + "This pick line was rejected. Please scan another lot.": "This pick line was rejected. Please scan another lot.", + "This pick line is already completed. No further pick needed.": "This pick line is already completed. No further pick needed.", + "No lot bound / no available stock line. Please scan nearby inbound or transfer QR.": "No lot bound / no available stock line. Please scan nearby inbound or transfer QR.", + "Lot status: depleted (no remaining stock)": "Lot status: depleted (no remaining stock)", + "Lot status: depleted (available qty is 0)": "Lot status: depleted (available qty is 0)", + "Lot status: unavailable (not put away or line unavailable)": "Lot status: unavailable (not put away or line unavailable)", + "Lot status: ready to pick": "Lot status: ready to pick", + "This lot ({{lot}}) was rejected and cannot be used. Please scan another lot.": "This lot ({{lot}}) was rejected and cannot be used. Please scan another lot.", + "No lot information for this item in the current order": "No lot information for this item in the current order", + "Lot label print (pick station)": "Lot label print (pick station)", + "Scan content": "Scan content", + "Scan then press Enter or click Look up": "Scan then press Enter or click Look up", + "Look up": "Look up", + "Clear": "Clear", + "Printer": "Printer", + "Please select": "Please select", + "Print copies": "Print copies", + "Refresh lot list": "Refresh lot list", + "Selected printer": "Selected: {{printer}}", + "Item code name": "Item: {{code}} {{name}}", + "No available lots on this floor": "No available lots on this floor (available qty > 0).", + " (current lot)": " (current lot)", + "Location with value": "Location: {{location}}", + "Available qty with uom": "Available qty: {{qty}} UOM: {{uom}}", + "Print label": "Print label", + "Show QR": "Show QR", + "This row has no QR payload": "This row has no QR payload (stockInLineId required)", + "This pick line already scanned or completed, QR cannot be shown": "This pick line is already scanned or completed, so QR cannot be shown", + "No lots available to print labels": "No lots available to print labels", + "Failed to load printer list": "Failed to load printer list", + "Loaded available lots for this item": "Loaded available lots for this item", + "Analysis failed": "Look-up failed", + "Invalid itemId, cannot load lot list.": "Invalid item ID, cannot load lot list.", + "Invalid scan format. Please scan again.": "Invalid scan format. Please scan again.", + "Scan or look up once before refreshing the lot list.": "Scan or look up once before refreshing the lot list.", + "Print quantity must be an integer of 1 or more": "Print quantity must be an integer of 1 or more", + "Print sent: Lot {{lotNo}}": "Print sent: Lot {{lotNo}}", + "Print failed": "Print failed", + "Failed to load FG carton quantity. Please try again later.": "Failed to load FG carton quantity. Please try again later.", + "Cartons": "Cartons", + "{{count}} cartons": "{{count}} cartons", + "No chart data": "No chart data", + "2/F carton qty": "2/F carton qty", + "4/F carton qty": "4/F carton qty", + "Truck X carton qty": "Truck X carton qty", + "Total carton qty": "Total carton qty", + "Summary": "Summary", + "All floors": "All floors", + "Last 7 days": "Last 7 days", + "This month": "This month", + "This year": "This year", + "FG carton qty last 7 days title": "FG carton qty (last 7 days) - {{floor}} - as of {{date}}", + "FG carton qty this month title": "FG carton qty (this month) - {{floor}} - {{period}}", + "FG carton qty this year title": "FG carton qty (this year) - {{floor}} - {{period}}", + "Exporting...": "Exporting...", + "Download Excel": "Download Excel", + "Truck Routing Summary (Workbench)": "Truck Routing Summary (Workbench)", + "2/F or 4/F": "2/F or 4/F", + "Lane": "Lane", + "Generating...": "Generating...", + "Download report (PDF)": "Download report (PDF)", + "Unpicked orders confirm download": "This lane still has {{count}} unpicked order(s).\nPrint / download the truck routing summary anyway?", + "Failed to download Workbench truck routing summary. Please try again later.": "Failed to download Workbench truck routing summary. Please try again later.", + "Confirm print drafts": "Print {{count}} draft(s)?", + "Floor": "Floor", + "All": "All" } diff --git a/src/i18n/en/ticketReleaseTable.json b/src/i18n/en/ticketReleaseTable.json index 6fc5468a..bb0a9732 100644 --- a/src/i18n/en/ticketReleaseTable.json +++ b/src/i18n/en/ticketReleaseTable.json @@ -16,11 +16,11 @@ "Departure Time": "Departure Time", "Floor": "Floor", "Force complete DO": "Force complete DO", - "Force complete hint": "Force complete hint", + "Force complete hint": "Marks the ticket completed and archived without changing picked quantities. Use when all lines are submitted but the system did not complete.", "Handler Name": "Handler Name", "Last updated": "Last updated", "Loading Sequence": "Loading Sequence", - "Manager only hint": "Manager only hint", + "Manager only hint": "Admin only", "No data available": "No data available", "Now": "Now", "Number of FG Items (Order Item(s) Count)": "Number of FG Items (Order Item(s) Count)", @@ -29,7 +29,7 @@ "Reload data": "Reload data", "Required Delivery Date": "Required Delivery Date", "Revert assignment": "Revert assignment", - "Revert assignment hint": "Revert assignment hint", + "Revert assignment hint": "Clears the assigned handler so the ticket returns to unassigned and can be taken again.", "Rows per page": "Rows per page", "Select All": "Select All", "Select Date": "Select Date", diff --git a/src/i18n/zh/doWorkbench.json b/src/i18n/zh/doWorkbench.json index 367c74db..2628d6e8 100644 --- a/src/i18n/zh/doWorkbench.json +++ b/src/i18n/zh/doWorkbench.json @@ -45,5 +45,19 @@ "Auto-refresh every 5 minutes": "每5分鐘自動刷新", "Last updated": "最後更新", "Truck Information": "車線資訊", - "Actions": "操作" + "Actions": "操作", + "FG Carton Qty": "成品出倉出箱數量", + "成品出倉出箱數量": "成品出倉出箱數量", + "Truck Routing Summary": "送貨路線摘要", + "送貨路線摘要": "送貨路線摘要", + "車線-X": "車線-X", + "Confirm print drafts": "確認列印 {{count}} 張草稿?", + "Truck Routing Summary (Workbench)": "送貨路線摘要 (Workbench)", + "2/F or 4/F": "2/F 或 4/F", + "Lane": "車線", + "Date": "日期", + "Generating...": "生成中...", + "Download report (PDF)": "下載報告 (PDF)", + "Unpicked orders confirm download": "此車線仍有 {{count}} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?", + "Failed to download Workbench truck routing summary. Please try again later.": "下載 Workbench 送貨路線摘要失敗,請稍後再試。" } diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index d9465c5e..d738c00f 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -568,5 +568,82 @@ "Lot status is unavailable. Cannot switch or bind; pick line was not updated.": "批號狀態為「不可用」,無法換批或綁定;揀貨行未更新。", "No lot rows. Select a line in the table above.": "尚無批號資料。請在上方表格勾選一行提料單明細。", "No stock out line for this lot": "此批號尚無出庫行,無法提交。", - "No pending pick line left for this item. It may already be completed or fully processed.": "此貨品已無待揀行,可能已完成或不在本單可掃範圍。" - } \ No newline at end of file + "No pending pick line left for this item. It may already be completed or fully processed.": "此貨品已無待揀行,可能已完成或不在本單可掃範圍。", + + "FG Carton Qty": "成品出倉出箱數量", + "成品出倉出箱數量": "成品出倉出箱數量", + "Truck Routing Summary": "送貨路線摘要", + "送貨路線摘要": "送貨路線摘要", + "車線-X": "車線-X", + "Lot QR Code": "批號 QR 碼", + " 批號 QR 碼": "批號 QR 碼", + "Cannot determine this lot status": "無法判斷此批號狀態", + "Lot status: expired": "此批號狀態:已過期", + "This pick line was rejected. Please scan another lot.": "此出庫行:已拒絕,請改掃其他批號", + "This pick line is already completed. No further pick needed.": "此出庫行:已完成,無需再提貨", + "No lot bound / no available stock line. Please scan nearby inbound or transfer QR.": "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR", + "Lot status: depleted (no remaining stock)": "此批號狀態:已用畢(無剩餘庫存)", + "Lot status: depleted (available qty is 0)": "此批號狀態:已用畢(可用量為 0)", + "Lot status: unavailable (not put away or line unavailable)": "此批號狀態:庫存不可用(未上架或行狀態不可用)", + "Lot status: ready to pick": "此批號狀態:可提貨", + "This lot ({{lot}}) was rejected and cannot be used. Please scan another lot.": "此批次({{lot}})已被拒絕,無法使用。請掃描其他批次。", + "No lot information for this item in the current order": "當前訂單中沒有此物品的批次資訊", + "Lot label print (pick station)": "批號標籤列印(提貨台)", + "Scan content": "掃碼內容", + "Scan then press Enter or click Look up": "掃描後按 Enter 或點「查詢」", + "Look up": "查詢", + "Clear": "清除", + "Printer": "印表機", + "Please select": "請選擇", + "Print copies": "列印張數", + "Refresh lot list": "刷新批號清單", + "Selected printer": "已選:{{printer}}", + "Item code name": "品號:{{code}} {{name}}", + "No available lots on this floor": "找不到該樓層有可用批號(availableQty > 0)。", + " (current lot)": "(當前批次)", + "Location with value": "位置:{{location}}", + "Available qty with uom": "可用量:{{qty}} 單位:{{uom}}", + "Print label": "列印標籤", + "Show QR": "顯示 QR", + "This row has no QR payload": "此列無法取得 QR payload(需 stockInLineId)", + "This pick line already scanned or completed, QR cannot be shown": "此出庫行已掃碼或已完成,無法顯示 QR", + "No lots available to print labels": "沒有任何批號可列印標籤", + "Failed to load printer list": "載入印表機清單失敗", + "Loaded available lots for this item": "已載入同品可用批號清單", + "Analysis failed": "分析失敗", + "Invalid itemId, cannot load lot list.": "無效 itemId,無法載入批號清單。", + "Invalid scan format. Please scan again.": "掃碼內容格式錯誤,請重新掃碼", + "Scan or look up once before refreshing the lot list.": "請先掃碼或查詢一次,才可刷新批號清單。", + "Print quantity must be an integer of 1 or more": "列印張數需為大於等於 1 的整數", + "Print sent: Lot {{lotNo}}": "已送出列印:Lot {{lotNo}}", + "Print failed": "列印失敗", + "Failed to load FG carton quantity. Please try again later.": "載入成品出倉出箱數量失敗,請稍後再試。", + "Cartons": "箱數", + "{{count}} cartons": "{{count}} 箱", + "No chart data": "沒有圖表資料", + "Total": "總數", + "2/F carton qty": "2/F 出箱數", + "4/F carton qty": "4/F 出箱數", + "Truck X carton qty": "車線-X 出箱數", + "Total carton qty": "總出箱數", + "Summary": "彙總", + "All floors": "全部樓層", + "Last 7 days": "最近7天", + "This month": "本月", + "This year": "本年", + "FG carton qty last 7 days title": "成品出倉出箱數量(最近7天)- {{floor}} - 基準日 {{date}}", + "FG carton qty this month title": "成品出倉出箱數量(本月)- {{floor}} - {{period}}", + "FG carton qty this year title": "成品出倉出箱數量(本年)- {{floor}} - {{period}}", + "Exporting...": "匯出中...", + "Download Excel": "下載 Excel", + "Truck Routing Summary (Workbench)": "送貨路線摘要 (Workbench)", + "2/F or 4/F": "2/F 或 4/F", + "Lane": "車線", + "Generating...": "生成中...", + "Download report (PDF)": "下載報告 (PDF)", + "Unpicked orders confirm download": "此車線仍有 {{count}} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?", + "Failed to download Workbench truck routing summary. Please try again later.": "下載 Workbench 送貨路線摘要失敗,請稍後再試。", + "Confirm print drafts": "確認列印 {{count}} 張草稿?", + "Floor": "樓層", + "All": "全部" +} diff --git a/src/utils/workbenchPickLotUtils.ts b/src/utils/workbenchPickLotUtils.ts index de0ab460..d73af4f6 100644 --- a/src/utils/workbenchPickLotUtils.ts +++ b/src/utils/workbenchPickLotUtils.ts @@ -150,44 +150,62 @@ export function buildUnpickableScanRowPatch( return patch; } -export function getWorkbenchSourceLotStatusSummary(lot: WorkbenchPickLotLike | null | undefined): { +export function getWorkbenchSourceLotStatusSummary( + lot: WorkbenchPickLotLike | null | undefined, + t?: PickOrderT, +): { severity: "success" | "warning" | "error"; text: string; } { + const tr = (key: string) => (t ? t(key) : key); if (!lot) { - return { severity: "warning", text: "無法判斷此批號狀態" }; + return { severity: "warning", text: tr("Cannot determine this lot status") }; } if (isWorkbenchSourceLotExpired(lot)) { - return { severity: "error", text: "此批號狀態:已過期" }; + return { severity: "error", text: tr("Lot status: expired") }; } const solSt = solStatusOf(lot); if (solSt === "rejected") { - return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" }; + return { + severity: "warning", + text: tr("This pick line was rejected. Please scan another lot."), + }; } if (solSt === "completed" || solSt === "partially_completed" || solSt === "partially_complete") { - return { severity: "warning", text: "此出庫行:已完成,無需再提貨" }; + return { + severity: "warning", + text: tr("This pick line is already completed. No further pick needed."), + }; } const isNoLotRow = lot.noLot === true || !lot.lotNo || String(lot.lotNo || "").trim() === ""; if (isNoLotRow) { return { severity: "warning", - text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR", + text: tr( + "No lot bound / no available stock line. Please scan nearby inbound or transfer QR.", + ), }; } const av = String(lot.lotAvailability || "").toLowerCase(); if (av === "insufficient_stock") { - return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" }; + return { + severity: "warning", + text: tr("Lot status: depleted (no remaining stock)"), + }; } const avail = Number(lot.availableQty); if (lot.lotNo && Number.isFinite(avail) && avail <= 0) { - return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" }; + return { + severity: "warning", + text: tr("Lot status: depleted (available qty is 0)"), + }; } if (isInventoryLotLineUnavailable(lot)) { return { severity: "warning", - text: "此批號狀態:庫存不可用(未上架或行狀態不可用)", + text: tr("Lot status: unavailable (not put away or line unavailable)"), }; } - return { severity: "success", text: "此批號狀態:可提貨" }; + return { severity: "success", text: tr("Lot status: ready to pick") }; } From a9db46af6150539bfed1d6c969c83e1c2a4ce514 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Mon, 21 Sep 2026 17:47:57 +0800 Subject: [PATCH 10/38] Show the API error text instead of wrapping it with a traceId. Inventory SIGNAL failures can display the Chinese reason returned by the backend. Co-authored-by: Cursor --- src/app/utils/fetchUtil.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/src/app/utils/fetchUtil.ts b/src/app/utils/fetchUtil.ts index aa445854..22327ebc 100644 --- a/src/app/utils/fetchUtil.ts +++ b/src/app/utils/fetchUtil.ts @@ -24,6 +24,27 @@ export interface searchParamsProps { searchParams: { [key: string]: string | string[] | undefined }; } +/** Prefer API `error` / `message` so SIGNAL 400s show the Chinese reason, not a traceId. */ +function userMessageFromErrorBody(body: string, fallback: string): string { + const trimmed = body.trim(); + if (!trimmed) return fallback; + try { + const errorJson = JSON.parse(trimmed) as { + error?: unknown; + message?: unknown; + }; + if (typeof errorJson.error === "string" && errorJson.error.trim()) { + return errorJson.error.trim(); + } + if (typeof errorJson.message === "string" && errorJson.message.trim()) { + return errorJson.message.trim(); + } + } catch { + return trimmed; + } + return trimmed; +} + export async function serverFetchWithNoContent(...args: FetchParams) { const response = await serverFetch(...args); @@ -57,7 +78,7 @@ export async function serverFetchWithNoContent(...args: FetchParams) { } console.error(`Server error (${response.status}):`, errorMessage); throw new ServerFetchError( - `Server error: ${response.status} ${response.statusText}. ${errorMessage}`, + userMessageFromErrorBody(errorMessage, errorMessage), response ); } @@ -107,7 +128,10 @@ export async function serverFetchJson(...args: FetchParams) { signOutUser(); default: throw new ServerFetchError( - `Server error: ${response.status} ${response.statusText}. ${errorText}`, + userMessageFromErrorBody( + errorText, + `Server error: ${response.status} ${response.statusText}`, + ), response, ); } From 4665592671afd12abde3fb951d12724a7b123ce0 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Mon, 21 Sep 2026 18:14:35 +0800 Subject: [PATCH 11/38] [Feat] DO Workbench -> Carton Qty now can search lane and shop + download btn for specified filters --- src/app/api/doworkbench/actions.ts | 2 - src/app/api/pickOrder/actions.ts | 2 - .../FinishedGoodCartonDashboardTab.tsx | 907 +++++++++++++++--- src/components/charts/SafeApexCharts.tsx | 91 +- src/i18n/en/doWorkbench.json | 21 + src/i18n/en/pickOrder.json | 18 + src/i18n/zh/doWorkbench.json | 21 + src/i18n/zh/pickOrder.json | 18 + 8 files changed, 905 insertions(+), 175 deletions(-) diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index 65af3d6e..60a0040d 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -216,7 +216,6 @@ export async function fetchWorkbenchStoreLaneSummary( return serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); } @@ -235,7 +234,6 @@ export async function fetchWorkbenchEtraLaneSummary( const data = await serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); return Array.isArray(data) ? data : []; } diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index f21a60b4..25409697 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -632,7 +632,6 @@ export async function fetchStoreLaneSummary(storeId: string, requiredDate?: stri const response = await serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); console.timeEnd(label); return response; @@ -856,7 +855,6 @@ export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => { method: "GET", // Must be fresh: determines whether shell shows Floor/Lane panel or Detail. cache: "no-store", - next: { revalidate: 0 }, }, ); }; diff --git a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx index cdf8d0e2..ba895bdb 100644 --- a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx +++ b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx @@ -1,8 +1,9 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, + Autocomplete, Box, Button, CircularProgress, @@ -10,13 +11,8 @@ import { MenuItem, Paper, Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, TextField, + Tooltip, Typography, } from "@mui/material"; import type { ApexOptions } from "apexcharts"; @@ -31,6 +27,7 @@ import SafeApexCharts from "@/components/charts/SafeApexCharts"; import { useTranslation } from "react-i18next"; type FloorFilter = "all" | "2/F" | "4/F"; +type BreakdownDimension = "lane" | "shop" | "floor"; type DailySummaryRow = { date: string; @@ -40,19 +37,143 @@ type DailySummaryRow = { total: number; }; +type BreakdownRow = { + key: string; + label: string; + qty: number; +}; + type Props = { mode?: "normal" | "workbench"; }; +const TRUCK_X_LANE = "車線-X"; +const ALL = "all"; +const CHART_ROW_H = 38; + +type FilterOption = { value: string; label: string }; + +function normalizeTruckLane(raw: string | null | undefined): string { + const value = String(raw ?? "").trim(); + return value || TRUCK_X_LANE; +} + +function shortLaneLabel(raw: string | null | undefined): string { + const value = normalizeTruckLane(raw); + const stripped = value + .replace(/^(車線)[-–—]?\s*/u, "") + .replace(/^(truck)\s*[-–—]?\s*/i, "") + .trim(); + return stripped || value; +} + +function normalizeShopCode(raw: string | null | undefined): string { + return String(raw ?? "").trim(); +} + +function shopGroup(raw: string | null | undefined): string { + const code = normalizeShopCode(raw).toUpperCase(); + if (!code) return ""; + return code.slice(0, 2); +} + +function isShopGroupValue(shop: string): boolean { + if (shop === ALL) return false; + const code = normalizeShopCode(shop).toUpperCase(); + return code.length > 0 && code === shopGroup(code); +} + +function recordMatchesShop( + recordShop: string | null | undefined, + shop: string, +): boolean { + if (shop === ALL) return true; + const code = normalizeShopCode(recordShop).toUpperCase(); + if (!code) return false; + const selected = normalizeShopCode(shop).toUpperCase(); + if (isShopGroupValue(selected)) { + return shopGroup(code) === selected; + } + return code === selected; +} + +function recordMatchesFilters( + record: CompletedDoPickOrderResponse, + floor: FloorFilter, + lane: string, + shop: string, +): boolean { + if (floor !== ALL && record.storeId !== floor) return false; + if (lane !== ALL && normalizeTruckLane(record.truckLanceCode) !== lane) return false; + if (!recordMatchesShop(record.shopCode, shop)) return false; + return true; +} + +function resolveBreakdownDimension(lane: string, shop: string): BreakdownDimension { + if (isShopGroupValue(shop)) return "shop"; + if (shop !== ALL && lane !== ALL) return "floor"; + if (lane !== ALL) return "shop"; + if (shop !== ALL) return "lane"; + return "lane"; +} + +type SearchableFilterProps = { + label: string; + options: FilterOption[]; + value: string; + onChange: (next: string) => void; +}; + +function SearchableFilterSelect({ label, options, value, onChange }: SearchableFilterProps) { + const selectable = options.filter((option) => option.value !== ALL); + const selected = selectable.find((option) => option.value === value) ?? null; + const placeholder = options.find((option) => option.value === ALL)?.label ?? ""; + + return ( + onChange(option?.value ?? ALL)} + getOptionLabel={(option) => option?.label ?? ""} + isOptionEqualToValue={(a, b) => a?.value === b?.value} + selectOnFocus + autoHighlight + handleHomeEndKeys + autoComplete + includeInputInList + size="small" + sx={{ width: "100%" }} + renderInput={(params) => ( + + )} + /> + ); +} + const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => { const { t, i18n } = useTranslation(); const numberLocale = i18n.language?.startsWith("zh") ? "zh-HK" : "en-US"; - const [floor, setFloor] = useState("all"); + const [floor, setFloor] = useState(ALL); + const [lane, setLane] = useState(ALL); + const [shop, setShop] = useState(ALL); const [date, setDate] = useState(dayjs().format("YYYY-MM-DD")); const [loading, setLoading] = useState(false); const [isExporting, setIsExporting] = useState(false); + const [isExportingFiltered, setIsExportingFiltered] = useState(false); + const exportInFlightRef = useRef(false); + const filteredExportInFlightRef = useRef(false); const [error, setError] = useState(""); const [records, setRecords] = useState([]); + const todayDate = dayjs().format("YYYY-MM-DD"); + const filtersAreActive = + floor !== ALL || lane !== ALL || shop !== ALL || date !== todayDate; + + const resetFilters = useCallback(() => { + setFloor(ALL); + setLane(ALL); + setShop(ALL); + setDate(dayjs().format("YYYY-MM-DD")); + }, []); const loadData = useCallback(async () => { setLoading(true); @@ -80,13 +201,61 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => loadData(); }, [loadData]); - const rows = useMemo(() => { - const filtered = - floor === "all" ? records : records.filter((record) => record.storeId === floor); + const laneOptions = useMemo(() => { + const byRaw = new Map(); + records.forEach((record) => { + if (floor !== ALL && record.storeId !== floor) return; + if (!recordMatchesShop(record.shopCode, shop)) return; + const raw = normalizeTruckLane(record.truckLanceCode); + if (!byRaw.has(raw)) byRaw.set(raw, shortLaneLabel(raw)); + }); + return Array.from(byRaw.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => { + if (a.value === TRUCK_X_LANE) return 1; + if (b.value === TRUCK_X_LANE) return -1; + return a.label.localeCompare(b.label, "zh-Hant"); + }); + }, [records, floor, shop]); + const shopOptions = useMemo(() => { + const groups = new Set(); + records.forEach((record) => { + if (floor !== ALL && record.storeId !== floor) return; + if (lane !== ALL && normalizeTruckLane(record.truckLanceCode) !== lane) return; + const group = shopGroup(record.shopCode); + if (group) groups.add(group); + }); + return Array.from(groups) + .sort((a, b) => a.localeCompare(b, "zh-Hant")) + .map((group) => ({ value: group, label: group })); + }, [records, floor, lane]); + + const laneFilterOptions = useMemo(() => { + const options: FilterOption[] = [{ value: ALL, label: t("All lanes") }, ...laneOptions]; + if (lane !== ALL && !options.some((option) => option.value === lane)) { + options.splice(1, 0, { value: lane, label: shortLaneLabel(lane) }); + } + return options; + }, [laneOptions, lane, t]); + + const shopFilterOptions = useMemo(() => { + const options: FilterOption[] = [{ value: ALL, label: t("All shops") }, ...shopOptions]; + if (shop !== ALL && !options.some((option) => option.value === shop)) { + options.splice(1, 0, { value: shop, label: shop }); + } + return options; + }, [shopOptions, shop, t]); + + const filteredRecords = useMemo( + () => records.filter((record) => recordMatchesFilters(record, floor, lane, shop)), + [records, floor, lane, shop], + ); + + const rows = useMemo(() => { const summary = new Map(); - filtered.forEach((record) => { + filteredRecords.forEach((record) => { const day = dayjs(record.deliveryDate).isValid() ? dayjs(record.deliveryDate).format("YYYY-MM-DD") : "-"; @@ -106,7 +275,7 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => if (record.storeId === "4/F") { current.floor4F += cartonQty; } - if (String(record.truckLanceCode ?? "").trim() === "車線-X") { + if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) { current.truckX += cartonQty; } @@ -115,88 +284,252 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => }); return Array.from(summary.values()).sort((a, b) => b.date.localeCompare(a.date)); - }, [records, floor]); + }, [filteredRecords]); + + const breakdownDimension = resolveBreakdownDimension(lane, shop); + + const breakdownRows = useMemo(() => { + const toSorted = (slices: BreakdownRow[]) => + slices.sort((a, b) => b.qty - a.qty || a.label.localeCompare(b.label, "zh-Hant")); + + if (breakdownDimension === "floor") { + let floor2F = 0; + let floor4F = 0; + let truckX = 0; + filteredRecords.forEach((record) => { + const cartonQty = Number(record.numberOfCartons ?? 0); + if (record.storeId === "2/F") floor2F += cartonQty; + if (record.storeId === "4/F") floor4F += cartonQty; + if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) truckX += cartonQty; + }); + return toSorted( + [ + { key: "2/F", label: "2/F", qty: floor2F }, + { key: "4/F", label: "4/F", qty: floor4F }, + { key: TRUCK_X_LANE, label: shortLaneLabel(TRUCK_X_LANE), qty: truckX }, + ].filter((row) => { + if (row.qty > 0) return true; + if (row.key === "2/F" || row.key === "4/F") return floor === row.key; + return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE; + }), + ); + } + + const grouped = new Map(); + filteredRecords.forEach((record) => { + const cartonQty = Number(record.numberOfCartons ?? 0); + if (breakdownDimension === "lane") { + const key = normalizeTruckLane(record.truckLanceCode); + const current = grouped.get(key) ?? { + key, + label: shortLaneLabel(key), + qty: 0, + }; + current.qty += cartonQty; + grouped.set(key, current); + return; + } + + const key = + shop !== ALL + ? normalizeShopCode(record.shopCode).toUpperCase() + : shopGroup(record.shopCode); + if (!key) return; + const current = grouped.get(key) ?? { key, label: key, qty: 0 }; + current.qty += cartonQty; + grouped.set(key, current); + }); + + return toSorted( + Array.from(grouped.values()).filter((row) => { + if (row.qty > 0) return true; + if (breakdownDimension === "shop") return shop !== ALL && row.key === shop; + return lane !== ALL && row.key === lane; + }), + ); + }, [breakdownDimension, filteredRecords, floor, lane, shop]); + + const breakdownCaption = + breakdownDimension === "shop" + ? shop !== ALL + ? t("Cartons by shop") + : t("Cartons by shop group") + : breakdownDimension === "floor" + ? t("Cartons by floor") + : t("Cartons by lane"); + + const applyBreakdownClick = useCallback( + (row: BreakdownRow) => { + if (breakdownDimension === "lane") { + setLane((prev) => (prev === row.key ? ALL : row.key)); + return; + } + if (breakdownDimension === "shop") { + setShop((prev) => { + if (prev === row.key) { + const group = shopGroup(row.key); + return group && group !== row.key ? group : ALL; + } + return row.key; + }); + return; + } + if (row.key === "2/F" || row.key === "4/F") { + setFloor((prev) => (prev === row.key ? ALL : row.key)); + return; + } + if (row.key === TRUCK_X_LANE) { + setLane((prev) => (prev === TRUCK_X_LANE ? ALL : TRUCK_X_LANE)); + } + }, + [breakdownDimension], + ); + + const isBreakdownRowActive = useCallback( + (row: BreakdownRow) => { + if (breakdownDimension === "lane") return lane === row.key; + if (breakdownDimension === "shop") return shop === row.key; + if (row.key === "2/F" || row.key === "4/F") return floor === row.key; + return lane === TRUCK_X_LANE && row.key === TRUCK_X_LANE; + }, + [breakdownDimension, floor, lane, shop], + ); + + const summary = useMemo(() => { + return rows.reduce( + (acc, row) => { + acc.floor2F += row.floor2F; + acc.floor4F += row.floor4F; + acc.truckX += row.truckX; + acc.total += row.total; + return acc; + }, + { floor2F: 0, floor4F: 0, truckX: 0, total: 0 }, + ); + }, [rows]); + + const chartMaxQty = Math.max(1, ...breakdownRows.map((row) => row.qty)); + const chartHeight = Math.max(CHART_ROW_H, breakdownRows.length * CHART_ROW_H); const chartOptions = useMemo( () => ({ chart: { type: "bar", toolbar: { show: false }, + sparkline: { enabled: false }, + parentHeightOffset: 0, + offsetY: 0, + animations: { + enabled: true, + easing: "easeinout", + speed: 450, + animateGradually: { enabled: true, delay: 40 }, + }, + selection: { enabled: false }, + zoom: { enabled: false }, + }, + states: { + hover: { filter: { type: "none" } }, + active: { filter: { type: "none" } }, + }, + colors: ["#1976d2"], + dataLabels: { + enabled: true, + formatter: (val) => Number(val || 0).toLocaleString(numberLocale), + offsetX: 12, + textAnchor: "start", + style: { + fontSize: "15px", + fontWeight: 800, + colors: ["#ffffff"], + }, + background: { + enabled: true, + foreColor: "#102a43", + padding: 6, + borderRadius: 4, + opacity: 1, + borderWidth: 1, + borderColor: "#90a4ae", + dropShadow: { + enabled: true, + color: "#000000", + top: 1, + left: 0, + blur: 3, + opacity: 0.22, + }, + }, }, - colors: ["#1976d2", "#9c27b0", "#ff9800", "#2e7d32"], - dataLabels: { enabled: false }, stroke: { show: true, width: 1, colors: ["transparent"] }, plotOptions: { bar: { - horizontal: false, - borderRadius: 3, - columnWidth: "55%", + horizontal: true, + borderRadius: 4, + barHeight: 22, + dataLabels: { + position: "top", + }, }, }, + grid: { + show: true, + borderColor: "#eceff1", + padding: { top: -8, bottom: -8, left: 4, right: 64 }, + xaxis: { lines: { show: true } }, + yaxis: { lines: { show: false } }, + }, xaxis: { - categories: rows.map((row) => row.date), - title: { text: t("Date") }, + categories: breakdownRows.map((row) => row.label), + labels: { show: false }, + axisBorder: { show: false }, + axisTicks: { show: false }, + min: 0, + max: Math.max(chartMaxQty * 1.4, chartMaxQty + 12), }, yaxis: { - title: { text: t("Cartons") }, - labels: { - formatter: (val) => Number(val || 0).toLocaleString(numberLocale), - }, + labels: { show: false }, }, tooltip: { + x: { + formatter: (_val, opts) => + breakdownRows[opts?.dataPointIndex ?? -1]?.label ?? String(_val ?? ""), + }, y: { - formatter: (val) => - t("{{count}} cartons", { - count: Number(val || 0).toLocaleString(numberLocale), - }), + formatter: (val, opts) => { + const qty = Number(val || 0); + const share = summary.total > 0 ? (qty / summary.total) * 100 : 0; + const label = breakdownRows[opts?.dataPointIndex ?? -1]?.label ?? ""; + return `${label}: ${qty.toLocaleString(numberLocale)} (${share.toFixed(1)}%)`; + }, }, }, - legend: { - position: "top", - }, - noData: { - text: t("No chart data"), - }, + legend: { show: false }, + noData: { text: t("No chart data") }, }), - [rows, t, numberLocale], + [breakdownRows, chartMaxQty, numberLocale, summary.total, t], ); const chartSeries = useMemo( - () => [ - { name: "2/F", data: rows.map((row) => row.floor2F) }, - { name: "4/F", data: rows.map((row) => row.floor4F) }, - { name: t("Truck X"), data: rows.map((row) => row.truckX) }, - { name: t("Total"), data: rows.map((row) => row.total) }, - ], - [rows, t], + () => [{ name: t("Cartons"), data: breakdownRows.map((row) => row.qty) }], + [breakdownRows, t], ); - const summary = useMemo(() => { - return rows.reduce( - (acc, row) => { - acc.floor2F += row.floor2F; - acc.floor4F += row.floor4F; - acc.truckX += row.truckX; - acc.total += row.total; - return acc; - }, - { floor2F: 0, floor4F: 0, truckX: 0, total: 0 }, - ); - }, [rows]); - const buildDailyRowsFromRecords = useCallback( ( sourceRecords: CompletedDoPickOrderResponse[], startDate: dayjs.Dayjs, endDate: dayjs.Dayjs, selectedFloor: FloorFilter, + selectedLane: string, + selectedShop: string, ): DailySummaryRow[] => { const summaryMap = new Map(); const start = startDate.startOf("day"); const end = endDate.endOf("day"); sourceRecords.forEach((record) => { - if (selectedFloor !== "all" && record.storeId !== selectedFloor) { + if (!recordMatchesFilters(record, selectedFloor, selectedLane, selectedShop)) { return; } @@ -217,7 +550,7 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => if (record.storeId === "2/F") current.floor2F += cartonQty; if (record.storeId === "4/F") current.floor4F += cartonQty; - if (String(record.truckLanceCode ?? "").trim() === "車線-X") current.truckX += cartonQty; + if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) current.truckX += cartonQty; current.total += cartonQty; summaryMap.set(dayKey, current); @@ -346,7 +679,84 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => [calcSummary, styleWorksheet, t], ); + const addBreakdownSheet = useCallback( + ( + workbook: XLSX.WorkBook, + sheetName: string, + reportTitle: string, + categoryLabel: string, + slices: BreakdownRow[], + totalQty: number, + ) => { + const aoa: (string | number)[][] = [ + [reportTitle, "", ""], + ["", "", ""], + [categoryLabel, t("Cartons"), t("Share")], + ...slices.map((row) => { + const share = totalQty > 0 ? (row.qty / totalQty) * 100 : 0; + return [row.label, row.qty, `${share.toFixed(1)}%`]; + }), + ["", "", ""], + [t("Total carton qty"), totalQty, ""], + ]; + + const worksheet = XLSX.utils.aoa_to_sheet(aoa); + worksheet["!cols"] = [{ wch: 28 }, { wch: 14 }, { wch: 12 }]; + worksheet["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }]; + + const titleStyle = { + font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } }, + alignment: { horizontal: "center", vertical: "center" }, + fill: { fgColor: { rgb: "EAF3FF" } }, + }; + const headerStyle = { + font: { bold: true, color: { rgb: "FFFFFF" } }, + fill: { fgColor: { rgb: "1976D2" } }, + alignment: { horizontal: "center", vertical: "center" }, + }; + const cellStyle = { + alignment: { vertical: "center" }, + border: { + top: { style: "thin", color: { rgb: "D0D7DE" } }, + bottom: { style: "thin", color: { rgb: "D0D7DE" } }, + left: { style: "thin", color: { rgb: "D0D7DE" } }, + right: { style: "thin", color: { rgb: "D0D7DE" } }, + }, + }; + const numberStyle = { + ...cellStyle, + alignment: { horizontal: "right", vertical: "center" }, + numFmt: "#,##0", + }; + + if (worksheet["A1"]) worksheet["A1"].s = titleStyle; + for (let c = 0; c <= 2; c += 1) { + const headerCell = XLSX.utils.encode_cell({ r: 2, c }); + if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle; + } + slices.forEach((_, index) => { + const r = 3 + index; + const labelAddr = XLSX.utils.encode_cell({ r, c: 0 }); + const qtyAddr = XLSX.utils.encode_cell({ r, c: 1 }); + const shareAddr = XLSX.utils.encode_cell({ r, c: 2 }); + if (worksheet[labelAddr]) worksheet[labelAddr].s = cellStyle; + if (worksheet[qtyAddr]) worksheet[qtyAddr].s = numberStyle; + if (worksheet[shareAddr]) worksheet[shareAddr].s = cellStyle; + }); + const totalRow = 4 + slices.length; + const totalLabelAddr = XLSX.utils.encode_cell({ r: totalRow, c: 0 }); + const totalValueAddr = XLSX.utils.encode_cell({ r: totalRow, c: 1 }); + if (worksheet[totalLabelAddr]) worksheet[totalLabelAddr].s = cellStyle; + if (worksheet[totalValueAddr]) worksheet[totalValueAddr].s = numberStyle; + + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31)); + }, + [t], + ); + const handleDownloadExcel = useCallback(async () => { + if (exportInFlightRef.current || filteredExportInFlightRef.current) return; + exportInFlightRef.current = true; setIsExporting(true); try { const allRecords = @@ -355,7 +765,9 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => : await fetchCompletedDoPickOrdersAll(); const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs(); - const floorLabel = floor === "all" ? t("All floors") : floor; + const floorLabel = floor === ALL ? t("All floors") : floor; + const laneLabel = lane === ALL ? t("All lanes") : lane; + const shopLabel = shop === ALL ? t("All shops") : shop; const dateLabel = baseDate.format("YYYY-MM-DD"); const monthPeriod = i18n.language?.startsWith("zh") ? baseDate.format("YYYY年MM月") @@ -369,18 +781,24 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => baseDate.subtract(6, "day"), baseDate, floor, + lane, + shop, ); const monthRows = buildDailyRowsFromRecords( allRecords, baseDate.startOf("month"), baseDate.endOf("month"), floor, + lane, + shop, ); const yearRows = buildDailyRowsFromRecords( allRecords, baseDate.startOf("year"), baseDate.endOf("year"), floor, + lane, + shop, ); const workbook = XLSX.utils.book_new(); @@ -403,26 +821,131 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => yearRows, ); - XLSX.writeFile( + const fileBits = [ + "FG_carton_qty", + floorLabel.replace("/", ""), + lane === ALL ? "" : laneLabel, + shop === ALL ? "" : shopLabel, + dateLabel, + ].filter(Boolean); + + XLSX.writeFile(workbook, `${fileBits.join("_")}.xlsx`); + } finally { + setIsExporting(false); + exportInFlightRef.current = false; + } + }, [mode, date, floor, lane, shop, buildDailyRowsFromRecords, addReportSheet, t, i18n.language]); + + const handleDownloadFilteredExcel = useCallback(async () => { + if (filteredExportInFlightRef.current || exportInFlightRef.current) return; + filteredExportInFlightRef.current = true; + setIsExportingFiltered(true); + try { + const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs(); + const floorLabel = floor === ALL ? t("All floors") : floor; + const laneLabel = lane === ALL ? t("All lanes") : shortLaneLabel(lane); + const shopLabel = shop === ALL ? t("All shops") : shop; + const dateLabel = baseDate.format("YYYY-MM-DD"); + const reportTitle = t("FG carton qty filtered title", { + floor: floorLabel, + lane: laneLabel, + shop: shopLabel, + date: dateLabel, + }); + const categoryLabel = + breakdownDimension === "shop" + ? t("Shop Code") + : breakdownDimension === "floor" + ? t("Floor") + : t("Lane"); + + const dailyRows = buildDailyRowsFromRecords( + records, + baseDate.startOf("day"), + baseDate.endOf("day"), + floor, + lane, + shop, + ); + const filteredTotal = calcSummary(dailyRows).total; + + const workbook = XLSX.utils.book_new(); + addReportSheet(workbook, t("Filtered"), reportTitle, dailyRows); + addBreakdownSheet( workbook, - `FG_carton_qty_${floorLabel.replace("/", "")}_${dateLabel}.xlsx`, + t("Breakdown"), + breakdownCaption, + categoryLabel, + breakdownRows, + filteredTotal, ); + + const fileBits = [ + "FG_carton_qty_filtered", + floorLabel.replace("/", ""), + lane === ALL ? "" : laneLabel, + shop === ALL ? "" : shopLabel, + dateLabel, + ].filter(Boolean); + + XLSX.writeFile(workbook, `${fileBits.join("_")}.xlsx`); } finally { - setIsExporting(false); + setIsExportingFiltered(false); + filteredExportInFlightRef.current = false; } - }, [mode, date, floor, buildDailyRowsFromRecords, addReportSheet, t, i18n.language]); + }, [ + date, + floor, + lane, + shop, + records, + breakdownDimension, + breakdownCaption, + breakdownRows, + buildDailyRowsFromRecords, + addReportSheet, + addBreakdownSheet, + calcSummary, + t, + ]); + + const isAnyExporting = isExporting || isExportingFiltered; return ( {t("FG Carton Qty")} - + + + + + + + + + + + + + {error && ( @@ -431,77 +954,183 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => )} - {loading ? ( - - - - ) : ( - - - - setFloor(event.target.value as FloorFilter)} - > - {t("All")} - 2/F - 4/F - - - - setDate(event.target.value)} - /> - + + + + setFloor(event.target.value as FloorFilter)} + > + {t("All")} + 2/F + 4/F + - - - - - - - - {t("2/F carton qty")} - {summary.floor2F.toLocaleString(numberLocale)} - - - {t("4/F carton qty")} - {summary.floor4F.toLocaleString(numberLocale)} - - - {t("Truck X carton qty")} - {summary.truckX.toLocaleString(numberLocale)} - - - {t("Total carton qty")} - {summary.total.toLocaleString(numberLocale)} - - -
-
-
- - - - - + + -
- )} + + + + + setDate(event.target.value)} + /> + +
+ + {loading ? ( + + + + ) : ( + + + + {[ + { label: t("2/F carton qty"), value: summary.floor2F }, + { label: t("4/F carton qty"), value: summary.floor4F }, + { label: t("Truck X carton qty"), value: summary.truckX }, + { label: t("Total carton qty"), value: summary.total }, + ].map((kpi, index) => ( + + + {kpi.label} + + + {kpi.value.toLocaleString(numberLocale)} + + + ))} + + + + + + {breakdownCaption} + + + {t("Click a row to filter")} + + {breakdownRows.length === 0 ? ( + + {t("No data available")} + + ) : ( + + + {breakdownRows.map((row) => { + const active = isBreakdownRowActive(row); + return ( + applyBreakdownClick(row)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + applyBreakdownClick(row); + } + }} + sx={{ + height: CHART_ROW_H, + display: "flex", + alignItems: "center", + minWidth: 0, + cursor: "pointer", + borderRadius: 1, + px: 0.5, + bgcolor: active ? "action.selected" : "transparent", + "&:hover": { bgcolor: "action.hover" }, + }} + > + + {row.label} + + + ); + })} + + { + const rect = event.currentTarget.getBoundingClientRect(); + const index = Math.floor((event.clientY - rect.top) / CHART_ROW_H); + const row = breakdownRows[index]; + if (row) applyBreakdownClick(row); + }} + > + + + + )} + + + )} +
); }; diff --git a/src/components/charts/SafeApexCharts.tsx b/src/components/charts/SafeApexCharts.tsx index 9b29f4b1..0e41126b 100644 --- a/src/components/charts/SafeApexCharts.tsx +++ b/src/components/charts/SafeApexCharts.tsx @@ -150,6 +150,34 @@ function buildApexConfig( const EMPTY_MESSAGE = "暫無圖表資料(後端無法連線或此區間無資料)。"; +/** Apex mutates `options` in place (circular refs). Never throw during render. */ +function safeStringify(value: unknown): string { + const seen = new WeakSet(); + try { + return JSON.stringify(value, (_key, nested) => { + if (typeof nested === "function") return undefined; + if (typeof nested === "object" && nested !== null) { + if (seen.has(nested)) return undefined; + seen.add(nested); + } + return nested; + }); + } catch { + return ""; + } +} + +function destroyChart(chartRef: { current: { destroy: () => void } | null }) { + const current = chartRef.current; + chartRef.current = null; + if (!current) return; + try { + current.destroy(); + } catch { + /* ignore */ + } +} + export default function SafeApexCharts(props: SafeApexChartsProps) { const { type, series, options, height, width, chartRevision, allowZeroSeries = false, ...rest } = props; const containerRef = useRef(null); @@ -162,18 +190,11 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { sanitized = alignSeriesToCategoryCount(sanitized, cats.length); } - if (shouldShowPlaceholder(type, options, sanitized, allowZeroSeries)) { - return ( - - {EMPTY_MESSAGE} - - ); - } - let chartOptions = options; let renderSeries: ApexChartProps["series"] = sanitized; + let showPlaceholder = shouldShowPlaceholder(type, options, sanitized, allowZeroSeries); - if (isRadialChart(type) && isNumberArray(sanitized)) { + if (!showPlaceholder && isRadialChart(type) && isNumberArray(sanitized)) { const prev = Array.isArray(options?.labels) ? (options!.labels as unknown[]) : []; const pairs = sanitized.map((v, i) => { const n = Number.isFinite(v) ? v : 0; @@ -183,20 +204,31 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { }); const nonzero = pairs.filter((p) => p.v > 0); if (nonzero.length === 0) { - return ( - - {EMPTY_MESSAGE} - - ); + showPlaceholder = true; + } else { + renderSeries = nonzero.map((p) => p.v); + chartOptions = { ...options, labels: nonzero.map((p) => p.label) }; } - renderSeries = nonzero.map((p) => p.v); - chartOptions = { ...options, labels: nonzero.map((p) => p.label) }; } const chartType = String(type ?? "line"); - const configSnapshot = `${String(chartRevision ?? "")}|${chartType}|${JSON.stringify(renderSeries ?? null)}|${JSON.stringify(chartOptions ?? {})}|${String(height ?? "")}|${String(width ?? "")}`; + const configSnapshot = [ + String(chartRevision ?? ""), + chartType, + String(showPlaceholder), + safeStringify(renderSeries ?? null), + safeStringify(cats ?? null), + safeStringify(chartOptions?.labels ?? null), + String(height ?? ""), + String(width ?? ""), + ].join("|"); useEffect(() => { + if (showPlaceholder) { + destroyChart(chartRef); + return; + } + const el = containerRef.current; if (!el) return; @@ -219,12 +251,7 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { if (disposed || containerRef.current !== el) { if (chartRef.current === instance) { - try { - instance.destroy(); - } catch { - /* ignore */ - } - chartRef.current = null; + destroyChart(chartRef); } return; } @@ -236,21 +263,21 @@ export default function SafeApexCharts(props: SafeApexChartsProps) { return () => { disposed = true; - const c = chartRef.current; - chartRef.current = null; - if (c) { - try { - c.destroy(); - } catch { - /* ignore */ - } - } + destroyChart(chartRef); }; }, [configSnapshot]); const minH = typeof height === "number" ? height : typeof height === "string" ? height : 240; const dom = rest as { className?: string; id?: string; style?: CSSProperties; sx?: object }; + if (showPlaceholder) { + return ( + + {EMPTY_MESSAGE} + + ); + } + return ( Date: Tue, 22 Sep 2026 12:21:36 +0800 Subject: [PATCH 12/38] reopen stock lot ledger report and improve stock ledger report and if INT show as x.00 --- src/app/(main)/report/ReportSelectionDashboard.tsx | 1 + src/app/(main)/report/reportCategories.ts | 2 +- src/config/reportConfig.ts | 4 +--- src/i18n/en/report.json | 8 ++++++++ src/i18n/zh/report.json | 8 ++++++++ 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/app/(main)/report/ReportSelectionDashboard.tsx b/src/app/(main)/report/ReportSelectionDashboard.tsx index b5eb3802..48ea716f 100644 --- a/src/app/(main)/report/ReportSelectionDashboard.tsx +++ b/src/app/(main)/report/ReportSelectionDashboard.tsx @@ -23,6 +23,7 @@ import { useReportLabels } from "./reportI18n"; const REPORT_ICON_MAP: Record = { "rep-011": Inventory2OutlinedIcon, + "rep-020": Inventory2OutlinedIcon, "rep-007": MonetizationOnOutlinedIcon, "rep-012": LayersOutlinedIcon, "rep-021": Inventory2OutlinedIcon, diff --git a/src/app/(main)/report/reportCategories.ts b/src/app/(main)/report/reportCategories.ts index 2b2f7aa7..f8c88e6a 100644 --- a/src/app/(main)/report/reportCategories.ts +++ b/src/app/(main)/report/reportCategories.ts @@ -17,7 +17,7 @@ export const REPORT_CATEGORIES: ReportCategoryConfig[] = [ headerBg: "#b8e0b8", bodyBg: "#eef8ee", accent: "#2e7d32", - reportIds: ["rep-011", "rep-007", "rep-012", "rep-021", "rep-010"], + reportIds: ["rep-011", "rep-020", "rep-007", "rep-012", "rep-021", "rep-010"], }, { id: "inbound-outbound", diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index c6ed8b5b..467a10da 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -300,7 +300,6 @@ export const REPORTS: ReportDefinition[] = [ asyncItemCodeField(), ] }, - /* Hidden for now: 庫存流水帳報告 (rep-020) { id: "rep-020", title: "庫存流水帳報告", @@ -309,10 +308,9 @@ export const REPORTS: ReportDefinition[] = [ fields: [ { label: "期間起 Period From(永遠該月1日)", name: "lastInDateStart", type: "date", required: true }, { label: "期間迄 Period To", name: "lastInDateEnd", type: "date", required: true }, - { label: "貨品編號 Item Code", name: "itemCode", type: "text", required: false }, + asyncItemCodeField(), ], }, - */ /* { id: "rep-007", diff --git a/src/i18n/en/report.json b/src/i18n/en/report.json index 17af3c26..42227b9d 100644 --- a/src/i18n/en/report.json +++ b/src/i18n/en/report.json @@ -118,6 +118,14 @@ "itemCode": "Item Code" } }, + "rep-020": { + "title": "Stock Lot Ledger Report", + "fields": { + "lastInDateStart": "Period From (always 1st of month)", + "lastInDateEnd": "Period To", + "itemCode": "Item Code" + } + }, "rep-007": { "title": "Stock Balance Report", "fields": { diff --git a/src/i18n/zh/report.json b/src/i18n/zh/report.json index 52a2a638..31e168f5 100644 --- a/src/i18n/zh/report.json +++ b/src/i18n/zh/report.json @@ -118,6 +118,14 @@ "itemCode": "貨品編號" } }, + "rep-020": { + "title": "庫存流水帳報告", + "fields": { + "lastInDateStart": "期間起(永遠該月1日)", + "lastInDateEnd": "期間迄", + "itemCode": "貨品編號" + } + }, "rep-007": { "title": "庫存結餘報告", "fields": { From 759df0b37966b39853bd5c61db9e5b3a24fcae4a Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Tue, 22 Sep 2026 17:40:00 +0800 Subject: [PATCH 13/38] [Fix] Amend few visual issues --- .../DoWorkbench/DoWorkbenchTabs.tsx | 12 +- .../FinishedGoodCartonDashboardTab.tsx | 604 ++++++++++-------- src/i18n/en/doWorkbench.json | 4 +- src/i18n/en/pickOrder.json | 4 +- src/i18n/zh/doWorkbench.json | 4 +- src/i18n/zh/pickOrder.json | 4 +- 6 files changed, 356 insertions(+), 276 deletions(-) diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index c247d87b..87a1eb2a 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -282,7 +282,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom sx={{ width: "100%", maxWidth: "100%", - minHeight: 56, + minHeight: 48, borderBottom: 1, borderColor: "divider", "& .MuiTabs-flexContainer": { @@ -291,12 +291,12 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom /* 否則 Tab 內 overflow:hidden 會把 Badge 數字裁成紅點 */ "& .MuiTab-root": { overflow: "visible", - minHeight: 56, + minHeight: 48, minWidth: 72, - maxWidth: 120, - px: 1, + maxWidth: "none", + px: 1.5, py: 0.75, - whiteSpace: "normal", + whiteSpace: "nowrap", lineHeight: 1.2, textAlign: "center", }, @@ -342,7 +342,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom variant="inherit" sx={{ pr: etraIncompleteDopoCount > 0 ? 1 : 0, - whiteSpace: "normal", + whiteSpace: "nowrap", lineHeight: 1.2, textAlign: "center", }} diff --git a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx index ba895bdb..5f74c65a 100644 --- a/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx +++ b/src/components/FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx @@ -15,15 +15,18 @@ import { Tooltip, Typography, } from "@mui/material"; -import type { ApexOptions } from "apexcharts"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import { DatePicker, LocalizationProvider } from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import dayjs from "dayjs"; +import "dayjs/locale/zh-hk"; import * as XLSX from "xlsx-js-style"; import { CompletedDoPickOrderResponse, fetchCompletedDoPickOrdersAll, fetchCompletedDoPickOrdersWorkbenchAll, } from "@/app/api/pickOrder/actions"; -import SafeApexCharts from "@/components/charts/SafeApexCharts"; +import { OUTPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; import { useTranslation } from "react-i18next"; type FloorFilter = "all" | "2/F" | "4/F"; @@ -37,6 +40,17 @@ type DailySummaryRow = { total: number; }; +type ShopDailyRow = DailySummaryRow & { + shopCode: string; + shopName: string; +}; + +type ShopQtyRow = { + code: string; + name: string; + qty: number; +}; + type BreakdownRow = { key: string; label: string; @@ -50,6 +64,9 @@ type Props = { const TRUCK_X_LANE = "車線-X"; const ALL = "all"; const CHART_ROW_H = 38; +/** Jasper / report standard used across FPSMS Excel output. */ +const EXCEL_FONT_NAME = "微軟正黑體"; +const DAILY_SHEET_LAST_COL = 6; type FilterOption = { value: string; label: string }; @@ -61,7 +78,7 @@ function normalizeTruckLane(raw: string | null | undefined): string { function shortLaneLabel(raw: string | null | undefined): string { const value = normalizeTruckLane(raw); const stripped = value - .replace(/^(車線)[-–—]?\s*/u, "") + .replace(/^(車線)[-–—]?\s*/, "") .replace(/^(truck)\s*[-–—]?\s*/i, "") .trim(); return stripped || value; @@ -77,6 +94,77 @@ function shopGroup(raw: string | null | undefined): string { return code.slice(0, 2); } +function digitsWithoutLeadingZeros(digits: string): string { + const stripped = digits.replace(/^0+/, ""); + return stripped || (digits ? "0" : ""); +} + +/** Shop names often already start with the code, sometimes zero-padded (`HP23` vs `HP023`). */ +function shopNameWithoutCode(code: string, name: string | null | undefined): string { + const shopName = String(name ?? "").trim(); + if (!shopName) return ""; + const normalized = normalizeShopCode(code).toUpperCase(); + const codeParts = normalized.match(/^([A-Z]+)(\d*)$/); + const nameParts = shopName.match(/^([A-Za-z]+)(\d*)(?:\s*[-–—]\s*|\s+)([\s\S]*)$/); + if (codeParts && nameParts) { + const sameLetters = nameParts[1].toUpperCase() === codeParts[1]; + const sameDigits = + !nameParts[2] || + digitsWithoutLeadingZeros(nameParts[2]) === digitsWithoutLeadingZeros(codeParts[2]); + if (sameLetters && sameDigits) return nameParts[3].trim(); + } + if (normalized && shopName.toUpperCase().startsWith(normalized)) { + return shopName.slice(normalized.length).replace(/^[\s\-–—]+/, "").trim(); + } + return shopName; +} + +function formatShopLabel(code: string, name: string | null | undefined): string { + const normalized = normalizeShopCode(code).toUpperCase(); + const displayName = shopNameWithoutCode(normalized, name); + if (!normalized) return displayName; + if (!displayName || displayName.toUpperCase() === normalized) return normalized; + return `${normalized} ${displayName}`; +} + +function applyProjectExcelFont(worksheet: XLSX.WorkSheet) { + if (!worksheet["!ref"]) return; + const range = XLSX.utils.decode_range(worksheet["!ref"]); + for (let r = range.s.r; r <= range.e.r; r += 1) { + for (let c = range.s.c; c <= range.e.c; c += 1) { + const addr = XLSX.utils.encode_cell({ r, c }); + const cell = worksheet[addr]; + if (!cell) continue; + const style = (cell.s ?? {}) as XLSX.CellStyle; + const font = style.font ?? {}; + cell.s = { + ...style, + font: { + ...font, + name: EXCEL_FONT_NAME, + sz: font.sz ?? 11, + }, + }; + } + } +} + +function buildShopQtyRows(source: CompletedDoPickOrderResponse[]): ShopQtyRow[] { + const grouped = new Map(); + source.forEach((record) => { + const code = normalizeShopCode(record.shopCode).toUpperCase(); + if (!code) return; + const name = shopNameWithoutCode(code, record.shopName); + const current = grouped.get(code) ?? { code, name, qty: 0 }; + if (!current.name && name) current.name = name; + current.qty += Number(record.numberOfCartons ?? 0); + grouped.set(code, current); + }); + return Array.from(grouped.values()).sort( + (a, b) => b.qty - a.qty || a.code.localeCompare(b.code, "zh-Hant"), + ); +} + function isShopGroupValue(shop: string): boolean { if (shop === ALL) return false; const code = normalizeShopCode(shop).toUpperCase(); @@ -110,10 +198,8 @@ function recordMatchesFilters( } function resolveBreakdownDimension(lane: string, shop: string): BreakdownDimension { - if (isShopGroupValue(shop)) return "shop"; - if (shop !== ALL && lane !== ALL) return "floor"; + if (shop !== ALL) return "shop"; if (lane !== ALL) return "shop"; - if (shop !== ALL) return "lane"; return "lane"; } @@ -239,13 +325,18 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => return options; }, [laneOptions, lane, t]); + const shopFilterValue = shop === ALL ? ALL : shopGroup(shop); + const shopFilterOptions = useMemo(() => { const options: FilterOption[] = [{ value: ALL, label: t("All shops") }, ...shopOptions]; - if (shop !== ALL && !options.some((option) => option.value === shop)) { - options.splice(1, 0, { value: shop, label: shop }); + if ( + shopFilterValue !== ALL && + !options.some((option) => option.value === shopFilterValue) + ) { + options.splice(1, 0, { value: shopFilterValue, label: shopFilterValue }); } return options; - }, [shopOptions, shop, t]); + }, [shopOptions, shopFilterValue, t]); const filteredRecords = useMemo( () => records.filter((record) => recordMatchesFilters(record, floor, lane, shop)), @@ -330,12 +421,13 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => return; } - const key = - shop !== ALL - ? normalizeShopCode(record.shopCode).toUpperCase() - : shopGroup(record.shopCode); + const code = normalizeShopCode(record.shopCode).toUpperCase(); + const name = String(record.shopName ?? "").trim(); + const key = shop !== ALL ? code : shopGroup(record.shopCode); if (!key) return; - const current = grouped.get(key) ?? { key, label: key, qty: 0 }; + const label = shop !== ALL ? formatShopLabel(code, name) : key; + const current = grouped.get(key) ?? { key, label, qty: 0 }; + if (shop !== ALL && name) current.label = formatShopLabel(code, name); current.qty += cartonQty; grouped.set(key, current); }); @@ -372,19 +464,27 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => } return row.key; }); - return; - } - if (row.key === "2/F" || row.key === "4/F") { - setFloor((prev) => (prev === row.key ? ALL : row.key)); - return; - } - if (row.key === TRUCK_X_LANE) { - setLane((prev) => (prev === TRUCK_X_LANE ? ALL : TRUCK_X_LANE)); } }, [breakdownDimension], ); + const viewingSpecificShop = shop !== ALL && !isShopGroupValue(shop); + const chartClicksEnabled = !viewingSpecificShop; + const chartCanGoBack = lane !== ALL || shop !== ALL; + + const goBackChartLevel = useCallback(() => { + if (shop !== ALL && !isShopGroupValue(shop)) { + setShop(shopGroup(shop) || ALL); + return; + } + if (shop !== ALL) { + setShop(ALL); + return; + } + if (lane !== ALL) setLane(ALL); + }, [lane, shop]); + const isBreakdownRowActive = useCallback( (row: BreakdownRow) => { if (breakdownDimension === "lane") return lane === row.key; @@ -409,111 +509,6 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => }, [rows]); const chartMaxQty = Math.max(1, ...breakdownRows.map((row) => row.qty)); - const chartHeight = Math.max(CHART_ROW_H, breakdownRows.length * CHART_ROW_H); - - const chartOptions = useMemo( - () => ({ - chart: { - type: "bar", - toolbar: { show: false }, - sparkline: { enabled: false }, - parentHeightOffset: 0, - offsetY: 0, - animations: { - enabled: true, - easing: "easeinout", - speed: 450, - animateGradually: { enabled: true, delay: 40 }, - }, - selection: { enabled: false }, - zoom: { enabled: false }, - }, - states: { - hover: { filter: { type: "none" } }, - active: { filter: { type: "none" } }, - }, - colors: ["#1976d2"], - dataLabels: { - enabled: true, - formatter: (val) => Number(val || 0).toLocaleString(numberLocale), - offsetX: 12, - textAnchor: "start", - style: { - fontSize: "15px", - fontWeight: 800, - colors: ["#ffffff"], - }, - background: { - enabled: true, - foreColor: "#102a43", - padding: 6, - borderRadius: 4, - opacity: 1, - borderWidth: 1, - borderColor: "#90a4ae", - dropShadow: { - enabled: true, - color: "#000000", - top: 1, - left: 0, - blur: 3, - opacity: 0.22, - }, - }, - }, - stroke: { show: true, width: 1, colors: ["transparent"] }, - plotOptions: { - bar: { - horizontal: true, - borderRadius: 4, - barHeight: 22, - dataLabels: { - position: "top", - }, - }, - }, - grid: { - show: true, - borderColor: "#eceff1", - padding: { top: -8, bottom: -8, left: 4, right: 64 }, - xaxis: { lines: { show: true } }, - yaxis: { lines: { show: false } }, - }, - xaxis: { - categories: breakdownRows.map((row) => row.label), - labels: { show: false }, - axisBorder: { show: false }, - axisTicks: { show: false }, - min: 0, - max: Math.max(chartMaxQty * 1.4, chartMaxQty + 12), - }, - yaxis: { - labels: { show: false }, - }, - tooltip: { - x: { - formatter: (_val, opts) => - breakdownRows[opts?.dataPointIndex ?? -1]?.label ?? String(_val ?? ""), - }, - y: { - formatter: (val, opts) => { - const qty = Number(val || 0); - const share = summary.total > 0 ? (qty / summary.total) * 100 : 0; - const label = breakdownRows[opts?.dataPointIndex ?? -1]?.label ?? ""; - return `${label}: ${qty.toLocaleString(numberLocale)} (${share.toFixed(1)}%)`; - }, - }, - }, - legend: { show: false }, - noData: { text: t("No chart data") }, - }), - [breakdownRows, chartMaxQty, numberLocale, summary.total, t], - ); - - const chartSeries = useMemo( - () => [{ name: t("Cartons"), data: breakdownRows.map((row) => row.qty) }], - [breakdownRows, t], - ); const buildDailyRowsFromRecords = useCallback( ( @@ -523,8 +518,8 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => selectedFloor: FloorFilter, selectedLane: string, selectedShop: string, - ): DailySummaryRow[] => { - const summaryMap = new Map(); + ): ShopDailyRow[] => { + const summaryMap = new Map(); const start = startDate.startOf("day"); const end = endDate.endOf("day"); @@ -539,24 +534,33 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => } const dayKey = deliveryDay.format("YYYY-MM-DD"); + const shopCode = normalizeShopCode(record.shopCode).toUpperCase() || "-"; + const shopName = shopNameWithoutCode(shopCode, record.shopName); + const mapKey = `${dayKey}|${shopCode}`; const cartonQty = Number(record.numberOfCartons ?? 0); - const current = summaryMap.get(dayKey) ?? { + const current = summaryMap.get(mapKey) ?? { date: dayKey, + shopCode, + shopName, floor2F: 0, floor4F: 0, truckX: 0, total: 0, }; + if (!current.shopName && shopName) current.shopName = shopName; if (record.storeId === "2/F") current.floor2F += cartonQty; if (record.storeId === "4/F") current.floor4F += cartonQty; if (normalizeTruckLane(record.truckLanceCode) === TRUCK_X_LANE) current.truckX += cartonQty; current.total += cartonQty; - summaryMap.set(dayKey, current); + summaryMap.set(mapKey, current); }); - return Array.from(summaryMap.values()).sort((a, b) => a.date.localeCompare(b.date)); + return Array.from(summaryMap.values()).sort( + (a, b) => + a.date.localeCompare(b.date) || a.shopCode.localeCompare(b.shopCode, "zh-Hant"), + ); }, [], ); @@ -578,10 +582,18 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => const summaryTitleRow = 4 + dataRowsCount; const summaryStartRow = 5 + dataRowsCount; - worksheet["!cols"] = [{ wch: 16 }, { wch: 14 }, { wch: 14 }, { wch: 16 }, { wch: 14 }]; + worksheet["!cols"] = [ + { wch: 14 }, + { wch: 14 }, + { wch: 28 }, + { wch: 16 }, + { wch: 16 }, + { wch: 18 }, + { wch: 14 }, + ]; worksheet["!merges"] = [ - { s: { r: 0, c: 0 }, e: { r: 0, c: 4 } }, - { s: { r: summaryTitleRow, c: 0 }, e: { r: summaryTitleRow, c: 4 } }, + { s: { r: 0, c: 0 }, e: { r: 0, c: DAILY_SHEET_LAST_COL } }, + { s: { r: summaryTitleRow, c: 0 }, e: { r: summaryTitleRow, c: DAILY_SHEET_LAST_COL } }, ]; const titleStyle = { @@ -620,29 +632,32 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => alignment: { horizontal: "left", vertical: "center" }, }; - for (let c = 0; c <= 4; c += 1) { + for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) { const headerCell = XLSX.utils.encode_cell({ r: 2, c }); if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle; } for (let r = 3; r < 3 + dataRowsCount; r += 1) { - for (let c = 0; c <= 4; c += 1) { + for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) { const addr = XLSX.utils.encode_cell({ r, c }); if (!worksheet[addr]) continue; - worksheet[addr].s = c === 0 ? cellStyle : numberStyle; + worksheet[addr].s = c < 3 ? cellStyle : numberStyle; } } for (let r = summaryStartRow; r <= summaryStartRow + 3; r += 1) { - const labelAddr = XLSX.utils.encode_cell({ r, c: 0 }); - const valueAddr = XLSX.utils.encode_cell({ r, c: 1 }); - if (worksheet[labelAddr]) worksheet[labelAddr].s = cellStyle; - if (worksheet[valueAddr]) worksheet[valueAddr].s = numberStyle; + for (let c = 0; c <= DAILY_SHEET_LAST_COL; c += 1) { + const addr = XLSX.utils.encode_cell({ r, c }); + const cell = worksheet[addr]; + if (!cell) continue; + cell.s = typeof cell.v === "number" ? numberStyle : cellStyle; + } } if (worksheet["A1"]) worksheet["A1"].s = titleStyle; const summaryTitleAddr = XLSX.utils.encode_cell({ r: summaryTitleRow, c: 0 }); if (worksheet[summaryTitleAddr]) worksheet[summaryTitleAddr].s = summaryTitleStyle; + applyProjectExcelFont(worksheet); }, []); const addReportSheet = useCallback( @@ -650,26 +665,37 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => workbook: XLSX.WorkBook, sheetName: string, reportTitle: string, - dailyRows: DailySummaryRow[], + dailyRows: ShopDailyRow[], ) => { const reportSummary = calcSummary(dailyRows); + const blank = ["", "", "", "", "", "", ""]; const aoa: (string | number)[][] = [ - [reportTitle, "", "", "", ""], - ["", "", "", "", ""], + [reportTitle, ...blank.slice(1)], + [...blank], [ t("Date"), + t("Shop code"), + t("Shop Name"), t("2/F carton qty"), t("4/F carton qty"), t("Truck X carton qty"), t("Total carton qty"), ], - ...dailyRows.map((row) => [row.date, row.floor2F, row.floor4F, row.truckX, row.total]), - ["", "", "", "", ""], - [t("Summary"), "", "", "", ""], - [t("2/F carton qty"), reportSummary.floor2F, "", "", ""], - [t("4/F carton qty"), reportSummary.floor4F, "", "", ""], - [t("Truck X carton qty"), reportSummary.truckX, "", "", ""], - [t("Total carton qty"), reportSummary.total, "", "", ""], + ...dailyRows.map((row) => [ + row.date, + row.shopCode, + row.shopName, + row.floor2F, + row.floor4F, + row.truckX, + row.total, + ]), + [...blank], + [t("Summary"), ...blank.slice(1)], + [t("2/F carton qty"), "", "", reportSummary.floor2F, "", "", ""], + [t("4/F carton qty"), "", "", "", reportSummary.floor4F, "", ""], + [t("Truck X carton qty"), "", "", "", "", reportSummary.truckX, ""], + [t("Total carton qty"), "", "", "", "", "", reportSummary.total], ]; const worksheet = XLSX.utils.aoa_to_sheet(aoa); @@ -684,25 +710,24 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => workbook: XLSX.WorkBook, sheetName: string, reportTitle: string, - categoryLabel: string, - slices: BreakdownRow[], + shops: ShopQtyRow[], totalQty: number, ) => { const aoa: (string | number)[][] = [ - [reportTitle, "", ""], - ["", "", ""], - [categoryLabel, t("Cartons"), t("Share")], - ...slices.map((row) => { + [reportTitle, "", "", ""], + ["", "", "", ""], + [t("Shop code"), t("Shop Name"), t("Cartons"), t("Share")], + ...shops.map((row) => { const share = totalQty > 0 ? (row.qty / totalQty) * 100 : 0; - return [row.label, row.qty, `${share.toFixed(1)}%`]; + return [row.code, row.name, row.qty, `${share.toFixed(1)}%`]; }), - ["", "", ""], - [t("Total carton qty"), totalQty, ""], + ["", "", "", ""], + [t("Total carton qty"), "", totalQty, ""], ]; const worksheet = XLSX.utils.aoa_to_sheet(aoa); - worksheet["!cols"] = [{ wch: 28 }, { wch: 14 }, { wch: 12 }]; - worksheet["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }]; + worksheet["!cols"] = [{ wch: 14 }, { wch: 28 }, { wch: 14 }, { wch: 12 }]; + worksheet["!merges"] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 3 } }]; const titleStyle = { font: { bold: true, sz: 14, color: { rgb: "1F2D3D" } }, @@ -730,24 +755,27 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => }; if (worksheet["A1"]) worksheet["A1"].s = titleStyle; - for (let c = 0; c <= 2; c += 1) { + for (let c = 0; c <= 3; c += 1) { const headerCell = XLSX.utils.encode_cell({ r: 2, c }); if (worksheet[headerCell]) worksheet[headerCell].s = headerStyle; } - slices.forEach((_, index) => { + shops.forEach((_, index) => { const r = 3 + index; - const labelAddr = XLSX.utils.encode_cell({ r, c: 0 }); - const qtyAddr = XLSX.utils.encode_cell({ r, c: 1 }); - const shareAddr = XLSX.utils.encode_cell({ r, c: 2 }); - if (worksheet[labelAddr]) worksheet[labelAddr].s = cellStyle; + const codeAddr = XLSX.utils.encode_cell({ r, c: 0 }); + const nameAddr = XLSX.utils.encode_cell({ r, c: 1 }); + const qtyAddr = XLSX.utils.encode_cell({ r, c: 2 }); + const shareAddr = XLSX.utils.encode_cell({ r, c: 3 }); + if (worksheet[codeAddr]) worksheet[codeAddr].s = cellStyle; + if (worksheet[nameAddr]) worksheet[nameAddr].s = cellStyle; if (worksheet[qtyAddr]) worksheet[qtyAddr].s = numberStyle; if (worksheet[shareAddr]) worksheet[shareAddr].s = cellStyle; }); - const totalRow = 4 + slices.length; + const totalRow = 4 + shops.length; const totalLabelAddr = XLSX.utils.encode_cell({ r: totalRow, c: 0 }); - const totalValueAddr = XLSX.utils.encode_cell({ r: totalRow, c: 1 }); + const totalValueAddr = XLSX.utils.encode_cell({ r: totalRow, c: 2 }); if (worksheet[totalLabelAddr]) worksheet[totalLabelAddr].s = cellStyle; if (worksheet[totalValueAddr]) worksheet[totalValueAddr].s = numberStyle; + applyProjectExcelFont(worksheet); XLSX.utils.book_append_sheet(workbook, worksheet, sheetName.slice(0, 31)); }, @@ -844,7 +872,8 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => const baseDate = dayjs(date || undefined).isValid() ? dayjs(date) : dayjs(); const floorLabel = floor === ALL ? t("All floors") : floor; const laneLabel = lane === ALL ? t("All lanes") : shortLaneLabel(lane); - const shopLabel = shop === ALL ? t("All shops") : shop; + const shopLabel = + shop === ALL ? t("All shops") : isShopGroupValue(shop) ? shop : formatShopLabel(shop, ""); const dateLabel = baseDate.format("YYYY-MM-DD"); const reportTitle = t("FG carton qty filtered title", { floor: floorLabel, @@ -852,12 +881,6 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => shop: shopLabel, date: dateLabel, }); - const categoryLabel = - breakdownDimension === "shop" - ? t("Shop Code") - : breakdownDimension === "floor" - ? t("Floor") - : t("Lane"); const dailyRows = buildDailyRowsFromRecords( records, @@ -868,15 +891,17 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => shop, ); const filteredTotal = calcSummary(dailyRows).total; + const shopRows = buildShopQtyRows( + records.filter((record) => recordMatchesFilters(record, floor, lane, shop)), + ); const workbook = XLSX.utils.book_new(); addReportSheet(workbook, t("Filtered"), reportTitle, dailyRows); addBreakdownSheet( workbook, t("Breakdown"), - breakdownCaption, - categoryLabel, - breakdownRows, + t("Cartons by shop"), + shopRows, filteredTotal, ); @@ -899,9 +924,6 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => lane, shop, records, - breakdownDimension, - breakdownCaption, - breakdownRows, buildDailyRowsFromRecords, addReportSheet, addBreakdownSheet, @@ -982,20 +1004,29 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => - setDate(event.target.value)} - /> + + { + if (newValue && dayjs(newValue).isValid()) { + setDate(dayjs(newValue).format(OUTPUT_DATE_FORMAT)); + } + }} + slotProps={{ + textField: { size: "small", fullWidth: true }, + }} + /> + @@ -1038,93 +1069,134 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => - - {breakdownCaption} - - - {t("Click a row to filter")} - + + + + {breakdownCaption} + + {chartClicksEnabled && ( + + {t("Click a row to filter")} + + )} + + + {breakdownRows.length === 0 ? ( {t("No data available")} ) : ( - - - {breakdownRows.map((row) => { - const active = isBreakdownRowActive(row); - return ( - applyBreakdownClick(row)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - applyBreakdownClick(row); - } + + {breakdownRows.map((row) => { + const active = isBreakdownRowActive(row); + const share = summary.total > 0 ? (row.qty / summary.total) * 100 : 0; + const barWidth = + row.qty <= 0 ? "0%" : `${(row.qty / chartMaxQty) * 100}%`; + return ( + { + if (chartClicksEnabled) applyBreakdownClick(row); + }} + onKeyDown={(event) => { + if (!chartClicksEnabled) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + applyBreakdownClick(row); + } + }} + title={`${row.label}: ${row.qty.toLocaleString(numberLocale)} (${share.toFixed(1)}%)`} + sx={{ + height: CHART_ROW_H, + display: "grid", + gridTemplateColumns: { + xs: "132px minmax(0, 1fr) 84px", + sm: "200px minmax(0, 1fr) 84px", + md: "280px minmax(0, 1fr) 84px", + }, + columnGap: 1, + alignItems: "center", + minWidth: 0, + cursor: chartClicksEnabled ? "pointer" : "default", + borderRadius: 1, + px: 0.5, + bgcolor: active ? "action.selected" : "transparent", + "&:hover": chartClicksEnabled ? { bgcolor: "action.hover" } : undefined, + }} + > + + {row.label} + + - 0 ? 4 : 0, + bgcolor: "#1976d2", + borderRadius: "4px", }} - > - {row.label} - + /> - ); - })} - - { - const rect = event.currentTarget.getBoundingClientRect(); - const index = Math.floor((event.clientY - rect.top) / CHART_ROW_H); - const row = breakdownRows[index]; - if (row) applyBreakdownClick(row); - }} - > - - + + {row.qty.toLocaleString(numberLocale)} + + + ); + })} )} diff --git a/src/i18n/en/doWorkbench.json b/src/i18n/en/doWorkbench.json index 5031d223..a74b25be 100644 --- a/src/i18n/en/doWorkbench.json +++ b/src/i18n/en/doWorkbench.json @@ -61,6 +61,8 @@ "2/F or 4/F": "2/F or 4/F", "Lane": "Lane", "Shop Code": "Shop group", + "Shop code": "Shop code", + "Back to previous level": "Back", "All lanes": "All lanes", "All shops": "All shop groups", "Cartons by lane": "Cartons by lane", @@ -75,7 +77,7 @@ "Date": "Date", "Download Excel": "Download Excel", "Download this view Excel": "Download Excel (Selected)", - "Download period Excel": "Download Excel (All)", + "Download period Excel": "Download Excel (Complete)", "Download this view Excel hint": "Excel of the current floor, lane, shop, and date, plus the chart breakdown.", "Download period Excel hint": "Excel with last 7 days, this month, and this year. Not limited to the current view.", "Filtered": "Filtered", diff --git a/src/i18n/en/pickOrder.json b/src/i18n/en/pickOrder.json index 6622011a..9bda0fff 100644 --- a/src/i18n/en/pickOrder.json +++ b/src/i18n/en/pickOrder.json @@ -625,7 +625,7 @@ "Exporting...": "Exporting...", "Download Excel": "Download Excel", "Download this view Excel": "Download Excel (Selected)", - "Download period Excel": "Download Excel (All)", + "Download period Excel": "Download Excel (Complete)", "Download this view Excel hint": "Excel of the current floor, lane, shop, and date, plus the chart breakdown.", "Download period Excel hint": "Excel with last 7 days, this month, and this year. Not limited to the current view.", "Filtered": "Filtered", @@ -635,6 +635,8 @@ "2/F or 4/F": "2/F or 4/F", "Lane": "Lane", "Shop Code": "Shop group", + "Shop code": "Shop code", + "Back to previous level": "Back", "All lanes": "All lanes", "All shops": "All shop groups", "Cartons by lane": "Cartons by lane", diff --git a/src/i18n/zh/doWorkbench.json b/src/i18n/zh/doWorkbench.json index 6fed28a9..2d446a83 100644 --- a/src/i18n/zh/doWorkbench.json +++ b/src/i18n/zh/doWorkbench.json @@ -56,6 +56,8 @@ "2/F or 4/F": "2/F 或 4/F", "Lane": "車線", "Shop Code": "店鋪組別", + "Shop code": "店鋪編號", + "Back to previous level": "返回上一層", "All lanes": "全部車線", "All shops": "全部店鋪組別", "Cartons by lane": "按車線統計箱數", @@ -70,7 +72,7 @@ "Date": "日期", "Download Excel": "下載 Excel", "Download this view Excel": "下載 Excel(已選)", - "Download period Excel": "下載 Excel(全部)", + "Download period Excel": "下載 Excel(完整)", "Download this view Excel hint": "匯出目前樓層、車線、店鋪組別、日期的箱數,以及圖表分項。", "Download period Excel hint": "匯出近7天、本月、本年的箱數,不限目前篩選畫面。", "Filtered": "篩選", diff --git a/src/i18n/zh/pickOrder.json b/src/i18n/zh/pickOrder.json index 5d596eb8..bdde2cf7 100644 --- a/src/i18n/zh/pickOrder.json +++ b/src/i18n/zh/pickOrder.json @@ -637,7 +637,7 @@ "Exporting...": "匯出中...", "Download Excel": "下載 Excel", "Download this view Excel": "下載 Excel(已選)", - "Download period Excel": "下載 Excel(全部)", + "Download period Excel": "下載 Excel(完整)", "Download this view Excel hint": "匯出目前樓層、車線、店鋪組別、日期的箱數,以及圖表分項。", "Download period Excel hint": "匯出近7天、本月、本年的箱數,不限目前篩選畫面。", "Filtered": "篩選", @@ -647,6 +647,8 @@ "2/F or 4/F": "2/F 或 4/F", "Lane": "車線", "Shop Code": "店鋪組別", + "Shop code": "店鋪編號", + "Back to previous level": "返回上一層", "All lanes": "全部車線", "All shops": "全部店鋪組別", "Cartons by lane": "按車線統計箱數", From acb34cbb6171f3ca9d149ddbec50f84e6b961088 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Tue, 22 Sep 2026 18:10:52 +0800 Subject: [PATCH 14/38] Version Checlist comments update --- src/components/DoWorkbench/DoWorkbenchTabs.tsx | 2 +- .../FinishedGoodSearch/FinishedGoodCartonDashboardTab.tsx | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index 69015ee6..70019418 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -401,7 +401,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom /** * FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 - * FP-MTMS Version Checklist | Functions Ref. No. 55 | v1.0.0 | 2026-08-06 + * FP-MTMS Version Checklist | Functions Ref. No. 55 | v1.0.1 | 2026-09-22 */ const DoWorkbenchTabs: React.FC = (props) => ( = ({ mode = "normal" }) => { const [floor, setFloor] = useState("all"); const [date, setDate] = useState(dayjs().format("YYYY-MM-DD")); @@ -334,6 +335,7 @@ const FinishedGoodCartonDashboardTab: React.FC = ({ mode = "normal" }) => [calcSummary, styleWorksheet], ); + /** FP-MTMS Version Checklist | Functions Ref. No. 86 | v1.0.0 | 2026-09-22 */ const handleDownloadExcel = useCallback(async () => { setIsExporting(true); try { From ed5b984ead97567aeff4f2d974ad829ccd7cf067 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Thu, 24 Sep 2026 00:22:42 +0800 Subject: [PATCH 15/38] added unit showing refine in stock take, allow user to set a warning percent to remind stock take difference when inputing the stock qty --- package.json | 1 + scripts/warmup-menu.mjs | 88 +++++++++++++ src/app/api/stockTake/actions.ts | 2 + .../StockTakeManagement/ApproverStockTake.tsx | 45 ++++++- .../ApproverStockTakeAll.tsx | 86 +++++++++--- .../StockTakeManagement/PickerReStockTake.tsx | 99 ++++++++++---- .../StockTakeManagement/PickerStockTake.tsx | 123 +++++++++++------- .../StockTakeQtyGapHint.tsx | 40 ++++++ .../StockTakeManagement/StockTakeTab.tsx | 86 +++++++++++- .../qtyGapWarnSettingClient.ts | 49 +++++++ .../stockTakeQtyAdornment.tsx | 55 ++++++++ .../stockTakeQtyGapWarning.ts | 62 +++++++++ .../useStockTakeQtyGapWarnPercent.ts | 33 +++++ src/i18n/en/stockTake.json | 7 +- src/i18n/zh/stockTake.json | 7 +- 15 files changed, 681 insertions(+), 102 deletions(-) create mode 100644 scripts/warmup-menu.mjs create mode 100644 src/components/StockTakeManagement/StockTakeQtyGapHint.tsx create mode 100644 src/components/StockTakeManagement/qtyGapWarnSettingClient.ts create mode 100644 src/components/StockTakeManagement/stockTakeQtyAdornment.tsx create mode 100644 src/components/StockTakeManagement/stockTakeQtyGapWarning.ts create mode 100644 src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts diff --git a/package.json b/package.json index 3a630eb6..20f4f65d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "warmup": "node scripts/warmup-menu.mjs", "build": "next build", "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", "lint": "next lint", diff --git a/scripts/warmup-menu.mjs b/scripts/warmup-menu.mjs new file mode 100644 index 00000000..03910fa1 --- /dev/null +++ b/scripts/warmup-menu.mjs @@ -0,0 +1,88 @@ +const BASE = process.env.WARMUP_BASE || "http://localhost:3000"; +const COOKIE = process.env.WARMUP_COOKIE || ""; + +const PATHS = [ + "/dashboard", + "/po", + "/pickOrder", + "/inventory", + "/itemTracing", + "/stocktakemanagement", + "/stockIssue", + "/putAway", + "/finishedGood/management", + "/stockRecord", + "/doworkbench", + "/do", + "/ps", + "/jo", + "/jodetail", + "/productionProcess", + "/bag", + "/bagPrint", + "/laserPrint", + "/report", + "/m18Syn", + "/chart/purchase", + "/chart/joborder", + "/chart/joborder/board", + "/chart/delivery", + "/chart/warehouse", + "/chart/forecast", + "/settings/user", + "/settings/clientMonitor", + "/settings/items", + "/settings/itemDefaultShelfLife", + "/settings/equipment", + "/settings/warehouse", + "/settings/printer", + "/settings/itemPrice", + "/settings/qcItem", + "/settings/qcCategory", + "/settings/qcItemAll", + "/settings/shop/board", + "/settings/deliveryOrderFloor", + "/settings/rss", + "/settings/bomWeighting", + "/settings/masterDataIssues", + "/settings/qrCodeHandle", + "/settings/m18ImportTesting", + "/settings/importExcel", + "/settings/importBom", +]; + +async function waitReady() { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/login`); + if (res.status < 500) return; + } catch { + // server still starting + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`dev server not ready at ${BASE}`); +} + +console.log(`Waiting for ${BASE}/login (Next compiles this on first hit; the site will look stuck until it finishes)…`); +await waitReady(); +console.log(`Dev server answered. Warming ${PATHS.length} menu pages at ${BASE}`); +if (!COOKIE) { + console.log( + "No WARMUP_COOKIE — private pages (/dashboard, /ps, /report, /settings, …) will redirect and stay uncompiled.", + ); +} + +for (const path of PATHS) { + process.stdout.write(`compiling ${path} … `); + const t0 = Date.now(); + const res = await fetch(`${BASE}${path}`, { + redirect: "manual", + headers: COOKIE ? { cookie: COOKIE } : {}, + }); + const redirected = res.status >= 300 && res.status < 400; + console.log( + `${res.status} ${Date.now() - t0}ms${redirected ? " (redirect, page not compiled)" : ""}`, + ); +} diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index f674efe0..4118c777 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -24,6 +24,8 @@ export interface InventoryLotDetailResponse { holdQty: number; availableQty: number; uom: string; + /** Stock UoM short label (e.g. 包) for the count input. */ + uomShortDesc?: string | null; warehouseCode: string; warehouseName: string; warehouseSlot: string; diff --git a/src/components/StockTakeManagement/ApproverStockTake.tsx b/src/components/StockTakeManagement/ApproverStockTake.tsx index e2bdbb13..a4c0754a 100644 --- a/src/components/StockTakeManagement/ApproverStockTake.tsx +++ b/src/components/StockTakeManagement/ApproverStockTake.tsx @@ -33,6 +33,10 @@ import { batchSaveApproverStockTakeRecords, updateStockTakeRecordStatusToNotMatch, } from "@/app/api/stockTake/actions"; +import { stockTakeQtyEndAdornment } from "./stockTakeQtyAdornment"; +import StockTakeQtyGapHint from "./StockTakeQtyGapHint"; +import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import dayjs from "dayjs"; @@ -52,6 +56,7 @@ const ApproverStockTake: React.FC = ({ onSnackbar, }) => { const { t } = useTranslation(["stockTake", "common"]); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const [inventoryLotDetails, setInventoryLotDetails] = useState([]); @@ -61,6 +66,7 @@ const ApproverStockTake: React.FC = ({ // 每个记录的选择状态,key 为 detail.id const [qtySelection, setQtySelection] = useState>({}); const [approverQty, setApproverQty] = useState>({}); + const [gapCheckOpen, setGapCheckOpen] = useState>({}); const [approverBadQty, setApproverBadQty] = useState>({}); const [saving, setSaving] = useState(false); const [batchSaving, setBatchSaving] = useState(false); @@ -255,7 +261,19 @@ const ApproverStockTake: React.FC = ({ selectedSession.stockTakeId ); - onSnackbar(t("Approver stock take record saved successfully"), "success"); + const gapText = + selection === "approver" + ? stockTakeQtyGapWarnText(t, approverQty[detail.id] || "", stockTakeHiddenOnHand(detail), qtyGapWarnPercent) + : null; + if (selection === "approver") { + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true })); + } + onSnackbar( + gapText + ? `${t("Approver stock take record saved successfully")} ${gapText}` + : t("Approver stock take record saved successfully"), + gapText ? "warning" : "success", + ); // 計算最終數量(合格數) const goodQty = finalQty - finalBadQty; @@ -292,7 +310,7 @@ const ApproverStockTake: React.FC = ({ } finally { setSaving(false); } - }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); + }, [selectedSession, qtySelection, approverQty, approverBadQty, t, currentUserId, onSnackbar, page, pageSize, loadDetails, qtyGapWarnPercent]); const handleUpdateStatusToNotMatch = useCallback(async (detail: InventoryLotDetailResponse) => { if (!detail.stockTakeRecordId) { @@ -598,17 +616,28 @@ const ApproverStockTake: React.FC = ({ size="small" type="number" value={approverQty[detail.id] || ""} + onFocus={() => { + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: false })); + if (selection !== "approver") { + setQtySelection({ ...qtySelection, [detail.id]: "approver" }); + } + }} + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true })) + } onChange={(e) => setApproverQty({ ...approverQty, [detail.id]: e.target.value })} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 168, + minWidth: 168, '& .MuiInputBase-input': { height: '1.4375em', padding: '4px 8px' } }} placeholder={t("Stock Take Qty") } - disabled={selection !== "approver"} /> = ({ = {formatNumber(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} + )} {(() => { diff --git a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx index 91f57485..11f94ec4 100644 --- a/src/components/StockTakeManagement/ApproverStockTakeAll.tsx +++ b/src/components/StockTakeManagement/ApproverStockTakeAll.tsx @@ -53,6 +53,10 @@ import { type ApproverInventoryLotDetailsQuery, } from "@/app/api/stockTake/actions"; import { fetchStockTakeSections } from "@/app/api/warehouse/actions"; +import { stockTakeQtyEndAdornment, StockTakeQtyWithUnit } from "./stockTakeQtyAdornment"; +import StockTakeQtyGapHint from "./StockTakeQtyGapHint"; +import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import dayjs from "dayjs"; @@ -226,6 +230,7 @@ const ApproverStockTakeAll: React.FC = ({ onSnackbar, }) => { const { t } = useTranslation(["stockTake", "common"]); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const [inventoryLotDetails, setInventoryLotDetails] = useState([]); @@ -235,6 +240,7 @@ const ApproverStockTakeAll: React.FC = ({ const [searchVarianceFilterStrict, setSearchVarianceFilterStrict] = useState(false); const [qtySelection, setQtySelection] = useState>({}); const [approverQty, setApproverQty] = useState>({}); + const [gapCheckOpen, setGapCheckOpen] = useState>({}); const [approverBadQty, setApproverBadQty] = useState>({}); const [saving, setSaving] = useState(false); const [batchSaving, setBatchSaving] = useState(false); @@ -654,7 +660,19 @@ const ApproverStockTakeAll: React.FC = ({ try { await saveApproverStockTakeRecord(request, selectedSession.stockTakeId); - onSnackbar(t("Approver stock take record saved successfully"), "success"); + const gapText = + selection === "approver" + ? stockTakeQtyGapWarnText(t, approverQty[detail.id] || "", stockTakeHiddenOnHand(detail), qtyGapWarnPercent) + : null; + if (selection === "approver") { + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true })); + } + onSnackbar( + gapText + ? `${t("Approver stock take record saved successfully")} ${gapText}` + : t("Approver stock take record saved successfully"), + gapText ? "warning" : "success", + ); setInventoryLotDetails((prev) => prev.map((d) => @@ -688,7 +706,7 @@ const ApproverStockTakeAll: React.FC = ({ setSaving(false); } }, - [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar, mode] + [selectedSession, currentUserId, qtySelection, approverQty, approverBadQty, t, onSnackbar, mode, qtyGapWarnPercent] ); const handleUpdateStatusToNotMatch = useCallback( @@ -934,10 +952,13 @@ const ApproverStockTakeAll: React.FC = ({ flex: 1, sortable: false, renderCell: (params) => ( - - + + {params.row.itemCode || "-"} {params.row.itemName || "-"} - + {params.row.lotNo || "-"} {params.row.expiryDate @@ -1052,12 +1073,12 @@ const ApproverStockTakeAll: React.FC = ({ /> {t("First")}:{" "} - {formatNumber( - (detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0) - )}{" "} - {/* - = {formatNumber(detail.firstStockTakeQty ?? 0)} - */} + )} @@ -1077,12 +1098,12 @@ const ApproverStockTakeAll: React.FC = ({ /> {t("Second")}:{" "} - {formatNumber( - (detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0) - )}{" "} - {/* - = {formatNumber(detail.secondStockTakeQty ?? 0)} - */} + @@ -1108,6 +1129,18 @@ const ApproverStockTakeAll: React.FC = ({ size="small" type="number" value={approverQty[detail.id] || ""} + onFocus={() => { + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: false })); + if (mode !== "approved" && selection !== "approver") { + setQtySelection({ + ...qtySelection, + [detail.id]: "approver", + }); + } + }} + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:approver`]: true })) + } onKeyDown={blockNonIntegerKeys} onChange={(e) => { const clean = sanitizeIntegerInput(e.target.value); @@ -1116,18 +1149,29 @@ const ApproverStockTakeAll: React.FC = ({ [detail.id]: clean, }); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 72, - minWidth: 72, + width: 120, + minWidth: 120, "& .MuiInputBase-input": { py: 0.5, px: 1, }, }} // placeholder={t("Stock Take Qty")} - disabled={mode === "approved" || selection !== "approver"} + disabled={mode === "approved"} inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} /> + + + {/* = {formatNumber(approverGoodQty)} @@ -1152,7 +1196,7 @@ const ApproverStockTakeAll: React.FC = ({ {summaryLine( `${t("Selected Qty")}:`, - formatNumber(selectedQty) + [formatNumber(selectedQty), detail.uomShortDesc?.trim()].filter(Boolean).join(" ") )} {summaryLine(`${t("Book Qty")}:`, formatNumber(bookQty))} {summaryLine( diff --git a/src/components/StockTakeManagement/PickerReStockTake.tsx b/src/components/StockTakeManagement/PickerReStockTake.tsx index 1c8ebd3b..925b1bbb 100644 --- a/src/components/StockTakeManagement/PickerReStockTake.tsx +++ b/src/components/StockTakeManagement/PickerReStockTake.tsx @@ -30,6 +30,10 @@ import { getInventoryLotDetailsBySectionNotMatch } from "@/app/api/stockTake/actions"; import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; +import { stockTakeQtyEndAdornment, StockTakeQtyWithUnit } from "./stockTakeQtyAdornment"; +import StockTakeQtyGapHint from "./StockTakeQtyGapHint"; +import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; import PickerBatchSaveFab from "./PickerBatchSaveFab"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; @@ -52,6 +56,7 @@ const PickerReStockTake: React.FC = ({ onSnackbar, }) => { const { t } = useTranslation(["stockTake", "common"]); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const [inventoryLotDetails, setInventoryLotDetails] = useState([]); @@ -65,6 +70,7 @@ const PickerReStockTake: React.FC = ({ remark: string; }>>({}); const [saving, setSaving] = useState(false); + const [gapCheckOpen, setGapCheckOpen] = useState>({}); const [batchSaving, setBatchSaving] = useState(false); const [shortcutInput, setShortcutInput] = useState(""); const [page, setPage] = useState(0); @@ -246,7 +252,19 @@ const PickerReStockTake: React.FC = ({ currentUserId ); - onSnackbar(t("Stock take record saved successfully"), "success"); + const gapText = stockTakeQtyGapWarnText( + t, + totalQtyStr ?? "", + stockTakeHiddenOnHand(detail), + qtyGapWarnPercent, + ); + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:${isFirstSubmit ? "first" : "second"}`]: true })); + onSnackbar( + gapText + ? `${t("Stock take record saved successfully")} ${gapText}` + : t("Stock take record saved successfully"), + gapText ? "warning" : "success", + ); const savedId = result?.id ?? detail.stockTakeRecordId; setInventoryLotDetails((prev) => @@ -286,7 +304,7 @@ const PickerReStockTake: React.FC = ({ } finally { setSaving(false); } - }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); + }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails, qtyGapWarnPercent]); const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { if (selectedSession?.status?.toLowerCase() === "completed") { @@ -492,7 +510,7 @@ const PickerReStockTake: React.FC = ({ {t("Warehouse Location")} {t("Item-lotNo-ExpiryDate")} {t("UOM")} - {t("Stock Take Qty(include Bad Qty)= Available Qty")} + {t("Stock Take Qty(include Bad Qty)= Available Qty")} {t("Action")} {/*{t("Remark")}*/} {t("Record Status")} @@ -520,28 +538,40 @@ const PickerReStockTake: React.FC = ({ {detail.warehouseArea || "-"}{detail.warehouseSlot || "-"} - {detail.itemCode || "-"} {detail.itemName || "-"} + + {detail.itemCode || "-"} {detail.itemName || "-"} + {detail.lotNo || "-"} {detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"} {detail.uom || "-"} - + {/* First */} {!submitDisabled && isFirstSubmit ? ( + {t("First")}: + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: false })) + } + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: true })) + } inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} onKeyDown={blockNonIntegerKeys} onChange={(e) => { @@ -553,9 +583,12 @@ const PickerReStockTake: React.FC = ({ [detail.id]: { ...(prev[detail.id] ?? defaultInputs), firstQty: val } })); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 148, + minWidth: 148, "& .MuiInputBase-input": { height: "1.4375em", padding: "4px 8px", @@ -590,28 +623,39 @@ const PickerReStockTake: React.FC = ({ placeholder={t("Bad Qty")} /> */} - - = {formatNumber(parseFloat(inputs.firstQty || "0") - parseFloat(inputs.firstBadQty || "0"))} - + + ) : detail.firstStockTakeQty != null ? ( {t("First")}:{" "} - {formatNumber((detail.firstStockTakeQty ?? 0) + (detail.firstBadQty ?? 0))}{" "} - {/* ({formatNumber(detail.firstBadQty ?? 0)}) */} - ={" "} - {formatNumber(detail.firstStockTakeQty ?? 0)} + ) : null} {/* Second */} {!submitDisabled && isSecondSubmit ? ( + {t("Second")}: + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: false })) + } + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: true })) + } inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} onKeyDown={blockNonIntegerKeys} onChange={(e) => { @@ -623,9 +667,12 @@ const PickerReStockTake: React.FC = ({ [detail.id]: { ...(prev[detail.id] ?? defaultInputs), secondQty: clean } })); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 148, + minWidth: 148, "& .MuiInputBase-input": { height: "1.4375em", padding: "4px 8px", @@ -660,17 +707,21 @@ const PickerReStockTake: React.FC = ({ placeholder={t("Bad Qty")} /> */} - - = {formatNumber(parseFloat(inputs.secondQty || "0") - parseFloat(inputs.secondBadQty || "0"))} - + + ) : detail.secondStockTakeQty != null ? ( {t("Second")}:{" "} - {formatNumber((detail.secondStockTakeQty ?? 0) + (detail.secondBadQty ?? 0))}{" "} - {/* ({formatNumber(detail.secondBadQty ?? 0)}) */} - ={" "} - {formatNumber(detail.secondStockTakeQty ?? 0)} + ) : null} diff --git a/src/components/StockTakeManagement/PickerStockTake.tsx b/src/components/StockTakeManagement/PickerStockTake.tsx index 220c7b3a..6d63b6b0 100644 --- a/src/components/StockTakeManagement/PickerStockTake.tsx +++ b/src/components/StockTakeManagement/PickerStockTake.tsx @@ -35,6 +35,10 @@ import { batchSavePickerStockTakeInputs, } from "@/app/api/stockTake/actions"; import { buildPickerBatchSaveRequests } from "./buildPickerBatchSaveRequests"; +import { stockTakeQtyEndAdornment, StockTakeQtyWithUnit } from "./stockTakeQtyAdornment"; +import StockTakeQtyGapHint from "./StockTakeQtyGapHint"; +import { stockTakeHiddenOnHand, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; import PickerBatchSaveFab from "./PickerBatchSaveFab"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; @@ -57,6 +61,7 @@ const PickerStockTake: React.FC = ({ onSnackbar, }) => { const { t } = useTranslation(["stockTake", "common"]); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); const { data: session } = useSession() as { data: SessionWithTokens | null }; const [inventoryLotDetails, setInventoryLotDetails] = useState([]); @@ -72,6 +77,8 @@ const PickerStockTake: React.FC = ({ const [savingRecordId, setSavingRecordId] = useState(null); const [remark, setRemark] = useState(""); const [saving, setSaving] = useState(false); + /** Qty fields the user has left, or just saved. Warning stays hidden while typing. */ + const [gapCheckOpen, setGapCheckOpen] = useState>({}); const [batchSaving, setBatchSaving] = useState(false); const [shortcutInput, setShortcutInput] = useState(""); const [page, setPage] = useState(0); @@ -240,7 +247,19 @@ const PickerStockTake: React.FC = ({ await saveStockTakeRecord(request, selectedSession.stockTakeId, currentUserId); - onSnackbar(t("Stock take record saved successfully"), "success"); + const gapText = stockTakeQtyGapWarnText( + t, + totalQtyStr ?? "", + stockTakeHiddenOnHand(detail), + qtyGapWarnPercent, + ); + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:${isFirstSubmit ? "first" : "second"}`]: true })); + onSnackbar( + gapText + ? `${t("Stock take record saved successfully")} ${gapText}` + : t("Stock take record saved successfully"), + gapText ? "warning" : "success", + ); //await loadDetails(page, pageSize, { silent: true }); setInventoryLotDetails((prev) => @@ -286,6 +305,7 @@ const PickerStockTake: React.FC = ({ t, currentUserId, onSnackbar, + qtyGapWarnPercent, ] ); @@ -540,7 +560,7 @@ const PickerStockTake: React.FC = ({ {t("Warehouse Location")} {t("Item-lotNo-ExpiryDate")} {t("UOM")} - {t("Stock Take Qty(include Bad Qty)= Available Qty")} + {t("Stock Take Qty(include Bad Qty)= Available Qty")} {t("Action")} {/*{t("Remark")}*/} {t("Record Status")} @@ -572,16 +592,19 @@ const PickerStockTake: React.FC = ({ - + {detail.itemCode || "-"} {detail.itemName || "-"} - + {detail.lotNo || "-"} {detail.expiryDate @@ -592,16 +615,23 @@ const PickerStockTake: React.FC = ({ {detail.uom || "-"} {/* Qty + Bad Qty 合并显示/输入 */} - + {/* First */} {!submitDisabled && isFirstSubmit ? ( + {t("First")}: + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: false })) + } + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:first`]: true })) + } inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} onKeyDown={blockNonIntegerKeys} onChange={(e) => { @@ -610,9 +640,12 @@ const PickerStockTake: React.FC = ({ if (val.includes("-")) return; setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], firstQty: val } })); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 148, + minWidth: 148, "& .MuiInputBase-input": { height: "1.4375em", padding: "4px 8px", @@ -652,40 +685,39 @@ const PickerStockTake: React.FC = ({ placeholder={t("Bad Qty")} /> */} - - = - {formatNumber( - parseFloat(recordInputs[detail.id]?.firstQty || "0") - - parseFloat(recordInputs[detail.id]?.firstBadQty || "0") - )} - + + ) : detail.firstStockTakeQty != null ? ( {t("First")}:{" "} - {formatNumber( - (detail.firstStockTakeQty ?? 0) + - (detail.firstBadQty ?? 0) - )}{" "} - {/* - ( - {formatNumber( - detail.firstBadQty ?? 0 - )} - */} - ={" "} - {formatNumber(detail.firstStockTakeQty ?? 0)} + ) : null} {/* Second */} {!submitDisabled && isSecondSubmit ? ( + {t("Second")}: + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: false })) + } + onBlur={() => + setGapCheckOpen((prev) => ({ ...prev, [`${detail.id}:second`]: true })) + } inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }} onKeyDown={blockNonIntegerKeys} onChange={(e) => { @@ -694,9 +726,12 @@ const PickerStockTake: React.FC = ({ if (val.includes("-")) return; setRecordInputs(prev => ({ ...prev, [detail.id]: { ...prev[detail.id], secondQty: val } })); }} + InputProps={{ + endAdornment: stockTakeQtyEndAdornment(detail.uomShortDesc), + }} sx={{ - width: 130, - minWidth: 130, + width: 148, + minWidth: 148, "& .MuiInputBase-input": { height: "1.4375em", padding: "4px 8px", @@ -728,29 +763,21 @@ const PickerStockTake: React.FC = ({ placeholder={t("Bad Qty")} /> */} - - = - {formatNumber( - parseFloat(recordInputs[detail.id]?.secondQty || "0") - - parseFloat(recordInputs[detail.id]?.secondBadQty || "0") - )} - + + ) : detail.secondStockTakeQty != null ? ( {t("Second")}:{" "} - {formatNumber( - (detail.secondStockTakeQty ?? 0) + - (detail.secondBadQty ?? 0) - )}{" "} - {/* - ( - {formatNumber( - detail.secondBadQty ?? 0 - )} - */} - ={" "} - {formatNumber(detail.secondStockTakeQty ?? 0)} + ) : null} diff --git a/src/components/StockTakeManagement/StockTakeQtyGapHint.tsx b/src/components/StockTakeManagement/StockTakeQtyGapHint.tsx new file mode 100644 index 00000000..3323d5e1 --- /dev/null +++ b/src/components/StockTakeManagement/StockTakeQtyGapHint.tsx @@ -0,0 +1,40 @@ +"use client"; + +import Typography from "@mui/material/Typography"; +import { useTranslation } from "react-i18next"; +import { STOCK_TAKE_QTY_GAP_WARN_PERCENT, stockTakeQtyGapWarnText } from "./stockTakeQtyGapWarning"; + +/** Shown after the field is left, or flagged on save. Does not show the hidden on-hand quantity. */ +export default function StockTakeQtyGapHint({ + entered, + currentQty, + open, + threshold = STOCK_TAKE_QTY_GAP_WARN_PERCENT, +}: { + entered: string; + currentQty: number | null | undefined; + /** False while the user is still typing. */ + open: boolean; + /** Warn when the count differs from on-hand by at least this percent. */ + threshold?: number; +}) { + const { t } = useTranslation("stockTake"); + if (!open) return null; + const text = stockTakeQtyGapWarnText(t, entered, currentQty, threshold); + if (!text) return null; + return ( + + {text} + + ); +} diff --git a/src/components/StockTakeManagement/StockTakeTab.tsx b/src/components/StockTakeManagement/StockTakeTab.tsx index ad4af356..957e1901 100644 --- a/src/components/StockTakeManagement/StockTakeTab.tsx +++ b/src/components/StockTakeManagement/StockTakeTab.tsx @@ -1,14 +1,23 @@ "use client"; -import { Box, Tab, Tabs, Snackbar, Alert, CircularProgress, Typography } from "@mui/material"; -import { useState, useCallback, useEffect } from "react"; +import { Box, Tab, Tabs, Snackbar, Alert, CircularProgress, Typography, TextField, Button, Stack } from "@mui/material"; +import { useState, useCallback, useEffect, useRef } from "react"; +import { useSession } from "next-auth/react"; import { useTranslation } from "react-i18next"; +import { AUTH } from "@/authorities"; +import { SessionWithTokens } from "@/config/authConfig"; import { AllPickedStockTakeListReponse, getLatestApproverStockTakeHeader } from "@/app/api/stockTake/actions"; import PickerCardList from "./PickerCardList"; import type { PickerCardListFilters } from "./PickerCardList"; import PickerStockTake from "./PickerStockTake"; import PickerReStockTake from "./PickerReStockTake"; import ApproverStockTakeAll from "./ApproverStockTakeAll"; +import { useStockTakeQtyGapWarnPercent } from "./useStockTakeQtyGapWarnPercent"; +import { + parseQtyGapWarnPercent, + saveStockTakeQtyGapWarnPercent, +} from "./qtyGapWarnSettingClient"; +import { STOCK_TAKE_QTY_GAP_WARN_PERCENT } from "./stockTakeQtyGapWarning"; type ViewScope = "picker" | "approver-all"; const DEFAULT_PICKER_CARD_LIST_FILTERS: PickerCardListFilters = { @@ -21,6 +30,14 @@ const DEFAULT_PICKER_CARD_LIST_FILTERS: PickerCardListFilters = { const StockTakeTab: React.FC = () => { const { t } = useTranslation(["stockTake", "common"]); + const { data: session } = useSession() as { data: SessionWithTokens | null }; + const isAdmin = (session?.abilities ?? session?.user?.abilities ?? []).some( + (ability) => String(ability).trim() === AUTH.ADMIN, + ); + const qtyGapWarnPercent = useStockTakeQtyGapWarnPercent(); + const [qtyGapDraft, setQtyGapDraft] = useState(String(STOCK_TAKE_QTY_GAP_WARN_PERCENT)); + const [qtyGapSaving, setQtyGapSaving] = useState(false); + const qtyGapSaveLock = useRef(false); const [tabValue, setTabValue] = useState(0); const [selectedSession, setSelectedSession] = useState(null); const [viewMode, setViewMode] = useState<"details" | "reStockTake">("details"); @@ -66,6 +83,31 @@ const StockTakeTab: React.FC = () => { }); }, []); + useEffect(() => { + setQtyGapDraft(String(qtyGapWarnPercent)); + }, [qtyGapWarnPercent]); + + const saveQtyGapWarnPercent = useCallback(async () => { + if (!isAdmin || qtyGapSaveLock.current) return; + const parsed = Number(qtyGapDraft.trim()); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 1000) { + handleSnackbar(t("qtyGapWarnPercentInvalid"), "warning"); + return; + } + qtyGapSaveLock.current = true; + setQtyGapSaving(true); + try { + await saveStockTakeQtyGapWarnPercent(parsed); + setQtyGapDraft(String(parseQtyGapWarnPercent(String(parsed)))); + handleSnackbar(t("qtyGapWarnPercentSaved"), "success"); + } catch (e) { + handleSnackbar(e instanceof Error ? e.message : t("qtyGapWarnPercentInvalid"), "error"); + } finally { + qtyGapSaveLock.current = false; + setQtyGapSaving(false); + } + }, [handleSnackbar, isAdmin, qtyGapDraft, t]); + useEffect(() => { if (tabValue !== 1 && tabValue !== 2) return; setApproverLoading(true); @@ -115,6 +157,46 @@ const StockTakeTab: React.FC = () => { return ( + {isAdmin && ( + + + {t("qtyGapWarnPercent")} + + setQtyGapDraft(e.target.value.replace(/[^\d]/g, ""))} + inputProps={{ min: 0, max: 1000, inputMode: "numeric" }} + sx={{ + width: 88, + m: 0, + "& .MuiFilledInput-root": { height: 40 }, + "& .MuiFilledInput-input.MuiInputBase-inputSizeSmall": { + height: 40, + boxSizing: "border-box", + paddingTop: 0, + paddingBottom: 0, + lineHeight: "40px", + }, + }} + /> + + + )} { diff --git a/src/components/StockTakeManagement/qtyGapWarnSettingClient.ts b/src/components/StockTakeManagement/qtyGapWarnSettingClient.ts new file mode 100644 index 00000000..ac6e81ee --- /dev/null +++ b/src/components/StockTakeManagement/qtyGapWarnSettingClient.ts @@ -0,0 +1,49 @@ +"use client"; + +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { STOCK_TAKE_QTY_GAP_WARN_PERCENT } from "./stockTakeQtyGapWarning"; + +export const STOCK_TAKE_QTY_GAP_WARN_SETTING = "STOCK_TAKE.qtyGapWarnPercent"; +export const STOCK_TAKE_QTY_GAP_SETTING_EVENT = "stock-take-qty-gap-warn-percent"; + +type SettingsRow = { name: string; value: string }; + +let cached: number | null = null; + +export function parseQtyGapWarnPercent(raw: string | null | undefined): number { + const text = String(raw ?? "").trim(); + if (!text) return STOCK_TAKE_QTY_GAP_WARN_PERCENT; + const n = Number(text); + if (!Number.isFinite(n) || n < 0 || n > 1000) return STOCK_TAKE_QTY_GAP_WARN_PERCENT; + return Math.round(n); +} + +export async function fetchStockTakeQtyGapWarnPercent(): Promise { + if (cached != null) return cached; + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + const res = await clientAuthFetch(`${base}/settings`, { method: "GET" }); + if (!res.ok) return STOCK_TAKE_QTY_GAP_WARN_PERCENT; + const rows = (await res.json()) as SettingsRow[]; + const value = rows.find((row) => row.name === STOCK_TAKE_QTY_GAP_WARN_SETTING)?.value; + cached = parseQtyGapWarnPercent(value); + return cached; +} + +export async function saveStockTakeQtyGapWarnPercent(percent: number): Promise { + const base = (NEXT_PUBLIC_API_URL ?? "").replace(/\/$/, ""); + const res = await clientAuthFetch( + `${base}/settings/${encodeURIComponent(STOCK_TAKE_QTY_GAP_WARN_SETTING)}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ value: String(percent) }), + }, + ); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Failed to save setting: ${res.status}`); + } + cached = percent; + window.dispatchEvent(new CustomEvent(STOCK_TAKE_QTY_GAP_SETTING_EVENT, { detail: percent })); +} diff --git a/src/components/StockTakeManagement/stockTakeQtyAdornment.tsx b/src/components/StockTakeManagement/stockTakeQtyAdornment.tsx new file mode 100644 index 00000000..88e7b7c2 --- /dev/null +++ b/src/components/StockTakeManagement/stockTakeQtyAdornment.tsx @@ -0,0 +1,55 @@ +"use client"; + +import InputAdornment from "@mui/material/InputAdornment"; +import Typography from "@mui/material/Typography"; + +/** Large unit reminder inside the count field, same style as PO stock-in qty. */ +export function stockTakeQtyEndAdornment(uomShortDesc?: string | null) { + const label = uomShortDesc?.trim() ?? ""; + if (!label) return undefined; + return ( + + + {label} + + + ); +} + +/** Saved count with the same large unit label, e.g. 140 包. */ +export function StockTakeQtyWithUnit({ + qty, + uomShortDesc, +}: { + qty: string; + uomShortDesc?: string | null; +}) { + const unit = uomShortDesc?.trim() ?? ""; + return ( + <> + {qty} + {unit ? ( + + {unit} + + ) : null} + + ); +} diff --git a/src/components/StockTakeManagement/stockTakeQtyGapWarning.ts b/src/components/StockTakeManagement/stockTakeQtyGapWarning.ts new file mode 100644 index 00000000..5bdb1144 --- /dev/null +++ b/src/components/StockTakeManagement/stockTakeQtyGapWarning.ts @@ -0,0 +1,62 @@ +/** Warn when the typed count is this far from hidden on-hand, either higher or lower. Save stays allowed. */ +export const STOCK_TAKE_QTY_GAP_WARN_PERCENT = 50; + +export type StockTakeQtyGap = + | { kind: "percent"; pct: number } + | { kind: "over" }; + +/** + * On-hand used only for the gap check. Prefer the stock-take book qty (frozen for this count); + * fall back to live available qty. Callers must not render this number. + */ +export function stockTakeHiddenOnHand(detail: { + bookQty?: number | null; + availableQty?: number | null; +}): number | null { + if (detail.bookQty != null && Number.isFinite(Number(detail.bookQty))) { + return Number(detail.bookQty); + } + if (detail.availableQty != null && Number.isFinite(Number(detail.availableQty))) { + return Number(detail.availableQty); + } + return null; +} + +/** Null when the field is empty, or the gap is within the threshold. */ +export function stockTakeQtyGapWarning( + enteredRaw: string, + currentQty: number | null | undefined, + threshold = STOCK_TAKE_QTY_GAP_WARN_PERCENT, +): StockTakeQtyGap | null { + const raw = enteredRaw.trim(); + if (!raw) return null; + const entered = Number(raw); + if (!Number.isFinite(entered)) return null; + if (currentQty == null || !Number.isFinite(Number(currentQty))) return null; + const current = Number(currentQty); + if (current === 0) { + return entered === 0 ? null : { kind: "over" }; + } + const pct = (Math.abs(entered - current) / Math.abs(current)) * 100; + if (pct + 1e-9 < threshold) return null; + return { kind: "percent", pct: Math.round(pct) }; +} + +export function stockTakeQtyGapWarnText( + t: (key: string, options?: Record) => string, + enteredRaw: string, + currentQty: number | null | undefined, + threshold = STOCK_TAKE_QTY_GAP_WARN_PERCENT, +): string | null { + const gap = stockTakeQtyGapWarning(enteredRaw, currentQty, threshold); + if (!gap) return null; + if (gap.kind === "percent") { + return t("stockTakeQtyGapWarn", { + pct: gap.pct, + threshold, + }); + } + return t("stockTakeQtyGapWarnOver", { + threshold, + }); +} diff --git a/src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts b/src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts new file mode 100644 index 00000000..289214b2 --- /dev/null +++ b/src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { STOCK_TAKE_QTY_GAP_WARN_PERCENT } from "./stockTakeQtyGapWarning"; +import { + fetchStockTakeQtyGapWarnPercent, + STOCK_TAKE_QTY_GAP_SETTING_EVENT, +} from "./qtyGapWarnSettingClient"; + +/** Warning threshold from settings (`STOCK_TAKE.qtyGapWarnPercent`), default 50. */ +export function useStockTakeQtyGapWarnPercent(): number { + const [percent, setPercent] = useState(STOCK_TAKE_QTY_GAP_WARN_PERCENT); + + useEffect(() => { + let cancelled = false; + fetchStockTakeQtyGapWarnPercent() + .then((n) => { + if (!cancelled) setPercent(n); + }) + .catch(() => {}); + const onChange = (event: Event) => { + const next = (event as CustomEvent).detail; + if (Number.isFinite(next)) setPercent(next); + }; + window.addEventListener(STOCK_TAKE_QTY_GAP_SETTING_EVENT, onChange); + return () => { + cancelled = true; + window.removeEventListener(STOCK_TAKE_QTY_GAP_SETTING_EVENT, onChange); + }; + }, []); + + return percent; +} diff --git a/src/i18n/en/stockTake.json b/src/i18n/en/stockTake.json index faaef94f..64f04baf 100644 --- a/src/i18n/en/stockTake.json +++ b/src/i18n/en/stockTake.json @@ -116,7 +116,7 @@ "Stock Take Management": "Stock Take Management", "Stock Take Qty": "Stock Take Qty", "Stock Take Qty Data and Variance Analysis": "Stock Take Qty Data and Variance Analysis", - "Stock Take Qty(include Bad Qty)= Available Qty": "Stock Take Qty(include Bad Qty)= Available Qty", + "Stock Take Qty(include Bad Qty)= Available Qty": "Stock Take Qty", "Stock Take Round": "Stock Take Round", "Stock Take Section": "Stock Take Section", "Stock Take Section (can use , to search multiple sections)": "Stock Take Section (can use , to search multiple sections)", @@ -173,6 +173,11 @@ "sections unit": "area(s)", "selected stock take qty": "selected stock take qty", "start time": "start time", + "qtyGapWarnPercent": "Qty variance warning %", + "qtyGapWarnPercentSaved": "Qty gap warning percent saved", + "qtyGapWarnPercentInvalid": "Enter a whole number from 0 to 1000", + "stockTakeQtyGapWarn": "Please check the entry. The count differs from current stock by {{pct}}%.", + "stockTakeQtyGapWarnOver": "Please check the entry. The count differs from current stock by more than {{threshold}}%.", "stockTaking": "Stock taking", "stock_take": "Stock take", "variance Percentage": "variance Percentage" diff --git a/src/i18n/zh/stockTake.json b/src/i18n/zh/stockTake.json index aad4cd7a..53745a40 100644 --- a/src/i18n/zh/stockTake.json +++ b/src/i18n/zh/stockTake.json @@ -53,7 +53,7 @@ "Warehouse Location": "倉庫位置", "Item-lotNo-ExpiryDate": "貨品-批號-到期日", "UOM": "單位", - "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數= 可用數", + "Stock Take Qty(include Bad Qty)= Available Qty": "盤點數", "Record Status": "盤點狀態", "No data": "沒有數據", "Difference": "差異", @@ -61,6 +61,11 @@ "Second": "第二次", "Approver Input": "審核員輸入", "Stock Take Qty": "盤點數", + "qtyGapWarnPercent": "出入差異警告%", + "qtyGapWarnPercentSaved": "出入差異警告%已保存", + "qtyGapWarnPercentInvalid": "請輸入 0 至 1000 的整數", + "stockTakeQtyGapWarn": "請小心輸入查看,盤點數與現時倉存有 {{pct}}% 的出入", + "stockTakeQtyGapWarnOver": "請小心輸入查看,盤點數與現時倉存的出入已超過 {{threshold}}%", "Bad Qty": "不良數量", "selected stock take qty": "已選擇盤點數量", "book qty": "帳面庫存", From a1f430a536175ef20f3d596b6ff7c588a8954111 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Mon, 28 Sep 2026 15:17:58 +0800 Subject: [PATCH 16/38] [Fix] Item search field: codes that weren't selected through dropdown still take effect --- .../report/AsyncItemCodeAutocomplete.tsx | 61 ++++++++--- .../report/SemiFGProductionAnalysisReport.tsx | 16 ++- src/app/(main)/report/page.tsx | 102 ++++++++++++++---- 3 files changed, 139 insertions(+), 40 deletions(-) diff --git a/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx b/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx index 3e1681a1..a1f68151 100644 --- a/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx +++ b/src/app/(main)/report/AsyncItemCodeAutocomplete.tsx @@ -1,14 +1,17 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Autocomplete, Chip, CircularProgress, TextField } from "@mui/material"; import { useTranslation } from "react-i18next"; import { searchItemCodes, type ItemCodeSearchHit } from "./itemCodeSearchApi"; +import { parseItemCodeTokens } from "./parseItemCodeTokens"; type Props = { label: string; value: string[]; onChange: (codes: string[]) => void; + /** Live text in the box, including text that has not been picked from the list. */ + onInputValueChange?: (value: string) => void; placeholder?: string; disabled?: boolean; minChars?: number; @@ -17,11 +20,12 @@ type Props = { const hitLabel = (hit: ItemCodeSearchHit) => hit.name ? `${hit.code} ${hit.name}` : hit.code; -/** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.0 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.1 | 2026-09-28 */ const AsyncItemCodeAutocomplete: React.FC = ({ label, value, onChange, + onInputValueChange, placeholder, disabled = false, minChars = 2, @@ -73,21 +77,47 @@ const AsyncItemCodeAutocomplete: React.FC = ({ }; }, [trimmedInput, minChars]); + const selectedCodes = useMemo( + () => new Set(value.map((code) => code.toUpperCase())), + [value], + ); + const options = useMemo(() => { const seen = new Set(); const codes: string[] = []; suggestions.forEach((hit) => { - if (seen.has(hit.code)) return; - seen.add(hit.code); + const key = hit.code.toUpperCase(); + if (seen.has(key) || selectedCodes.has(key)) return; + seen.add(key); codes.push(hit.code); }); - value.forEach((code) => { - if (seen.has(code)) return; - seen.add(code); - codes.push(code); - }); return codes; - }, [suggestions, value]); + }, [suggestions, selectedCodes]); + + useEffect(() => { + const trimmed = inputValue.trim(); + if (!trimmed) return; + const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); + if (hit) onInputValueChange?.(hit.code); + }, [suggestions, inputValue, onInputValueChange]); + + const inputValueRef = useRef(inputValue); + inputValueRef.current = inputValue; + const previousValueRef = useRef(value); + useEffect(() => { + const previous = previousValueRef.current; + previousValueRef.current = value; + const tokens = parseItemCodeTokens(inputValueRef.current); + if (tokens.length === 0) return; + const previousKeys = new Set(previous.map((code) => code.toUpperCase())); + const nextKeys = new Set(value.map((code) => code.toUpperCase())); + const justCommitted = tokens.every( + (token) => nextKeys.has(token.toUpperCase()) && !previousKeys.has(token.toUpperCase()), + ); + if (!justCommitted) return; + setInputValue(""); + onInputValueChange?.(""); + }, [value, onInputValueChange]); const noOptionsText = needsMoreChars ? t("typeToSearchItemCode", { min: minChars }) @@ -112,9 +142,11 @@ const AsyncItemCodeAutocomplete: React.FC = ({ filterOptions={(opts) => trimmedInput.length < minChars ? [] - : opts.filter((code) => !value.includes(code)) + : opts.filter((code) => !selectedCodes.has(code.toUpperCase())) + } + isOptionEqualToValue={(option, selected) => + option.toUpperCase() === selected.toUpperCase() } - isOptionEqualToValue={(option, selected) => option === selected} autoHighlight noOptionsText={noOptionsText} sx={{ @@ -160,11 +192,16 @@ const AsyncItemCodeAutocomplete: React.FC = ({ }, }} onInputChange={(_, next, reason) => { + if (reason === "blur") return; if (reason === "reset") { setInputValue(""); + onInputValueChange?.(""); return; } setInputValue(next); + const trimmed = next.trim(); + const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); + onInputValueChange?.(hit?.code ?? next); }} onChange={(_, newValue) => { const codes = (Array.isArray(newValue) ? newValue : []) diff --git a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx index f676ba66..9a55d1cf 100644 --- a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx +++ b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx @@ -30,6 +30,7 @@ import { parseItemCodeTokens } from './parseItemCodeTokens'; interface SemiFGProductionAnalysisReportProps { criteria: Record; + prepareCriteria?: () => Record; requiredFieldLabels: string[]; loading: boolean; setLoading: (loading: boolean) => void; @@ -39,6 +40,7 @@ interface SemiFGProductionAnalysisReportProps { export default function SemiFGProductionAnalysisReport({ criteria, + prepareCriteria, requiredFieldLabels, loading, setLoading, @@ -70,6 +72,7 @@ export default function SemiFGProductionAnalysisReport({ }, [criteria.stockCategory]); const handleExportClick = async (format: 'pdf' | 'excel') => { + const activeCriteria = prepareCriteria?.() ?? criteria; setExportFormat(format); // Validate required fields if (requiredFieldLabels.length > 0) { @@ -78,10 +81,10 @@ export default function SemiFGProductionAnalysisReport({ } const selectedCodes = parseItemCodeTokens( - [criteria.itemCode, criteria.itemCodePaste].filter(Boolean).join(' '), + [activeCriteria.itemCode, activeCriteria.itemCodePaste].filter(Boolean).join(' '), ); if (selectedCodes.length === 0) { - await executeExport(format); + await executeExport(format, activeCriteria); return; } @@ -97,13 +100,16 @@ export default function SemiFGProductionAnalysisReport({ setShowConfirmDialog(true); }; - const executeExport = async (format: 'pdf' | 'excel' = exportFormat) => { + const executeExport = async ( + format: 'pdf' | 'excel' = exportFormat, + activeCriteria: Record = prepareCriteria?.() ?? criteria, + ) => { setLoading(true); try { if (format === 'excel') { - await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle); + await generateSemiFGProductionAnalysisReportExcel(activeCriteria, reportTitle); } else { - await generateSemiFGProductionAnalysisReport(criteria, reportTitle); + await generateSemiFGProductionAnalysisReport(activeCriteria, reportTitle); } onExportSuccess?.(format); setShowConfirmDialog(false); diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 89f442e8..1c69d010 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -26,7 +26,7 @@ import { import DownloadIcon from '@mui/icons-material/Download'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { REPORTS } from '@/config/reportConfig'; -import { mergePastedItemCodes, buildItemCodePasteRequestBody } from './parseItemCodeTokens'; +import { mergePastedItemCodes, buildItemCodePasteRequestBody, parseItemCodeTokens } from './parseItemCodeTokens'; import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; @@ -86,8 +86,8 @@ const ITEM_CODE_PASTE_POST_REPORTS = new Set([ /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.3 | 2026-09-10 */ /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ -/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ -/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.3 | 2026-09-28 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-28 */ export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; const { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel } = useReportLabels(); @@ -98,6 +98,37 @@ export default function ReportPage() { const [selectedReportId, setSelectedReportId] = useState(''); const [criteria, setCriteria] = useState>({}); + const criteriaRef = useRef>({}); + const pendingItemCodeInputRef = useRef>({}); + const updateCriteria = ( + updater: Record | ((prev: Record) => Record), + ) => { + const next = typeof updater === 'function' ? updater(criteriaRef.current) : updater; + criteriaRef.current = next; + setCriteria(next); + }; + /** Typed item codes are not in criteria until a dropdown option is chosen. Merge them in before the request. */ + const flushPendingItemCodeInputs = (): Record => { + const pendingEntries = Object.entries(pendingItemCodeInputRef.current).filter(([, raw]) => raw.trim()); + if (pendingEntries.length === 0) return criteriaRef.current; + + const next = { ...criteriaRef.current }; + let changed = false; + for (const [field, raw] of pendingEntries) { + pendingItemCodeInputRef.current[field] = ''; + const merged = parseItemCodeTokens([next[field], raw].filter(Boolean).join(' ')); + const joined = merged.join(','); + if (joined && joined !== (next[field] || '')) { + next[field] = joined; + changed = true; + } + } + if (changed) { + criteriaRef.current = next; + setCriteria(next); + } + return criteriaRef.current; + }; const [loading, setLoading] = useState(false); const excelInFlightRef = useRef(false); const [dynamicOptions, setDynamicOptions] = useState>({}); @@ -124,14 +155,15 @@ export default function ReportPage() { if (reportId === selectedReportId) return; setSelectedReportId(reportId); setFieldErrors({}); + pendingItemCodeInputRef.current = {}; if (reportId === 'rep-010') { - setCriteria({ qcType: 'all', qcItemScope: 'all' }); + updateCriteria({ qcType: 'all', qcItemScope: 'all' }); } else if (reportId === 'rep-004') { - setCriteria({ storeId: 'All', poPrefix: 'All' }); + updateCriteria({ storeId: 'All', poPrefix: 'All' }); } else if (reportId === 'rep-021') { - setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); + updateCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); } else { - setCriteria({}); + updateCriteria({}); } }; @@ -143,7 +175,7 @@ export default function ReportPage() { delete next[name]; return next; }); - setCriteria((prev) => { + updateCriteria((prev) => { const next = { ...prev, [name]: stringValue }; if (currentReport?.id === 'rep-021' && name === 'warehouse') { const m = stringValue.trim().match(/^w(\d)/i); @@ -252,7 +284,7 @@ export default function ReportPage() { // Default "All" (no filter) for stock take variance report conditions. if (selectedReportId === 'rep-012') { - setCriteria({ + updateCriteria({ store_id: 'All', status: 'All', type: 'All', @@ -264,7 +296,7 @@ export default function ReportPage() { useEffect(() => { if (selectedReportId !== 'rep-012' || !rep012MultiRound) return; if (criteria.status === 'completed') return; - setCriteria((prev) => ({ ...prev, status: 'completed' })); + updateCriteria((prev) => ({ ...prev, status: 'completed' })); }, [selectedReportId, rep012MultiRound, criteria.status]); // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice. @@ -329,19 +361,20 @@ export default function ReportPage() { /** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */ const buildRep012QueryString = (): string => { + const active = criteriaRef.current; const p = new URLSearchParams(); p.set('stockTakeRoundId', rep012RoundIds.join(',')); - const code = criteria.itemCode?.trim(); + const code = active.itemCode?.trim(); if (code) p.set('itemCode', code); - const store = criteria.store_id?.trim(); + const store = active.store_id?.trim(); if (store && store !== 'All') p.set('store_id', store); if (rep012MultiRound) { p.set('status', 'completed'); } else { - const status = criteria.status?.trim(); + const status = active.status?.trim(); if (status && status !== 'All') p.set('status', status); } - const lotType = criteria.type?.trim(); + const lotType = active.type?.trim(); if (lotType && lotType !== 'All') p.set('type', lotType); return p.toString(); }; @@ -349,15 +382,16 @@ export default function ReportPage() { /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ /** rep-010:qcItemScope → includeMeasurable / includeOther;qcType=all 不傳篩選 */ const buildRep010QueryString = (): string => { + const active = criteriaRef.current; const p = new URLSearchParams(); - Object.entries(criteria).forEach(([key, value]) => { + Object.entries(active).forEach(([key, value]) => { if (key === 'qcItemScope') return; if (key === 'qcType' && String(value).trim().toLowerCase() === 'all') return; if (value != null && String(value).trim() !== '') { p.set(key, String(value)); } }); - const scope = (criteria.qcItemScope || 'all').trim().toLowerCase(); + const scope = (active.qcItemScope || 'all').trim().toLowerCase(); if (scope === 'measurable') { p.set('includeMeasurable', 'true'); p.set('includeOther', 'false'); @@ -380,7 +414,7 @@ export default function ReportPage() { if (!currentReport) return ''; if (currentReport.id === 'rep-012') return buildRep012QueryString(); if (currentReport.id === 'rep-010') return buildRep010QueryString(); - const merged = mergePastedItemCodes(criteria, itemCodeFieldName()); + const merged = mergePastedItemCodes(criteriaRef.current, itemCodeFieldName()); const p = new URLSearchParams(merged); if (currentReport.id === 'rep-016') { const day = (merged.dateStart || '').trim(); @@ -393,6 +427,7 @@ export default function ReportPage() { }; const handlePrint = async () => { + flushPendingItemCodeInputs(); if (!currentReport) return; if (!validateRequiredFields()) return; @@ -409,6 +444,7 @@ export default function ReportPage() { }; const handleExcelPrint = async () => { + flushPendingItemCodeInputs(); if (!currentReport) return; if (!validateRequiredFields()) return; await executeExcelReport(); @@ -420,17 +456,18 @@ export default function ReportPage() { excelInFlightRef.current = true; setLoading(true); try { + const activeCriteria = criteriaRef.current; if (currentReport.id === 'rep-014') { await generateGrnReportExcel( - criteria, + activeCriteria, reportTitle(currentReport), includeGrnFinancialColumns, t, ); } else if (currentReport.id === 'rep-015') { - await generateBomShopSyncReportExcel(criteria, reportTitle(currentReport), t); + await generateBomShopSyncReportExcel(activeCriteria, reportTitle(currentReport), t); } else if (currentReport.id === 'rep-017') { - await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); + await generateShopOrderReplenishmentReportExcel(activeCriteria, reportTitle(currentReport), t); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. const usesItemCodePastePost = isItemCodePastePostReport(); @@ -445,7 +482,7 @@ export default function ReportPage() { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'Content-Type': 'application/json', }, - body: JSON.stringify(buildItemCodePasteRequestBody(criteria, itemCodeFieldName())), + body: JSON.stringify(buildItemCodePasteRequestBody(criteriaRef.current, itemCodeFieldName())), } : { method: 'GET', @@ -514,7 +551,7 @@ export default function ReportPage() { Accept: 'application/pdf', 'Content-Type': 'application/json', }, - body: JSON.stringify(buildItemCodePasteRequestBody(criteria, itemCodeFieldName())), + body: JSON.stringify(buildItemCodePasteRequestBody(criteriaRef.current, itemCodeFieldName())), } : { method: 'GET', @@ -692,7 +729,13 @@ export default function ReportPage() { label={translatedLabel} placeholder={field.placeholder || "e.g. FA0591"} value={selectedCodes} - onChange={(codes) => handleFieldChange(field.name, codes)} + onInputValueChange={(text) => { + pendingItemCodeInputRef.current[field.name] = text; + }} + onChange={(codes) => { + pendingItemCodeInputRef.current[field.name] = ""; + handleFieldChange(field.name, codes); + }} minChars={field.asyncSearchMinChars ?? 2} disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} /> @@ -711,8 +754,20 @@ export default function ReportPage() { opt.value)} value={autocompleteValue} + isOptionEqualToValue={(option, selected) => + String(option).toUpperCase() === String(selected).toUpperCase() + } + onInputChange={(_, input, reason) => { + if (reason === "reset" || reason === "clear") { + pendingItemCodeInputRef.current[field.name] = ""; + return; + } + if (reason === "blur") return; + pendingItemCodeInputRef.current[field.name] = input; + }} onChange={(event, newValue, reason) => { if (field.multiple) { // Handle multiple selection - newValue is an array @@ -895,6 +950,7 @@ export default function ReportPage() { {currentReport.id === 'rep-005' ? ( f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))} loading={loading} setLoading={setLoading} From d908f2378c88c68f5068c29a99d528d5f68be1b7 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Tue, 29 Sep 2026 22:24:09 +0800 Subject: [PATCH 17/38] added access right LANE for car line --- src/app/(main)/settings/shop/layout.tsx | 27 +++++++++++++++++++ src/authorities.ts | 2 ++ .../NavigationContent/NavigationContent.tsx | 19 ++++++++++++- 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/settings/shop/layout.tsx diff --git a/src/app/(main)/settings/shop/layout.tsx b/src/app/(main)/settings/shop/layout.tsx new file mode 100644 index 00000000..357f7952 --- /dev/null +++ b/src/app/(main)/settings/shop/layout.tsx @@ -0,0 +1,27 @@ +import { I18nProvider } from "@/i18n"; +import { authOptions } from "@/config/authConfig"; +import { AUTH, hasAbility } from "@/authorities"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; + +/** Route board and shop/truck lane pages: ADMIN or 車線 (TRUCK_LANE). */ +export default async function ShopSettingsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerSession(authOptions); + const abilities = session?.user?.abilities ?? []; + const canAccess = + hasAbility(abilities, AUTH.TRUCK_LANE) || hasAbility(abilities, AUTH.ADMIN); + + if (!canAccess) { + redirect("/dashboard"); + } + + return ( + + {children} + + ); +} diff --git a/src/authorities.ts b/src/authorities.ts index ad4aeb9e..12747b66 100644 --- a/src/authorities.ts +++ b/src/authorities.ts @@ -27,6 +27,8 @@ export const AUTH = { REPORT_MGMT: "REPORT_MGMT", /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 — Manual M18 sync page (/m18Syn): ADMIN or this ability */ M18_SYNC: "M18_SYNC", + /** Route board (/settings/shop/board): ADMIN or this ability (DB name: 車線) */ + TRUCK_LANE: "TRUCK_LANE", } as const; /** diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 957cb95b..05916740 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -303,7 +303,7 @@ const NavigationContent: React.FC = () => { icon: , labelKey: "nav.settings", path: "", - requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], + requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN, AUTH.TRUCK_LANE], children: [ { id: "nav.settings.user", @@ -325,78 +325,91 @@ const NavigationContent: React.FC = () => { icon: , labelKey: "nav.settings.items", path: "/settings/items", + requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], }, { id: "nav.settings.itemDefaultShelfLife", icon: , labelKey: "nav.settings.itemDefaultShelfLife", path: "/settings/itemDefaultShelfLife", + requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], }, { id: "nav.settings.equipment", icon: , labelKey: "nav.settings.equipment", path: "/settings/equipment", + requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], }, { id: "nav.settings.warehouse", icon: , labelKey: "nav.settings.warehouse", path: "/settings/warehouse", + requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], }, { id: "nav.settings.printer", icon: , labelKey: "nav.settings.printer", path: "/settings/printer", + requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], }, { id: "nav.settings.priceInquiry", icon: , labelKey: "nav.settings.priceInquiry", path: "/settings/itemPrice", + requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], }, { id: "nav.settings.qcItem", icon: , labelKey: "nav.settings.qcItem", path: "/settings/qcItem", + requiredAbility: [AUTH.VIEW_USER, AUTH.ADMIN], }, { id: "nav.settings.qcCategory", icon: {/* Remark */} diff --git a/src/components/StockTakeManagement/StockTakeTab.tsx b/src/components/StockTakeManagement/StockTakeTab.tsx index 957e1901..2ef820ba 100644 --- a/src/components/StockTakeManagement/StockTakeTab.tsx +++ b/src/components/StockTakeManagement/StockTakeTab.tsx @@ -145,9 +145,15 @@ const StockTakeTab: React.FC = () => { setSnackbar({ ...snackbar, open: false })} + onClose={(_event, reason) => { + if (reason === "clickaway") return; + setSnackbar((prev) => ({ ...prev, open: false })); + }} > - setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}> + setSnackbar((prev) => ({ ...prev, open: false }))} + severity={snackbar.severity} + > {snackbar.message} @@ -268,9 +274,15 @@ const StockTakeTab: React.FC = () => { setSnackbar({ ...snackbar, open: false })} + onClose={(_event, reason) => { + if (reason === "clickaway") return; + setSnackbar((prev) => ({ ...prev, open: false })); + }} > - setSnackbar({ ...snackbar, open: false })} severity={snackbar.severity}> + setSnackbar((prev) => ({ ...prev, open: false }))} + severity={snackbar.severity} + > {snackbar.message} diff --git a/src/i18n/en/stockTake.json b/src/i18n/en/stockTake.json index 64f04baf..a866d2bb 100644 --- a/src/i18n/en/stockTake.json +++ b/src/i18n/en/stockTake.json @@ -98,6 +98,8 @@ "Return": "Return", "Rows per page": "Rows per page", "Save": "Save", + "Change": "Change", + "oneChangeLeft": "Only 1 change left", "Search": "Search", "Search Criteria": "Search Criteria", "Search section code or name": "Search code or name (e.g. ST-042 or drinks)", diff --git a/src/i18n/zh/stockTake.json b/src/i18n/zh/stockTake.json index 53745a40..3287df0f 100644 --- a/src/i18n/zh/stockTake.json +++ b/src/i18n/zh/stockTake.json @@ -8,6 +8,8 @@ "Remark": "備註", "Action": "操作", "Save": "保存", + "Change": "更改", + "oneChangeLeft": "只剩 1 次更改機會", "Selected Qty": "選擇數量", "Not Match": "要求重點", "Search Criteria": "搜索條件", From 8af1f2c9a109860e555aaae745ea03c0032bc555 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Sun, 4 Oct 2026 13:58:43 +0800 Subject: [PATCH 22/38] make the enter box num and print delivery note faster --- .../GoodPickExecutionWorkbenchRecord.tsx | 242 +++++++++++------- 1 file changed, 151 insertions(+), 91 deletions(-) diff --git a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx index d38a07a8..284a017e 100644 --- a/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx +++ b/src/components/DoWorkbench/GoodPickExecutionWorkbenchRecord.tsx @@ -67,6 +67,8 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; const [loading, setLoading] = useState(false); + const [isPrinting, setIsPrinting] = useState(false); + const printInFlightRef = useRef(false); const [records, setRecords] = useState([]); const initialSearchTargetDate = useMemo( () => resolveWorkbenchRecordTargetDate(initialTargetDate), @@ -183,6 +185,65 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ return t("All dates"); }, [searchQuery.targetDate, t]); + const rememberCartonQty = useCallback((recordId: number, cartonQty: number) => { + setRecords((prev) => + prev.map((row) => + row.doPickOrderRecordId === recordId ? { ...row, numberOfCartons: cartonQty } : row, + ), + ); + }, []); + + const submitPrint = useCallback( + async (job: () => Promise<{ success: boolean }>) => { + await new Promise((resolve) => { + void Swal.fire({ + title: t("Printing..."), + allowOutsideClick: false, + allowEscapeKey: false, + showConfirmButton: false, + didOpen: () => { + Swal.showLoading(); + resolve(); + }, + }); + }); + try { + const response = await job(); + Swal.close(); + if (response.success) { + await Swal.fire({ + position: "bottom-end", + icon: "success", + text: t("Printed Successfully."), + showConfirmButton: false, + timer: 1500, + }); + return true; + } + await Swal.fire({ + position: "bottom-end", + icon: "error", + text: t("Print failed"), + showConfirmButton: false, + timer: 2000, + }); + return false; + } catch (error) { + console.error("print error:", error); + Swal.close(); + await Swal.fire({ + position: "bottom-end", + icon: "error", + text: t("Print failed"), + showConfirmButton: false, + timer: 2000, + }); + return false; + } + }, + [t], + ); + const askNumOfCarton = useCallback(async () => { const result = await Swal.fire({ title: t("Enter the number of cartons: "), @@ -210,6 +271,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ const handlePrintDN = useCallback( async (recordId: number) => { + if (printInFlightRef.current) return; if (!a4Printer) { await Swal.fire({ position: "bottom-end", @@ -220,31 +282,32 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ }); return; } - const cartonQty = await askNumOfCarton(); - if (!cartonQty) return; - const response = await printDNWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: a4Printer.id, - printQty: 1, - isDraft: false, - numOfCarton: cartonQty, - }); - if (response.success) { - await Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500, - }); - await loadData(); + printInFlightRef.current = true; + setIsPrinting(true); + try { + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const ok = await submitPrint(() => + printDNWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: a4Printer.id, + printQty: 1, + isDraft: false, + numOfCarton: cartonQty, + }), + ); + if (ok) rememberCartonQty(recordId, cartonQty); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } }, - [a4Printer, askNumOfCarton, loadData, t], + [a4Printer, askNumOfCarton, rememberCartonQty, submitPrint, t], ); const handlePrintLabel = useCallback( async (recordId: number) => { + if (printInFlightRef.current) return; if (!labelPrinter) { await Swal.fire({ position: "bottom-end", @@ -255,30 +318,31 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ }); return; } - const cartonQty = await askNumOfCarton(); - if (!cartonQty) return; - const response = await printDNLabelsWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: labelPrinter.id, - printQty: 1, - numOfCarton: cartonQty, - }); - if (response.success) { - await Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500, - }); - await loadData(); + printInFlightRef.current = true; + setIsPrinting(true); + try { + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const ok = await submitPrint(() => + printDNLabelsWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: labelPrinter.id, + printQty: 1, + numOfCarton: cartonQty, + }), + ); + if (ok) rememberCartonQty(recordId, cartonQty); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } }, - [askNumOfCarton, labelPrinter, loadData, t], + [askNumOfCarton, labelPrinter, rememberCartonQty, submitPrint, t], ); const handlePrintBoth = useCallback( async (recordId: number) => { + if (printInFlightRef.current) return; if (!a4Printer || !labelPrinter) { await Swal.fire({ position: "bottom-end", @@ -289,37 +353,39 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ }); return; } - const cartonQty = await askNumOfCarton(); - if (!cartonQty) return; - const [labelRes, dnRes] = await Promise.all([ - printDNLabelsWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: labelPrinter.id, - printQty: 1, - numOfCarton: cartonQty, - }), - printDNWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: a4Printer.id, - printQty: 1, - isDraft: false, - numOfCarton: cartonQty, - }), - ]); - if (labelRes.success && dnRes.success) { - await Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500, + printInFlightRef.current = true; + setIsPrinting(true); + try { + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const ok = await submitPrint(async () => { + const [labelRes, dnRes] = await Promise.all([ + printDNLabelsWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: labelPrinter.id, + printQty: 1, + numOfCarton: cartonQty, + }), + printDNWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: a4Printer.id, + printQty: 1, + isDraft: false, + numOfCarton: cartonQty, + }), + ]); + return { success: labelRes.success && dnRes.success }; }); - await loadData(); + if (ok) rememberCartonQty(recordId, cartonQty); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } }, - [a4Printer, askNumOfCarton, labelPrinter, loadData, t], + [a4Printer, askNumOfCarton, labelPrinter, rememberCartonQty, submitPrint, t], ); const handleLabelReprint = useCallback(async (doPickOrder: CompletedDoPickOrderResponse) => { + if (printInFlightRef.current) return; if (!labelPrinter) { Swal.fire({ position: "bottom-end", @@ -331,6 +397,9 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ return; } + printInFlightRef.current = true; + setIsPrinting(true); + try { const defaultTotalCartons = Math.max(1, doPickOrder.numberOfCartons || 1); const result = await Swal.fire({ title: t("Reprint DN Label"), @@ -390,31 +459,22 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ return; } - try { - const response = await printDNLabelsReprintWorkbench({ - deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId, - printerId: labelPrinter.id, - printQty: 1, - fromCarton: result.value.fromCarton, - toCarton: result.value.toCarton, - totalCartonsOnShipment: result.value.totalCartonsOnShipment, - }); - - if (response.success) { - Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500 - }); - } else { - console.error("Reprint failed:", response.message); - } - } catch (error) { - console.error("reprint error: ", error); + const ok = await submitPrint(() => + printDNLabelsReprintWorkbench({ + deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId, + printerId: labelPrinter.id, + printQty: 1, + fromCarton: result.value.fromCarton, + toCarton: result.value.toCarton, + totalCartonsOnShipment: result.value.totalCartonsOnShipment, + }), + ); + if (ok) rememberCartonQty(doPickOrder.doPickOrderRecordId, result.value.totalCartonsOnShipment); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } - }, [labelPrinter, t]); + }, [labelPrinter, rememberCartonQty, submitPrint, t]); const handleDetailClick = useCallback( async (record: CompletedDoPickOrderResponse) => { @@ -735,16 +795,16 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ - - - - From d22ef70f7437209ea1165dd6bcfccbff5d71a9cd Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Sun, 4 Oct 2026 14:34:13 +0800 Subject: [PATCH 23/38] =?UTF-8?q?=E7=99=BC=E8=B2=A8=E8=88=87=E9=85=8D?= =?UTF-8?q?=E9=80=81>=E5=93=A1=E5=B7=A5=E7=99=BC=E8=B2=A8=E7=B8=BE?= =?UTF-8?q?=E6=95=88=20added=20a=20new=20access=20right=20=E7=B8=BE?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/chart/delivery/page.tsx | 19 +++++++++++++++++-- src/authorities.ts | 2 ++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/app/(main)/chart/delivery/page.tsx b/src/app/(main)/chart/delivery/page.tsx index bbbf3bb8..4cc04524 100644 --- a/src/app/(main)/chart/delivery/page.tsx +++ b/src/app/(main)/chart/delivery/page.tsx @@ -1,6 +1,8 @@ "use client"; import React, { useCallback, useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; +import { AUTH, hasAbility } from "@/authorities"; import { Box, Typography, @@ -68,6 +70,9 @@ const defaultCriteria: Criteria = { /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ export default function DeliveryChartPage() { + const { data: session } = useSession(); + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const canSeeStaffPerformance = hasAbility(abilities, AUTH.PERFORMANCE); const [criteria, setCriteria] = useState(defaultCriteria); const [topItemsSelected, setTopItemsSelected] = useState([]); const [topItemOptions, setTopItemOptions] = useState([]); @@ -132,6 +137,10 @@ export default function DeliveryChartPage() { }, [criteria.topItems, topItemsSelected, setChartLoading]); React.useEffect(() => { + if (!canSeeStaffPerformance) { + setChartData((prev) => ({ ...prev, staffPerf: [] })); + return; + } const s = criteria.staffPerf.startDate; const e = criteria.staffPerf.endDate; if (!s || !e) { @@ -161,13 +170,17 @@ export default function DeliveryChartPage() { ) .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) .finally(() => setChartLoading("staffPerf", false)); - }, [criteria.staffPerf, staffSelected, setChartLoading]); + }, [canSeeStaffPerformance, criteria.staffPerf, staffSelected, setChartLoading]); React.useEffect(() => { + if (!canSeeStaffPerformance) { + setStaffOptions([]); + return; + } fetchStaffDeliveryPerformanceHandlers() .then(setStaffOptions) .catch(() => setStaffOptions([])); - }, []); + }, [canSeeStaffPerformance]); React.useEffect(() => { const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([])); @@ -317,6 +330,7 @@ export default function DeliveryChartPage() { )} + {canSeeStaffPerformance && ( )} + )} ); } diff --git a/src/authorities.ts b/src/authorities.ts index 12747b66..b601e86f 100644 --- a/src/authorities.ts +++ b/src/authorities.ts @@ -29,6 +29,8 @@ export const AUTH = { M18_SYNC: "M18_SYNC", /** Route board (/settings/shop/board): ADMIN or this ability (DB name: 車線) */ TRUCK_LANE: "TRUCK_LANE", + /** 員工發貨績效 on /chart/delivery (DB name: 績效) */ + PERFORMANCE: "PERFORMANCE", } as const; /** From 83736a130d33a5d3265676baa58138fd1ba90f14 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Wed, 7 Oct 2026 14:13:51 +0800 Subject: [PATCH 24/38] make user with KPI can see the chart with KPI only --- src/components/NavigationContent/NavigationContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 05916740..d5d3de24 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -251,7 +251,7 @@ const NavigationContent: React.FC = () => { icon: , labelKey: "nav.chartReports", path: "", - requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE], isHidden: false, children: [ { @@ -280,7 +280,7 @@ const NavigationContent: React.FC = () => { icon: , labelKey: "nav.chart.delivery", path: "/chart/delivery", - requiredAbility: [AUTH.TESTING, AUTH.ADMIN], + requiredAbility: [AUTH.TESTING, AUTH.ADMIN, AUTH.PERFORMANCE], }, { id: "nav.chart.warehouse", From 39263d4d372748fbc7e6938e84297e5b279fc651 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 11:21:11 +0800 Subject: [PATCH 25/38] 2.1 fix and 2.7 fix improve report update --- src/app/api/stockLedgerFix/client.ts | 8 ++++- .../StockLedgerFixPageClient.tsx | 34 +++++++++++++------ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/app/api/stockLedgerFix/client.ts b/src/app/api/stockLedgerFix/client.ts index 2b0d9778..2d16121c 100644 --- a/src/app/api/stockLedgerFix/client.ts +++ b/src/app/api/stockLedgerFix/client.ts @@ -46,6 +46,11 @@ export type StockLedgerFixRunResponse = { stillMissUom: number; stillMissInventoryId: number; stillMissLotQty: number; + deletedUnlinkedLot?: number; + existingLotMatch?: number; + existingLotMismatch?: number; + deletedBlankOutLine?: number; + alignedOutLine?: number; }; export type StockLedgerFixInventoryPreview = { @@ -103,6 +108,7 @@ export type StockLedgerFixAdjResponse = { filledLotQty: number; filledBalance: number; dayRowsWritten: number; + adjustmentRecords?: number; }; export async function runStockLedgerFixAdj( @@ -265,7 +271,7 @@ export async function downloadStockLedgerFixSql( const a = document.createElement("a"); a.href = url; const partTag = - parts && parts.length > 0 && parts.length < 5 + parts && parts.length > 0 && parts.length < 6 ? `_${parts.join("-").replaceAll(".", "")}` : ""; a.download = `stock_ledger_fix_${from.replaceAll("-", "")}_${to.replaceAll("-", "")}${partTag}.sql`; diff --git a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx index 08bcc880..bad36cd1 100644 --- a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx +++ b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx @@ -43,6 +43,7 @@ import { type StockLedgerFixCheckPart, type StockLedgerFixDayDetail, type StockLedgerFixInventoryPreview, + type StockLedgerFixRunResponse, type StockLedgerFixScopeDetail, type StockLedgerFixSearchInventoryHit, type StockLedgerFixSearchLotHit, @@ -70,10 +71,11 @@ const DAY_STEP_LABEL: Record = { }; /** Export SQL parts (aligned with fix steps + 1.0 / 2.7). */ -const EXPORT_PARTS = ["1.0", "2.3", "ledger", "2.6", "2.7"] as const; +const EXPORT_PARTS = ["1.0", "2.1", "2.3", "ledger", "2.6", "2.7"] as const; type ExportPart = (typeof EXPORT_PARTS)[number]; const DEFAULT_EXPORT_PARTS: Record = { "1.0": false, + "2.1": false, "2.3": false, ledger: true, "2.6": true, @@ -81,6 +83,7 @@ const DEFAULT_EXPORT_PARTS: Record = { }; const FULL_EXPORT_PARTS: Record = { "1.0": true, + "2.1": true, "2.3": true, ledger: true, "2.6": true, @@ -88,10 +91,11 @@ const FULL_EXPORT_PARTS: Record = { }; const EXPORT_PART_LABEL: Record = { "1.0": "1.0 inventory", + "2.1": "2.1 軟刪流水帳+出倉列批號", "2.3": "2.3 inventoryId", ledger: "ledger lot/uom/lotQty/balance", "2.6": "2.6 日結", - "2.7": "2.7 ADJ+入出倉單", + "2.7": "2.7 ADJ+入出倉單+調整記錄", }; function selectedExportParts(flags: Record): ExportPart[] { @@ -115,6 +119,16 @@ function stepsPayload(flags: Record): string[] | undefined return selected; } +function lotLineFixNote(res: StockLedgerFixRunResponse): string { + const deleted = res.deletedUnlinkedLot ?? 0; + const matched = res.existingLotMatch ?? 0; + const mismatched = res.existingLotMismatch ?? 0; + const blankOut = res.deletedBlankOutLine ?? 0; + const alignedOut = res.alignedOutLine ?? 0; + if (deleted === 0 && matched === 0 && mismatched === 0 && blankOut === 0 && alignedOut === 0) return ""; + return `、軟刪未連批號 ${deleted}、出倉列空而軟刪 ${blankOut}、出倉列已改跟帳本 ${alignedOut}、已有批號相符 ${matched}、不相符 ${mismatched}`; +} + function apiErrorMessage(e: unknown, fallback: string): string { if (e && typeof e === "object" && "response" in e) { const data = (e as { response?: { data?: unknown } }).response?.data; @@ -380,7 +394,7 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixDay(date, steps); setFixMessage( - `已修 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + `已修 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`, ); await loadDay(date); } catch (e) { @@ -429,7 +443,7 @@ const StockLedgerFixPageClient: React.FC = () => { return; } const ok = window.confirm( - `會在 ${d} 對最後餘額不等於 onHandQty 的庫存桶各寫一筆 ADJ(入或出),掛在既有 lot 上。不改 lot line、不改 inventory。已對齊的桶不會再寫。確定?`, + `會在 ${d} 對最後餘額不等於 onHandQty 的庫存桶各寫一筆 ADJ(入或出),掛在既有 lot 上,並寫入庫存調整記錄。不改 lot line、不改 inventory。已對齊的桶不會再寫;缺調整記錄的舊 ADJ 會補上。確定?`, ); if (!ok) return; adjRunInFlight.current = true; @@ -439,7 +453,7 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixAdj(d); setAdjMessage( - `ADJ ${res.adjDate}:入 ${res.insertedIn}、出 ${res.insertedOut}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + `ADJ ${res.adjDate}:入 ${res.insertedIn}、出 ${res.insertedOut}、調整記錄 ${res.adjustmentRecords ?? 0}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, ); } catch (e) { console.error(e); @@ -535,7 +549,7 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixInventoryScope(scope.id); setFixMessage( - `已修 inventory ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + `已修 inventory ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`, ); await loadInventoryScope(scope.id); } catch (e) { @@ -560,7 +574,7 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixLotScope(scope.id); setFixMessage( - `已修 lot ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、日結 ${res.dayRowsWritten}`, + `已修 lot ${scope.id}:lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`, ); await loadLotScope(scope.id); } catch (e) { @@ -616,7 +630,7 @@ const StockLedgerFixPageClient: React.FC = () => { try { const res = await runStockLedgerFixRange(from, to, steps); setFixMessage( - `區間修完成 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + `區間修完成 ${res.date}(${stepLabel}):lot ${res.filledLotLineId}、uom ${res.filledUomId}、inventory ${res.filledInventoryId}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}${lotLineFixNote(res)}`, ); setSelected(dayjs(to)); setChainProgress(null); @@ -907,8 +921,8 @@ const StockLedgerFixPageClient: React.FC = () => { 匯出已修 SQL(新庫用) - 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → ledger(±2.3)→ 2.7 入出倉單+ADJ INSERT → - 2.6。完整檔(含 1.0+2.3)且正式庫 freeze/id 同源時,可跳過正式庫 1.0/2.3。預設只匯 + 勾選要匯出的段落(同日修步驟)。套檔順序:1.0 → 2.1 → ledger(±2.3)→ 2.7 入出倉單+調整記錄+ADJ → + 2.6。完整檔且正式庫 freeze/id 同源、日期蓋過全部已修日子時,結果與已修庫相同。預設只匯 ledger+日結(舊行為)。 From 0195ebbb58cf65acafe8528a020f36cdc4d164ba Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 15:26:19 +0800 Subject: [PATCH 26/38] 2.7 fix be 2.7 a 2.7 b --- src/app/api/stockLedgerFix/client.ts | 3 +- .../StockLedgerFixPageClient.tsx | 31 +++++++++++++------ 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/app/api/stockLedgerFix/client.ts b/src/app/api/stockLedgerFix/client.ts index 2d16121c..5cc82325 100644 --- a/src/app/api/stockLedgerFix/client.ts +++ b/src/app/api/stockLedgerFix/client.ts @@ -113,10 +113,11 @@ export type StockLedgerFixAdjResponse = { export async function runStockLedgerFixAdj( adjDate?: string, + step?: "2.7a" | "2.7b", ): Promise { const response = await axiosInstance.post( `${NEXT_PUBLIC_API_URL}/stock-ledger-fix/adj`, - adjDate ? { adjDate } : {}, + { ...(adjDate ? { adjDate } : {}), ...(step ? { step } : {}) }, { timeout: 1200000 }, ); return response.data; diff --git a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx index bad36cd1..c2d964c4 100644 --- a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx +++ b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx @@ -431,7 +431,7 @@ const StockLedgerFixPageClient: React.FC = () => { } }; - const onAdjApply = async () => { + const onAdjApply = async (step: "2.7a" | "2.7b") => { if (adjRunInFlight.current) return; const d = adjDate.trim(); if (!d) { @@ -443,7 +443,9 @@ const StockLedgerFixPageClient: React.FC = () => { return; } const ok = window.confirm( - `會在 ${d} 對最後餘額不等於 onHandQty 的庫存桶各寫一筆 ADJ(入或出),掛在既有 lot 上,並寫入庫存調整記錄。不改 lot line、不改 inventory。已對齊的桶不會再寫;缺調整記錄的舊 ADJ 會補上。確定?`, + step === "2.7a" + ? `會在 ${d} 找出出數量大於入數量的批號列,把入數量改成和出數量一樣,並在同一條批寫一筆入倉 ADJ(差額)和庫存調整記錄。已補過的列再跑不會再寫。確定?` + : `會在 ${d} 對最後餘額不等於 onHandQty 的庫存桶各寫一筆 ADJ(入或出),掛在既有 lot 上,並寫入庫存調整記錄。不改 lot line、不改 inventory。已對齊的桶不會再寫;缺調整記錄的舊 ADJ 會補上。確定?`, ); if (!ok) return; adjRunInFlight.current = true; @@ -451,9 +453,11 @@ const StockLedgerFixPageClient: React.FC = () => { setAdjError(null); setAdjMessage(null); try { - const res = await runStockLedgerFixAdj(d); + const res = await runStockLedgerFixAdj(d, step); setAdjMessage( - `ADJ ${res.adjDate}:入 ${res.insertedIn}、出 ${res.insertedOut}、調整記錄 ${res.adjustmentRecords ?? 0}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, + step === "2.7a" + ? `2.7a ${res.adjDate}:批號列 ${res.insertedIn}、調整記錄 ${res.adjustmentRecords ?? 0}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}` + : `2.7b ${res.adjDate}:入 ${res.insertedIn}、出 ${res.insertedOut}、調整記錄 ${res.adjustmentRecords ?? 0}、lotQty ${res.filledLotQty}、balance ${res.filledBalance}、日結 ${res.dayRowsWritten}`, ); } catch (e) { console.error(e); @@ -730,11 +734,10 @@ const StockLedgerFixPageClient: React.FC = () => { - 2.7 ADJ:對齊最後餘額與 onHandQty + 2.7 ADJ - 選 ADJ 日期後直接執行。預設昨天;freeze dump 夜可選今天。最後餘額低於 onHandQty - 的桶補一筆入倉 ADJ,高於 onHandQty 的桶補一筆出倉 ADJ,掛在既有 lot 上。不改 lot - line、不改 inventory。一次執行共用一張入倉單、一張出倉單;已對齊的桶再跑不會再寫。 + 先跑 2.7a,再跑 2.7b。2.7a 只處理出數量大於入數量的批號列:入數量改成和出數量一樣,並在同一條批寫入倉 + ADJ。2.7b 對最後餘額不等於 onHandQty 的庫存桶補一筆入或出,不改批號列。兩邊再跑都不會重複寫已對齊的資料。 { variant="contained" color="warning" disabled={adjRunning} - onClick={() => void onAdjApply()} + onClick={() => void onAdjApply("2.7a")} > - {adjRunning ? "寫入 ADJ 中…" : "Apply ADJ"} + {adjRunning ? "寫入 ADJ 中…" : "Apply 2.7a"} + + From 3d1a865d312acd2f72b85f650f2e7a6ebbdd7603 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 9 Oct 2026 16:06:38 +0800 Subject: [PATCH 27/38] 2.7 b fix --- src/components/StockLedgerFix/StockLedgerFixPageClient.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx index c2d964c4..88000dca 100644 --- a/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx +++ b/src/components/StockLedgerFix/StockLedgerFixPageClient.tsx @@ -445,7 +445,7 @@ const StockLedgerFixPageClient: React.FC = () => { const ok = window.confirm( step === "2.7a" ? `會在 ${d} 找出出數量大於入數量的批號列,把入數量改成和出數量一樣,並在同一條批寫一筆入倉 ADJ(差額)和庫存調整記錄。已補過的列再跑不會再寫。確定?` - : `會在 ${d} 對最後餘額不等於 onHandQty 的庫存桶各寫一筆 ADJ(入或出),掛在既有 lot 上,並寫入庫存調整記錄。不改 lot line、不改 inventory。已對齊的桶不會再寫;缺調整記錄的舊 ADJ 會補上。確定?`, + : `會在 ${d} 對「批號列剩餘」和「這條批的帳本進出」不一樣的批,各寫一筆 ADJ(差額),不改批號列。寫完後這幾個庫存桶的餘額會按進出重算。已經一樣的批不會再寫。確定?`, ); if (!ok) return; adjRunInFlight.current = true; @@ -737,7 +737,7 @@ const StockLedgerFixPageClient: React.FC = () => { 2.7 ADJ 先跑 2.7a,再跑 2.7b。2.7a 只處理出數量大於入數量的批號列:入數量改成和出數量一樣,並在同一條批寫入倉 - ADJ。2.7b 對最後餘額不等於 onHandQty 的庫存桶補一筆入或出,不改批號列。兩邊再跑都不會重複寫已對齊的資料。 + ADJ。2.7b 只補每一條批自己的差額(批號列剩餘減帳本剩餘),不改批號列,再按進出重算這些桶的餘額。已經一樣的批不會再寫。 Date: Mon, 12 Oct 2026 11:44:22 +0800 Subject: [PATCH 28/38] =?UTF-8?q?Merge=20branch=20'production'=20into=20fi?= =?UTF-8?q?x=E8=B2=A0=E6=95=B8=E5=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + scripts/warmup-menu.mjs | 88 ++ src/app/(main)/axios/AxiosProvider.tsx | 156 ++- src/app/(main)/axios/axiosInstance.ts | 86 +- src/app/(main)/chart/delivery/page.tsx | 19 +- src/app/(main)/m18Syn/page.tsx | 185 ++- .../report/AsyncItemCodeAutocomplete.tsx | 60 +- .../report/SemiFGProductionAnalysisReport.tsx | 110 +- src/app/(main)/report/bomShopSyncReportApi.ts | 12 +- src/app/(main)/report/grnReportApi.ts | 14 +- src/app/(main)/report/page.tsx | 206 +++- src/app/(main)/report/parseItemCodeTokens.ts | 62 + src/app/(main)/report/reportI18n.ts | 8 +- .../report/semiFGProductionAnalysisApi.ts | 73 +- src/app/(main)/settings/shop/layout.tsx | 27 + .../(main)/settings/stockLedgerFix/page.tsx | 18 +- src/app/api/doworkbench/actions.ts | 2 - src/app/api/itemTracing/schema.ts | 38 +- src/app/api/pickOrder/actions.ts | 2 - src/app/api/po/index.ts | 4 + src/app/api/settings/uom/index.ts | 2 + src/app/api/stockTake/actions.ts | 6 + src/app/utils/authToken.ts | 149 ++- src/app/utils/clientAuthFetch.ts | 47 +- src/authorities.ts | 4 + src/components/AppBar/Profile.tsx | 9 +- src/components/Breadcrumb/Breadcrumb.tsx | 1 + .../DoWorkbench/DoWorkbenchTabs.tsx | 36 +- .../GoodPickExecutionWorkbenchRecord.tsx | 242 ++-- .../TruckRoutingSummaryTabWorkbench.tsx | 25 +- .../WorkbenchGoodPickExecutionDetail.tsx | 42 +- .../WorkbenchLotLabelPrintModal.tsx | 92 +- .../FinishedGoodCartonDashboardTab.tsx | 1082 ++++++++++++++--- src/components/LoginPage/LoginForm.tsx | 16 +- src/components/LogoutPage/LogoutPage.tsx | 2 + .../NavigationContent/NavigationContent.tsx | 19 +- .../WorkbenchPickExecution.tsx | 8 +- src/components/PoDetail/PoDetail.tsx | 10 +- src/components/PoDetail/PoDetailRow.tsx | 166 ++- src/components/PoDetail/PoInputGrid.tsx | 9 +- src/components/PoDetail/PutAwayForm.tsx | 13 +- src/components/PoDetail/QcStockInModal.tsx | 5 +- .../PoDetail/StockInLineRowActions.tsx | 2 +- .../PoDetail/originSilPutAwayQty.ts | 60 + src/components/PoDetail/poPurchaseUom.ts | 96 ++ src/components/PoDetail/stockQtyRound.ts | 90 +- src/components/PoSearch/PoSearch.tsx | 112 +- src/components/Qc/QcComponent.tsx | 7 +- src/components/Qc/QcStockInModal.tsx | 55 +- src/components/SearchBox/SearchBox.tsx | 21 +- .../StockLedgerFixPageClient.tsx | 449 ++++--- .../StockTakeManagement/ApproverStockTake.tsx | 47 +- .../ApproverStockTakeAll.tsx | 86 +- .../StockTakeManagement/PickerReStockTake.tsx | 99 +- .../StockTakeManagement/PickerStockTake.tsx | 249 +++- .../StockTakeQtyGapHint.tsx | 40 + .../StockTakeManagement/StockTakeTab.tsx | 106 +- .../qtyGapWarnSettingClient.ts | 49 + .../stockTakeQtyAdornment.tsx | 55 + .../stockTakeQtyGapWarning.ts | 62 + .../useStockTakeQtyGapWarnPercent.ts | 33 + src/components/charts/SafeApexCharts.tsx | 91 +- src/config/api.ts | 5 +- src/config/authConfig.ts | 121 +- src/config/reportConfig.ts | 22 + src/i18n/en/common.json | 222 ++-- src/i18n/en/doWorkbench.json | 44 +- src/i18n/en/navigation.json | 2 + src/i18n/en/pickOrder.json | 112 +- src/i18n/en/purchaseOrder.json | 3 + src/i18n/en/report.json | 54 +- src/i18n/en/stockLedgerFix.json | 155 +++ src/i18n/en/stockTake.json | 9 +- src/i18n/en/ticketReleaseTable.json | 6 +- src/i18n/zh/common.json | 2 + src/i18n/zh/doWorkbench.json | 39 +- src/i18n/zh/navigation.json | 2 + src/i18n/zh/pickOrder.json | 101 +- src/i18n/zh/purchaseOrder.json | 3 + src/i18n/zh/report.json | 54 +- src/i18n/zh/stockLedgerFix.json | 155 +++ src/i18n/zh/stockTake.json | 9 +- src/utils/workbenchPickLotUtils.ts | 38 +- 83 files changed, 4890 insertions(+), 1233 deletions(-) create mode 100644 scripts/warmup-menu.mjs create mode 100644 src/app/(main)/report/parseItemCodeTokens.ts create mode 100644 src/app/(main)/settings/shop/layout.tsx create mode 100644 src/components/PoDetail/originSilPutAwayQty.ts create mode 100644 src/components/PoDetail/poPurchaseUom.ts create mode 100644 src/components/StockTakeManagement/StockTakeQtyGapHint.tsx create mode 100644 src/components/StockTakeManagement/qtyGapWarnSettingClient.ts create mode 100644 src/components/StockTakeManagement/stockTakeQtyAdornment.tsx create mode 100644 src/components/StockTakeManagement/stockTakeQtyGapWarning.ts create mode 100644 src/components/StockTakeManagement/useStockTakeQtyGapWarnPercent.ts create mode 100644 src/i18n/en/stockLedgerFix.json create mode 100644 src/i18n/zh/stockLedgerFix.json diff --git a/package.json b/package.json index 3a630eb6..20f4f65d 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "private": true, "scripts": { "dev": "next dev", + "warmup": "node scripts/warmup-menu.mjs", "build": "next build", "start": "set NODE_OPTIONS=--inspect --max-old-space-size=6144&& next start", "lint": "next lint", diff --git a/scripts/warmup-menu.mjs b/scripts/warmup-menu.mjs new file mode 100644 index 00000000..03910fa1 --- /dev/null +++ b/scripts/warmup-menu.mjs @@ -0,0 +1,88 @@ +const BASE = process.env.WARMUP_BASE || "http://localhost:3000"; +const COOKIE = process.env.WARMUP_COOKIE || ""; + +const PATHS = [ + "/dashboard", + "/po", + "/pickOrder", + "/inventory", + "/itemTracing", + "/stocktakemanagement", + "/stockIssue", + "/putAway", + "/finishedGood/management", + "/stockRecord", + "/doworkbench", + "/do", + "/ps", + "/jo", + "/jodetail", + "/productionProcess", + "/bag", + "/bagPrint", + "/laserPrint", + "/report", + "/m18Syn", + "/chart/purchase", + "/chart/joborder", + "/chart/joborder/board", + "/chart/delivery", + "/chart/warehouse", + "/chart/forecast", + "/settings/user", + "/settings/clientMonitor", + "/settings/items", + "/settings/itemDefaultShelfLife", + "/settings/equipment", + "/settings/warehouse", + "/settings/printer", + "/settings/itemPrice", + "/settings/qcItem", + "/settings/qcCategory", + "/settings/qcItemAll", + "/settings/shop/board", + "/settings/deliveryOrderFloor", + "/settings/rss", + "/settings/bomWeighting", + "/settings/masterDataIssues", + "/settings/qrCodeHandle", + "/settings/m18ImportTesting", + "/settings/importExcel", + "/settings/importBom", +]; + +async function waitReady() { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + try { + const res = await fetch(`${BASE}/login`); + if (res.status < 500) return; + } catch { + // server still starting + } + await new Promise((r) => setTimeout(r, 1000)); + } + throw new Error(`dev server not ready at ${BASE}`); +} + +console.log(`Waiting for ${BASE}/login (Next compiles this on first hit; the site will look stuck until it finishes)…`); +await waitReady(); +console.log(`Dev server answered. Warming ${PATHS.length} menu pages at ${BASE}`); +if (!COOKIE) { + console.log( + "No WARMUP_COOKIE — private pages (/dashboard, /ps, /report, /settings, …) will redirect and stay uncompiled.", + ); +} + +for (const path of PATHS) { + process.stdout.write(`compiling ${path} … `); + const t0 = Date.now(); + const res = await fetch(`${BASE}${path}`, { + redirect: "manual", + headers: COOKIE ? { cookie: COOKIE } : {}, + }); + const redirected = res.status >= 300 && res.status < 400; + console.log( + `${res.status} ${Date.now() - t0}ms${redirected ? " (redirect, page not compiled)" : ""}`, + ); +} 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)/chart/delivery/page.tsx b/src/app/(main)/chart/delivery/page.tsx index bbbf3bb8..4cc04524 100644 --- a/src/app/(main)/chart/delivery/page.tsx +++ b/src/app/(main)/chart/delivery/page.tsx @@ -1,6 +1,8 @@ "use client"; import React, { useCallback, useMemo, useState } from "react"; +import { useSession } from "next-auth/react"; +import { AUTH, hasAbility } from "@/authorities"; import { Box, Typography, @@ -68,6 +70,9 @@ const defaultCriteria: Criteria = { /** FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10 */ export default function DeliveryChartPage() { + const { data: session } = useSession(); + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const canSeeStaffPerformance = hasAbility(abilities, AUTH.PERFORMANCE); const [criteria, setCriteria] = useState(defaultCriteria); const [topItemsSelected, setTopItemsSelected] = useState([]); const [topItemOptions, setTopItemOptions] = useState([]); @@ -132,6 +137,10 @@ export default function DeliveryChartPage() { }, [criteria.topItems, topItemsSelected, setChartLoading]); React.useEffect(() => { + if (!canSeeStaffPerformance) { + setChartData((prev) => ({ ...prev, staffPerf: [] })); + return; + } const s = criteria.staffPerf.startDate; const e = criteria.staffPerf.endDate; if (!s || !e) { @@ -161,13 +170,17 @@ export default function DeliveryChartPage() { ) .catch((err) => setError(err instanceof Error ? err.message : "Request failed")) .finally(() => setChartLoading("staffPerf", false)); - }, [criteria.staffPerf, staffSelected, setChartLoading]); + }, [canSeeStaffPerformance, criteria.staffPerf, staffSelected, setChartLoading]); React.useEffect(() => { + if (!canSeeStaffPerformance) { + setStaffOptions([]); + return; + } fetchStaffDeliveryPerformanceHandlers() .then(setStaffOptions) .catch(() => setStaffOptions([])); - }, []); + }, [canSeeStaffPerformance]); React.useEffect(() => { const { startDate: s, endDate: e } = toDateRange(criteria.topItems.rangeDays); fetchTopDeliveryItemsItemOptions(s, e).then(setTopItemOptions).catch(() => setTopItemOptions([])); @@ -317,6 +330,7 @@ export default function DeliveryChartPage() { )} + {canSeeStaffPerformance && ( )} + )} ); } diff --git a/src/app/(main)/m18Syn/page.tsx b/src/app/(main)/m18Syn/page.tsx index df97ff33..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); @@ -135,26 +210,63 @@ export default function M18SynPage() { } }; + /** 送貨訂單:手動按 code 同步(可輸入多個 code,用逗號或換行分隔) */ const handleSyncM18DoByCode = async () => { if (m18DoInFlightRef.current) return; - if (!m18DoCode.trim()) { - alert("Please enter DO / shop PO code."); + const raw = m18DoCode.trim(); + if (!raw) { + alert("Please enter DO / shop PO code(s)."); + return; + } + const codes = raw + .split(/[\n,]+/g) + .map((s) => s.trim()) + .filter(Boolean); + if (codes.length === 0) { + alert("Please enter at least one code."); return; } m18DoInFlightRef.current = true; setIsSyncingM18Do(true); setM18DoSyncResult(""); try { - const response = await clientAuthFetch( - `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(m18DoCode.trim())}`, - { method: "GET" }, - ); - if (response.status === 401 || response.status === 403) return; - const text = await response.text(); - setM18DoSyncResult(formatSyncResultText("送貨訂單", text)); - if (!response.ok) { - alert(`Sync failed: ${response.status}`); + const outputs: string[] = []; + const okCodes: string[] = []; + const notFoundCodes: string[] = []; + const skippedReleasedCodes: string[] = []; + const otherFailCodes: string[] = []; + for (const code of codes) { + const response = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/m18/test/do-by-code?code=${encodeURIComponent(code)}`, + { method: "GET" }, + ); + if (response.status === 401 || response.status === 403) return; + const text = await response.text(); + 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 = + parsed.kind === "success" ? "成功同步" : + parsed.kind === "not_found" ? parsed.message : + parsed.message; + outputs.push(`${code}: ${perCodeMsg}${response.ok ? "" : ` (HTTP ${response.status})`}`); + } + + outputs.push(""); + outputs.push(`共${okCodes.length}張送貨訂單成功`); + 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")); } catch (e) { console.error("M18 DO Sync By Code Error:", e); alert("M18 DO sync failed. Check console/network."); @@ -188,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( @@ -200,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 = @@ -213,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")); @@ -272,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"> @@ -312,18 +433,22 @@ export default function M18SynPage() {
- + setM18DoCode(e.target.value)} - placeholder="e.g. same document code as M18 shop PO" - sx={{ minWidth: 320 }} + placeholder="可輸入多個 code,用逗號或換行分隔" + fullWidth + multiline + minRows={6} + maxRows={14} /> - + + + {m18DoSyncResult ? ( void; + /** Live text in the box, including text that has not been picked from the list. */ + onInputValueChange?: (value: string) => void; placeholder?: string; disabled?: boolean; minChars?: number; @@ -17,11 +20,12 @@ type Props = { const hitLabel = (hit: ItemCodeSearchHit) => hit.name ? `${hit.code} ${hit.name}` : hit.code; -/** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.0 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 81 | v1.0.1 | 2026-09-28 */ const AsyncItemCodeAutocomplete: React.FC = ({ label, value, onChange, + onInputValueChange, placeholder, disabled = false, minChars = 2, @@ -73,21 +77,47 @@ const AsyncItemCodeAutocomplete: React.FC = ({ }; }, [trimmedInput, minChars]); + const selectedCodes = useMemo( + () => new Set(value.map((code) => code.toUpperCase())), + [value], + ); + const options = useMemo(() => { const seen = new Set(); const codes: string[] = []; suggestions.forEach((hit) => { - if (seen.has(hit.code)) return; - seen.add(hit.code); + const key = hit.code.toUpperCase(); + if (seen.has(key) || selectedCodes.has(key)) return; + seen.add(key); codes.push(hit.code); }); - value.forEach((code) => { - if (seen.has(code)) return; - seen.add(code); - codes.push(code); - }); return codes; - }, [suggestions, value]); + }, [suggestions, selectedCodes]); + + useEffect(() => { + const trimmed = inputValue.trim(); + if (!trimmed) return; + const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); + if (hit) onInputValueChange?.(hit.code); + }, [suggestions, inputValue, onInputValueChange]); + + const inputValueRef = useRef(inputValue); + inputValueRef.current = inputValue; + const previousValueRef = useRef(value); + useEffect(() => { + const previous = previousValueRef.current; + previousValueRef.current = value; + const tokens = parseItemCodeTokens(inputValueRef.current); + if (tokens.length === 0) return; + const previousKeys = new Set(previous.map((code) => code.toUpperCase())); + const nextKeys = new Set(value.map((code) => code.toUpperCase())); + const justCommitted = tokens.every( + (token) => nextKeys.has(token.toUpperCase()) && !previousKeys.has(token.toUpperCase()), + ); + if (!justCommitted) return; + setInputValue(""); + onInputValueChange?.(""); + }, [value, onInputValueChange]); const noOptionsText = needsMoreChars ? t("typeToSearchItemCode", { min: minChars }) @@ -112,9 +142,11 @@ const AsyncItemCodeAutocomplete: React.FC = ({ filterOptions={(opts) => trimmedInput.length < minChars ? [] - : opts.filter((code) => !value.includes(code)) + : opts.filter((code) => !selectedCodes.has(code.toUpperCase())) + } + isOptionEqualToValue={(option, selected) => + option.toUpperCase() === selected.toUpperCase() } - isOptionEqualToValue={(option, selected) => option === selected} autoHighlight noOptionsText={noOptionsText} sx={{ @@ -162,9 +194,13 @@ const AsyncItemCodeAutocomplete: React.FC = ({ onInputChange={(_, next, reason) => { if (reason === "reset") { setInputValue(""); + onInputValueChange?.(""); return; } setInputValue(next); + const trimmed = next.trim(); + const hit = suggestions.find((item) => item.code.toUpperCase() === trimmed.toUpperCase()); + onInputValueChange?.(hit?.code ?? next); }} onChange={(_, newValue) => { const codes = (Array.isArray(newValue) ? newValue : []) diff --git a/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx b/src/app/(main)/report/SemiFGProductionAnalysisReport.tsx index 5592a93c..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,16 +19,21 @@ 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; requiredFieldLabels: string[]; loading: boolean; setLoading: (loading: boolean) => void; @@ -38,6 +43,7 @@ interface SemiFGProductionAnalysisReportProps { export default function SemiFGProductionAnalysisReport({ criteria, + prepareCriteria, requiredFieldLabels, loading, setLoading, @@ -48,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(() => { @@ -68,24 +75,43 @@ 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; + } + } - // If no itemCode is selected, export directly without confirmation - if (!criteria.itemCode) { - await executeExport(format); + const selectedCodes = parseItemCodeTokens( + [activeCriteria.itemCode, activeCriteria.itemCodePaste].filter(Boolean).join(' '), + ); + if (selectedCodes.length === 0) { + await executeExport(format, activeCriteria); return; } - // If itemCode is selected, show confirmation dialog - const selectedCodes = criteria.itemCode.split(',').filter((code) => code.trim()); - const itemCodesInfo: ItemCodeWithCategory[] = selectedCodes.map((code) => { - const codeTrimmed = code.trim(); + const itemCodesInfo: ItemCodeWithCategory[] = selectedCodes.map((codeTrimmed) => { const categoryInfo = itemCodesWithCategory[codeTrimmed]; return { code: codeTrimmed, @@ -97,24 +123,47 @@ export default function SemiFGProductionAnalysisReport({ setShowConfirmDialog(true); }; - const executeExport = async (format: 'pdf' | 'excel' = exportFormat) => { + const executeExport = async ( + format: ExportFormat = exportFormat, + activeCriteria: Record = prepareCriteria?.() ?? criteria, + ) => { + if (inFlightRef.current) return; + inFlightRef.current = true; setLoading(true); try { - if (format === 'excel') { - await generateSemiFGProductionAnalysisReportExcel(criteria, reportTitle); + if (format === 'excel-daily') { + await generateSemiFGProductionAnalysisReportExcelDaily(activeCriteria, reportTitle); + onExportSuccess?.('excel'); + } else if (format === 'excel') { + await generateSemiFGProductionAnalysisReportExcel(activeCriteria, reportTitle); + onExportSuccess?.('excel'); } else { - await generateSemiFGProductionAnalysisReport(criteria, reportTitle); + 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 ( <>
@@ -126,7 +175,7 @@ export default function SemiFGProductionAnalysisReport({ disabled={loading} sx={{ px: 4 }} > - {loading ? t('generatingPdf') : t('downloadPdf')} + {loading && exportFormat === 'pdf' ? t('generatingPdf') : t('downloadPdf')} +
@@ -196,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/bomShopSyncReportApi.ts b/src/app/(main)/report/bomShopSyncReportApi.ts index 08b31c48..d3eee465 100644 --- a/src/app/(main)/report/bomShopSyncReportApi.ts +++ b/src/app/(main)/report/bomShopSyncReportApi.ts @@ -2,6 +2,7 @@ import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { buildItemCodePasteRequestBody } from "./parseItemCodeTokens"; import { exportMultiSheetToXlsx, } from "@/app/(main)/chart/_components/exportChartToXlsx"; @@ -124,12 +125,15 @@ function emptyMaterialSheetRow( export async function fetchBomShopSyncReportData( criteria: Record, ): Promise { - const queryParams = new URLSearchParams(criteria).toString(); - const url = `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history?${queryParams}`; + const url = `${NEXT_PUBLIC_API_URL}/report/bom-shop-sync-history`; const response = await clientAuthFetch(url, { - method: "GET", - headers: { Accept: "application/json" }, + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteria, "finishedItemCode")), }); if (response.status === 401 || response.status === 403) diff --git a/src/app/(main)/report/grnReportApi.ts b/src/app/(main)/report/grnReportApi.ts index a8972cec..2e2f001f 100644 --- a/src/app/(main)/report/grnReportApi.ts +++ b/src/app/(main)/report/grnReportApi.ts @@ -1,6 +1,7 @@ "use client"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { buildItemCodePasteRequestBody } from './parseItemCodeTokens'; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { exportChartToXlsx, @@ -64,17 +65,20 @@ export interface GrnReportResponse { /** * Fetch GRN (Goods Received Note) report data by date range. - * Backend: GET /report/grn-report?receiptDateStart=&receiptDateEnd=&itemCode= + * Backend: POST /report/grn-report { receiptDateStart, receiptDateEnd, itemCodes } */ export async function fetchGrnReportData( criteria: Record ): Promise<{ rows: GrnReportRow[]; listedPoAmounts?: ListedPoAmounts }> { - const queryParams = new URLSearchParams(criteria).toString(); - const url = `${NEXT_PUBLIC_API_URL}/report/grn-report?${queryParams}`; + const url = `${NEXT_PUBLIC_API_URL}/report/grn-report`; const response = await clientAuthFetch(url, { - method: "GET", - headers: { Accept: "application/json" }, + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteria, "itemCode")), }); if (response.status === 401 || response.status === 403) diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 6dec1c10..0bd8e61b 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -26,6 +26,7 @@ import { import DownloadIcon from '@mui/icons-material/Download'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { REPORTS } from '@/config/reportConfig'; +import { mergePastedItemCodes, buildItemCodePasteRequestBody, parseItemCodeTokens } from './parseItemCodeTokens'; import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; import SemiFGProductionAnalysisReport from './SemiFGProductionAnalysisReport'; @@ -74,13 +75,22 @@ const FIELD_ERROR_SX = { }, }; +const ITEM_CODE_PASTE_POST_REPORTS = new Set([ + 'rep-004', + 'rep-006', + 'rep-007', + 'rep-008', + 'rep-011', + 'rep-013', +]); + /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.3 | 2026-09-10 */ /** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.1 | 2026-08-11 */ -/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.2 | 2026-09-10 */ -/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.0 | 2026-09-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 69 | v1.0.3 | 2026-09-28 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 82 | v1.0.1 | 2026-09-28 */ export default function ReportPage() { const { data: session } = useSession() as { data: SessionWithTokens | null }; - const { t, i18n, reportTitle, fieldLabel, optionLabel } = useReportLabels(); + const { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel } = useReportLabels(); const isZh = (i18n.language || 'zh').startsWith('zh'); const dateDisplayFormat = isZh ? 'DD/MM/YYYY' : 'DD/MM/YYYY'; const includeGrnFinancialColumns = @@ -88,6 +98,37 @@ export default function ReportPage() { const [selectedReportId, setSelectedReportId] = useState(''); const [criteria, setCriteria] = useState>({}); + const criteriaRef = useRef>({}); + const pendingItemCodeInputRef = useRef>({}); + const updateCriteria = ( + updater: Record | ((prev: Record) => Record), + ) => { + const next = typeof updater === 'function' ? updater(criteriaRef.current) : updater; + criteriaRef.current = next; + setCriteria(next); + }; + /** Typed item codes are not in criteria until a dropdown option is chosen. Merge them in before the request. */ + const flushPendingItemCodeInputs = (): Record => { + const pendingEntries = Object.entries(pendingItemCodeInputRef.current).filter(([, raw]) => raw.trim()); + if (pendingEntries.length === 0) return criteriaRef.current; + + const next = { ...criteriaRef.current }; + let changed = false; + for (const [field, raw] of pendingEntries) { + pendingItemCodeInputRef.current[field] = ''; + const merged = parseItemCodeTokens([next[field], raw].filter(Boolean).join(' ')); + const joined = merged.join(','); + if (joined && joined !== (next[field] || '')) { + next[field] = joined; + changed = true; + } + } + if (changed) { + criteriaRef.current = next; + setCriteria(next); + } + return criteriaRef.current; + }; const [loading, setLoading] = useState(false); const excelInFlightRef = useRef(false); const [dynamicOptions, setDynamicOptions] = useState>({}); @@ -114,14 +155,15 @@ export default function ReportPage() { if (reportId === selectedReportId) return; setSelectedReportId(reportId); setFieldErrors({}); + pendingItemCodeInputRef.current = {}; if (reportId === 'rep-010') { - setCriteria({ qcType: 'all', qcItemScope: 'all' }); + updateCriteria({ qcType: 'all', qcItemScope: 'all' }); } else if (reportId === 'rep-004') { - setCriteria({ storeId: 'All', poPrefix: 'All' }); + updateCriteria({ storeId: 'All', poPrefix: 'All' }); } else if (reportId === 'rep-021') { - setCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); + updateCriteria({ storeId: 'All', stockTakeSectionDescription: 'All', lotOrigin: 'All' }); } else { - setCriteria({}); + updateCriteria({}); } }; @@ -133,7 +175,7 @@ export default function ReportPage() { delete next[name]; return next; }); - setCriteria((prev) => { + updateCriteria((prev) => { const next = { ...prev, [name]: stringValue }; if (currentReport?.id === 'rep-021' && name === 'warehouse') { const m = stringValue.trim().match(/^w(\d)/i); @@ -242,7 +284,7 @@ export default function ReportPage() { // Default "All" (no filter) for stock take variance report conditions. if (selectedReportId === 'rep-012') { - setCriteria({ + updateCriteria({ store_id: 'All', status: 'All', type: 'All', @@ -254,7 +296,7 @@ export default function ReportPage() { useEffect(() => { if (selectedReportId !== 'rep-012' || !rep012MultiRound) return; if (criteria.status === 'completed') return; - setCriteria((prev) => ({ ...prev, status: 'completed' })); + updateCriteria((prev) => ({ ...prev, status: 'completed' })); }, [selectedReportId, rep012MultiRound, criteria.status]); // React 18 Strict Mode (dev) mounts → unmounts → remounts, so effects with [] run twice. @@ -319,19 +361,20 @@ export default function ReportPage() { /** rep-012:單輪送 status;多輪送 stockTakeRoundId 清單且 status=completed */ const buildRep012QueryString = (): string => { + const active = criteriaRef.current; const p = new URLSearchParams(); p.set('stockTakeRoundId', rep012RoundIds.join(',')); - const code = criteria.itemCode?.trim(); + const code = active.itemCode?.trim(); if (code) p.set('itemCode', code); - const store = criteria.store_id?.trim(); + const store = active.store_id?.trim(); if (store && store !== 'All') p.set('store_id', store); if (rep012MultiRound) { p.set('status', 'completed'); } else { - const status = criteria.status?.trim(); + const status = active.status?.trim(); if (status && status !== 'All') p.set('status', status); } - const lotType = criteria.type?.trim(); + const lotType = active.type?.trim(); if (lotType && lotType !== 'All') p.set('type', lotType); return p.toString(); }; @@ -339,15 +382,16 @@ export default function ReportPage() { /** FP-MTMS Version Checklist | Functions Ref. No. 64 | v1.0.0 | 2026-08-11 */ /** rep-010:qcItemScope → includeMeasurable / includeOther;qcType=all 不傳篩選 */ const buildRep010QueryString = (): string => { + const active = criteriaRef.current; const p = new URLSearchParams(); - Object.entries(criteria).forEach(([key, value]) => { + Object.entries(active).forEach(([key, value]) => { if (key === 'qcItemScope') return; if (key === 'qcType' && String(value).trim().toLowerCase() === 'all') return; if (value != null && String(value).trim() !== '') { p.set(key, String(value)); } }); - const scope = (criteria.qcItemScope || 'all').trim().toLowerCase(); + const scope = (active.qcItemScope || 'all').trim().toLowerCase(); if (scope === 'measurable') { p.set('includeMeasurable', 'true'); p.set('includeOther', 'false'); @@ -358,7 +402,32 @@ export default function ReportPage() { return p.toString(); }; + const itemCodeFieldName = (): string => + currentReport?.fields.some((f) => f.name === 'finishedItemCode') + ? 'finishedItemCode' + : 'itemCode'; + + const isItemCodePastePostReport = (): boolean => + !!currentReport && ITEM_CODE_PASTE_POST_REPORTS.has(currentReport.id); + + const buildCriteriaQueryString = (): string => { + if (!currentReport) return ''; + if (currentReport.id === 'rep-012') return buildRep012QueryString(); + if (currentReport.id === 'rep-010') return buildRep010QueryString(); + const merged = mergePastedItemCodes(criteriaRef.current, itemCodeFieldName()); + const p = new URLSearchParams(merged); + if (currentReport.id === 'rep-016') { + const day = (merged.dateStart || '').trim(); + if (day) { + p.set('dateStart', day); + p.set('dateEnd', day); + } + } + return p.toString(); + }; + const handlePrint = async () => { + flushPendingItemCodeInputs(); if (!currentReport) return; if (!validateRequiredFields()) return; @@ -375,6 +444,7 @@ export default function ReportPage() { }; const handleExcelPrint = async () => { + flushPendingItemCodeInputs(); if (!currentReport) return; if (!validateRequiredFields()) return; await executeExcelReport(); @@ -386,41 +456,38 @@ export default function ReportPage() { excelInFlightRef.current = true; setLoading(true); try { + const activeCriteria = criteriaRef.current; if (currentReport.id === 'rep-014') { await generateGrnReportExcel( - criteria, + activeCriteria, reportTitle(currentReport), includeGrnFinancialColumns, t, ); } else if (currentReport.id === 'rep-015') { - await generateBomShopSyncReportExcel(criteria, reportTitle(currentReport), t); + await generateBomShopSyncReportExcel(activeCriteria, reportTitle(currentReport), t); } else if (currentReport.id === 'rep-017') { - await generateShopOrderReplenishmentReportExcel(criteria, reportTitle(currentReport), t); + await generateShopOrderReplenishmentReportExcel(activeCriteria, reportTitle(currentReport), t); } else { // Backend returns actual .xlsx bytes for this Excel endpoint. - let queryParams = - currentReport.id === 'rep-012' - ? buildRep012QueryString() - : currentReport.id === 'rep-010' - ? buildRep010QueryString() - : new URLSearchParams(criteria).toString(); - // rep-016: single-day UI — mirror dateStart to dateEnd for backend API. - if (currentReport.id === 'rep-016') { - const p = new URLSearchParams(criteria); - const day = (criteria.dateStart || '').trim(); - if (day) { - p.set('dateStart', day); - p.set('dateEnd', day); - } - queryParams = p.toString(); - } - const excelUrl = `${currentReport.apiEndpoint}-excel?${queryParams}`; + const usesItemCodePastePost = isItemCodePastePostReport(); + const excelUrl = usesItemCodePastePost + ? `${currentReport.apiEndpoint}-excel` + : `${currentReport.apiEndpoint}-excel?${buildCriteriaQueryString()}`; - const response = await clientAuthFetch(excelUrl, { - method: 'GET', - headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, - }); + const response = await clientAuthFetch(excelUrl, usesItemCodePastePost + ? { + method: 'POST', + headers: { + Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteriaRef.current, itemCodeFieldName())), + } + : { + method: 'GET', + headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + }); if (response.status === 401 || response.status === 403) return; if (response.status === 204) { @@ -472,18 +539,24 @@ export default function ReportPage() { setLoading(true); try { - let queryParams = - currentReport.id === 'rep-012' - ? buildRep012QueryString() - : currentReport.id === 'rep-010' - ? buildRep010QueryString() - : new URLSearchParams(criteria).toString(); - const url = `${currentReport.apiEndpoint}?${queryParams}`; + const usesItemCodePastePost = isItemCodePastePostReport(); + const url = usesItemCodePastePost + ? currentReport.apiEndpoint + : `${currentReport.apiEndpoint}?${buildCriteriaQueryString()}`; - const response = await clientAuthFetch(url, { - method: 'GET', - headers: { 'Accept': 'application/pdf' }, - }); + const response = await clientAuthFetch(url, usesItemCodePastePost + ? { + method: 'POST', + headers: { + Accept: 'application/pdf', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteriaRef.current, itemCodeFieldName())), + } + : { + method: 'GET', + headers: { Accept: 'application/pdf' }, + }); if (response.status === 401 || response.status === 403) return; if (!response.ok) { @@ -574,7 +647,10 @@ export default function ReportPage() { : currentValue; // Use larger grid size for 成品/半成品生產分析報告 - const gridSize = currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; + const gridSize = field.multiline + ? { xs: 12 } + : currentReport.id === 'rep-005' ? { xs: 12, sm: 12, md: 6 } : { xs: 12, sm: 6 }; + const hintText = fieldHint(currentReport.id, field.name); const disabledByCheckedCheckbox = currentReport.fields.some((f) => { if (f.type !== 'checkbox' || criteria[f.name] !== 'true') return false; @@ -653,7 +729,13 @@ export default function ReportPage() { label={translatedLabel} placeholder={field.placeholder || "e.g. FA0591"} value={selectedCodes} - onChange={(codes) => handleFieldChange(field.name, codes)} + onInputValueChange={(text) => { + pendingItemCodeInputRef.current[field.name] = text; + }} + onChange={(codes) => { + pendingItemCodeInputRef.current[field.name] = ""; + handleFieldChange(field.name, codes); + }} minChars={field.asyncSearchMinChars ?? 2} disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} /> @@ -672,8 +754,19 @@ export default function ReportPage() { opt.value)} value={autocompleteValue} + isOptionEqualToValue={(option, selected) => + String(option).toUpperCase() === String(selected).toUpperCase() + } + onInputChange={(_, input, reason) => { + if (reason === "reset" || reason === "clear") { + pendingItemCodeInputRef.current[field.name] = ""; + return; + } + pendingItemCodeInputRef.current[field.name] = input; + }} onChange={(event, newValue, reason) => { if (field.multiple) { // Handle multiple selection - newValue is an array @@ -762,9 +855,11 @@ export default function ReportPage() { fullWidth required={field.required} error={Boolean(fieldErrors[field.name])} - helperText={fieldErrors[field.name] || undefined} + helperText={fieldErrors[field.name] || hintText || undefined} label={translatedLabel} - type={field.type} + type={field.multiline ? 'text' : field.type} + multiline={field.multiline} + minRows={field.multiline ? (field.minRows ?? 4) : undefined} placeholder={field.placeholder} disabled={disabledByCheckedCheckbox || disabledRep012Status || !!field.disabled} sx={{ @@ -854,6 +949,7 @@ export default function ReportPage() { {currentReport.id === 'rep-005' ? ( f.required && !criteria[f.name]).map(f => fieldLabel(currentReport.id, f))} loading={loading} setLoading={setLoading} diff --git a/src/app/(main)/report/parseItemCodeTokens.ts b/src/app/(main)/report/parseItemCodeTokens.ts new file mode 100644 index 00000000..fcddfe79 --- /dev/null +++ b/src/app/(main)/report/parseItemCodeTokens.ts @@ -0,0 +1,62 @@ +/** Split pasted / typed item codes from Excel or a text field. */ +const ITEM_CODE_SEPARATORS = /[\s,;,、\u00A0\u3000]+/; + +export function parseItemCodeTokens(raw: string | undefined | null): string[] { + if (!raw) return []; + const seen = new Set(); + const tokens: string[] = []; + for (const part of raw.split(ITEM_CODE_SEPARATORS)) { + const token = part.trim(); + if (!token) continue; + const key = token.toUpperCase(); + if (seen.has(key)) continue; + seen.add(key); + tokens.push(token); + } + return tokens; +} + +export function resolveItemCodeFieldName(criteria: Record): string { + return Object.prototype.hasOwnProperty.call(criteria, "finishedItemCode") + ? "finishedItemCode" + : "itemCode"; +} + +/** Merge multi-select item codes with pasted `itemCodePaste`; drop the paste field from API params. */ +export function mergePastedItemCodes( + criteria: Record, + codeField = resolveItemCodeFieldName(criteria), +): Record { + const next: Record = { ...criteria }; + delete next.itemCodePaste; + const merged = parseItemCodeTokens( + [criteria[codeField], criteria.itemCodePaste].filter(Boolean).join(" "), + ); + if (merged.length > 0) { + next[codeField] = merged.join(","); + } else { + delete next[codeField]; + } + return next; +} + +/** POST body so pasted codes are not limited by URL length. */ +export function buildItemCodePasteRequestBody( + criteria: Record, + codeField = resolveItemCodeFieldName(criteria), +): Record { + const itemCodes = parseItemCodeTokens( + [criteria[codeField], criteria.itemCodePaste].filter(Boolean).join(" "), + ); + const body: Record = {}; + for (const [key, value] of Object.entries(criteria)) { + if (key === "itemCodePaste" || key === codeField) continue; + const trimmed = value?.trim(); + if (trimmed) body[key] = trimmed; + } + if (itemCodes.length) { + body[codeField] = itemCodes.join(","); + body.itemCodes = itemCodes; + } + return body; +} diff --git a/src/app/(main)/report/reportI18n.ts b/src/app/(main)/report/reportI18n.ts index 7e8be629..7031a3af 100644 --- a/src/app/(main)/report/reportI18n.ts +++ b/src/app/(main)/report/reportI18n.ts @@ -19,6 +19,12 @@ export function useReportLabels() { defaultValue: field.label, }); + const fieldHint = (reportId: string, fieldName: string, fallback = "") => { + const key = `reports.${reportId}.fieldHints.${fieldName}`; + if (!i18n.exists(key, { ns: "report" })) return fallback; + return String(t(key, { defaultValue: fallback })); + }; + const optionLabel = ( reportId: string, fieldName: string, @@ -35,7 +41,7 @@ export function useReportLabels() { const categoryTitle = (id: string, fallback: string) => t(`categories.${id}`, { defaultValue: fallback }); - return { t, i18n, reportTitle, fieldLabel, optionLabel, categoryTitle }; + return { t, i18n, reportTitle, fieldLabel, fieldHint, optionLabel, categoryTitle }; } export function reportExcelT( diff --git a/src/app/(main)/report/semiFGProductionAnalysisApi.ts b/src/app/(main)/report/semiFGProductionAnalysisApi.ts index b4e8b69c..0f7f845a 100644 --- a/src/app/(main)/report/semiFGProductionAnalysisApi.ts +++ b/src/app/(main)/report/semiFGProductionAnalysisApi.ts @@ -2,6 +2,7 @@ import { NEXT_PUBLIC_API_URL } from '@/config/api'; import { clientAuthFetch } from '@/app/utils/clientAuthFetch'; +import { buildItemCodePasteRequestBody } from './parseItemCodeTokens'; export interface ItemCodeWithName { code: string; @@ -72,12 +73,15 @@ export const generateSemiFGProductionAnalysisReport = async ( criteria: Record, reportTitle: string = '成品/半成品生產分析報告' ): Promise => { - const queryParams = new URLSearchParams(criteria).toString(); - const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis?${queryParams}`; + const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis`; const response = await clientAuthFetch(url, { - method: 'GET', - headers: { Accept: 'application/pdf' }, + method: 'POST', + headers: { + Accept: 'application/pdf', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(buildItemCodePasteRequestBody(criteria, 'itemCode')), }); if (response.status === 401 || response.status === 403) throw new Error("Unauthorized"); @@ -111,12 +115,15 @@ export const generateSemiFGProductionAnalysisReportExcel = async ( criteria: Record, reportTitle: string = '成品/半成品生產分析報告' ): Promise => { - const queryParams = new URLSearchParams(criteria).toString(); - const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis-excel?${queryParams}`; + const url = `${NEXT_PUBLIC_API_URL}/report/print-semi-fg-production-analysis-excel`; const response = await clientAuthFetch(url, { - method: 'GET', - headers: { Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, + 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'); @@ -139,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/(main)/settings/shop/layout.tsx b/src/app/(main)/settings/shop/layout.tsx new file mode 100644 index 00000000..357f7952 --- /dev/null +++ b/src/app/(main)/settings/shop/layout.tsx @@ -0,0 +1,27 @@ +import { I18nProvider } from "@/i18n"; +import { authOptions } from "@/config/authConfig"; +import { AUTH, hasAbility } from "@/authorities"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; + +/** Route board and shop/truck lane pages: ADMIN or 車線 (TRUCK_LANE). */ +export default async function ShopSettingsLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerSession(authOptions); + const abilities = session?.user?.abilities ?? []; + const canAccess = + hasAbility(abilities, AUTH.TRUCK_LANE) || hasAbility(abilities, AUTH.ADMIN); + + if (!canAccess) { + redirect("/dashboard"); + } + + return ( + + {children} + + ); +} diff --git a/src/app/(main)/settings/stockLedgerFix/page.tsx b/src/app/(main)/settings/stockLedgerFix/page.tsx index 1602d68c..d477b351 100644 --- a/src/app/(main)/settings/stockLedgerFix/page.tsx +++ b/src/app/(main)/settings/stockLedgerFix/page.tsx @@ -1,16 +1,22 @@ import { Metadata } from "next"; import PageTitleBar from "@/components/PageTitleBar"; import StockLedgerFixPageClient from "@/components/StockLedgerFix/StockLedgerFixPageClient"; +import { getServerI18n, I18nProvider } from "@/i18n"; -export const metadata: Metadata = { - title: "Stock Ledger Fix", -}; +export async function generateMetadata(): Promise { + const { t } = await getServerI18n("stockLedgerFix"); + return { title: t("pageTitle") }; +} + +const StockLedgerFixPage: React.FC = async () => { + const { t } = await getServerI18n("stockLedgerFix"); -const StockLedgerFixPage: React.FC = () => { return ( <> - - + + + + ); }; diff --git a/src/app/api/doworkbench/actions.ts b/src/app/api/doworkbench/actions.ts index 65af3d6e..60a0040d 100644 --- a/src/app/api/doworkbench/actions.ts +++ b/src/app/api/doworkbench/actions.ts @@ -216,7 +216,6 @@ export async function fetchWorkbenchStoreLaneSummary( return serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); } @@ -235,7 +234,6 @@ export async function fetchWorkbenchEtraLaneSummary( const data = await serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); return Array.isArray(data) ? data : []; } diff --git a/src/app/api/itemTracing/schema.ts b/src/app/api/itemTracing/schema.ts index edf0774b..3bbb3fd4 100644 --- a/src/app/api/itemTracing/schema.ts +++ b/src/app/api/itemTracing/schema.ts @@ -85,7 +85,7 @@ const ItemLotTraceMaterialInputSchema: z.ZodType> = z.la consoCode: z.string(), pickedAt: nullableStr, stockInOrigin: ItemLotTraceOriginSchema.nullable().optional(), - purchaseEvents: z.array(z.record(z.unknown())).optional(), + purchaseEvents: z.array(z.record(z.string(), z.unknown())).optional(), qcResults: z.array(ItemLotTraceQcResultSchema), putawayEvents: z.array(ItemLotTracePutawayEventSchema).optional(), nestedJoPrelude: ItemLotTraceJoPreludeSchema.nullable().optional(), @@ -107,7 +107,7 @@ const ItemLotTraceJoPreludeSchema: z.ZodType> = z.lazy(( status: z.string(), }) .passthrough(), - pickOrders: z.array(z.record(z.unknown())), + pickOrders: z.array(z.record(z.string(), z.unknown())), materialInputs: z.array(ItemLotTraceMaterialInputSchema), }) .passthrough(), @@ -158,28 +158,28 @@ export const ItemLotTraceResponseSchema = z primaryStockInLineId: nullableNum, }) .passthrough(), - warehouseLines: z.array(z.record(z.unknown())), + warehouseLines: z.array(z.record(z.string(), z.unknown())), origins: z.array(ItemLotTraceOriginSchema), - purchaseEvents: z.array(z.record(z.unknown())).optional(), + purchaseEvents: z.array(z.record(z.string(), z.unknown())).optional(), qcResults: z.array(ItemLotTraceQcResultSchema), putawayEvents: z.array(ItemLotTracePutawayEventSchema), - movements: z.array(z.record(z.unknown())), - outboundUsage: z.array(z.record(z.unknown())), - stockTakeEvents: z.array(z.record(z.unknown())), - adjustments: z.array(z.record(z.unknown())), - transfers: z.array(z.record(z.unknown())), - bomTrace: z.record(z.unknown()), + movements: z.array(z.record(z.string(), z.unknown())), + outboundUsage: z.array(z.record(z.string(), z.unknown())), + stockTakeEvents: z.array(z.record(z.string(), z.unknown())), + adjustments: z.array(z.record(z.string(), z.unknown())), + transfers: z.array(z.record(z.string(), z.unknown())), + bomTrace: z.record(z.string(), z.unknown()), joPrelude: ItemLotTraceJoPreludeSchema.nullable(), productionSteps: z.array(ItemLotTraceProductionStepSchema), - byproductLots: z.array(z.record(z.unknown())), - openMovements: z.array(z.record(z.unknown())), - failEvents: z.array(z.record(z.unknown())), - doDeliveries: z.array(z.record(z.unknown())), - replenishmentEvents: z.array(z.record(z.unknown())).optional(), - returnEvents: z.array(z.record(z.unknown())), - lotRelations: z.array(z.record(z.unknown())), - alternateLocations: z.array(z.record(z.unknown())), - locationBlocks: z.array(z.record(z.unknown())).optional(), + byproductLots: z.array(z.record(z.string(), z.unknown())), + openMovements: z.array(z.record(z.string(), z.unknown())), + failEvents: z.array(z.record(z.string(), z.unknown())), + doDeliveries: z.array(z.record(z.string(), z.unknown())), + replenishmentEvents: z.array(z.record(z.string(), z.unknown())).optional(), + returnEvents: z.array(z.record(z.string(), z.unknown())), + lotRelations: z.array(z.record(z.string(), z.unknown())), + alternateLocations: z.array(z.record(z.string(), z.unknown())), + locationBlocks: z.array(z.record(z.string(), z.unknown())).optional(), traceGraph: ItemLotTraceGraphSchema, }) .passthrough(); diff --git a/src/app/api/pickOrder/actions.ts b/src/app/api/pickOrder/actions.ts index f21a60b4..25409697 100644 --- a/src/app/api/pickOrder/actions.ts +++ b/src/app/api/pickOrder/actions.ts @@ -632,7 +632,6 @@ export async function fetchStoreLaneSummary(storeId: string, requiredDate?: stri const response = await serverFetchJson(url, { method: "GET", cache: "no-store", - next: { revalidate: 0 }, }); console.timeEnd(label); return response; @@ -856,7 +855,6 @@ export const fetchFGPickOrdersByUserIdWorkbench = async (userId: number) => { method: "GET", // Must be fresh: determines whether shell shows Floor/Lane panel or Detail. cache: "no-store", - next: { revalidate: 0 }, }, ); }; diff --git a/src/app/api/po/index.ts b/src/app/api/po/index.ts index ed051581..e20cb25f 100644 --- a/src/app/api/po/index.ts +++ b/src/app/api/po/index.ts @@ -54,11 +54,15 @@ export interface StockUomForPoLine { id: number; stockUomCode: string; stockUomDesc: string; + stockUomShortDesc?: string | null; stockQty: number; stockRatioN: number; stockRatioD: number; purchaseRatioN: number; purchaseRatioD: number; + sourceRatioN?: number | null; + sourceRatioD?: number | null; + stockQtyInteger?: boolean | null; } // DEPRECIATED diff --git a/src/app/api/settings/uom/index.ts b/src/app/api/settings/uom/index.ts index e7e8526e..4f5765ca 100644 --- a/src/app/api/settings/uom/index.ts +++ b/src/app/api/settings/uom/index.ts @@ -9,6 +9,8 @@ export interface Uom { id: number; code: string; udfudesc: string; + /** Short UoM label from M18 (`udfShortDesc`), e.g. 箱. */ + udfShortDesc?: string | null; unit1: string; unit1Qty: number; unit2?: string; diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index f674efe0..e588dc4c 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -24,6 +24,8 @@ export interface InventoryLotDetailResponse { holdQty: number; availableQty: number; uom: string; + /** Stock UoM short label (e.g. 包) for the count input. */ + uomShortDesc?: string | null; warehouseCode: string; warehouseName: string; warehouseSlot: string; @@ -51,6 +53,8 @@ export interface InventoryLotDetailResponse { stockTakeEndTime?: string | string[] | null; /** ISO string or backend LocalDateTime array */ approverTime?: string | string[] | null; + /** Times this saved first count has been changed. */ + pickerReviseCount?: number | null; } /** @@ -122,6 +126,8 @@ export interface SaveStockTakeRecordRequest { badQty: number; //stockTakerName: string; remark?: string | null; + /** Overwrite the saved first count while the section is still stock-taking. */ + reviseFirstQty?: boolean; } export interface AllPickedStockTakeListReponse { id: number; 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/authorities.ts b/src/authorities.ts index ad4aeb9e..b601e86f 100644 --- a/src/authorities.ts +++ b/src/authorities.ts @@ -27,6 +27,10 @@ export const AUTH = { REPORT_MGMT: "REPORT_MGMT", /** FP-MTMS Version Checklist | Functions Ref. No. 67 | v1.0.0 | 2026-08-13 — Manual M18 sync page (/m18Syn): ADMIN or this ability */ M18_SYNC: "M18_SYNC", + /** Route board (/settings/shop/board): ADMIN or this ability (DB name: 車線) */ + TRUCK_LANE: "TRUCK_LANE", + /** 員工發貨績效 on /chart/delivery (DB name: 績效) */ + PERFORMANCE: "PERFORMANCE", } as const; /** 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/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index a5804a95..cea41b5e 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -35,6 +35,7 @@ const pathToLabelKey: { [path: string]: string } = { "/settings/qrCodeHandle": "nav.breadcrumb.qrCodeHandle", "/settings/deliveryOrderFloor": "nav.breadcrumb.deliveryOrderFloor", "/settings/masterDataIssues": "nav.breadcrumb.masterDataIssues", + "/settings/stockLedgerFix": "nav.breadcrumb.stockLedgerFix", "/settings/rss": "nav.breadcrumb.demandForecast", "/settings/equipment": "nav.breadcrumb.equipment", "/settings/equipment/MaintenanceEdit": "nav.breadcrumb.equipmentMaintenanceEdit", diff --git a/src/components/DoWorkbench/DoWorkbenchTabs.tsx b/src/components/DoWorkbench/DoWorkbenchTabs.tsx index 69015ee6..6ab37110 100644 --- a/src/components/DoWorkbench/DoWorkbenchTabs.tsx +++ b/src/components/DoWorkbench/DoWorkbenchTabs.tsx @@ -186,7 +186,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom const confirmResult = await Swal.fire({ title: t("Batch Print"), - text: `${t("Confirm print: (")}${releasedOrders.length}${t("piece(s))")}`, + text: t("Confirm print drafts", { count: releasedOrders.length }), icon: "question", showCancelButton: true, confirmButtonText: t("Confirm"), @@ -276,18 +276,29 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom @@ -297,7 +308,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom sx={{ overflow: "visible", /* 徽章在標籤右側外凸,預留空間避免與下一個 Tab 貼死 */ - pr: etraIncompleteDopoCount > 99 ? 5 : etraIncompleteDopoCount > 0 ? 4 : 2, + pr: etraIncompleteDopoCount > 99 ? 2.5 : etraIncompleteDopoCount > 0 ? 2 : 1, }} label={ = ({ defaultTabIndex = 0, printerCom 0 ? 1 : 0 }} + sx={{ + pr: etraIncompleteDopoCount > 0 ? 1 : 0, + whiteSpace: "nowrap", + lineHeight: 1.2, + textAlign: "center", + }} > {t("Etra Pick Order Detail")} @@ -341,8 +357,8 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom - - + + @@ -401,7 +417,7 @@ const DoWorkbenchTabsInner: React.FC = ({ defaultTabIndex = 0, printerCom /** * FP-MTMS Version Checklist | Functions Ref. No. 20 | v1.0.0 | 2026-07-19 - * FP-MTMS Version Checklist | Functions Ref. No. 55 | v1.0.0 | 2026-08-06 + * FP-MTMS Version Checklist | Functions Ref. No. 55 | v1.0.1 | 2026-09-22 */ const DoWorkbenchTabs: React.FC = (props) => ( = ({ const currentUserId = session?.id ? parseInt(session.id, 10) : undefined; const [loading, setLoading] = useState(false); + const [isPrinting, setIsPrinting] = useState(false); + const printInFlightRef = useRef(false); const [records, setRecords] = useState([]); const initialSearchTargetDate = useMemo( () => resolveWorkbenchRecordTargetDate(initialTargetDate), @@ -183,6 +185,65 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ return t("All dates"); }, [searchQuery.targetDate, t]); + const rememberCartonQty = useCallback((recordId: number, cartonQty: number) => { + setRecords((prev) => + prev.map((row) => + row.doPickOrderRecordId === recordId ? { ...row, numberOfCartons: cartonQty } : row, + ), + ); + }, []); + + const submitPrint = useCallback( + async (job: () => Promise<{ success: boolean }>) => { + await new Promise((resolve) => { + void Swal.fire({ + title: t("Printing..."), + allowOutsideClick: false, + allowEscapeKey: false, + showConfirmButton: false, + didOpen: () => { + Swal.showLoading(); + resolve(); + }, + }); + }); + try { + const response = await job(); + Swal.close(); + if (response.success) { + await Swal.fire({ + position: "bottom-end", + icon: "success", + text: t("Printed Successfully."), + showConfirmButton: false, + timer: 1500, + }); + return true; + } + await Swal.fire({ + position: "bottom-end", + icon: "error", + text: t("Print failed"), + showConfirmButton: false, + timer: 2000, + }); + return false; + } catch (error) { + console.error("print error:", error); + Swal.close(); + await Swal.fire({ + position: "bottom-end", + icon: "error", + text: t("Print failed"), + showConfirmButton: false, + timer: 2000, + }); + return false; + } + }, + [t], + ); + const askNumOfCarton = useCallback(async () => { const result = await Swal.fire({ title: t("Enter the number of cartons: "), @@ -210,6 +271,7 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ const handlePrintDN = useCallback( async (recordId: number) => { + if (printInFlightRef.current) return; if (!a4Printer) { await Swal.fire({ position: "bottom-end", @@ -220,31 +282,32 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ }); return; } - const cartonQty = await askNumOfCarton(); - if (!cartonQty) return; - const response = await printDNWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: a4Printer.id, - printQty: 1, - isDraft: false, - numOfCarton: cartonQty, - }); - if (response.success) { - await Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500, - }); - await loadData(); + printInFlightRef.current = true; + setIsPrinting(true); + try { + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const ok = await submitPrint(() => + printDNWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: a4Printer.id, + printQty: 1, + isDraft: false, + numOfCarton: cartonQty, + }), + ); + if (ok) rememberCartonQty(recordId, cartonQty); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } }, - [a4Printer, askNumOfCarton, loadData, t], + [a4Printer, askNumOfCarton, rememberCartonQty, submitPrint, t], ); const handlePrintLabel = useCallback( async (recordId: number) => { + if (printInFlightRef.current) return; if (!labelPrinter) { await Swal.fire({ position: "bottom-end", @@ -255,30 +318,31 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ }); return; } - const cartonQty = await askNumOfCarton(); - if (!cartonQty) return; - const response = await printDNLabelsWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: labelPrinter.id, - printQty: 1, - numOfCarton: cartonQty, - }); - if (response.success) { - await Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500, - }); - await loadData(); + printInFlightRef.current = true; + setIsPrinting(true); + try { + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const ok = await submitPrint(() => + printDNLabelsWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: labelPrinter.id, + printQty: 1, + numOfCarton: cartonQty, + }), + ); + if (ok) rememberCartonQty(recordId, cartonQty); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } }, - [askNumOfCarton, labelPrinter, loadData, t], + [askNumOfCarton, labelPrinter, rememberCartonQty, submitPrint, t], ); const handlePrintBoth = useCallback( async (recordId: number) => { + if (printInFlightRef.current) return; if (!a4Printer || !labelPrinter) { await Swal.fire({ position: "bottom-end", @@ -289,37 +353,39 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ }); return; } - const cartonQty = await askNumOfCarton(); - if (!cartonQty) return; - const [labelRes, dnRes] = await Promise.all([ - printDNLabelsWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: labelPrinter.id, - printQty: 1, - numOfCarton: cartonQty, - }), - printDNWorkbench({ - deliveryOrderPickOrderId: recordId, - printerId: a4Printer.id, - printQty: 1, - isDraft: false, - numOfCarton: cartonQty, - }), - ]); - if (labelRes.success && dnRes.success) { - await Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500, + printInFlightRef.current = true; + setIsPrinting(true); + try { + const cartonQty = await askNumOfCarton(); + if (!cartonQty) return; + const ok = await submitPrint(async () => { + const [labelRes, dnRes] = await Promise.all([ + printDNLabelsWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: labelPrinter.id, + printQty: 1, + numOfCarton: cartonQty, + }), + printDNWorkbench({ + deliveryOrderPickOrderId: recordId, + printerId: a4Printer.id, + printQty: 1, + isDraft: false, + numOfCarton: cartonQty, + }), + ]); + return { success: labelRes.success && dnRes.success }; }); - await loadData(); + if (ok) rememberCartonQty(recordId, cartonQty); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } }, - [a4Printer, askNumOfCarton, labelPrinter, loadData, t], + [a4Printer, askNumOfCarton, labelPrinter, rememberCartonQty, submitPrint, t], ); const handleLabelReprint = useCallback(async (doPickOrder: CompletedDoPickOrderResponse) => { + if (printInFlightRef.current) return; if (!labelPrinter) { Swal.fire({ position: "bottom-end", @@ -331,6 +397,9 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ return; } + printInFlightRef.current = true; + setIsPrinting(true); + try { const defaultTotalCartons = Math.max(1, doPickOrder.numberOfCartons || 1); const result = await Swal.fire({ title: t("Reprint DN Label"), @@ -390,31 +459,22 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ return; } - try { - const response = await printDNLabelsReprintWorkbench({ - deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId, - printerId: labelPrinter.id, - printQty: 1, - fromCarton: result.value.fromCarton, - toCarton: result.value.toCarton, - totalCartonsOnShipment: result.value.totalCartonsOnShipment, - }); - - if (response.success) { - Swal.fire({ - position: "bottom-end", - icon: "success", - text: t("Printed Successfully."), - showConfirmButton: false, - timer: 1500 - }); - } else { - console.error("Reprint failed:", response.message); - } - } catch (error) { - console.error("reprint error: ", error); + const ok = await submitPrint(() => + printDNLabelsReprintWorkbench({ + deliveryOrderPickOrderId: doPickOrder.doPickOrderRecordId, + printerId: labelPrinter.id, + printQty: 1, + fromCarton: result.value.fromCarton, + toCarton: result.value.toCarton, + totalCartonsOnShipment: result.value.totalCartonsOnShipment, + }), + ); + if (ok) rememberCartonQty(doPickOrder.doPickOrderRecordId, result.value.totalCartonsOnShipment); + } finally { + setIsPrinting(false); + printInFlightRef.current = false; } - }, [labelPrinter, t]); + }, [labelPrinter, rememberCartonQty, submitPrint, t]); const handleDetailClick = useCallback( async (record: CompletedDoPickOrderResponse) => { @@ -735,16 +795,16 @@ const GoodPickExecutionWorkbenchRecord: React.FC = ({ - - - - diff --git a/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx b/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx index 3325a539..c47689f1 100644 --- a/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx +++ b/src/components/DoWorkbench/TruckRoutingSummaryTabWorkbench.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { Box, Button, MenuItem, Stack, TextField, Typography } from "@mui/material"; import DownloadIcon from "@mui/icons-material/Download"; +import { useTranslation } from "react-i18next"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { @@ -18,6 +19,7 @@ import { } from "@/lib/featureUsageLog"; const TruckRoutingSummaryTabWorkbench: React.FC = () => { + const { t } = useTranslation(); const [storeOptions, setStoreOptions] = useState([]); const [laneOptions, setLaneOptions] = useState([]); const [storeId, setStoreId] = useState(""); @@ -45,6 +47,9 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { const canDownload = storeId && truckLanceCode && date && !loading; + const displayOptionLabel = (label: string) => + String(label).trim() === "車線-X" ? t("Truck X") : label; + const onDownload = async () => { if (!canDownload) return; try { @@ -55,7 +60,9 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { }); if (precheck.hasUnpickedOrders) { const confirmed = window.confirm( - `此車線仍有 ${precheck.unpickedOrderCount} 張訂單未執拾。\n是否仍要列印 / 下載送貨路線摘要?` + t("Unpicked orders confirm download", { + count: precheck.unpickedOrderCount, + }), ); if (!confirmed) return; } @@ -92,7 +99,7 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { ); } catch (error) { console.error("Failed to download Workbench Truck Routing Summary", error); - alert("下載 Workbench 送貨路線摘要失敗,請稍後再試。"); + alert(t("Failed to download Workbench truck routing summary. Please try again later.")); } finally { setLoading(false); } @@ -101,39 +108,39 @@ const TruckRoutingSummaryTabWorkbench: React.FC = () => { return ( - 送貨路線摘要 (Workbench) + {t("Truck Routing Summary (Workbench)")} onStoreChange(e.target.value)} > {storeOptions.map((opt) => ( - {opt.label} + {displayOptionLabel(opt.label)} ))} setTruckLanceCode(e.target.value)} disabled={!storeId} > {laneOptions.map((opt) => ( - {opt.label} + {displayOptionLabel(opt.label)} ))} { disabled={!canDownload} onClick={onDownload} > - {loading ? "生成中..." : "下載報告 (PDF)"} + {loading ? t("Generating...") : t("Download report (PDF)")} ); diff --git a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx index 29536591..91578a07 100644 --- a/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx +++ b/src/components/DoWorkbench/WorkbenchGoodPickExecutionDetail.tsx @@ -421,22 +421,30 @@ function isWorkbenchSourceLotExpired(lot: any): boolean { return false; } -function getWorkbenchSourceLotStatusSummary(lot: any): { +type PickOrderT = (key: string, options?: Record) => string; + +function getWorkbenchSourceLotStatusSummary(lot: any, t: PickOrderT): { severity: "success" | "warning" | "error"; text: string; } { if (!lot) { - return { severity: "warning", text: "無法判斷此批號狀態" }; + return { severity: "warning", text: t("Cannot determine this lot status") }; } if (isWorkbenchSourceLotExpired(lot)) { - return { severity: "error", text: "此批號狀態:已過期" }; + return { severity: "error", text: t("Lot status: expired") }; } const solSt = String(lot.stockOutLineStatus || "").toLowerCase(); if (solSt === "rejected") { - return { severity: "warning", text: "此出庫行:已拒絕,請改掃其他批號" }; + return { + severity: "warning", + text: t("This pick line was rejected. Please scan another lot."), + }; } if (solSt === "completed" || solSt === "partially_completed") { - return { severity: "warning", text: "此出庫行:已完成,無需再提貨" }; + return { + severity: "warning", + text: t("This pick line is already completed. No further pick needed."), + }; } /** * 無批次列:後端仍標 insufficient_stock,語意是「尚無可出庫批號」而非「已用畢」。 @@ -449,28 +457,28 @@ function getWorkbenchSourceLotStatusSummary(lot: any): { if (isNoLotRow) { return { severity: "warning", - text: "尚未綁定批號/無可用庫存列:請掃描週邊入庫或轉倉 QR", + text: t( + "No lot bound / no available stock line. Please scan nearby inbound or transfer QR.", + ), }; } const av = String(lot.lotAvailability || "").toLowerCase(); if (av === "insufficient_stock") { - return { severity: "warning", text: "此批號狀態:已用畢(無剩餘庫存)" }; + return { severity: "warning", text: t("Lot status: depleted (no remaining stock)") }; } const avail = Number(lot.availableQty); if (lot.lotNo && Number.isFinite(avail) && avail <= 0) { - return { severity: "warning", text: "此批號狀態:已用畢(可用量為 0)" }; + return { severity: "warning", text: t("Lot status: depleted (available qty is 0)") }; } if (isInventoryLotLineUnavailable(lot)) { return { severity: "warning", - text: "此批號狀態:庫存不可用(未上架或行狀態不可用)", + text: t("Lot status: unavailable (not put away or line unavailable)"), }; } - return { severity: "success", text: "此批號狀態:可提貨" }; + return { severity: "success", text: t("Lot status: ready to pick") }; } -type PickOrderT = (key: string, options?: Record) => string; - function translateWorkbenchRejectMessage(raw: string, t: PickOrderT): string { const msg = raw.trim(); if (!msg) return msg; @@ -1334,9 +1342,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO severity: undefined as "success" | "warning" | "error" | undefined, }; } - const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot); + const s = getWorkbenchSourceLotStatusSummary(workbenchLotLabelContextLot, t); return { text: s.text, severity: s.severity }; - }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot]); + }, [workbenchLotLabelModalOpen, workbenchLotLabelContextLot, t]); const workbenchLotLabelSubmitQty = useMemo(() => { if (!workbenchLotLabelContextLot) return 0; @@ -1811,7 +1819,9 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO setQrScanError(true); setQrScanSuccess(false); setQrScanErrorMsg( - `此批次(${scannedLot.lotNo || scannedStockInLineId})已被拒绝,无法使用。请扫描其他批次。` + t("This lot ({{lot}}) was rejected and cannot be used. Please scan another lot.", { + lot: scannedLot.lotNo || scannedStockInLineId, + }), ); }); // Mark this SOL as processed to prevent re-processing @@ -1864,7 +1874,7 @@ const fetchAllCombinedLotData = useCallback(async (userId?: number, pickOrderIdO startTransition(() => { setQrScanError(true); setQrScanSuccess(false); - setQrScanErrorMsg("当前订单中没有此物品的批次信息"); + setQrScanErrorMsg(t("No lot information for this item in the current order")); }); return; } diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx index 2863a5b9..1d477005 100644 --- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -37,6 +37,7 @@ import { printWorkbenchLotLabel, } from "@/app/api/doworkbench/actions"; import { QRCodeSVG } from "qrcode.react"; +import { useTranslation } from "react-i18next"; type ScanPayload = { itemId: number; @@ -167,6 +168,7 @@ const WorkbenchLotLabelPrintModal: React.FC = submitQty = null, onSubmitQtyChange, }) => { + const { t } = useTranslation(); const scanInputRef = useRef(null); const [scanInput, setScanInput] = useState(""); const [scanError, setScanError] = useState(null); @@ -210,8 +212,8 @@ const WorkbenchLotLabelPrintModal: React.FC = useEffect(() => { if (!open) return; resetAll(); - const t = setTimeout(() => scanInputRef.current?.focus(), 50); - return () => clearTimeout(t); + const focusTimer = setTimeout(() => scanInputRef.current?.focus(), 50); + return () => clearTimeout(focusTimer); }, [open, resetAll]); const loadPrinters = useCallback(async () => { @@ -224,13 +226,13 @@ const WorkbenchLotLabelPrintModal: React.FC = setPrinters([]); setSnackbar({ open: true, - message: e instanceof Error ? e.message : "載入印表機清單失敗", + message: e instanceof Error ? e.message : t("Failed to load printer list"), severity: "error", }); } finally { setPrintersLoading(false); } - }, []); + }, [t]); useEffect(() => { if (!open) return; @@ -283,23 +285,23 @@ const WorkbenchLotLabelPrintModal: React.FC = setAnalysis(data); setSnackbar({ open: true, - message: "已載入同品可用批號清單", + message: t("Loaded available lots for this item"), severity: "success", }); } catch (e) { setAnalysis(null); - setScanError(e instanceof Error ? e.message : "分析失敗"); + setScanError(e instanceof Error ? e.message : t("Analysis failed")); } finally { setAnalysisLoading(false); } }, - [resolveExpectedUomId], + [resolveExpectedUomId, t], ); const analyzeByItem = useCallback( async (itemId: number) => { if (!Number.isFinite(itemId) || itemId <= 0) { - setScanError("無效 itemId,無法載入批號清單。"); + setScanError(t("Invalid itemId, cannot load lot list.")); return; } setLastItemId(itemId); @@ -325,17 +327,17 @@ const WorkbenchLotLabelPrintModal: React.FC = }); setSnackbar({ open: true, - message: "已載入同品可用批號清單", + message: t("Loaded available lots for this item"), severity: "success", }); } catch (e) { setAnalysis(null); - setScanError(e instanceof Error ? e.message : "分析失敗"); + setScanError(e instanceof Error ? e.message : t("Analysis failed")); } finally { setAnalysisLoading(false); } }, - [resolveExpectedUomId], + [resolveExpectedUomId, t], ); const handleAnalyze = useCallback(async () => { @@ -343,13 +345,13 @@ const WorkbenchLotLabelPrintModal: React.FC = const payload = safeParseScanPayload(raw); if (!payload) { setScanError( - '掃碼內容格式錯誤,請重新掃碼', + t("Invalid scan format. Please scan again."), ); setAnalysis(null); return; } await analyzePayload(payload); - }, [scanInput, analyzePayload]); + }, [scanInput, analyzePayload, t]); const handleRefreshLots = useCallback(async () => { const payload = lastPayload ?? safeParseScanPayload(scanInput.trim()); @@ -368,12 +370,12 @@ const WorkbenchLotLabelPrintModal: React.FC = if (!payload) { setSnackbar({ open: true, - message: "請先掃碼或查詢一次,才可刷新批號清單。", + message: t("Scan or look up once before refreshing the lot list."), severity: "info", }); return; } - }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput]); + }, [analyzeByItem, analyzePayload, initialItemId, lastItemId, lastPayload, scanInput, t]); useEffect(() => { if (!open) return; @@ -470,7 +472,7 @@ const WorkbenchLotLabelPrintModal: React.FC = if (selectedPrinterId === "") { setSnackbar({ open: true, - message: "請先選擇印表機", + message: t("Please select a printer first"), severity: "error", }); return; @@ -478,7 +480,7 @@ const WorkbenchLotLabelPrintModal: React.FC = if (printQty < 1 || !Number.isFinite(printQty)) { setSnackbar({ open: true, - message: "列印張數需為大於等於 1 的整數", + message: t("Print quantity must be an integer of 1 or more"), severity: "error", }); return; @@ -493,25 +495,25 @@ const WorkbenchLotLabelPrintModal: React.FC = }); setSnackbar({ open: true, - message: `已送出列印:Lot ${lotNo}`, + message: t("Print sent: Lot {{lotNo}}", { lotNo }), severity: "success", }); } catch (e) { setSnackbar({ open: true, - message: e instanceof Error ? e.message : "列印失敗", + message: e instanceof Error ? e.message : t("Print failed"), severity: "error", }); } finally { setPrintingLotLineId(null); } }, - [selectedPrinterId, printQty], + [selectedPrinterId, printQty, t], ); return ( - 批號標籤列印(提貨台) + {t("Lot label print (pick station)")} {statusTitleText ? ( @@ -548,13 +550,13 @@ const WorkbenchLotLabelPrintModal: React.FC = > setScanInput(e.target.value)} fullWidth size="small" error={!!scanError} - helperText={scanError || "掃描後按 Enter 或點「查詢」"} + helperText={scanError || t("Scan then press Enter or click Look up")} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); @@ -568,7 +570,7 @@ const WorkbenchLotLabelPrintModal: React.FC = onClick={() => void handleAnalyze()} disabled={analysisLoading || !scanInput.trim()} > - {analysisLoading ? : "查詢"} + {analysisLoading ? : t("Look up")} @@ -594,16 +596,16 @@ const WorkbenchLotLabelPrintModal: React.FC = sx={{ minWidth: 260 }} disabled={printersLoading} > - 印表機 + {t("Printer")} { - const arr = selected as string[]; - if (arr.length === 0) { - return ( - - {t("lane_selectedNone")} - - ); - } - if (filterActive) - return t("lane_selectedCount", { - count: selectedVisible, - }); - return t("lane_selectedCount", { count: arr.length }); - }} - onChange={(e) => { - const value = e.target.value as unknown as string[]; - setSelectedLaneIds(value); - }} - MenuProps={{ - autoFocus: false, - MenuListProps: { autoFocus: false, dense: false }, - PaperProps: { - sx: { - maxHeight: 420, - minWidth: 360, - maxWidth: "min(100vw - 32px, 480px)", - }, - }, + - { + const arr = selected as string[]; + if (arr.length === 0) { + return ( + + {t("lane_selectedNone")} + + ); + } + if (filterActive) + return t("lane_selectedCount", { + count: selectedVisible, + }); + return t("lane_selectedCount", { count: arr.length }); }} - onClick={(e) => e.stopPropagation()} + onChange={(e) => { + const value = e.target.value as unknown as string[]; + setSelectedLaneIds( + Array.isArray(value) ? value : [String(value)], + ); + }} + MenuProps={{ + autoFocus: false, + PaperProps: { + component: LaneSelectMenuPaper, + }, + MenuListProps: { + autoFocus: false, + dense: false, + sx: { + flex: "1 1 320px", + minWidth: 280, + maxWidth: laneFilterPanelOpen ? 400 : "100%", + maxHeight: 420, + overflowY: "auto", + overflowX: "hidden", + py: 0, + }, + }, + }} + sx={{ mt: 1 }} > - - - setLaneFilter((prev) => ({ - ...prev, - query: e.target.value, - })) - } - onKeyDown={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - onMouseDown={(e) => e.stopPropagation()} - InputProps={{ - startAdornment: ( - e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + > + + + setLaneFilter((prev) => ({ + ...prev, + query: e.target.value, + })) + } + onKeyDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + onMouseDown={(e) => e.stopPropagation()} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + flex: 1, + minWidth: 0, + "& .MuiOutlinedInput-root": { + borderRadius: 2, + bgcolor: "grey.50", + }, + "& .MuiInputBase-input": { + textAlign: "left", + fontSize: "0.8125rem", + color: "text.secondary", + py: 0.75, + }, + "& .MuiInputBase-input::placeholder": { + opacity: 1, + color: "text.disabled", + textAlign: "left", + }, + }} + /> + + + e.stopPropagation()} + onClick={(e) => { + e.stopPropagation(); + setLaneFilterPanelOpen((open) => { + const next = !open; + if (next) { + setLaneFilter((prev) => ({ + ...prev, + floor: "2F", + zone: "all", + weekday: "all", + area: [], + })); + } + return next; + }); + }} sx={{ - mr: 0.5, - display: "inline-flex", - color: "text.disabled", + flexShrink: 0, + width: 32, + height: 32, }} > - - - ), - }} - sx={{ - flex: 1, - minWidth: 0, - "& .MuiOutlinedInput-root": { - borderRadius: 2, - bgcolor: "grey.50", - }, - "& .MuiInputBase-input": { - textAlign: "left", - fontSize: "0.8125rem", - color: "text.secondary", - py: 0.75, - }, - "& .MuiInputBase-input::placeholder": { - opacity: 1, - color: "text.disabled", - textAlign: "left", - }, - }} - /> - - - e.stopPropagation()} - onClick={(e) => { - e.stopPropagation(); - setLaneFilterAnchor(e.currentTarget); - }} - sx={{ - flexShrink: 0, - width: 32, - height: 32, - }} - > - - - - - - - - {visibleLaneOptions.map((lane) => { + + + + + +
+ + {visibleLaneOptions.map((lane) => { const checked = selectedLaneIds.includes(lane.id); const rem = lane.remark != null && @@ -6950,61 +7420,8 @@ const RouteBoard: React.FC = () => { ); })} - - - setLaneFilterAnchor(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "left" }} - transformOrigin={{ vertical: "top", horizontal: "left" }} - > - - - - {t("floor_label")} - - - - - - - - - + + {routeBoardTab === "board" && ( diff --git a/src/components/ProductionProcess/ProductionProcessDetail.tsx b/src/components/ProductionProcess/ProductionProcessDetail.tsx index 7700330f..2c30fa27 100644 --- a/src/components/ProductionProcess/ProductionProcessDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessDetail.tsx @@ -64,13 +64,16 @@ interface ProductProcessDetailProps { jobOrderId: number; onBack: () => void; fromJosave?: boolean; + /** 進入步驟執行頁時通知父層(用來隱藏「返回列表」) */ + onStepViewChange?: (inStepView: boolean) => void; } -/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.1 | 2026-08-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 50 | v1.0.2 | 2026-10-29 */ const ProductionProcessDetail: React.FC = ({ jobOrderId, onBack, fromJosave, + onStepViewChange, }) => { // console.log(" ProductionProcessDetail RENDER", { jobOrderId, fromJosave }); @@ -143,6 +146,12 @@ const fetchProcessDetailRef = useRef<() => Promise>(); useEffect(() => { onBackRef.current = onBack; }, [onBack]); + + useEffect(() => { + const inStepView = isExecutingLine && selectedLineId != null; + onStepViewChange?.(inStepView); + return () => onStepViewChange?.(false); + }, [isExecutingLine, selectedLineId, onStepViewChange]); // 获取 process 和 lines 数据 const fetchProcessDetail = useCallback(async () => { // console.log(" fetchProcessDetail CALLED", { jobOrderId, timestamp: new Date().toISOString() }); diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index 78818a53..8fb026bc 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -54,7 +54,7 @@ interface ProductProcessJobOrderDetailProps { initialTabIndex?: number; } -/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.7 | 2026-08-06 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */ const ProductionProcessJobOrderDetail: React.FC = ({ jobOrderId, onBack, @@ -68,6 +68,7 @@ const ProductionProcessJobOrderDetail: React.FC([]); const [tabIndex, setTabIndex] = useState(initialTabIndex); const [selectedProcessId, setSelectedProcessId] = useState(null); + const [inStepView, setInStepView] = useState(false); const [operationPriority, setOperationPriority] = useState(50); const [openOperationPriorityDialog, setOpenOperationPriorityDialog] = useState(false); const [openPlanStartDialog, setOpenPlanStartDialog] = useState(false); @@ -320,6 +321,9 @@ const handleRelease = useCallback(async (jobOrderId: number) => { const handleTabChange = useCallback>( (_e, newValue) => { setTabIndex(newValue); + if (newValue !== 2) { + setInStepView(false); + } }, [], ); @@ -796,12 +800,14 @@ const handleRelease = useCallback(async (jobOrderId: number) => { return ( - {/* 返回按钮 */} - - - + {/* 步驟執行頁不顯示「返回列表」,避免誤離開工單 */} + {!inStepView && ( + + + + )} {/* 标签页 */} @@ -833,6 +839,7 @@ const handleRelease = useCallback(async (jobOrderId: number) => { }} fromJosave={Boolean(fromJosave && !isPutAwayed)} + onStepViewChange={setInStepView} /> )} {tabIndex === 3 && } diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index 38667525..48d6d8d1 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -179,7 +179,7 @@ function isWaitingQcPutAway( return s !== "completed" && s !== "rejected"; } -/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.10 | 2026-10-15 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 40 | v1.0.11 | 2026-10-29 */ const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo, diff --git a/src/components/ProductionProcess/ProductionProcessStepExecution.tsx b/src/components/ProductionProcess/ProductionProcessStepExecution.tsx index 096acbb5..3fa51bff 100644 --- a/src/components/ProductionProcess/ProductionProcessStepExecution.tsx +++ b/src/components/ProductionProcess/ProductionProcessStepExecution.tsx @@ -28,6 +28,7 @@ import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import StopIcon from "@mui/icons-material/Stop"; import PauseIcon from "@mui/icons-material/Pause"; import PlayArrowIcon from "@mui/icons-material/PlayArrow"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { useTranslation } from "react-i18next"; import { JobOrderProcessLineDetailResponse, @@ -47,6 +48,35 @@ import { fetchNameList, NameList } from "@/app/api/user/actions"; import BagConsumptionForm from "./BagConsumptionForm"; // ✅ 添加导入 import OverallTimeRemainingCard from "./OverallTimeRemainingCard"; // ✅ 添加导入 import dayjs from "dayjs"; + +type QtyInput = number | ""; + +type OutputFormState = Omit< + UpdateProductProcessLineQtyRequest, + | "outputFromProcessQty" + | "byproductQty" + | "defectQty" + | "defect2Qty" + | "defect3Qty" + | "scrapQty" +> & { + outputFromProcessQty: QtyInput; + byproductQty: QtyInput; + defectQty: QtyInput; + defect2Qty: QtyInput; + defect3Qty: QtyInput; + scrapQty: QtyInput; +}; + +const parseQtyInput = (raw: string): QtyInput => { + if (raw === "") return ""; + const n = parseInt(raw, 10); + return Number.isNaN(n) ? "" : n; +}; + +const toSubmitQty = (v: QtyInput | null | undefined): number => + v === "" || v == null || Number.isNaN(Number(v)) ? 0 : Number(v); + interface ProductionProcessStepExecutionProps { lineId: number | null; onBack: () => void; @@ -55,7 +85,7 @@ interface ProductionProcessStepExecutionProps { jobOrderId?: number; // ✅ 添加 } -/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.0 | 2026-08-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 59 | v1.0.1 | 2026-10-29 */ const ProductionProcessStepExecution: React.FC = ({ lineId, onBack, @@ -75,24 +105,20 @@ const ProductionProcessStepExecution: React.FC({ + const [outputData, setOutputData] = useState({ productProcessLineId: lineId ?? 0, - outputFromProcessQty: 0, + outputFromProcessQty: "", outputFromProcessUom: "", - defectQty: 0, + defectQty: "", defectUom: "", - scrapQty: 0, + scrapQty: "", scrapUom: "", byproductName: "", - byproductQty: 0, + byproductQty: "", byproductUom: "", - defect2Qty: 0, + defect2Qty: "", defect2Uom: "", - defect3Qty: 0, + defect3Qty: "", defect3Uom: "", defectDescription: "", defectDescription2: "", @@ -153,14 +179,14 @@ const ProductionProcessStepExecution: React.FC ({ ...prev, productProcessLineId: detail.id, - outputFromProcessQty: (detail as any).outputFromProcessQty || 0, + outputFromProcessQty: (detail as any).outputFromProcessQty || "", outputFromProcessUom: (detail as any).outputFromProcessUom || "", - defectQty: detail.defectQty || 0, + defectQty: detail.defectQty || "", defectUom: detail.defectUom || "", - scrapQty: detail.scrapQty || 0, + scrapQty: detail.scrapQty || "", scrapUom: detail.scrapUom || "", byproductName: detail.byproductName || "", - byproductQty: detail.byproductQty || 0, + byproductQty: detail.byproductQty || "", byproductUom: detail.byproductUom || "" })); }) @@ -351,20 +377,20 @@ const ProductionProcessStepExecution: React.FC ({ ...prev, productProcessLineId: detail.id, - outputFromProcessQty: (detail as any).outputFromProcessQty || 0, + outputFromProcessQty: (detail as any).outputFromProcessQty || "", outputFromProcessUom: (detail as any).outputFromProcessUom || "", - defectQty: detail.defectQty || 0, + defectQty: detail.defectQty || "", defectUom: detail.defectUom || "", defectDescription: detail.defectDescription || "", defectDescription2: detail.defectDescription2 || "", defectDescription3: detail.defectDescription3 || "", - defectQty2: detail.defectQty2 || 0, - defectUom2: detail.defectUom2 || "", - defectQty3: detail.defectQty3 || 0, - defectUom3: detail.defectUom3 || "", - scrapQty: detail.scrapQty || 0, + defect2Qty: (detail as any).defect2Qty || "", + defect2Uom: (detail as any).defect2Uom || "", + defect3Qty: (detail as any).defect3Qty || "", + defect3Uom: (detail as any).defect3Uom || "", + scrapQty: detail.scrapQty || "", scrapUom: detail.scrapUom || "", byproductName: detail.byproductName || "", - byproductQty: detail.byproductQty || 0, + byproductQty: detail.byproductQty || "", byproductUom: detail.byproductUom || "" })); }) @@ -561,8 +587,13 @@ const ProductionProcessStepExecution: React.FC - {processData && ( @@ -812,7 +843,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - outputFromProcessQty: parseInt(e.target.value) || 0 + outputFromProcessQty: parseQtyInput(e.target.value) })} /> @@ -854,7 +885,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - defectQty: parseInt(e.target.value) || 0 + defectQty: parseQtyInput(e.target.value) })} /> @@ -902,7 +933,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - defect2Qty: parseInt(e.target.value) || 0 + defect2Qty: parseQtyInput(e.target.value) })} /> @@ -950,7 +981,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - defect3Qty: parseInt(e.target.value) || 0 + defect3Qty: parseQtyInput(e.target.value) })} /> @@ -998,7 +1029,7 @@ const ProductionProcessStepExecution: React.FC setOutputData({ ...outputData, - scrapQty: parseInt(e.target.value) || 0 + scrapQty: parseQtyInput(e.target.value) })} /> diff --git a/src/i18n/en/jo.json b/src/i18n/en/jo.json index 06a212c3..dcfa37ea 100644 --- a/src/i18n/en/jo.json +++ b/src/i18n/en/jo.json @@ -32,6 +32,8 @@ "BOM Type": "BOM Type", "Back": "Back", "Back to List": "Back to List", + "Back to Process": "Back to Process Steps", + "Back to Process Steps": "Back to Process Steps", "Bad Item Qty": "Bad Item Qty", "Bad Package Qty": "Bad Package Qty", "Bag": "Bag", diff --git a/src/i18n/en/productionProcess.json b/src/i18n/en/productionProcess.json index efdff3e8..17f8b3c1 100644 --- a/src/i18n/en/productionProcess.json +++ b/src/i18n/en/productionProcess.json @@ -10,6 +10,8 @@ "Auto-refresh every 10 minutes": "Auto-refresh every 10 minutes", "Back": "Back", "Back to List": "Back to List", + "Back to Process": "Back to Process Steps", + "Back to Process Steps": "Back to Process Steps", "Bag": "Bag", "Bag Consumption": "Bag Consumption", "Balance": "Balance", diff --git a/src/i18n/zh/jo.json b/src/i18n/zh/jo.json index 84135db3..ffe12d1a 100644 --- a/src/i18n/zh/jo.json +++ b/src/i18n/zh/jo.json @@ -34,6 +34,8 @@ "BOM Type": "BOM 類型", "Back": "返回", "Back to List": "返回列表", + "Back to Process": "返回列表", + "Back to Process Steps": "返回列表", "Bad Item Qty": "不良物品數量", "Bad Package Qty": "不良包裝數量", "Bag": "包裝袋", diff --git a/src/i18n/zh/productionProcess.json b/src/i18n/zh/productionProcess.json index 814ff3fc..d0130616 100644 --- a/src/i18n/zh/productionProcess.json +++ b/src/i18n/zh/productionProcess.json @@ -10,6 +10,8 @@ "Auto-refresh every 10 minutes": "每10分鐘自動刷新", "Back": "返回", "Back to List": "返回列表", + "Back to Process": "返回列表", + "Back to Process Steps": "返回列表", "Bag": "包裝袋", "Bag Consumption": "包裝袋消耗", "Balance": "可用數量", From c0738db99770b61632c36063cfd3be3f729867b9 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Fri, 30 Oct 2026 00:23:13 +0800 Subject: [PATCH 35/38] type fix --- src/components/Shop/RouteBoard.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx index 1ccf20bf..4a0d451a 100644 --- a/src/components/Shop/RouteBoard.tsx +++ b/src/components/Shop/RouteBoard.tsx @@ -378,7 +378,7 @@ function LaneFilterFacetMultiButtons(props: { const next = new Set(selected); if (on) next.delete(opt); else next.add(opt); - onChange([...next]); + onChange(Array.from(next)); }} sx={laneFilterBtnSx} > @@ -659,9 +659,9 @@ function parseLanePickFacets( scan(remark); return { special, - zones: [...zones], - weekdays: [...weekdays], - areas: [...areas], + zones: Array.from(zones), + weekdays: Array.from(weekdays), + areas: Array.from(areas), }; } @@ -2280,14 +2280,14 @@ const RouteBoard: React.FC = () => { for (const area of facets.areas) areas.add(area); } return { - specials: [...specials].sort(compareSpecialLaneToken), - zones: [...zones].sort((a, b) => { + specials: Array.from(specials).sort(compareSpecialLaneToken), + zones: Array.from(zones).sort((a, b) => { const na = Number(/^區(\d+)/.exec(a)?.[1] ?? 0); const nb = Number(/^區(\d+)/.exec(b)?.[1] ?? 0); return na - nb || a.localeCompare(b, "zh-Hant"); }), weekdays: LANE_WEEKDAYS.filter((day) => weekdays.has(day)), - areas: [...areas].sort(compareLaneAreaName), + areas: Array.from(areas).sort(compareLaneAreaName), }; }, [lanes, laneFilter.floor]); From 11d133562dd8cf5495b9163f73e2a8fe13af643d Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Fri, 30 Oct 2026 00:26:13 +0800 Subject: [PATCH 36/38] type fix 2 --- src/components/Shop/RouteBoard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Shop/RouteBoard.tsx b/src/components/Shop/RouteBoard.tsx index 4a0d451a..4a1af874 100644 --- a/src/components/Shop/RouteBoard.tsx +++ b/src/components/Shop/RouteBoard.tsx @@ -4090,7 +4090,7 @@ const RouteBoard: React.FC = () => { if (oldId === newId) return; setDirtyMoves((prev) => { const next = new Map(prev); - for (const [shopId, laneId] of next) { + for (const [shopId, laneId] of Array.from(next.entries())) { if (laneId === oldId) next.set(shopId, newId); } return next; From a9253fb9f439a8c2be094ea8fa0a0aff9dcfc610 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 2 Nov 2026 14:51:52 +0800 Subject: [PATCH 37/38] =?UTF-8?q?=E5=B7=A5=E5=96=AE=E6=8F=90=E8=B2=A8?= =?UTF-8?q?=E5=8F=B0=E6=89=B9=E8=99=9F=E6=A8=99=E7=B1=A4=E6=B8=85=E5=96=AE?= =?UTF-8?q?=E6=8E=92=E9=99=A4=E5=85=A8=E9=83=A8=204F=20=E5=80=89=E4=BD=8D?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../WorkbenchLotLabelPrintModal.tsx | 27 ++++++++++++++----- .../JoWorkbench/newJobPickExecution.tsx | 3 ++- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx index 1d477005..7ed3bbc8 100644 --- a/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx +++ b/src/components/DoWorkbench/WorkbenchLotLabelPrintModal.tsx @@ -91,6 +91,12 @@ export interface WorkbenchLotLabelPrintModalProps { /** 與 statusTitleText 搭配;預設 error(舊版固定紅字) */ statusTitleSeverity?: "success" | "warning" | "error"; warehouseCodePrefixFilter?: string; + /** + * Hide lots whose warehouse code equals an entry or starts with `{entry}-`. + * JO passes `4F` so the pick-station list matches the suggestion block list. + * The row that opened the dialog stays visible. + */ + excludeWarehouseCodePrefixes?: string[]; /** * When true, omit the API 「scanned」 lot from the merged list (legacy FG-style). * Workbench should leave false so the current row’s lot appears for label print / scan-pick. @@ -147,7 +153,7 @@ function isLabelPrinter(p: Printer): boolean { return s.includes("label") && !s.includes("a4"); } -/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.1 | 2026-07-22 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 30 | v1.0.2 | 2026-11-02 */ const WorkbenchLotLabelPrintModal: React.FC = ({ open, onClose, @@ -159,6 +165,7 @@ const WorkbenchLotLabelPrintModal: React.FC = statusTitleText, statusTitleSeverity = "error", warehouseCodePrefixFilter, + excludeWarehouseCodePrefixes, hideTriggeredLot = false, triggerLotAvailableQty = null, triggerLotUom = null, @@ -450,14 +457,22 @@ const WorkbenchLotLabelPrintModal: React.FC = ]); const filteredLots = useMemo(() => { - const prefix = String(warehouseCodePrefixFilter ?? "").trim(); - if (!prefix) return availableLots; + const prefix = String(warehouseCodePrefixFilter ?? "").trim().toUpperCase(); + const blocked = (excludeWarehouseCodePrefixes ?? []) + .map((entry) => entry.trim().toUpperCase()) + .filter((entry) => entry.length > 0); return availableLots.filter((lot) => { - // 使用者從本列開啟視窗:即使 API 未帶 warehouseCode,仍應顯示目前這筆批號 + // 使用者從本列開啟視窗:即使倉位在排除清單,仍顯示目前這筆批號 if (lot._scanned) return true; - return String(lot.warehouseCode ?? "").startsWith(prefix); + const code = String(lot.warehouseCode ?? "").trim().toUpperCase(); + const blockedHit = blocked.some( + (entry) => code === entry || code.startsWith(`${entry}-`), + ); + if (blockedHit) return false; + if (!prefix) return true; + return code.startsWith(prefix); }); - }, [availableLots, warehouseCodePrefixFilter]); + }, [availableLots, warehouseCodePrefixFilter, excludeWarehouseCodePrefixes]); const selectedPrinter = useMemo(() => { if (selectedPrinterId === "") return null; diff --git a/src/components/JoWorkbench/newJobPickExecution.tsx b/src/components/JoWorkbench/newJobPickExecution.tsx index e62f9d5a..dc51879e 100644 --- a/src/components/JoWorkbench/newJobPickExecution.tsx +++ b/src/components/JoWorkbench/newJobPickExecution.tsx @@ -618,7 +618,7 @@ const QrCodeModal: React.FC<{ ); }; -/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.1 | 2026-08-10 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 43 | v1.0.2 | 2026-11-02 */ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCombo = [] }) => { const workbenchMode = true; const { t } = useTranslation("jo"); @@ -4642,6 +4642,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList, printerCo workbenchLotLabelContextLot != null } warehouseCodePrefixFilter={lotFloorPrefixFilter} + excludeWarehouseCodePrefixes={["4F"]} triggerLotAvailableQty={ workbenchLotLabelContextLot != null ? Number(workbenchLotLabelContextLot.availableQty) From 0157ce4b468ec44bf89bf9a14b5c9ca791e58de4 Mon Sep 17 00:00:00 2001 From: Harry Groves Date: Wed, 4 Nov 2026 13:29:27 +0800 Subject: [PATCH 38/38] Stock Lot Ledger UI fix --- src/app/(main)/report/page.tsx | 86 ++++++++++++++++++++++++++++++++++ src/config/reportConfig.ts | 12 ++++- src/i18n/en/report.json | 2 +- src/i18n/zh/report.json | 2 +- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/app/(main)/report/page.tsx b/src/app/(main)/report/page.tsx index 0bd8e61b..06d4006d 100644 --- a/src/app/(main)/report/page.tsx +++ b/src/app/(main)/report/page.tsx @@ -57,6 +57,21 @@ interface ItemCodeWithName { name: string; } +/** Months from the earliest ledger month through the current month. Value stays YYYY-MM-01. */ +function ledgerMonthOptions(isZh: boolean, earliest: string) { + const start = dayjs().startOf('month'); + let end = dayjs(earliest).startOf('month'); + if (!end.isValid() || end.isAfter(start)) end = start; + const count = start.diff(end, 'month') + 1; + return Array.from({ length: count }, (_, i) => { + const d = start.subtract(i, 'month'); + return { + value: d.format(OUTPUT_DATE_FORMAT), + label: isZh ? `${d.year()}年${d.month() + 1}月` : d.format('MMMM YYYY'), + }; + }); +} + const FIELD_ERROR_SX = { '& .MuiOutlinedInput-root.Mui-error': { '& .MuiOutlinedInput-notchedOutline': { @@ -97,6 +112,7 @@ export default function ReportPage() { session?.abilities?.includes(AUTH.ADMIN) ?? false; const [selectedReportId, setSelectedReportId] = useState(''); + const [ledgerEarliest, setLedgerEarliest] = useState<{ reportId: string; earliest: string } | null>(null); const [criteria, setCriteria] = useState>({}); const criteriaRef = useRef>({}); const pendingItemCodeInputRef = useRef>({}); @@ -269,6 +285,32 @@ export default function ReportPage() { } }; + // Earliest stock_ledger month for the period-start list. Newest month is always the current month. + useEffect(() => { + const endpoint = currentReport?.fields.find( + (field) => field.type === 'month' && field.earliestMonthEndpoint, + )?.earliestMonthEndpoint; + if (!currentReport || !endpoint) return; + const reportId = currentReport.id; + let cancelled = false; + (async () => { + try { + const response = await clientAuthFetch(endpoint); + if (!response.ok) return; + const data = await response.json(); + const earliest = typeof data?.earliest === 'string' ? data.earliest : ''; + if (!cancelled && earliest) { + setLedgerEarliest({ reportId, earliest }); + } + } catch (error) { + console.error('Failed to load ledger months', error); + } + })(); + return () => { + cancelled = true; + }; + }, [currentReport]); + // Load initial options when report is selected useEffect(() => { if (currentReport) { @@ -661,6 +703,50 @@ export default function ReportPage() { field.name === 'status' && rep012MultiRound; + if (field.type === 'month') { + const earliestForReport = + ledgerEarliest?.reportId === currentReport.id ? ledgerEarliest.earliest : ''; + const monthOptions = earliestForReport + ? ledgerMonthOptions(isZh, earliestForReport) + : []; + return ( + + handleFieldChange(field.name, e.target.value)} + sx={{ + ...FIELD_ERROR_SX, + ...(currentReport.id === 'rep-005' ? { + '& .MuiOutlinedInput-root': { + minHeight: '64px', + fontSize: '1rem' + }, + '& .MuiInputLabel-root': { + fontSize: '1rem' + } + } : {}), + }} + > + + {isZh ? '請選擇月份' : 'Select month'} + + {monthOptions.map((opt) => ( + + {opt.label} + + ))} + + + ); + } + if (field.type === 'date') { const parsed = currentValue ? dayjs(currentValue) : null; const dateError = fieldErrors[field.name]; diff --git a/src/config/reportConfig.ts b/src/config/reportConfig.ts index b01954f4..483e61cf 100644 --- a/src/config/reportConfig.ts +++ b/src/config/reportConfig.ts @@ -1,5 +1,5 @@ /** FP-MTMS Version Checklist | Functions Ref. No. 39 | v1.0.0 | 2026-08-03 */ -export type FieldType = 'date' | 'text' | 'select' | 'number' | 'checkbox'; +export type FieldType = 'date' | 'month' | 'text' | 'select' | 'number' | 'checkbox'; import { NEXT_PUBLIC_API_URL } from "@/config/api"; @@ -29,6 +29,8 @@ export interface ReportField { multiline?: boolean; /** Rows for multiline text areas. Default 4. */ minRows?: number; + /** Month fields: GET returning { earliest: "YYYY-MM-DD" }. List runs from that month through the current month. */ + earliestMonthEndpoint?: string; } export type ReportResponseType = 'pdf' | 'excel'; @@ -322,7 +324,13 @@ export const REPORTS: ReportDefinition[] = [ apiEndpoint: `${NEXT_PUBLIC_API_URL}/report/print-stock-lot-ledger`, responseType: "excel", fields: [ - { label: "期間起 Period From(永遠該月1日)", name: "lastInDateStart", type: "date", required: true }, + { + label: "期間起 Period From", + name: "lastInDateStart", + type: "month", + required: true, + earliestMonthEndpoint: `${NEXT_PUBLIC_API_URL}/report/stock-lot-ledger-earliest`, + }, { label: "期間迄 Period To", name: "lastInDateEnd", type: "date", required: true }, asyncItemCodeField(), ], diff --git a/src/i18n/en/report.json b/src/i18n/en/report.json index ea517d8e..b8ad8a2c 100644 --- a/src/i18n/en/report.json +++ b/src/i18n/en/report.json @@ -139,7 +139,7 @@ "rep-020": { "title": "Stock Lot Ledger Report", "fields": { - "lastInDateStart": "Period From (always 1st of month)", + "lastInDateStart": "Period From", "lastInDateEnd": "Period To", "itemCode": "Item Code" } diff --git a/src/i18n/zh/report.json b/src/i18n/zh/report.json index 1c349ee5..c74e58e8 100644 --- a/src/i18n/zh/report.json +++ b/src/i18n/zh/report.json @@ -139,7 +139,7 @@ "rep-020": { "title": "庫存流水帳報告", "fields": { - "lastInDateStart": "期間起(永遠該月1日)", + "lastInDateStart": "期間起", "lastInDateEnd": "期間迄", "itemCode": "貨品編號" }