FPSMS-frontend
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 

1371 Zeilen
56 KiB

  1. "use client";
  2. import React, { useState, useEffect, useMemo, useRef } from "react";
  3. import Search from "@mui/icons-material/Search";
  4. import Visibility from "@mui/icons-material/Visibility";
  5. import FormatListNumbered from "@mui/icons-material/FormatListNumbered";
  6. import ShowChart from "@mui/icons-material/ShowChart";
  7. import Download from "@mui/icons-material/Download";
  8. import Hub from "@mui/icons-material/Hub";
  9. import Settings from "@mui/icons-material/Settings";
  10. import Clear from "@mui/icons-material/Clear";
  11. import Refresh from "@mui/icons-material/Refresh";
  12. import { CircularProgress } from "@mui/material";
  13. import PageTitleBar from "@/components/PageTitleBar";
  14. import dayjs from "dayjs";
  15. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  16. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  17. import { exportChartToXlsx } from "@/app/(main)/chart/_components/exportChartToXlsx";
  18. import * as XLSX from "xlsx";
  19. type ItemDailyOutRow = {
  20. itemCode: string;
  21. itemName: string;
  22. unit?: string;
  23. onHandQty?: number | null;
  24. fakeOnHandQty?: number | null;
  25. avgQtyLastMonth?: number;
  26. dailyQty?: number | null;
  27. isCoffee?: number;
  28. isTea?: number;
  29. isLemon?: number;
  30. };
  31. export default function ProductionSchedulePage() {
  32. const [searchDate, setSearchDate] = useState(dayjs().format("YYYY-MM-DD"));
  33. const [schedules, setSchedules] = useState<any[]>([]);
  34. const [selectedLines, setSelectedLines] = useState<any[]>([]);
  35. const [isDetailOpen, setIsDetailOpen] = useState(false);
  36. const [selectedPs, setSelectedPs] = useState<any>(null);
  37. const [loading, setLoading] = useState(false);
  38. const [isGenerating, setIsGenerating] = useState(false);
  39. const [isForecastDialogOpen, setIsForecastDialogOpen] = useState(false);
  40. const [forecastStartDate, setForecastStartDate] = useState(
  41. dayjs().format("YYYY-MM-DD")
  42. );
  43. const [forecastDays, setForecastDays] = useState<number | "">(7);
  44. const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
  45. const [exportFromDate, setExportFromDate] = useState(
  46. dayjs().format("YYYY-MM-DD")
  47. );
  48. const [isDoQtyExportDialogOpen, setIsDoQtyExportDialogOpen] = useState(false);
  49. const [doQtyExportFromDate, setDoQtyExportFromDate] = useState(
  50. dayjs().subtract(6, "day").format("YYYY-MM-DD")
  51. );
  52. const [doQtyExportToDate, setDoQtyExportToDate] = useState(
  53. dayjs().format("YYYY-MM-DD")
  54. );
  55. const [isDoQtyExporting, setIsDoQtyExporting] = useState(false);
  56. const doQtyExportInFlightRef = useRef(false);
  57. const [isDailyOutPanelOpen, setIsDailyOutPanelOpen] = useState(false);
  58. const [itemDailyOutList, setItemDailyOutList] = useState<ItemDailyOutRow[]>([]);
  59. const [itemDailyOutLoading, setItemDailyOutLoading] = useState(false);
  60. const [dailyOutSavingCode, setDailyOutSavingCode] = useState<string | null>(null);
  61. const [dailyOutClearingCode, setDailyOutClearingCode] = useState<string | null>(null);
  62. const [coffeeOrTeaUpdating, setCoffeeOrTeaUpdating] = useState<string | null>(null);
  63. const [fakeOnHandSavingCode, setFakeOnHandSavingCode] = useState<string | null>(null);
  64. const [fakeOnHandClearingCode, setFakeOnHandClearingCode] = useState<string | null>(null);
  65. const [isImportingFakeOnHand, setIsImportingFakeOnHand] = useState(false);
  66. const [isRefreshingOnHand, setIsRefreshingOnHand] = useState(false);
  67. const refreshOnHandInFlightRef = useRef(false);
  68. const itemDailyOutRequestRef = useRef(0);
  69. useEffect(() => {
  70. handleSearch();
  71. }, []);
  72. const formatBackendDate = (dateVal: any) => {
  73. if (Array.isArray(dateVal)) {
  74. const [year, month, day] = dateVal;
  75. return dayjs(new Date(year, month - 1, day)).format("DD MMM (dddd)");
  76. }
  77. return dayjs(dateVal).format("DD MMM (dddd)");
  78. };
  79. const formatNum = (num: any) => {
  80. return new Intl.NumberFormat("en-US").format(Number(num) || 0);
  81. };
  82. const handleSearch = async () => {
  83. setLoading(true);
  84. try {
  85. const response = await clientAuthFetch(
  86. `${NEXT_PUBLIC_API_URL}/ps/search-ps?produceAt=${searchDate}`,
  87. { method: "GET" }
  88. );
  89. if (response.status === 401 || response.status === 403) return;
  90. const data = await response.json();
  91. setSchedules(Array.isArray(data) ? data : []);
  92. } catch (e) {
  93. console.error("Search Error:", e);
  94. } finally {
  95. setLoading(false);
  96. }
  97. };
  98. const handleConfirmForecast = async () => {
  99. if (!forecastStartDate || forecastDays === "" || forecastDays < 1) {
  100. alert("Please enter a valid start date and number of days (≥1).");
  101. return;
  102. }
  103. setLoading(true);
  104. setIsForecastDialogOpen(false);
  105. try {
  106. const params = new URLSearchParams({
  107. startDate: forecastStartDate,
  108. days: forecastDays.toString(),
  109. });
  110. const url = `${NEXT_PUBLIC_API_URL}/productionSchedule/testDetailedSchedule?${params.toString()}`;
  111. const response = await clientAuthFetch(url, { method: "GET" });
  112. if (response.status === 401 || response.status === 403) return;
  113. if (response.ok) {
  114. await handleSearch();
  115. alert("成功計算排期!");
  116. } else {
  117. const errorText = await response.text();
  118. console.error("Forecast failed:", errorText);
  119. alert(`計算錯誤: ${response.status} - ${errorText.substring(0, 120)}`);
  120. }
  121. } catch (e) {
  122. console.error("Forecast Error:", e);
  123. alert("發生不明狀況.");
  124. } finally {
  125. setLoading(false);
  126. }
  127. };
  128. const handleConfirmDoQtyExport = async () => {
  129. if (doQtyExportInFlightRef.current) return;
  130. if (!doQtyExportFromDate || !doQtyExportToDate) {
  131. alert("請選擇開始及結束日期。");
  132. return;
  133. }
  134. if (dayjs(doQtyExportFromDate).isAfter(dayjs(doQtyExportToDate))) {
  135. alert("開始日期不可晚於結束日期。");
  136. return;
  137. }
  138. doQtyExportInFlightRef.current = true;
  139. setIsDoQtyExporting(true);
  140. setIsDoQtyExportDialogOpen(false);
  141. try {
  142. const params = new URLSearchParams({
  143. fromDate: doQtyExportFromDate,
  144. toDate: doQtyExportToDate,
  145. });
  146. const response = await clientAuthFetch(
  147. `${NEXT_PUBLIC_API_URL}/ps/export-do-qty-by-date?${params.toString()}`,
  148. { method: "GET" }
  149. );
  150. if (response.status === 401 || response.status === 403) return;
  151. if (!response.ok) {
  152. const errText = await response.text().catch(() => "");
  153. throw new Error(errText || `Export failed: ${response.status}`);
  154. }
  155. const blob = await response.blob();
  156. const url = window.URL.createObjectURL(blob);
  157. const a = document.createElement("a");
  158. a.href = url;
  159. a.download = `do_qty_${doQtyExportFromDate.replace(/-/g, "")}_${doQtyExportToDate.replace(/-/g, "")}.xlsx`;
  160. document.body.appendChild(a);
  161. a.click();
  162. window.URL.revokeObjectURL(url);
  163. document.body.removeChild(a);
  164. } catch (e) {
  165. console.error("DO qty export Error:", e);
  166. alert("匯出送貨單數量失敗。");
  167. } finally {
  168. setIsDoQtyExporting(false);
  169. doQtyExportInFlightRef.current = false;
  170. }
  171. };
  172. const handleConfirmExport = async () => {
  173. if (!exportFromDate) {
  174. alert("Please select a from date.");
  175. return;
  176. }
  177. setLoading(true);
  178. setIsExportDialogOpen(false);
  179. try {
  180. const params = new URLSearchParams({ fromDate: exportFromDate });
  181. const response = await clientAuthFetch(
  182. `${NEXT_PUBLIC_API_URL}/productionSchedule/export-prod-schedule?${params.toString()}`,
  183. { method: "GET" }
  184. );
  185. if (response.status === 401 || response.status === 403) return;
  186. if (!response.ok) throw new Error(`Export failed: ${response.status}`);
  187. const blob = await response.blob();
  188. const url = window.URL.createObjectURL(blob);
  189. const a = document.createElement("a");
  190. a.href = url;
  191. a.download = `production_schedule_from_${exportFromDate.replace(/-/g, "")}.xlsx`;
  192. document.body.appendChild(a);
  193. a.click();
  194. window.URL.revokeObjectURL(url);
  195. document.body.removeChild(a);
  196. } catch (e) {
  197. console.error("Export Error:", e);
  198. alert("Failed to export file.");
  199. } finally {
  200. setLoading(false);
  201. }
  202. };
  203. const handleViewDetail = async (ps: any) => {
  204. if (!ps?.id) {
  205. alert("Cannot open details: missing schedule ID");
  206. return;
  207. }
  208. setSelectedPs(ps);
  209. setLoading(true);
  210. try {
  211. const url = `${NEXT_PUBLIC_API_URL}/ps/search-ps-line?psId=${ps.id}`;
  212. const response = await clientAuthFetch(url, { method: "GET" });
  213. if (response.status === 401 || response.status === 403) return;
  214. if (!response.ok) {
  215. const errorText = await response.text().catch(() => "(no text)");
  216. alert(`Server error ${response.status}: ${errorText}`);
  217. return;
  218. }
  219. const data = await response.json();
  220. setSelectedLines(Array.isArray(data) ? data : []);
  221. setIsDetailOpen(true);
  222. } catch (err) {
  223. console.error("Fetch failed:", err);
  224. alert("Network or fetch error – check console");
  225. } finally {
  226. setLoading(false);
  227. }
  228. };
  229. const handleAutoGenJob = async () => {
  230. setIsGenerating(true);
  231. try {
  232. const response = await clientAuthFetch(
  233. `${NEXT_PUBLIC_API_URL}/productionSchedule/detail/detailed/release`,
  234. {
  235. method: "POST",
  236. headers: { "Content-Type": "application/json" },
  237. body: JSON.stringify({ id: selectedPs.id }),
  238. }
  239. );
  240. if (response.status === 401 || response.status === 403) return;
  241. if (response.ok) {
  242. const data = await response.json();
  243. alert(data.message || "Operation completed.");
  244. setIsDetailOpen(false);
  245. } else {
  246. alert("Failed to generate jobs.");
  247. }
  248. } catch (e) {
  249. console.error("Release Error:", e);
  250. } finally {
  251. setIsGenerating(false);
  252. }
  253. };
  254. const fromDateDefault = dayjs().subtract(6, "day").format("YYYY-MM-DD");
  255. const toDateDefault = dayjs().add(1, "day").format("YYYY-MM-DD");
  256. const fetchItemDailyOut = async (force: boolean = false) => {
  257. // Avoid starting a new fetch while an import is in progress,
  258. // unless explicitly forced (after a successful import).
  259. if (!force && isImportingFakeOnHand) return;
  260. const currentReq = itemDailyOutRequestRef.current + 1;
  261. itemDailyOutRequestRef.current = currentReq;
  262. setItemDailyOutLoading(true);
  263. try {
  264. const params = new URLSearchParams({
  265. fromDate: fromDateDefault,
  266. toDate: toDateDefault,
  267. });
  268. const response = await clientAuthFetch(
  269. `${NEXT_PUBLIC_API_URL}/ps/itemDailyOut.json?${params.toString()}`,
  270. { method: "GET" }
  271. );
  272. if (response.status === 401 || response.status === 403) return;
  273. const data = await response.json();
  274. // If a newer request has started, ignore this response to avoid overwriting with stale data
  275. if (itemDailyOutRequestRef.current !== currentReq) {
  276. return;
  277. }
  278. const rows: ItemDailyOutRow[] = (Array.isArray(data) ? data : []).map(
  279. (r: any) => ({
  280. itemCode: r.itemCode ?? "",
  281. itemName: r.itemName ?? "",
  282. unit: r.unit != null ? String(r.unit) : "",
  283. onHandQty: r.onHandQty != null ? Number(r.onHandQty) : null,
  284. fakeOnHandQty:
  285. r.fakeOnHandQty != null && r.fakeOnHandQty !== ""
  286. ? Number(r.fakeOnHandQty)
  287. : null,
  288. avgQtyLastMonth:
  289. r.avgQtyLastMonth != null ? Number(r.avgQtyLastMonth) : undefined,
  290. dailyQty:
  291. r.dailyQty != null && r.dailyQty !== ""
  292. ? Number(r.dailyQty)
  293. : null,
  294. isCoffee: r.isCoffee != null ? Number(r.isCoffee) : 0,
  295. isTea: r.isTea != null ? Number(r.isTea) : 0,
  296. isLemon: r.isLemon != null ? Number(r.isLemon) : 0,
  297. })
  298. );
  299. setItemDailyOutList(rows);
  300. } catch (e) {
  301. console.error("itemDailyOut Error:", e);
  302. setItemDailyOutList([]);
  303. } finally {
  304. // Only clear loading state if this is the latest request
  305. if (itemDailyOutRequestRef.current === currentReq) {
  306. setItemDailyOutLoading(false);
  307. }
  308. }
  309. };
  310. const openSettingsPanel = () => {
  311. setIsDailyOutPanelOpen(true);
  312. fetchItemDailyOut();
  313. };
  314. const handleRefreshInventoryOnHand = async () => {
  315. if (refreshOnHandInFlightRef.current) return;
  316. refreshOnHandInFlightRef.current = true;
  317. setIsRefreshingOnHand(true);
  318. try {
  319. const response = await clientAuthFetch(
  320. `${NEXT_PUBLIC_API_URL}/ps/refresh-inventory-onhand`,
  321. { method: "POST" }
  322. );
  323. if (response.status === 401 || response.status === 403) return;
  324. if (!response.ok) {
  325. const msg = await response.text().catch(() => "");
  326. alert(`刷新庫存失敗(${response.status})${msg ? `: ${msg.slice(0, 120)}` : ""}`);
  327. return;
  328. }
  329. await response.json().catch(() => null);
  330. await fetchItemDailyOut(true);
  331. } catch (e) {
  332. console.error("refreshInventoryOnHand Error:", e);
  333. alert("刷新庫存失敗。");
  334. } finally {
  335. setIsRefreshingOnHand(false);
  336. refreshOnHandInFlightRef.current = false;
  337. }
  338. };
  339. /** Download current fake on-hand overrides (item_fake_onhand) as Excel template. */
  340. const handleExportFakeOnHand = () => {
  341. const rows = itemDailyOutList
  342. .filter((row) => row.fakeOnHandQty != null)
  343. .map((row) => ({
  344. itemCode: row.itemCode,
  345. onHandQty: row.fakeOnHandQty,
  346. }));
  347. exportChartToXlsx(rows, "item_fake_onhand", "item_fake_onhand");
  348. };
  349. /** Upload Excel and bulk update item_fake_onhand via /ps/setFakeOnHand. */
  350. const handleImportFakeOnHand = async (file: File) => {
  351. try {
  352. setIsImportingFakeOnHand(true);
  353. const data = await file.arrayBuffer();
  354. const workbook = XLSX.read(data, { type: "array" });
  355. const sheetName = workbook.SheetNames[0];
  356. if (!sheetName) {
  357. alert("Excel 沒有工作表。");
  358. return;
  359. }
  360. const sheet = workbook.Sheets[sheetName];
  361. const rows: any[] = XLSX.utils.sheet_to_json(sheet, { defval: null });
  362. if (!rows.length) {
  363. alert("Excel 內容為空。");
  364. return;
  365. }
  366. // Build allowed itemCodes (BOM scope) from current list
  367. const allowedCodes = new Set(itemDailyOutList.map((r) => r.itemCode));
  368. const invalidCodes: string[] = [];
  369. // Map Excel rows to backend payload format
  370. const payload = rows
  371. .map((row) => {
  372. const itemCode = (row.itemCode ?? row.ItemCode ?? row["Item Code"])?.toString().trim();
  373. if (!itemCode) return null;
  374. if (!allowedCodes.has(itemCode)) {
  375. invalidCodes.push(itemCode);
  376. }
  377. const rawQty = row.onHandQty ?? row.OnHandQty ?? row["On Hand Qty"];
  378. const qtyNum =
  379. rawQty === null || rawQty === "" || typeof rawQty === "undefined"
  380. ? null
  381. : Number(rawQty);
  382. return { itemCode, onHandQty: qtyNum };
  383. })
  384. .filter((r): r is { itemCode: string; onHandQty: number | null } => r !== null);
  385. if (!payload.length) {
  386. alert("找不到任何有效的 itemCode。");
  387. return;
  388. }
  389. // Warn user about itemCodes that are not in BOM scope (won't affect forecast)
  390. if (invalidCodes.length) {
  391. const preview = invalidCodes.slice(0, 10).join(", ");
  392. alert(
  393. `注意:以下物料編號不在排期 BOM 範圍內,預測不會受影響,只會寫入覆蓋表。\n\n` +
  394. `${preview}${invalidCodes.length > 10 ? ` 等共 ${invalidCodes.length} 筆` : ""}`
  395. );
  396. }
  397. const resp = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/ps/importFakeOnHand`, {
  398. method: "POST",
  399. headers: { "Content-Type": "application/json" },
  400. body: JSON.stringify(payload),
  401. });
  402. if (resp.status === 401 || resp.status === 403) {
  403. alert("登入已過期或沒有權限,請重新登入後再試。");
  404. return;
  405. }
  406. if (!resp.ok) {
  407. const msg = await resp.text().catch(() => "");
  408. alert(
  409. `匯入失敗(狀態碼 ${resp.status})。${
  410. msg ? `\n伺服器訊息:${msg.slice(0, 120)}` : ""
  411. }`
  412. );
  413. return;
  414. }
  415. const result = await resp.json().catch(() => ({ count: payload.length }));
  416. // Backend clears item_fake_onhand then inserts payload rows,
  417. // so after success the table should exactly match the uploaded Excel.
  418. await fetchItemDailyOut(true);
  419. alert(`已成功匯入並更新 ${result.count ?? payload.length} 筆排期庫存 (item_fake_onhand)。`);
  420. } catch (e) {
  421. console.error("Import fake on hand error:", e);
  422. alert("匯入失敗,請檢查檔案格式。");
  423. } finally {
  424. setIsImportingFakeOnHand(false);
  425. }
  426. };
  427. const handleSaveDailyQty = async (itemCode: string, dailyQty: number) => {
  428. setDailyOutSavingCode(itemCode);
  429. try {
  430. const response = await clientAuthFetch(
  431. `${NEXT_PUBLIC_API_URL}/ps/setDailyQtyOut`,
  432. {
  433. method: "POST",
  434. headers: { "Content-Type": "application/json" },
  435. body: JSON.stringify({ itemCode, dailyQty }),
  436. }
  437. );
  438. if (response.status === 401 || response.status === 403) return;
  439. if (response.ok) {
  440. setItemDailyOutList((prev) =>
  441. prev.map((r) =>
  442. r.itemCode === itemCode ? { ...r, dailyQty } : r
  443. )
  444. );
  445. } else {
  446. alert("儲存失敗");
  447. }
  448. } catch (e) {
  449. console.error("setDailyQtyOut Error:", e);
  450. alert("儲存失敗");
  451. } finally {
  452. setDailyOutSavingCode(null);
  453. }
  454. };
  455. const handleClearDailyQty = async (itemCode: string) => {
  456. if (!confirm(`確定要清除${itemCode}的設定排期每天出貨量嗎?`)) return;
  457. setDailyOutClearingCode(itemCode);
  458. try {
  459. const response = await clientAuthFetch(
  460. `${NEXT_PUBLIC_API_URL}/ps/clearDailyQtyOut`,
  461. {
  462. method: "POST",
  463. headers: { "Content-Type": "application/json" },
  464. body: JSON.stringify({ itemCode }),
  465. }
  466. );
  467. if (response.status === 401 || response.status === 403) return;
  468. if (response.ok) {
  469. setItemDailyOutList((prev) =>
  470. prev.map((r) =>
  471. r.itemCode === itemCode ? { ...r, dailyQty: null } : r
  472. )
  473. );
  474. } else {
  475. alert("清除失敗");
  476. }
  477. } catch (e) {
  478. console.error("clearDailyQtyOut Error:", e);
  479. alert("清除失敗");
  480. } finally {
  481. setDailyOutClearingCode(null);
  482. }
  483. };
  484. const handleSetCoffeeOrTea = async (
  485. itemCode: string,
  486. systemType: "coffee" | "tea" | "lemon",
  487. enabled: boolean
  488. ) => {
  489. const key = `${itemCode}-${systemType}`;
  490. setCoffeeOrTeaUpdating(key);
  491. try {
  492. const response = await clientAuthFetch(
  493. `${NEXT_PUBLIC_API_URL}/ps/setCoffeeOrTea`,
  494. {
  495. method: "POST",
  496. headers: { "Content-Type": "application/json" },
  497. body: JSON.stringify({ itemCode, systemType, enabled }),
  498. }
  499. );
  500. if (response.status === 401 || response.status === 403) return;
  501. if (response.ok) {
  502. setItemDailyOutList((prev) =>
  503. prev.map((r) => {
  504. if (r.itemCode !== itemCode) return r;
  505. const next = { ...r };
  506. if (systemType === "coffee") next.isCoffee = enabled ? 1 : 0;
  507. if (systemType === "tea") next.isTea = enabled ? 1 : 0;
  508. if (systemType === "lemon") next.isLemon = enabled ? 1 : 0;
  509. return next;
  510. })
  511. );
  512. } else {
  513. alert("設定失敗");
  514. }
  515. } catch (e) {
  516. console.error("setCoffeeOrTea Error:", e);
  517. alert("設定失敗");
  518. } finally {
  519. setCoffeeOrTeaUpdating(null);
  520. }
  521. };
  522. const handleSetFakeOnHand = async (itemCode: string, onHandQty: number) => {
  523. setFakeOnHandSavingCode(itemCode);
  524. try {
  525. const response = await clientAuthFetch(
  526. `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`,
  527. {
  528. method: "POST",
  529. headers: { "Content-Type": "application/json" },
  530. body: JSON.stringify({ itemCode, onHandQty }),
  531. }
  532. );
  533. if (response.status === 401 || response.status === 403) return;
  534. if (response.ok) {
  535. setItemDailyOutList((prev) =>
  536. prev.map((r) =>
  537. r.itemCode === itemCode ? { ...r, fakeOnHandQty: onHandQty } : r
  538. )
  539. );
  540. } else {
  541. alert("設定失敗");
  542. }
  543. } catch (e) {
  544. console.error("setFakeOnHand Error:", e);
  545. alert("設定失敗");
  546. } finally {
  547. setFakeOnHandSavingCode(null);
  548. }
  549. };
  550. const handleClearFakeOnHand = async (itemCode: string) => {
  551. if (!confirm("確定要清除此物料的設定排期庫存嗎?")) return;
  552. setFakeOnHandClearingCode(itemCode);
  553. try {
  554. const response = await clientAuthFetch(
  555. `${NEXT_PUBLIC_API_URL}/ps/setFakeOnHand`,
  556. {
  557. method: "POST",
  558. headers: { "Content-Type": "application/json" },
  559. body: JSON.stringify({ itemCode, onHandQty: null }),
  560. }
  561. );
  562. if (response.status === 401 || response.status === 403) return;
  563. if (response.ok) {
  564. setItemDailyOutList((prev) =>
  565. prev.map((r) =>
  566. r.itemCode === itemCode ? { ...r, fakeOnHandQty: null } : r
  567. )
  568. );
  569. } else {
  570. alert("清除失敗");
  571. }
  572. } catch (e) {
  573. console.error("clearFakeOnHand Error:", e);
  574. alert("清除失敗");
  575. } finally {
  576. setFakeOnHandClearingCode(null);
  577. }
  578. };
  579. return (
  580. <div className="space-y-4">
  581. <PageTitleBar
  582. title="生產排程"
  583. actions={
  584. <>
  585. <button
  586. type="button"
  587. onClick={openSettingsPanel}
  588. 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"
  589. >
  590. <Settings sx={{ fontSize: 16 }} />
  591. 排期設定
  592. </button>
  593. <button
  594. type="button"
  595. onClick={() => setIsExportDialogOpen(true)}
  596. className="inline-flex items-center gap-2 rounded-lg border border-emerald-500/70 bg-white px-4 py-2 text-sm font-semibold text-emerald-600 shadow-sm transition hover:bg-emerald-50 dark:border-emerald-500/50 dark:bg-slate-800 dark:text-emerald-400 dark:hover:bg-emerald-500/10"
  597. >
  598. <Download sx={{ fontSize: 16 }} />
  599. 匯出計劃/物料需求Excel
  600. </button>
  601. <button
  602. type="button"
  603. onClick={() => setIsDoQtyExportDialogOpen(true)}
  604. disabled={isDoQtyExporting}
  605. className="inline-flex items-center gap-2 rounded-lg border border-violet-500/70 bg-white px-4 py-2 text-sm font-semibold text-violet-600 shadow-sm transition hover:bg-violet-50 disabled:opacity-50 dark:border-violet-500/50 dark:bg-slate-800 dark:text-violet-400 dark:hover:bg-violet-500/10"
  606. >
  607. {isDoQtyExporting ? (
  608. <CircularProgress size={16} sx={{ display: "block" }} />
  609. ) : (
  610. <Download sx={{ fontSize: 16 }} />
  611. )}
  612. 匯出送貨單數量
  613. </button>
  614. <button
  615. type="button"
  616. onClick={() => setIsForecastDialogOpen(true)}
  617. disabled={loading}
  618. className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-600 disabled:opacity-50"
  619. >
  620. {loading ? (
  621. <CircularProgress size={16} sx={{ display: "block" }} />
  622. ) : (
  623. <ShowChart sx={{ fontSize: 16 }} />
  624. )}
  625. 預測排期
  626. </button>
  627. </>
  628. }
  629. className="mb-4"
  630. />
  631. {/* Query Bar */}
  632. <div className="app-search-criteria mb-4 flex flex-wrap items-center gap-2 p-4">
  633. <label className="sr-only" htmlFor="ps-search-date">
  634. 生產日期
  635. </label>
  636. <input
  637. id="ps-search-date"
  638. type="date"
  639. value={searchDate}
  640. onChange={(e) => setSearchDate(e.target.value)}
  641. 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"
  642. />
  643. <button
  644. type="button"
  645. onClick={handleSearch}
  646. className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600"
  647. >
  648. <Search sx={{ fontSize: 16 }} />
  649. 搜索
  650. </button>
  651. </div>
  652. {/* Main Table */}
  653. <div className="overflow-hidden rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-700 dark:bg-slate-800">
  654. <div className="overflow-x-auto">
  655. <table className="w-full min-w-[320px] text-left text-sm">
  656. <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
  657. <tr>
  658. <th className="w-[100px] px-4 py-3 text-center font-bold text-slate-700 dark:text-slate-200">
  659. 詳細
  660. </th>
  661. <th className="px-4 py-3 font-bold text-slate-700 dark:text-slate-200">
  662. 生產日期
  663. </th>
  664. <th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
  665. 預計生產數
  666. </th>
  667. <th className="px-4 py-3 text-right font-bold text-slate-700 dark:text-slate-200">
  668. 成品款數
  669. </th>
  670. </tr>
  671. </thead>
  672. <tbody>
  673. {schedules.map((ps) => (
  674. <tr
  675. key={ps.id}
  676. className="border-t border-slate-200 text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-700/50"
  677. >
  678. <td className="px-4 py-3 text-center">
  679. <button
  680. type="button"
  681. onClick={() => handleViewDetail(ps)}
  682. className="rounded p-1 text-blue-500 hover:bg-blue-50 hover:text-blue-600 dark:text-blue-400 dark:hover:bg-blue-500/20"
  683. >
  684. <Visibility sx={{ fontSize: 16 }} />
  685. </button>
  686. </td>
  687. <td className="px-4 py-3">
  688. {formatBackendDate(ps.produceAt)}
  689. </td>
  690. <td className="px-4 py-3 text-right">
  691. {formatNum(ps.totalEstProdCount)}
  692. </td>
  693. <td className="px-4 py-3 text-right">
  694. {formatNum(ps.totalFGType)}
  695. </td>
  696. </tr>
  697. ))}
  698. </tbody>
  699. </table>
  700. </div>
  701. </div>
  702. {/* Detail Modal – z-index above sidebar drawer (1200) so they don't overlap on small windows */}
  703. {isDetailOpen && (
  704. <div
  705. className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
  706. role="dialog"
  707. aria-modal="true"
  708. aria-labelledby="detail-title"
  709. >
  710. <div
  711. className="absolute inset-0 bg-black/50"
  712. onClick={() => !isGenerating && setIsDetailOpen(false)}
  713. />
  714. <div className="relative z-10 flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-lg border border-slate-200 bg-white shadow-xl dark:border-slate-700 dark:bg-slate-800">
  715. <div className="flex items-center gap-2 border-b border-slate-200 bg-blue-500 px-4 py-3 text-white dark:border-slate-700">
  716. <FormatListNumbered sx={{ fontSize: 20, flexShrink: 0 }} />
  717. <h2 id="detail-title" className="text-lg font-semibold">
  718. 排期詳細: {selectedPs?.id} (
  719. {formatBackendDate(selectedPs?.produceAt)})
  720. </h2>
  721. </div>
  722. <div className="max-h-[65vh] overflow-auto">
  723. <table className="w-full text-left text-sm">
  724. <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
  725. <tr>
  726. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
  727. 工單號
  728. </th>
  729. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
  730. 物料編號
  731. </th>
  732. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
  733. 物料名稱
  734. </th>
  735. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  736. 每日平均出貨量
  737. </th>
  738. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  739. 出貨前預計存貨量
  740. </th>
  741. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">
  742. 單位
  743. </th>
  744. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  745. 可用日
  746. </th>
  747. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  748. 生產量(批)
  749. </th>
  750. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">
  751. 預計生產包數
  752. </th>
  753. <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">
  754. 優先度
  755. </th>
  756. </tr>
  757. </thead>
  758. <tbody>
  759. {selectedLines.map((line: any) => (
  760. <tr
  761. key={line.id}
  762. 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"
  763. >
  764. <td className="px-4 py-2 font-semibold text-blue-600 dark:text-blue-400">
  765. {line.joCode || "-"}
  766. </td>
  767. <td className="px-4 py-2 font-semibold">
  768. {line.itemCode}
  769. </td>
  770. <td className="px-4 py-2">{line.itemName}</td>
  771. <td className="px-4 py-2 text-right">
  772. {formatNum(line.avgQtyLastMonth)}
  773. </td>
  774. <td className="px-4 py-2 text-right">
  775. {formatNum(line.stockQty)}
  776. </td>
  777. <td className="px-4 py-2">{line.stockUnit}</td>
  778. <td
  779. className={`px-4 py-2 text-right ${
  780. line.daysLeft < 5
  781. ? "font-bold text-red-600 dark:text-red-400"
  782. : ""
  783. }`}
  784. >
  785. {line.daysLeft}
  786. </td>
  787. <td className="px-4 py-2 text-right">
  788. {formatNum(line.batchNeed)}
  789. </td>
  790. <td className="px-4 py-2 text-right font-semibold">
  791. {formatNum(line.prodQty)}
  792. </td>
  793. <td className="px-4 py-2 text-center">
  794. {line.itemPriority}
  795. </td>
  796. </tr>
  797. ))}
  798. </tbody>
  799. </table>
  800. </div>
  801. <div className="flex gap-2 border-t border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800">
  802. <button
  803. type="button"
  804. onClick={handleAutoGenJob}
  805. disabled={isGenerating}
  806. className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600 disabled:opacity-50"
  807. >
  808. {isGenerating ? (
  809. <CircularProgress size={16} sx={{ display: "block" }} />
  810. ) : (
  811. <Hub sx={{ fontSize: 16 }} />
  812. )}
  813. 自動生成工單
  814. </button>
  815. <button
  816. type="button"
  817. onClick={() => setIsDetailOpen(false)}
  818. disabled={isGenerating}
  819. className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
  820. >
  821. 關閉
  822. </button>
  823. </div>
  824. </div>
  825. </div>
  826. )}
  827. {/* Forecast Dialog */}
  828. {isForecastDialogOpen && (
  829. <div
  830. className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
  831. role="dialog"
  832. aria-modal="true"
  833. >
  834. <div
  835. className="absolute inset-0 bg-black/50"
  836. onClick={() => setIsForecastDialogOpen(false)}
  837. />
  838. <div className="relative z-10 w-full max-w-sm rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800 sm:max-w-md">
  839. <h3 className="mb-4 text-lg font-semibold text-slate-900 dark:text-white">
  840. 準備生成預計排期
  841. </h3>
  842. <div className="flex flex-col gap-4">
  843. <div>
  844. <label
  845. htmlFor="forecast-start"
  846. className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
  847. >
  848. 開始日期
  849. </label>
  850. <input
  851. id="forecast-start"
  852. type="date"
  853. value={forecastStartDate}
  854. onChange={(e) => setForecastStartDate(e.target.value)}
  855. min={dayjs().subtract(30, "day").format("YYYY-MM-DD")}
  856. 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"
  857. />
  858. </div>
  859. <div>
  860. <label
  861. htmlFor="forecast-days"
  862. className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
  863. >
  864. 排期日數
  865. </label>
  866. <input
  867. id="forecast-days"
  868. type="number"
  869. min={1}
  870. max={365}
  871. value={forecastDays}
  872. onChange={(e) => {
  873. const val =
  874. e.target.value === "" ? "" : Number(e.target.value);
  875. if (
  876. val === "" ||
  877. (Number.isInteger(val) && val >= 1 && val <= 365)
  878. ) {
  879. setForecastDays(val);
  880. }
  881. }}
  882. 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"
  883. />
  884. </div>
  885. </div>
  886. <div className="mt-6 flex justify-end gap-2">
  887. <button
  888. type="button"
  889. onClick={() => setIsForecastDialogOpen(false)}
  890. className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
  891. >
  892. 取消
  893. </button>
  894. <button
  895. type="button"
  896. onClick={handleConfirmForecast}
  897. disabled={
  898. !forecastStartDate || forecastDays === "" || loading
  899. }
  900. className="inline-flex items-center gap-2 rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-600 disabled:opacity-50"
  901. >
  902. {loading ? (
  903. <CircularProgress size={16} sx={{ display: "block" }} />
  904. ) : (
  905. <ShowChart sx={{ fontSize: 16 }} />
  906. )}
  907. 計算預測排期
  908. </button>
  909. </div>
  910. </div>
  911. </div>
  912. )}
  913. {/* DO Qty Export Dialog */}
  914. {isDoQtyExportDialogOpen && (
  915. <div
  916. className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
  917. role="dialog"
  918. aria-modal="true"
  919. >
  920. <div
  921. className="absolute inset-0 bg-black/50"
  922. onClick={() => !isDoQtyExporting && setIsDoQtyExportDialogOpen(false)}
  923. />
  924. <div className="relative z-10 w-full max-w-sm rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800">
  925. <h3 className="mb-2 text-lg font-semibold text-slate-900 dark:text-white">
  926. 匯出送貨單數量
  927. </h3>
  928. <p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
  929. 選擇日期範圍(僅含具 BOM 的物料,數量為庫存單位)
  930. </p>
  931. <div className="flex flex-col gap-4">
  932. <div>
  933. <label
  934. htmlFor="do-qty-export-from"
  935. className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
  936. >
  937. 開始日期
  938. </label>
  939. <input
  940. id="do-qty-export-from"
  941. type="date"
  942. value={doQtyExportFromDate}
  943. onChange={(e) => setDoQtyExportFromDate(e.target.value)}
  944. className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
  945. />
  946. </div>
  947. <div>
  948. <label
  949. htmlFor="do-qty-export-to"
  950. className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
  951. >
  952. 結束日期
  953. </label>
  954. <input
  955. id="do-qty-export-to"
  956. type="date"
  957. value={doQtyExportToDate}
  958. onChange={(e) => setDoQtyExportToDate(e.target.value)}
  959. className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-slate-900 focus:border-violet-500 focus:outline-none focus:ring-2 focus:ring-violet-500/20 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100"
  960. />
  961. </div>
  962. </div>
  963. <div className="mt-6 flex justify-end gap-2">
  964. <button
  965. type="button"
  966. onClick={() => setIsDoQtyExportDialogOpen(false)}
  967. disabled={isDoQtyExporting}
  968. className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
  969. >
  970. 取消
  971. </button>
  972. <button
  973. type="button"
  974. onClick={handleConfirmDoQtyExport}
  975. disabled={
  976. !doQtyExportFromDate ||
  977. !doQtyExportToDate ||
  978. isDoQtyExporting
  979. }
  980. className="inline-flex items-center gap-2 rounded-lg bg-violet-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-violet-600 disabled:opacity-50"
  981. >
  982. {isDoQtyExporting ? (
  983. <CircularProgress size={16} sx={{ display: "block" }} />
  984. ) : (
  985. <Download sx={{ fontSize: 16 }} />
  986. )}
  987. 匯出
  988. </button>
  989. </div>
  990. </div>
  991. </div>
  992. )}
  993. {/* Export Dialog */}
  994. {isExportDialogOpen && (
  995. <div
  996. className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
  997. role="dialog"
  998. aria-modal="true"
  999. >
  1000. <div
  1001. className="absolute inset-0 bg-black/50"
  1002. onClick={() => setIsExportDialogOpen(false)}
  1003. />
  1004. <div className="relative z-10 w-full max-w-xs rounded-lg border border-slate-200 bg-white p-4 shadow-xl dark:border-slate-700 dark:bg-slate-800">
  1005. <h3 className="mb-2 text-lg font-semibold text-slate-900 dark:text-white">
  1006. 匯出排期/物料用量預計
  1007. </h3>
  1008. <p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
  1009. 選擇要匯出的起始日期
  1010. </p>
  1011. <label
  1012. htmlFor="export-from"
  1013. className="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
  1014. >
  1015. 起始日期
  1016. </label>
  1017. <input
  1018. id="export-from"
  1019. type="date"
  1020. value={exportFromDate}
  1021. onChange={(e) => setExportFromDate(e.target.value)}
  1022. min={dayjs().subtract(90, "day").format("YYYY-MM-DD")}
  1023. 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"
  1024. />
  1025. <div className="flex justify-end gap-2">
  1026. <button
  1027. type="button"
  1028. onClick={() => setIsExportDialogOpen(false)}
  1029. className="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-300 dark:hover:bg-slate-600"
  1030. >
  1031. 取消
  1032. </button>
  1033. <button
  1034. type="button"
  1035. onClick={handleConfirmExport}
  1036. disabled={!exportFromDate || loading}
  1037. className="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-emerald-600 disabled:opacity-50"
  1038. >
  1039. {loading ? (
  1040. <CircularProgress size={16} sx={{ display: "block" }} />
  1041. ) : (
  1042. <Download sx={{ fontSize: 16 }} />
  1043. )}
  1044. 匯出
  1045. </button>
  1046. </div>
  1047. </div>
  1048. </div>
  1049. )}
  1050. {/* 排期設定 Dialog */}
  1051. {isDailyOutPanelOpen && (
  1052. <div
  1053. className="fixed inset-0 z-[1300] flex items-center justify-center p-4"
  1054. role="dialog"
  1055. aria-modal="true"
  1056. aria-labelledby="settings-panel-title"
  1057. >
  1058. <div
  1059. className="absolute inset-0 bg-black/50"
  1060. onClick={() => setIsDailyOutPanelOpen(false)}
  1061. />
  1062. <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">
  1063. <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">
  1064. <h2
  1065. id="settings-panel-title"
  1066. className="text-lg font-semibold text-slate-900 dark:text-white"
  1067. >
  1068. 排期設定
  1069. </h2>
  1070. <div className="flex items-center gap-2">
  1071. <button
  1072. type="button"
  1073. onClick={() => void handleRefreshInventoryOnHand()}
  1074. disabled={isRefreshingOnHand || itemDailyOutLoading}
  1075. className="inline-flex items-center gap-1 rounded border border-sky-500/80 bg-sky-50 px-3 py-1 text-xs font-semibold text-sky-700 shadow-sm hover:bg-sky-100 disabled:opacity-50 dark:border-sky-400/80 dark:bg-slate-800 dark:text-sky-300 dark:hover:bg-sky-500/20"
  1076. >
  1077. {isRefreshingOnHand ? (
  1078. <CircularProgress size={14} sx={{ display: "block" }} />
  1079. ) : (
  1080. <Refresh sx={{ fontSize: 14 }} />
  1081. )}
  1082. 刷新系統庫存
  1083. </button>
  1084. <button
  1085. type="button"
  1086. onClick={handleExportFakeOnHand}
  1087. 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"
  1088. >
  1089. 匯出排期庫存 Excel
  1090. </button>
  1091. <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">
  1092. <span className="inline-flex h-2 w-2 rounded-full bg-amber-500" />
  1093. <span className="text-amber-700 dark:text-amber-300">匯入排期庫存 Excel(覆蓋設定)</span>
  1094. <input
  1095. type="file"
  1096. accept=".xlsx,.xls"
  1097. className="hidden"
  1098. onChange={(e) => {
  1099. const file = e.target.files?.[0];
  1100. if (file) {
  1101. void handleImportFakeOnHand(file);
  1102. e.target.value = "";
  1103. }
  1104. }}
  1105. />
  1106. </label>
  1107. <button
  1108. type="button"
  1109. onClick={() => setIsDailyOutPanelOpen(false)}
  1110. 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"
  1111. >
  1112. 關閉
  1113. </button>
  1114. </div>
  1115. </div>
  1116. <p className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400">
  1117. 預設為過去 30 天(含今日)。設定排期每天出貨量、設定排期庫存可編輯並按列儲存。
  1118. </p>
  1119. <div className="max-h-[60vh] overflow-auto">
  1120. {itemDailyOutLoading ? (
  1121. <div className="flex items-center justify-center py-12">
  1122. <CircularProgress />
  1123. </div>
  1124. ) : (
  1125. <table className="w-full min-w-[900px] text-left text-sm">
  1126. <thead className="sticky top-0 bg-slate-50 dark:bg-slate-700">
  1127. <tr>
  1128. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料編號</th>
  1129. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">物料名稱</th>
  1130. <th className="px-4 py-2 font-bold text-slate-700 dark:text-slate-200">單位</th>
  1131. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">庫存</th>
  1132. <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期庫存</th>
  1133. <th className="px-4 py-2 text-right font-bold text-slate-700 dark:text-slate-200">過去平均出貨量</th>
  1134. <th className="px-4 py-2 text-left font-bold text-slate-700 dark:text-slate-200">設定排期每天出貨量</th>
  1135. <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">咖啡</th>
  1136. <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">茶</th>
  1137. <th className="px-4 py-2 text-center font-bold text-slate-700 dark:text-slate-200">檸檬</th>
  1138. </tr>
  1139. </thead>
  1140. <tbody>
  1141. {itemDailyOutList.map((row, idx) => (
  1142. <DailyOutRow
  1143. key={`${row.itemCode}-${idx}`}
  1144. row={row}
  1145. onSave={handleSaveDailyQty}
  1146. onClear={handleClearDailyQty}
  1147. onSetCoffeeOrTea={handleSetCoffeeOrTea}
  1148. onSetFakeOnHand={handleSetFakeOnHand}
  1149. onClearFakeOnHand={handleClearFakeOnHand}
  1150. saving={dailyOutSavingCode === row.itemCode}
  1151. clearing={dailyOutClearingCode === row.itemCode}
  1152. coffeeOrTeaUpdating={coffeeOrTeaUpdating}
  1153. fakeOnHandSaving={fakeOnHandSavingCode === row.itemCode}
  1154. fakeOnHandClearing={fakeOnHandClearingCode === row.itemCode}
  1155. formatNum={formatNum}
  1156. />
  1157. ))}
  1158. </tbody>
  1159. </table>
  1160. )}
  1161. </div>
  1162. </div>
  1163. </div>
  1164. )}
  1165. </div>
  1166. );
  1167. }
  1168. function DailyOutRow({
  1169. row,
  1170. onSave,
  1171. onClear,
  1172. onSetCoffeeOrTea,
  1173. onSetFakeOnHand,
  1174. onClearFakeOnHand,
  1175. saving,
  1176. clearing,
  1177. coffeeOrTeaUpdating,
  1178. fakeOnHandSaving,
  1179. fakeOnHandClearing,
  1180. formatNum,
  1181. }: {
  1182. row: ItemDailyOutRow;
  1183. onSave: (itemCode: string, dailyQty: number) => void;
  1184. onClear: (itemCode: string) => void;
  1185. onSetCoffeeOrTea: (itemCode: string, systemType: "coffee" | "tea" | "lemon", enabled: boolean) => void;
  1186. onSetFakeOnHand: (itemCode: string, onHandQty: number) => void;
  1187. onClearFakeOnHand: (itemCode: string) => void;
  1188. saving: boolean;
  1189. clearing: boolean;
  1190. coffeeOrTeaUpdating: string | null;
  1191. fakeOnHandSaving: boolean;
  1192. fakeOnHandClearing: boolean;
  1193. formatNum: (n: any) => string;
  1194. }) {
  1195. const [editQty, setEditQty] = useState<string>(
  1196. row.dailyQty != null ? String(row.dailyQty) : ""
  1197. );
  1198. const [editFakeOnHand, setEditFakeOnHand] = useState<string>(
  1199. row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : ""
  1200. );
  1201. useEffect(() => {
  1202. setEditQty(row.dailyQty != null ? String(row.dailyQty) : "");
  1203. }, [row.dailyQty]);
  1204. useEffect(() => {
  1205. setEditFakeOnHand(row.fakeOnHandQty != null ? String(row.fakeOnHandQty) : "");
  1206. }, [row.fakeOnHandQty]);
  1207. const numVal = parseFloat(editQty);
  1208. const isValid = !Number.isNaN(numVal) && numVal >= 0;
  1209. const hasSetQty = row.dailyQty != null;
  1210. const fakeOnHandNum = parseFloat(editFakeOnHand);
  1211. const isValidFakeOnHand = !Number.isNaN(fakeOnHandNum) && fakeOnHandNum >= 0;
  1212. const hasSetFakeOnHand = row.fakeOnHandQty != null;
  1213. const isCoffee = (row.isCoffee ?? 0) > 0;
  1214. const isTea = (row.isTea ?? 0) > 0;
  1215. const isLemon = (row.isLemon ?? 0) > 0;
  1216. const updatingCoffee = coffeeOrTeaUpdating === `${row.itemCode}-coffee`;
  1217. const updatingTea = coffeeOrTeaUpdating === `${row.itemCode}-tea`;
  1218. const updatingLemon = coffeeOrTeaUpdating === `${row.itemCode}-lemon`;
  1219. return (
  1220. <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">
  1221. <td className="px-4 py-2 font-medium">{row.itemCode}</td>
  1222. <td className="px-4 py-2">{row.itemName}</td>
  1223. <td className="px-4 py-2">{row.unit ?? ""}</td>
  1224. <td className="px-4 py-2 text-right">{formatNum(row.onHandQty)}</td>
  1225. <td className="px-4 py-2 text-left">
  1226. <div className="flex items-center justify-start gap-0.5">
  1227. <input
  1228. type="number"
  1229. min={0}
  1230. step={1}
  1231. value={editFakeOnHand}
  1232. onChange={(e) => setEditFakeOnHand(e.target.value)}
  1233. onBlur={() => {
  1234. if (isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum);
  1235. }}
  1236. onKeyDown={(e) => {
  1237. if (e.key === "Enter" && isValidFakeOnHand) onSetFakeOnHand(row.itemCode, fakeOnHandNum);
  1238. }}
  1239. 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"
  1240. />
  1241. {hasSetFakeOnHand && (
  1242. <button
  1243. type="button"
  1244. disabled={fakeOnHandClearing}
  1245. onClick={() => onClearFakeOnHand(row.itemCode)}
  1246. title="清除設定排期庫存"
  1247. 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"
  1248. >
  1249. {fakeOnHandClearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}
  1250. </button>
  1251. )}
  1252. </div>
  1253. </td>
  1254. <td className="px-4 py-2 text-right">{formatNum(row.avgQtyLastMonth)}</td>
  1255. <td className="px-4 py-2 text-left">
  1256. <div className="flex items-center justify-start gap-0.5">
  1257. <input
  1258. type="number"
  1259. min={0}
  1260. step={1}
  1261. value={editQty}
  1262. onChange={(e) => setEditQty(e.target.value)}
  1263. onBlur={() => {
  1264. if (isValid) onSave(row.itemCode, numVal);
  1265. }}
  1266. onKeyDown={(e) => {
  1267. if (e.key === "Enter" && isValid) onSave(row.itemCode, numVal);
  1268. }}
  1269. 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"
  1270. />
  1271. {hasSetQty && (
  1272. <button
  1273. type="button"
  1274. disabled={clearing}
  1275. onClick={() => onClear(row.itemCode)}
  1276. title="清除設定排期每天出貨量"
  1277. 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"
  1278. >
  1279. {clearing ? <CircularProgress size={14} sx={{ display: "block" }} /> : <Clear sx={{ fontSize: 18 }} />}
  1280. </button>
  1281. )}
  1282. </div>
  1283. </td>
  1284. <td className="px-4 py-2 text-center">
  1285. <label className="inline-flex cursor-pointer items-center gap-1">
  1286. <input
  1287. type="checkbox"
  1288. checked={isCoffee}
  1289. disabled={updatingCoffee}
  1290. onChange={(e) => onSetCoffeeOrTea(row.itemCode, "coffee", e.target.checked)}
  1291. className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
  1292. />
  1293. {updatingCoffee && <CircularProgress size={14} sx={{ display: "block" }} />}
  1294. </label>
  1295. </td>
  1296. <td className="px-4 py-2 text-center">
  1297. <label className="inline-flex cursor-pointer items-center gap-1">
  1298. <input
  1299. type="checkbox"
  1300. checked={isTea}
  1301. disabled={updatingTea}
  1302. onChange={(e) => onSetCoffeeOrTea(row.itemCode, "tea", e.target.checked)}
  1303. className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
  1304. />
  1305. {updatingTea && <CircularProgress size={14} sx={{ display: "block" }} />}
  1306. </label>
  1307. </td>
  1308. <td className="px-4 py-2 text-center">
  1309. <label className="inline-flex cursor-pointer items-center gap-1">
  1310. <input
  1311. type="checkbox"
  1312. checked={isLemon}
  1313. disabled={updatingLemon}
  1314. onChange={(e) => onSetCoffeeOrTea(row.itemCode, "lemon", e.target.checked)}
  1315. className="h-4 w-4 rounded border-slate-300 text-blue-500 focus:ring-blue-500"
  1316. />
  1317. {updatingLemon && <CircularProgress size={14} sx={{ display: "block" }} />}
  1318. </label>
  1319. </td>
  1320. </tr>
  1321. );
  1322. }