"use client"; import React, { useState, useEffect, useMemo } from "react"; import Search from "@mui/icons-material/Search"; import Visibility from "@mui/icons-material/Visibility"; import FormatListNumbered from "@mui/icons-material/FormatListNumbered"; import ShowChart from "@mui/icons-material/ShowChart"; import Download from "@mui/icons-material/Download"; import Hub from "@mui/icons-material/Hub"; import Settings from "@mui/icons-material/Settings"; import Clear from "@mui/icons-material/Clear"; import { CircularProgress } from "@mui/material"; import PageTitleBar from "@/components/PageTitleBar"; import dayjs from "dayjs"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; type ItemDailyOutRow = { itemCode: string; itemName: string; unit?: string; onHandQty?: number | null; fakeOnHandQty?: number | null; avgQtyLastMonth?: number; dailyQty?: number | null; isCoffee?: number; isTea?: number; isLemon?: number; }; export default function ProductionSchedulePage() { const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD")); const [schedules, setSchedules] = useState([]); const [selectedLines, setSelectedLines] = useState([]); const [isDetailOpen, setIsDetailOpen] = useState(false); const [selectedPs, setSelectedPs] = useState(null); const [loading, setLoading] = useState(false); const [isGenerating, setIsGenerating] = useState(false); const [isForecastDialogOpen, setIsForecastDialogOpen] = useState(false); const [forecastStartDate, setForecastStartDate] = useState( dayjs().format("YYYY-MM-DD") ); const [forecastDays, setForecastDays] = useState(7); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [exportFromDate, setExportFromDate] = useState( dayjs().format("YYYY-MM-DD") ); const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false); const [itemDailyOutList, setItemDailyOutList] = useState([]); const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); const [dailyOutSavingCode, setDailyOutSavingCode] = useState(null); const [dailyOutClearingCode, setDailyOutClearingCode] = useState(null); const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState(null); const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState(null); const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState(null); useEffect(() => { handleSearch(); }, []); const formatBackendDate = (dateVal: any) => { if (Array.isArray(dateVal)) { const [year, month, day] = dateVal; return dayjs(new Date(year, month - 1, day)).format("DD MMM (dddd)"); } return dayjs(dateVal).format("DD MMM (dddd)"); }; const formatNum = (num: any) => { return new Intl.NumberFormat("en-US").format(Number(num) || 0); }; const handleSearch = async () => { setLoading(true); try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`, { method: "GET" } ); if (response.status === 401 || response.status === 403) return; const data = await response.json(); setSchedules(Array.isArray(data) ? data : []); } catch (e) { console.error("Search Error:", e); } finally { setLoading(false); } }; const handleConfirmForecast = async () => { if (!forecastStartDate || forecastDays === "" || forecastDays < 1) { alert("Please enter a valid start date and number of days (≥1)."); return; } setLoading(true); setIsForecastDialogOpen(false); try { const params = new URLSearchParams({ startDate: forecastStartDate, days: forecastDays.toString(), }); const url = `${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule?${params.toString()}`; const response = await clientAuthFetch(url, { method: "GET" }); if (response.status === 401 || response.status === 403) return; if (response.ok) { await handleSearch(); alert("成功計算排期!"); } else { const errorText = await response.text(); console.error("Forecast failed:", errorText); alert(`計算錯誤: ${response.status} - ${errorText.substring(0, 120)}`); } } catch (e) { console.error("Forecast Error:", e); alert("發生不明狀況."); } finally { setLoading(false); } }; const handleConfirmExport = async () => { if (!exportFromDate) { alert("Please select a from date."); return; } setLoading(true); setIsExportDialogOpen(false); try { const params = new URLSearchParams({ fromDate: exportFromDate }); const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`, { method: "GET" } ); if (response.status === 401 || response.status === 403) return; if (!response.ok) throw new Error(`Export failed: ${response.status}`); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `production_schedule_from_${exportFromDate.replace(/-/g, "")}.xlsx`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); } catch (e) { console.error("Export Error:", e); alert("Failed to export file."); } finally { setLoading(false); } }; const handleViewDetail = async (ps: any) => { if (!ps?.id) { alert("Cannot open details: missing schedule ID"); return; } setSelectedPs(ps); setLoading(true); try { const url = `${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`; const response = await clientAuthFetch(url, { method: "GET" }); if (response.status === 401 || response.status === 403) return; if (!response.ok) { const errorText = await response.text().catch(() => "(no text)"); alert(`Server error ${response.status}: ${errorText}`); return; } const data = await response.json(); setSelectedLines(Array.isArray(data) ? data : []); setIsDetailOpen(true); } catch (err) { console.error("Fetch failed:", err); alert("Network or fetch error – check console"); } finally { setLoading(false); } }; const handleAutoGenJob = async () => { setIsGenerating(true); try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: selectedPs.id }), } ); if (response.status === 401 || response.status === 403) return; if (response.ok) { const data = await response.json(); alert(data.message || "Operation completed."); setIsDetailOpen(false); } else { alert("Failed to generate jobs."); } } catch (e) { console.error("Release Error:", e); } finally { setIsGenerating(false); } }; const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD"); const toDateDefault = dayjs().format("YYYY-MM-DD"); const fetchItemDailyOut = async () => { setItemDailyOutLoading(true); try { const params = new URLSearchParams({ fromDate: fromDateDefault, toDate: toDateDefault, }); const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/ps/itemDailyOut.json?${params.toString()}`, { method: "GET" } ); if (response.status === 401 || response.status === 403) return; const data = await response.json(); const rows: ItemDailyOutRow[] = (Array.isArray(data) ? data : []).map( (r: any) => ({ itemCode: r.itemCode ?? "", itemName: r.itemName ?? "", unit: r.unit != null ? String(r.unit) : "", onHandQty: r.onHandQty != null ? Number(r.onHandQty) : null, fakeOnHandQty: r.fakeOnHandQty != null && r.fakeOnHandQty !== "" ? Number(r.fakeOnHandQty) : null, avgQtyLastMonth: r.avgQtyLastMonth != null ? Number(r.avgQtyLastMonth) : undefined, dailyQty: r.dailyQty != null && r.dailyQty !== "" ? Number(r.dailyQty) : null, isCoffee: r.isCoffee != null ? Number(r.isCoffee) : 0, isTea: r.isTea != null ? Number(r.isTea) : 0, isLemon: r.isLemon != null ? Number(r.isLemon) : 0, }) ); setItemDailyOutList(rows); } catch (e) { console.error("itemDailyOut Error:", e); setItemDailyOutList([]); } finally { setItemDailyOutLoading(false); } }; const openSettingsPanel = () => { setIsDailyOutPanelOpen(true); fetchItemDailyOut(); }; const handleSaveDailyQty = async (itemCode: string, dailyQty: number) => { setDailyOutSavingCode(itemCode); try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/ps/setDailyQtyOut`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemCode, dailyQty }), } ); if (response.status === 401 || response.status === 403) return; if (response.ok) { setItemDailyOutList((prev) => prev.map((r) => r.itemCode === itemCode ? { ...r, dailyQty } : r ) ); } else { alert("儲存失敗"); } } catch (e) { console.error("setDailyQtyOut Error:", e); alert("儲存失敗"); } finally { setDailyOutSavingCode(null); } }; const handleClearDailyQty = async (itemCode: string) => { if (!confirm(`確定要清除${itemCode}的設定排期每天出貨量嗎?`)) return; setDailyOutClearingCode(itemCode); try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/ps/clearDailyQtyOut`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemCode }), } ); if (response.status === 401 || response.status === 403) return; if (response.ok) { setItemDailyOutList((prev) => prev.map((r) => r.itemCode === itemCode ? { ...r, dailyQty: null } : r ) ); } else { alert("清除失敗"); } } catch (e) { console.error("clearDailyQtyOut Error:", e); alert("清除失敗"); } finally { setDailyOutClearingCode(null); } }; const handleSetCoffeeOrTea = async ( itemCode: string, systemType: "coffee" | "tea" | "lemon", enabled: boolean ) => { const key = `${itemCode}-${systemType}`; setCoffeeOrTeaUpdating(key); try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/ps/setCoffeeOrTea`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemCode, systemType, enabled }), } ); if (response.status === 401 || response.status === 403) return; if (response.ok) { setItemDailyOutList((prev) => prev.map((r) => { if (r.itemCode !== itemCode) return r; const next = { ...r }; if (systemType === "coffee") next.isCoffee = enabled ? 1 : 0; if (systemType === "tea") next.isTea = enabled ? 1 : 0; if (systemType === "lemon") next.isLemon = enabled ? 1 : 0; return next; }) ); } else { alert("設定失敗"); } } catch (e) { console.error("setCoffeeOrTea Error:", e); alert("設定失敗"); } finally { setCoffeeOrTeaUpdating(null); } }; const handleSetFakeOnHand = async (itemCode: string, onHandQty: number) => { setFakeOnHandSavingCode(itemCode); try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemCode, onHandQty }), } ); if (response.status === 401 || response.status === 403) return; if (response.ok) { setItemDailyOutList((prev) => prev.map((r) => r.itemCode === itemCode ? { ...r, fakeOnHandQty: onHandQty } : r ) ); } else { alert("設定失敗"); } } catch (e) { console.error("setFakeOnHand Error:", e); alert("設定失敗"); } finally { setFakeOnHandSavingCode(null); } }; const handleClearFakeOnHand = async (itemCode: string) => { if (!confirm("確定要清除此物料的設定排期庫存嗎?")) return; setFakeOnHandClearingCode(itemCode); try { const response = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ itemCode, onHandQty: null }), } ); if (response.status === 401 || response.status === 403) return; if (response.ok) { setItemDailyOutList((prev) => prev.map((r) => r.itemCode === itemCode ? { ...r, fakeOnHandQty: null } : r ) ); } else { alert("清除失敗"); } } catch (e) { console.error("clearFakeOnHand Error:", e); alert("清除失敗"); } finally { setFakeOnHandClearingCode(null); } }; return (
} className="mb-4" /> {/* Query Bar */}
setSearchDate(e.target.value)} className="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder-slate-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" />
{/* Main Table */}
{schedules.map((ps) => ( ))}
詳細 生產日期 預計生產數 成品款數
{formatBackendDate(ps.produceAt)} {formatNum(ps.totalEstProdCount)} {formatNum(ps.totalFGType)}
{/* Detail Modal – z-index above sidebar drawer (1200) so they don't overlap on small windows */} {isDetailOpen && (
!isGenerating && setIsDetailOpen(false)} />

排期詳細: {selectedPs?.id} ( {formatBackendDate(selectedPs?.produceAt)})

{selectedLines.map((line: any) => ( ))}
工單號 物料編號 物料名稱 每日平均出貨量 出貨前預計存貨量 單位 可用日 生產量(批) 預計生產包數 優先度
{line.joCode || "-"} {line.itemCode} {line.itemName} {formatNum(line.avgQtyLastMonth)} {formatNum(line.stockQty)} {line.stockUnit} {line.daysLeft} {formatNum(line.batchNeed)} {formatNum(line.prodQty)} {line.itemPriority}
)} {/* Forecast Dialog */} {isForecastDialogOpen && (
setIsForecastDialogOpen(false)} />

準備生成預計排期

setForecastStartDate(e.target.value)} min={dayjs().subtract(30, "day").format("YYYY-MM-DD")} className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" />
{ const val = e.target.value === "" ? "" : Number(e.target.value); if ( val === "" || (Number.isInteger(val) && val >= 1 && val <= 365) ) { setForecastDays(val); } }} className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" />
)} {/* Export Dialog */} {isExportDialogOpen && (
setIsExportDialogOpen(false)} />

匯出排期/物料用量預計

選擇要匯出的起始日期

setExportFromDate(e.target.value)} min={dayjs().subtract(90, "day").format("YYYY-MM-DD")} className="mb-4 w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" />
)} {/* 排期設定 Dialog */} {isDailyOutPanelOpen && (
setIsDailyOutPanelOpen(false)} />

排期設定

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

{itemDailyOutLoading ? (
) : ( {itemDailyOutList.map((row, idx) => ( ))}
物料編號 物料名稱 單位 庫存 設定排期庫存 過去平均出貨量 設定排期每天出貨量 咖啡 檸檬
)}
)}
); } function DailyOutRow({ row, onSave, onClear, onSetCoffeeOrTea, onSetFakeOnHand, onClearFakeOnHand, saving, clearing, coffeeOrTeaUpdating, fakeOnHandSaving, fakeOnHandClearing, formatNum, }: { row: ItemDailyOutRow; onSave: (itemCode: string, dailyQty: number) => void; onClear: (itemCode: string) => void; onSetCoffeeOrTea: (itemCode: string, systemType: "coffee" | "tea" | "lemon", enabled: boolean) => void; onSetFakeOnHand: (itemCode: string, onHandQty: number) => void; onClearFakeOnHand: (itemCode: string) => void; saving: boolean; clearing: boolean; coffeeOrTeaUpdating: string | null; fakeOnHandSaving: boolean; fakeOnHandClearing: boolean; formatNum: (n: any) => string; }) { const [editQty, setEditQty] = useState( row.dailyQty != null ? String(row.dailyQty) : "" ); const [editFakeOnHand, setEditFakeOnHand] = useState( row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : "" ); useEffect(() => { setEditQty(row.dailyQty != null ? String(row.dailyQty) : ""); }, [row.dailyQty]); useEffect(() => { setEditFakeOnHand(row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : ""); }, [row.fakeOnHandQty]); const numVal = parseFloat(editQty); const isValid = !Number.isNaN(numVal) && numVal >= 0; const hasSetQty = row.dailyQty != null; const fakeOnHandNum = parseFloat(editFakeOnHand); const isValidFakeOnHand = !Number.isNaN(fakeOnHandNum) && fakeOnHandNum >= 0; const hasSetFakeOnHand = row.fakeOnHandQty != null; const isCoffee = (row.isCoffee ?? 0) > 0; const isTea = (row.isTea ?? 0) > 0; const isLemon = (row.isLemon ?? 0) > 0; const updatingCoffee = coffeeOrTeaUpdating === `${row.itemCode}-coffee`; const updatingTea = coffeeOrTeaUpdating === `${row.itemCode}-tea`; const updatingLemon = coffeeOrTeaUpdating === `${row.itemCode}-lemon`; return ( {row.itemCode} {row.itemName} {row.unit ?? ""} {formatNum(row.onHandQty)}
setEditFakeOnHand(e.target.value)} onBlur={() => { if (isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum); }} onKeyDown={(e) => { if (e.key === "Enter" && isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum); }} className="w-24 rounded border border-slate-300 bg-white px-2 py-1 text-left text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" /> {hasSetFakeOnHand && ( )}
{formatNum(row.avgQtyLastMonth)}
setEditQty(e.target.value)} onBlur={() => { if (isValid) onSave(row.itemCode, numVal); }} onKeyDown={(e) => { if (e.key === "Enter" && isValid) onSave(row.itemCode, numVal); }} className="w-24 rounded border border-slate-300 bg-white px-2 py-1 text-left text-slate-900 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100" /> {hasSetQty && ( )}
); }