| @@ -1,6 +1,6 @@ | |||||
| "use client"; | "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 Search from "@mui/icons-material/Search"; | ||||
| import Visibility from "@mui/icons-material/Visibility"; | import Visibility from "@mui/icons-material/Visibility"; | ||||
| import FormatListNumbered from "@mui/icons-material/FormatListNumbered"; | import FormatListNumbered from "@mui/icons-material/FormatListNumbered"; | ||||
| @@ -14,6 +14,8 @@ import PageTitleBar from "@/components/PageTitleBar"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { NEXT_PUBLIC_API_URL } from "@/config/api"; | import { NEXT_PUBLIC_API_URL } from "@/config/api"; | ||||
| import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; | ||||
| import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx"; | |||||
| import * as XLSX from "xlsx"; | |||||
| type ItemDailyOutRow = { | type ItemDailyOutRow = { | ||||
| itemCode: string; | itemCode: string; | ||||
| @@ -56,6 +58,8 @@ export default function ProductionSchedulePage() { | |||||
| const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState<string | null>(null); | const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState<string | null>(null); | ||||
| const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null); | const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null); | ||||
| const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null); | const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null); | ||||
| const [isImportingFakeOnHand, setIsImportingFakeOnHand] = useState(false); | |||||
| const itemDailyOutRequestRef = useRef(0); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| handleSearch(); | handleSearch(); | ||||
| @@ -209,7 +213,13 @@ export default function ProductionSchedulePage() { | |||||
| const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD"); | const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD"); | ||||
| const toDateDefault = dayjs().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); | setItemDailyOutLoading(true); | ||||
| try { | try { | ||||
| const params = new URLSearchParams({ | const params = new URLSearchParams({ | ||||
| @@ -222,6 +232,10 @@ export default function ProductionSchedulePage() { | |||||
| ); | ); | ||||
| if (response.status === 401 || response.status === 403) return; | if (response.status === 401 || response.status === 403) return; | ||||
| const data = await response.json(); | 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( | const rows: ItemDailyOutRow[] = (Array.isArray(data) ? data : []).map( | ||||
| (r: any) => ({ | (r: any) => ({ | ||||
| itemCode: r.itemCode ?? "", | itemCode: r.itemCode ?? "", | ||||
| @@ -248,7 +262,10 @@ export default function ProductionSchedulePage() { | |||||
| console.error("itemDailyOut Error:", e); | console.error("itemDailyOut Error:", e); | ||||
| setItemDailyOutList([]); | setItemDailyOutList([]); | ||||
| } finally { | } 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(); | 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) => { | const handleSaveDailyQty = async (itemCode: string, dailyQty: number) => { | ||||
| setDailyOutSavingCode(itemCode); | setDailyOutSavingCode(itemCode); | ||||
| try { | 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="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"> | <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> | </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> | </div> | ||||
| <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400"> | <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400"> | ||||
| 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 | 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 | ||||