|
|
|
@@ -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 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 |
|
|
|
|