diff --git a/src/app/(main)/ps/page.tsx b/src/app/(main)/ps/page.tsx index de3b1be..6c72bc6 100644 --- a/src/app/(main)/ps/page.tsx +++ b/src/app/(main)/ps/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useMemo } from "react"; +import React, { useState, useEffect, useMemo, useRef } from "react"; import Search from "@mui/icons-material/Search"; import Visibility from "@mui/icons-material/Visibility"; import FormatListNumbered from "@mui/icons-material/FormatListNumbered"; @@ -14,6 +14,8 @@ import PageTitleBar from "@/components/PageTitleBar"; import dayjs from "dayjs"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; +import * as XLSX from "xlsx"; type ItemDailyOutRow = { itemCode: string; @@ -56,6 +58,8 @@ export default function ProductionSchedulePage() { const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState(null); const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState(null); const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState(null); + const [isImportingFakeOnHand, setIsImportingFakeOnHand] = useState(false); + const itemDailyOutRequestRef = useRef(0); useEffect(() => { handleSearch(); @@ -209,7 +213,13 @@ export default function ProductionSchedulePage() { const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD"); const toDateDefault = dayjs().format("YYYY-MM-DD"); - const fetchItemDailyOut = async () => { + const fetchItemDailyOut = async (force: boolean = false) => { + // Avoid starting a new fetch while an import is in progress, + // unless explicitly forced (after a successful import). + if (!force && isImportingFakeOnHand) return; + + const currentReq = itemDailyOutRequestRef.current + 1; + itemDailyOutRequestRef.current = currentReq; setItemDailyOutLoading(true); try { const params = new URLSearchParams({ @@ -222,6 +232,10 @@ export default function ProductionSchedulePage() { ); if (response.status === 401 || response.status === 403) return; const data = await response.json(); + // If a newer request has started, ignore this response to avoid overwriting with stale data + if (itemDailyOutRequestRef.current !== currentReq) { + return; + } const rows: ItemDailyOutRow[] = (Array.isArray(data) ? data : []).map( (r: any) => ({ itemCode: r.itemCode ?? "", @@ -248,7 +262,10 @@ export default function ProductionSchedulePage() { console.error("itemDailyOut Error:", e); setItemDailyOutList([]); } finally { - setItemDailyOutLoading(false); + // Only clear loading state if this is the latest request + if (itemDailyOutRequestRef.current === currentReq) { + setItemDailyOutLoading(false); + } } }; @@ -257,6 +274,107 @@ export default function ProductionSchedulePage() { fetchItemDailyOut(); }; + /** Download current fake on-hand overrides (item_fake_onhand) as Excel template. */ + const handleExportFakeOnHand = () => { + const rows = itemDailyOutList + .filter((row) => row.fakeOnHandQty != null) + .map((row) => ({ + itemCode: row.itemCode, + onHandQty: row.fakeOnHandQty, + })); + exportChartToXlsx(rows, "item_fake_onhand", "item_fake_onhand"); + }; + + /** Upload Excel and bulk update item_fake_onhand via /ps/setFakeOnHand. */ + const handleImportFakeOnHand = async (file: File) => { + try { + setIsImportingFakeOnHand(true); + + const data = await file.arrayBuffer(); + const workbook = XLSX.read(data, { type: "array" }); + const sheetName = workbook.SheetNames[0]; + if (!sheetName) { + alert("Excel 沒有工作表。"); + return; + } + const sheet = workbook.Sheets[sheetName]; + const rows: any[] = XLSX.utils.sheet_to_json(sheet, { defval: null }); + if (!rows.length) { + alert("Excel 內容為空。"); + return; + } + + // Build allowed itemCodes (BOM scope) from current list + const allowedCodes = new Set(itemDailyOutList.map((r) => r.itemCode)); + + const invalidCodes: string[] = []; + + // Map Excel rows to backend payload format + const payload = rows + .map((row) => { + const itemCode = (row.itemCode ?? row.ItemCode ?? row["Item Code"])?.toString().trim(); + if (!itemCode) return null; + if (!allowedCodes.has(itemCode)) { + invalidCodes.push(itemCode); + } + const rawQty = row.onHandQty ?? row.OnHandQty ?? row["On Hand Qty"]; + const qtyNum = + rawQty === null || rawQty === "" || typeof rawQty === "undefined" + ? null + : Number(rawQty); + return { itemCode, onHandQty: qtyNum }; + }) + .filter((r): r is { itemCode: string; onHandQty: number | null } => r !== null); + + if (!payload.length) { + alert("找不到任何有效的 itemCode。"); + return; + } + + // Warn user about itemCodes that are not in BOM scope (won't affect forecast) + if (invalidCodes.length) { + const preview = invalidCodes.slice(0, 10).join(", "); + alert( + `注意:以下物料編號不在排期 BOM 範圍內,預測不會受影響,只會寫入覆蓋表。\n\n` + + `${preview}${invalidCodes.length > 10 ? ` 等共 ${invalidCodes.length} 筆` : ""}` + ); + } + + const resp = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/ps/importFakeOnHand`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + + if (resp.status === 401 || resp.status === 403) { + alert("登入已過期或沒有權限,請重新登入後再試。"); + return; + } + + if (!resp.ok) { + const msg = await resp.text().catch(() => ""); + alert( + `匯入失敗(狀態碼 ${resp.status})。${ + msg ? `\n伺服器訊息:${msg.slice(0, 120)}` : "" + }` + ); + return; + } + + const result = await resp.json().catch(() => ({ count: payload.length })); + + // Backend clears item_fake_onhand then inserts payload rows, + // so after success the table should exactly match the uploaded Excel. + await fetchItemDailyOut(true); + alert(`已成功匯入並更新 ${result.count ?? payload.length} 筆排期庫存 (item_fake_onhand)。`); + } catch (e) { + console.error("Import fake on hand error:", e); + alert("匯入失敗,請檢查檔案格式。"); + } finally { + setIsImportingFakeOnHand(false); + } + }; + const handleSaveDailyQty = async (itemCode: string, dailyQty: number) => { setDailyOutSavingCode(itemCode); try { @@ -812,16 +930,44 @@ export default function ProductionSchedulePage() { />
-

+

排期設定

- +
+ + + +

預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。