| @@ -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<string | null>(null); | |||
| const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null); | |||
| const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(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() { | |||
| /> | |||
| <div className="relative z-10 flex max-h-[90vh] w-full max-w-6xl flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-800"> | |||
| <div className="flex items-center justify-between border-b border-slate-200 bg-slate-100 px-4 py-3 dark:border-slate-700 dark:bg-slate-700/50"> | |||
| <h2 id="settings-panel-title" className="text-lg font-semibold text-slate-900 dark:text-white"> | |||
| <h2 | |||
| id="settings-panel-title" | |||
| className="text-lg font-semibold text-slate-900 dark:text-white" | |||
| > | |||
| 排期設定 | |||
| </h2> | |||
| <button | |||
| type="button" | |||
| onClick={() => setIsDailyOutPanelOpen(false)} | |||
| className="rounded p-1 text-slate-500 hover:bg-slate-200 hover:text-slate-700 dark:hover:bg-slate-600 dark:hover:text-slate-200" | |||
| > | |||
| 關閉 | |||
| </button> | |||
| <div className="flex items-center gap-2"> | |||
| <button | |||
| type="button" | |||
| onClick={handleExportFakeOnHand} | |||
| className="inline-flex items-center gap-1 rounded border border-amber-500/80 bg-amber-50 px-3 py-1 text-xs font-semibold text-amber-700 shadow-sm hover:bg-amber-100 dark:border-amber-400/80 dark:bg-slate-800 dark:text-amber-300 dark:hover:bg-amber-500/20" | |||
| > | |||
| 匯出排期庫存 Excel | |||
| </button> | |||
| <label className="inline-flex cursor-pointer items-center gap-1 rounded border border-blue-500/70 bg-white px-3 py-1 text-xs font-semibold text-blue-600 shadow-sm hover:bg-blue-50 dark:border-blue-500/50 dark:bg-slate-800 dark:text-blue-400 dark:hover:bg-blue-500/10"> | |||
| <span className="inline-flex h-2 w-2 rounded-full bg-amber-500" /> | |||
| <span className="text-amber-700 dark:text-amber-300">匯入排期庫存 Excel(覆蓋設定)</span> | |||
| <input | |||
| type="file" | |||
| accept=".xlsx,.xls" | |||
| className="hidden" | |||
| onChange={(e) => { | |||
| const file = e.target.files?.[0]; | |||
| if (file) { | |||
| void handleImportFakeOnHand(file); | |||
| e.target.value = ""; | |||
| } | |||
| }} | |||
| /> | |||
| </label> | |||
| <button | |||
| type="button" | |||
| onClick={() => setIsDailyOutPanelOpen(false)} | |||
| className="rounded p-1 text-slate-500 hover:bg-slate-200 hover:text-slate-700 dark:hover:bg-slate-600 dark:hover:text-slate-200" | |||
| > | |||
| 關閉 | |||
| </button> | |||
| </div> | |||
| </div> | |||
| <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400"> | |||
| 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 | |||