FPSMS-frontend
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

page.tsx 47 KiB

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