| @@ -7,12 +7,27 @@ 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<any[]>([]); | |||
| @@ -33,6 +48,15 @@ export default function ProductionSchedulePage() { | |||
| dayjs().format("YYYY-MM-DD") | |||
| ); | |||
| const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false); | |||
| const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]); | |||
| const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false); | |||
| const [dailyOutSavingCode, setDailyOutSavingCode] = useState<string | null>(null); | |||
| const [dailyOutClearingCode, setDailyOutClearingCode] = useState<string | null>(null); | |||
| const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState<string | null>(null); | |||
| const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null); | |||
| const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null); | |||
| useEffect(() => { | |||
| handleSearch(); | |||
| }, []); | |||
| @@ -182,12 +206,228 @@ export default function ProductionSchedulePage() { | |||
| } | |||
| }; | |||
| 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 ( | |||
| <div className="space-y-4"> | |||
| <PageTitleBar | |||
| title="排程" | |||
| actions={ | |||
| <> | |||
| <button | |||
| type="button" | |||
| onClick={openSettingsPanel} | |||
| className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 shadow-sm transition hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700" | |||
| > | |||
| <Settings sx={{ fontSize: 16 }} /> | |||
| 排期設定 | |||
| </button> | |||
| <button | |||
| type="button" | |||
| onClick={() => setIsExportDialogOpen(true)} | |||
| @@ -557,6 +797,237 @@ export default function ProductionSchedulePage() { | |||
| </div> | |||
| </div> | |||
| )} | |||
| {/* 排期設定 Dialog */} | |||
| {isDailyOutPanelOpen && ( | |||
| <div | |||
| className="fixed inset-0 z-[1300] flex items-center justify-center p-4" | |||
| role="dialog" | |||
| aria-modal="true" | |||
| aria-labelledby="settings-panel-title" | |||
| > | |||
| <div | |||
| className="absolute inset-0 bg-black/50" | |||
| onClick={() => setIsDailyOutPanelOpen(false)} | |||
| /> | |||
| <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> | |||
| <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> | |||
| <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400"> | |||
| 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。 | |||
| </p> | |||
| <div className="max-h-[60vh] overflow-auto"> | |||
| {itemDailyOutLoading ? ( | |||
| <div className="flex items-center justify-center py-12"> | |||
| <CircularProgress /> | |||
| </div> | |||
| ) : ( | |||
| <table className="w-full min-w-[900px] text-left text-sm"> | |||
| <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700"> | |||
| <tr> | |||
| <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料編號</th> | |||
| <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料名稱</th> | |||
| <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">單位</th> | |||
| <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">庫存</th> | |||
| <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期庫存</th> | |||
| <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">過去平均出貨量</th> | |||
| <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期每天出貨量</th> | |||
| <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">咖啡</th> | |||
| <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">茶</th> | |||
| <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">檸檬</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| {itemDailyOutList.map((row, idx) => ( | |||
| <DailyOutRow | |||
| key={`${row.itemCode}-${idx}`} | |||
| row={row} | |||
| onSave={handleSaveDailyQty} | |||
| onClear={handleClearDailyQty} | |||
| onSetCoffeeOrTea={handleSetCoffeeOrTea} | |||
| onSetFakeOnHand={handleSetFakeOnHand} | |||
| onClearFakeOnHand={handleClearFakeOnHand} | |||
| saving={dailyOutSavingCode === row.itemCode} | |||
| clearing={dailyOutClearingCode === row.itemCode} | |||
| coffeeOrTeaUpdating={coffeeOrTeaUpdating} | |||
| fakeOnHandSaving={fakeOnHandSavingCode === row.itemCode} | |||
| fakeOnHandClearing={fakeOnHandClearingCode === row.itemCode} | |||
| formatNum={formatNum} | |||
| /> | |||
| ))} | |||
| </tbody> | |||
| </table> | |||
| )} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ); | |||
| } | |||
| 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<string>( | |||
| row.dailyQty != null ? String(row.dailyQty) : "" | |||
| ); | |||
| const [editFakeOnHand, setEditFakeOnHand] = useState<string>( | |||
| 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 ( | |||
| <tr className="border-t border-slate-200 text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700/30"> | |||
| <td className="px-4 py-2 font-medium">{row.itemCode}</td> | |||
| <td className="px-4 py-2">{row.itemName}</td> | |||
| <td className="px-4 py-2">{row.unit ?? ""}</td> | |||
| <td className="px-4 py-2 text-right">{formatNum(row.onHandQty)}</td> | |||
| <td className="px-4 py-2 text-left"> | |||
| <div className="flex items-center justify-start gap-0.5"> | |||
| <input | |||
| type="number" | |||
| min={0} | |||
| step={1} | |||
| value={editFakeOnHand} | |||
| onChange={(e) => 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 && ( | |||
| <button | |||
| type="button" | |||
| disabled={fakeOnHandClearing} | |||
| onClick={() => onClearFakeOnHand(row.itemCode)} | |||
| title="清除設定排期庫存" | |||
| className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200" | |||
| > | |||
| {fakeOnHandClearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />} | |||
| </button> | |||
| )} | |||
| </div> | |||
| </td> | |||
| <td className="px-4 py-2 text-right">{formatNum(row.avgQtyLastMonth)}</td> | |||
| <td className="px-4 py-2 text-left"> | |||
| <div className="flex items-center justify-start gap-0.5"> | |||
| <input | |||
| type="number" | |||
| min={0} | |||
| step={1} | |||
| value={editQty} | |||
| onChange={(e) => 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 && ( | |||
| <button | |||
| type="button" | |||
| disabled={clearing} | |||
| onClick={() => onClear(row.itemCode)} | |||
| title="清除設定排期每天出貨量" | |||
| className="rounded p-0.5 text-slate-400 hover:bg-slate-200 hover:text-slate-600 disabled:opacity-50 dark:hover:bg-slate-600 dark:hover:text-slate-200" | |||
| > | |||
| {clearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />} | |||
| </button> | |||
| )} | |||
| </div> | |||
| </td> | |||
| <td className="px-4 py-2 text-center"> | |||
| <label className="inline-flex cursor-pointer items-center gap-1"> | |||
| <input | |||
| type="checkbox" | |||
| checked={isCoffee} | |||
| disabled={updatingCoffee} | |||
| onChange={(e) => onSetCoffeeOrTea(row.itemCode, "coffee", e.target.checked)} | |||
| className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500" | |||
| /> | |||
| {updatingCoffee && <CircularProgress size={14} sx={{ display: "block" }} />} | |||
| </label> | |||
| </td> | |||
| <td className="px-4 py-2 text-center"> | |||
| <label className="inline-flex cursor-pointer items-center gap-1"> | |||
| <input | |||
| type="checkbox" | |||
| checked={isTea} | |||
| disabled={updatingTea} | |||
| onChange={(e) => onSetCoffeeOrTea(row.itemCode, "tea", e.target.checked)} | |||
| className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500" | |||
| /> | |||
| {updatingTea && <CircularProgress size={14} sx={{ display: "block" }} />} | |||
| </label> | |||
| </td> | |||
| <td className="px-4 py-2 text-center"> | |||
| <label className="inline-flex cursor-pointer items-center gap-1"> | |||
| <input | |||
| type="checkbox" | |||
| checked={isLemon} | |||
| disabled={updatingLemon} | |||
| onChange={(e) => onSetCoffeeOrTea(row.itemCode, "lemon", e.target.checked)} | |||
| className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500" | |||
| /> | |||
| {updatingLemon && <CircularProgress size={14} sx={{ display: "block" }} />} | |||
| </label> | |||
| </td> | |||
| </tr> | |||
| ); | |||
| } | |||