FPSMS-frontend
Nie możesz wybrać więcej, niż 25 tematów Tematy muszą się zaczynać od litery lub cyfry, mogą zawierać myślniki ('-') i mogą mieć do 35 znaków.
 
 

1180 wiersze
47 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 { 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. }