FPSMS-frontend
Não pode escolher mais do que 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.
 
 

1344 linhas
48 KiB

  1. "use client";
  2. import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
  3. import {
  4. Alert,
  5. Box,
  6. Button,
  7. Chip,
  8. FormControl,
  9. InputLabel,
  10. MenuItem,
  11. Select,
  12. Stack,
  13. Typography,
  14. Paper,
  15. CircularProgress,
  16. SelectChangeEvent,
  17. Dialog,
  18. DialogTitle,
  19. DialogContent,
  20. DialogActions,
  21. TextField,
  22. Snackbar,
  23. Switch,
  24. Table,
  25. TableBody,
  26. TableCell,
  27. TableContainer,
  28. TableHead,
  29. TableRow,
  30. TableSortLabel,
  31. Tooltip,
  32. } from "@mui/material";
  33. import ChevronLeft from "@mui/icons-material/ChevronLeft";
  34. import ChevronRight from "@mui/icons-material/ChevronRight";
  35. import Settings from "@mui/icons-material/Settings";
  36. import Print from "@mui/icons-material/Print";
  37. import Download from "@mui/icons-material/Download";
  38. import {
  39. buildOnPackJobOrdersPayload,
  40. checkPrinterStatus,
  41. downloadOnPackQrZip,
  42. downloadOnPackQrZipWithExpiry,
  43. downloadOnPackTextQrZip,
  44. downloadOnPackTextQrZipWithExpiry,
  45. fetchJobOrders,
  46. fetchOnPackExpiryCodes,
  47. addOnPackExpiryCode,
  48. updateOnPackExpiryCode,
  49. deleteOnPackExpiryCode,
  50. fetchOnPackSupportedCatalog,
  51. JobOrderListItem,
  52. OnPackExpiryItemCodeDto,
  53. } from "@/app/api/bagPrint/actions";
  54. import dayjs from "dayjs";
  55. import { useSession } from "next-auth/react";
  56. import { SessionWithTokens } from "@/config/authConfig";
  57. import { NEXT_PUBLIC_API_URL } from "@/config/api";
  58. import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
  59. // Light blue theme (matching Python Bag1)
  60. const BG_TOP = "#E8F4FC";
  61. const BG_LIST = "#D4E8F7";
  62. const BG_ROW = "#C5E1F5";
  63. const BG_ROW_SELECTED = "#6BB5FF";
  64. const BG_STATUS_ERROR = "#FFCCCB";
  65. const BG_STATUS_OK = "#90EE90";
  66. const FG_STATUS_ERROR = "#B22222";
  67. const FG_STATUS_OK = "#006400";
  68. const PRINTER_OPTIONS = [
  69. { value: "dataflex", label: "打袋機 DataFlex" },
  70. { value: "laser", label: "激光機" },
  71. ];
  72. const REFRESH_MS = 60 * 1000;
  73. const PRINTER_CHECK_MS = 60 * 1000;
  74. const PRINTER_RETRY_MS = 30 * 1000;
  75. const SETTINGS_KEY = "bagPrint_settings";
  76. const ONPACK_ADMIN_USERNAME = "2fi";
  77. /** Login username from backend JWT `sub` (UserDetails.username). */
  78. function loginUsernameFromSession(session: SessionWithTokens | null | undefined): string {
  79. const token = session?.accessToken?.trim();
  80. if (token) {
  81. try {
  82. const parts = token.split(".");
  83. if (parts.length >= 2) {
  84. const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
  85. const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
  86. const payload = JSON.parse(atob(padded)) as { sub?: unknown };
  87. if (typeof payload.sub === "string" && payload.sub.trim()) {
  88. return payload.sub.trim();
  89. }
  90. }
  91. } catch {
  92. // fall through to display name
  93. }
  94. }
  95. return (session?.user?.name ?? "").trim();
  96. }
  97. const DEFAULT_SETTINGS = {
  98. dabag_ip: "",
  99. dabag_port: "3008",
  100. laser_ip: "192.168.17.10",
  101. laser_port: "45678",
  102. };
  103. function loadSettings(): typeof DEFAULT_SETTINGS {
  104. if (typeof window === "undefined") return DEFAULT_SETTINGS;
  105. try {
  106. const s = localStorage.getItem(SETTINGS_KEY);
  107. if (s) return { ...DEFAULT_SETTINGS, ...JSON.parse(s) };
  108. } catch {}
  109. return DEFAULT_SETTINGS;
  110. }
  111. function saveSettings(s: typeof DEFAULT_SETTINGS) {
  112. if (typeof window === "undefined") return;
  113. try {
  114. localStorage.setItem(SETTINGS_KEY, JSON.stringify(s));
  115. } catch {}
  116. }
  117. function formatQty(val: number | null | undefined): string {
  118. if (val == null) return "—";
  119. try {
  120. const n = Number(val);
  121. if (Number.isInteger(n)) return n.toLocaleString();
  122. return n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 2 }).replace(/\.?0+$/, "");
  123. } catch {
  124. return String(val);
  125. }
  126. }
  127. function getBatch(jo: JobOrderListItem): string {
  128. return (jo.lotNo || "—").trim() || "—";
  129. }
  130. function expiryCodeSet(rows: OnPackExpiryItemCodeDto[]): Set<string> {
  131. return new Set(rows.map((r) => r.itemCode.trim().toUpperCase()).filter(Boolean));
  132. }
  133. function daysLabel(value: number | null | undefined): string {
  134. return value == null ? "未設" : String(value);
  135. }
  136. type ExpirySortKey =
  137. | "itemCode"
  138. | "name"
  139. | "defaultDays"
  140. | "minus18Days"
  141. | "useMinus18"
  142. | "effectiveDays";
  143. function displayName(row: OnPackExpiryItemCodeDto): string {
  144. return (row.printName || row.defaultPrintName || "").trim();
  145. }
  146. function cmpText(a: string, b: string): number {
  147. return a.localeCompare(b, "zh-Hant", { numeric: true, sensitivity: "base" });
  148. }
  149. /** Null (未設) sorts first on asc so unset rows are easy to find. */
  150. function cmpDays(a: number | null | undefined, b: number | null | undefined): number {
  151. const av = a == null ? Number.NEGATIVE_INFINITY : a;
  152. const bv = b == null ? Number.NEGATIVE_INFINITY : b;
  153. return av - bv;
  154. }
  155. function skippedExpirySnackbar(okMessage: string, skipped: string[]): {
  156. open: true;
  157. message: string;
  158. severity: "success" | "warning";
  159. duration: number;
  160. } {
  161. if (skipped.length === 0) {
  162. return { open: true, message: okMessage, severity: "success", duration: 3000 };
  163. }
  164. return {
  165. open: true,
  166. message: `${okMessage}。以下品號沒有到期日,已略過不入 ZIP:${skipped.join("、")}。請到設定 → 物品預設保質期新增。`,
  167. severity: "warning",
  168. duration: 10000,
  169. };
  170. }
  171. function sortExpiryRows(
  172. rows: OnPackExpiryItemCodeDto[],
  173. key: ExpirySortKey,
  174. dir: "asc" | "desc",
  175. ): OnPackExpiryItemCodeDto[] {
  176. const sign = dir === "asc" ? 1 : -1;
  177. return [...rows].sort((a, b) => {
  178. let cmp = 0;
  179. switch (key) {
  180. case "itemCode":
  181. cmp = cmpText(a.itemCode, b.itemCode);
  182. break;
  183. case "name":
  184. cmp = cmpText(displayName(a), displayName(b));
  185. break;
  186. case "defaultDays":
  187. cmp = cmpDays(a.defaultDays, b.defaultDays);
  188. break;
  189. case "minus18Days":
  190. cmp = cmpDays(a.minus18Days, b.minus18Days);
  191. break;
  192. case "useMinus18":
  193. cmp = Number(a.useMinus18 === true) - Number(b.useMinus18 === true);
  194. break;
  195. case "effectiveDays":
  196. cmp = cmpDays(a.effectiveDays, b.effectiveDays);
  197. break;
  198. }
  199. if (cmp === 0) cmp = cmpText(a.itemCode, b.itemCode);
  200. return cmp * sign;
  201. });
  202. }
  203. const BagPrintSearch: React.FC = () => {
  204. const { data: session } = useSession() as { data: SessionWithTokens | null };
  205. const canSeeOnPackAdmin =
  206. loginUsernameFromSession(session).toLowerCase() === ONPACK_ADMIN_USERNAME;
  207. const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD"));
  208. const [jobOrders, setJobOrders] = useState<JobOrderListItem[]>([]);
  209. const [loading, setLoading] = useState(true);
  210. const [error, setError] = useState<string | null>(null);
  211. const [connected, setConnected] = useState(false);
  212. const [printer, setPrinter] = useState<string>("dataflex");
  213. const [selectedId, setSelectedId] = useState<number | null>(null);
  214. const [printDialogOpen, setPrintDialogOpen] = useState(false);
  215. const [printTarget, setPrintTarget] = useState<JobOrderListItem | null>(null);
  216. const [printCount, setPrintCount] = useState(0);
  217. const [printContinuous, setPrintContinuous] = useState(false);
  218. const [printing, setPrinting] = useState(false);
  219. const [settingsOpen, setSettingsOpen] = useState(false);
  220. const [templatesOpen, setTemplatesOpen] = useState(false);
  221. const [expiryCodes, setExpiryCodes] = useState<OnPackExpiryItemCodeDto[]>([]);
  222. const [expiryCodeInput, setExpiryCodeInput] = useState("");
  223. const [expiryCodesLoading, setExpiryCodesLoading] = useState(false);
  224. const [nameDrafts, setNameDrafts] = useState<Record<string, string>>({});
  225. const [expirySortKey, setExpirySortKey] = useState<ExpirySortKey>("itemCode");
  226. const [expirySortDir, setExpirySortDir] = useState<"asc" | "desc">("asc");
  227. const [lemonCodeSet, setLemonCodeSet] = useState<Set<string>>(() => new Set());
  228. const expiryAddRef = useRef(false);
  229. const expiryDeleteRef = useRef(false);
  230. const expirySaveRef = useRef<Set<string>>(new Set());
  231. const expiryToggleRef = useRef<Set<string>>(new Set());
  232. const [snackbar, setSnackbar] = useState<{
  233. open: boolean;
  234. message: string;
  235. severity?: "success" | "info" | "warning" | "error";
  236. duration?: number;
  237. }>({ open: false, message: "" });
  238. const [settings, setSettings] = useState(DEFAULT_SETTINGS);
  239. const [printerConnected, setPrinterConnected] = useState(false);
  240. const [printerMessage, setPrinterMessage] = useState("列印機未連接");
  241. const [downloadingOnPack, setDownloadingOnPack] = useState(false);
  242. const [downloadingOnPackExp, setDownloadingOnPackExp] = useState(false);
  243. const [downloadingOnPackText, setDownloadingOnPackText] = useState(false);
  244. const [downloadingOnPackTextExp, setDownloadingOnPackTextExp] = useState(false);
  245. const downloadingOnPackRef = useRef(false);
  246. const downloadingOnPackExpRef = useRef(false);
  247. const downloadingOnPackTextRef = useRef(false);
  248. const downloadingOnPackTextExpRef = useRef(false);
  249. useEffect(() => {
  250. setSettings(loadSettings());
  251. }, []);
  252. const loadJobOrders = useCallback(async (fromUserChange = false) => {
  253. setLoading(true);
  254. setError(null);
  255. try {
  256. const data = await fetchJobOrders(planDate);
  257. setJobOrders(data);
  258. setConnected(true);
  259. if (fromUserChange) setSelectedId(null);
  260. } catch (e) {
  261. setError(e instanceof Error ? e.message : "連接不到服務器");
  262. setConnected(false);
  263. setJobOrders([]);
  264. } finally {
  265. setLoading(false);
  266. }
  267. }, [planDate]);
  268. useEffect(() => {
  269. loadJobOrders(true);
  270. }, [planDate]);
  271. useEffect(() => {
  272. if (!connected) return;
  273. const id = setInterval(() => loadJobOrders(false), REFRESH_MS);
  274. return () => clearInterval(id);
  275. }, [connected, loadJobOrders]);
  276. const checkCurrentPrinter = useCallback(async () => {
  277. try {
  278. const request =
  279. printer === "dataflex"
  280. ? {
  281. printerType: "dataflex" as const,
  282. printerIp: settings.dabag_ip,
  283. printerPort: Number(settings.dabag_port || 3008),
  284. }
  285. : {
  286. printerType: "laser" as const,
  287. printerIp: settings.laser_ip,
  288. printerPort: Number(settings.laser_port || 45678),
  289. };
  290. const result = await checkPrinterStatus(request);
  291. setPrinterConnected(result.connected);
  292. setPrinterMessage(result.message);
  293. } catch (e) {
  294. setPrinterConnected(false);
  295. setPrinterMessage(e instanceof Error ? e.message : "列印機狀態檢查失敗");
  296. }
  297. }, [printer, settings]);
  298. useEffect(() => {
  299. checkCurrentPrinter();
  300. }, [checkCurrentPrinter]);
  301. useEffect(() => {
  302. const intervalMs = printerConnected ? PRINTER_CHECK_MS : PRINTER_RETRY_MS;
  303. const id = setInterval(() => {
  304. checkCurrentPrinter();
  305. }, intervalMs);
  306. return () => clearInterval(id);
  307. }, [printerConnected, checkCurrentPrinter]);
  308. const goPrevDay = () => {
  309. setPlanDate((d) => dayjs(d).subtract(1, "day").format("YYYY-MM-DD"));
  310. };
  311. const goNextDay = () => {
  312. setPlanDate((d) => dayjs(d).add(1, "day").format("YYYY-MM-DD"));
  313. };
  314. const handlePrinterChange = (e: SelectChangeEvent<string>) => {
  315. setPrinter(e.target.value);
  316. };
  317. const handleRowClick = (jo: JobOrderListItem) => {
  318. setSelectedId(jo.id);
  319. const batch = getBatch(jo);
  320. const itemCode = jo.itemCode || "—";
  321. const itemName = jo.itemName || "—";
  322. setSnackbar({ open: true, message: `已點選:批次 ${batch} 品號 ${itemCode} ${itemName}`, severity: "info" });
  323. // Align with Bag2.py "click row -> ask bag count -> print" for DataFlex.
  324. if (printer === "dataflex") {
  325. setPrintTarget(jo);
  326. setPrintCount(0);
  327. setPrintContinuous(false);
  328. setPrintDialogOpen(true);
  329. }
  330. };
  331. const confirmPrintDataFlex = async () => {
  332. if (!printTarget) return;
  333. if (printer !== "dataflex") {
  334. setSnackbar({ open: true, message: "此頁目前只支援打袋機 DataFlex 列印", severity: "error" });
  335. return;
  336. }
  337. if (!printContinuous && printCount < 1) {
  338. setSnackbar({ open: true, message: "請先按 +50、+10、+5 或 +1 選擇數量。", severity: "error" });
  339. return;
  340. }
  341. const qty = printContinuous ? -1 : printCount;
  342. const printerIp = settings.dabag_ip;
  343. const printerPort = Number(settings.dabag_port || 3008);
  344. if (!printerIp) {
  345. setSnackbar({ open: true, message: "請先在設定中填寫打袋機 DataFlex 的 IP。", severity: "error" });
  346. return;
  347. }
  348. setPrinting(true);
  349. try {
  350. const resp = await clientAuthFetch(`${NEXT_PUBLIC_API_URL}/plastic/print-dataflex`, {
  351. method: "POST",
  352. headers: { "Content-Type": "application/json" },
  353. body: JSON.stringify({
  354. itemCode: printTarget.itemCode || "—",
  355. itemName: printTarget.itemName || "—",
  356. lotNo: printTarget.lotNo || "—",
  357. // DataFlex zpl (Bag2.py) only needs itemId + stockInLineId for QR payload (optional).
  358. itemId: printTarget.itemId,
  359. stockInLineId: printTarget.stockInLineId,
  360. printerIp,
  361. printerPort,
  362. printQty: qty,
  363. }),
  364. });
  365. if (resp.status === 401 || resp.status === 403) return;
  366. if (!resp.ok) {
  367. const msg = await resp.text().catch(() => "");
  368. setSnackbar({
  369. open: true,
  370. message: `DataFlex 列印失敗(狀態碼 ${resp.status})。${msg ? msg.slice(0, 120) : ""}`,
  371. severity: "error",
  372. });
  373. return;
  374. }
  375. const batch = getBatch(printTarget);
  376. const printedText = qty === -1 ? "連續 (C)" : `${qty}`;
  377. setSnackbar({ open: true, message: `已送出列印:批次 ${batch} x ${printedText}`, severity: "success" });
  378. setPrintDialogOpen(false);
  379. } catch (e) {
  380. setSnackbar({ open: true, message: e instanceof Error ? e.message : "DataFlex 列印失敗", severity: "error" });
  381. } finally {
  382. setPrinting(false);
  383. }
  384. };
  385. const handleDownloadOnPackQr = async () => {
  386. if (downloadingOnPackRef.current) return;
  387. const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);
  388. if (onPackJobOrders.length === 0) {
  389. setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
  390. return;
  391. }
  392. downloadingOnPackRef.current = true;
  393. setDownloadingOnPack(true);
  394. try {
  395. const blob = await downloadOnPackQrZip({
  396. jobOrders: onPackJobOrders,
  397. });
  398. const url = window.URL.createObjectURL(blob);
  399. const link = document.createElement("a");
  400. link.href = url;
  401. link.setAttribute("download", `onpack_qr_${planDate}.zip`);
  402. document.body.appendChild(link);
  403. link.click();
  404. link.remove();
  405. window.URL.revokeObjectURL(url);
  406. setSnackbar({ open: true, message: "OnPack QR code ZIP 已下載", severity: "success" });
  407. } catch (e) {
  408. setSnackbar({
  409. open: true,
  410. message: e instanceof Error ? e.message : "下載 OnPack QR code 失敗",
  411. severity: "error",
  412. });
  413. } finally {
  414. setDownloadingOnPack(false);
  415. downloadingOnPackRef.current = false;
  416. }
  417. };
  418. const handleDownloadOnPackQrWithExpiry = async () => {
  419. if (downloadingOnPackExpRef.current) return;
  420. const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);
  421. if (onPackJobOrders.length === 0) {
  422. setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
  423. return;
  424. }
  425. downloadingOnPackExpRef.current = true;
  426. setDownloadingOnPackExp(true);
  427. try {
  428. const { blob, skippedWithoutExpiry } = await downloadOnPackQrZipWithExpiry({
  429. jobOrders: onPackJobOrders,
  430. planDate,
  431. });
  432. const url = window.URL.createObjectURL(blob);
  433. const link = document.createElement("a");
  434. link.href = url;
  435. link.setAttribute("download", `onpack_qr_exp_${planDate}.zip`);
  436. document.body.appendChild(link);
  437. link.click();
  438. link.remove();
  439. window.URL.revokeObjectURL(url);
  440. setSnackbar(skippedExpirySnackbar("OnPack 汁水機(含到期日)ZIP 已下載", skippedWithoutExpiry));
  441. } catch (e) {
  442. setSnackbar({
  443. open: true,
  444. message: e instanceof Error ? e.message : "下載 OnPack 汁水機(含到期日)失敗",
  445. severity: "error",
  446. });
  447. } finally {
  448. setDownloadingOnPackExp(false);
  449. downloadingOnPackExpRef.current = false;
  450. }
  451. };
  452. const handleDownloadOnPackTextQr = async () => {
  453. if (downloadingOnPackTextRef.current) return;
  454. const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);
  455. if (onPackJobOrders.length === 0) {
  456. setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
  457. return;
  458. }
  459. downloadingOnPackTextRef.current = true;
  460. setDownloadingOnPackText(true);
  461. try {
  462. const blob = await downloadOnPackTextQrZip({
  463. jobOrders: onPackJobOrders,
  464. });
  465. const url = window.URL.createObjectURL(blob);
  466. const link = document.createElement("a");
  467. link.href = url;
  468. link.setAttribute("download", `onpack2023_lemon_qr_${planDate}.zip`);
  469. document.body.appendChild(link);
  470. link.click();
  471. link.remove();
  472. window.URL.revokeObjectURL(url);
  473. setSnackbar({ open: true, message: "OnPack2023檸檬機 ZIP 已下載", severity: "success" });
  474. } catch (e) {
  475. setSnackbar({
  476. open: true,
  477. message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機 失敗",
  478. severity: "error",
  479. });
  480. } finally {
  481. setDownloadingOnPackText(false);
  482. downloadingOnPackTextRef.current = false;
  483. }
  484. };
  485. const handleDownloadOnPackTextQrWithExpiry = async () => {
  486. if (downloadingOnPackTextExpRef.current) return;
  487. const onPackJobOrders = buildOnPackJobOrdersPayload(jobOrders);
  488. if (onPackJobOrders.length === 0) {
  489. setSnackbar({ open: true, message: "當日沒有可下載的 job order", severity: "error" });
  490. return;
  491. }
  492. downloadingOnPackTextExpRef.current = true;
  493. setDownloadingOnPackTextExp(true);
  494. try {
  495. const { blob, skippedWithoutExpiry } = await downloadOnPackTextQrZipWithExpiry({
  496. jobOrders: onPackJobOrders,
  497. planDate,
  498. });
  499. const url = window.URL.createObjectURL(blob);
  500. const link = document.createElement("a");
  501. link.href = url;
  502. link.setAttribute("download", `onpack2023_lemon_qr_exp_${planDate}.zip`);
  503. document.body.appendChild(link);
  504. link.click();
  505. link.remove();
  506. window.URL.revokeObjectURL(url);
  507. setSnackbar(skippedExpirySnackbar("OnPack2023檸檬機(含到期日)ZIP 已下載", skippedWithoutExpiry));
  508. } catch (e) {
  509. setSnackbar({
  510. open: true,
  511. message: e instanceof Error ? e.message : "下載 OnPack2023檸檬機(含到期日)失敗",
  512. severity: "error",
  513. });
  514. } finally {
  515. setDownloadingOnPackTextExp(false);
  516. downloadingOnPackTextExpRef.current = false;
  517. }
  518. };
  519. const loadExpiryCodes = useCallback(async (notify = false) => {
  520. setExpiryCodesLoading(true);
  521. try {
  522. const rows = await fetchOnPackExpiryCodes("juice");
  523. setExpiryCodes(rows);
  524. setNameDrafts(
  525. Object.fromEntries(
  526. rows.map((r) => [r.itemCode, r.printName || r.defaultPrintName || ""]),
  527. ),
  528. );
  529. } catch (e) {
  530. if (notify) {
  531. setSnackbar({
  532. open: true,
  533. message: e instanceof Error ? e.message : "讀取到期日 ZIP 品號失敗",
  534. severity: "error",
  535. });
  536. }
  537. } finally {
  538. setExpiryCodesLoading(false);
  539. }
  540. }, []);
  541. useEffect(() => {
  542. void loadExpiryCodes();
  543. }, [loadExpiryCodes]);
  544. useEffect(() => {
  545. void (async () => {
  546. try {
  547. const catalog = await fetchOnPackSupportedCatalog();
  548. setLemonCodeSet(
  549. new Set(
  550. (catalog.lemon ?? [])
  551. .filter((row) => row.printable)
  552. .map((row) => row.itemCode.trim().toUpperCase())
  553. .filter(Boolean),
  554. ),
  555. );
  556. } catch {
  557. /* 檸檬機標籤可沒有;不擋畫面 */
  558. }
  559. })();
  560. }, []);
  561. useEffect(() => {
  562. if (!templatesOpen) return;
  563. void loadExpiryCodes(true);
  564. }, [templatesOpen, loadExpiryCodes]);
  565. const handleAddExpiryCode = async () => {
  566. if (expiryAddRef.current) return;
  567. const itemCode = expiryCodeInput.trim();
  568. if (!itemCode) {
  569. setSnackbar({ open: true, message: "請先填寫品號", severity: "error" });
  570. return;
  571. }
  572. expiryAddRef.current = true;
  573. try {
  574. await addOnPackExpiryCode(itemCode, "juice");
  575. setExpiryCodeInput("");
  576. setSnackbar({ open: true, message: `已加入到期日 ZIP:${itemCode.toUpperCase()}`, severity: "success" });
  577. await loadExpiryCodes();
  578. } catch (e) {
  579. setSnackbar({
  580. open: true,
  581. message: e instanceof Error ? e.message : "新增到期日 ZIP 品號失敗",
  582. severity: "error",
  583. });
  584. } finally {
  585. expiryAddRef.current = false;
  586. }
  587. };
  588. const handleDeleteExpiryCode = async (itemCode: string) => {
  589. if (expiryDeleteRef.current) return;
  590. expiryDeleteRef.current = true;
  591. try {
  592. await deleteOnPackExpiryCode(itemCode, "juice");
  593. setSnackbar({ open: true, message: `已從到期日 ZIP 移除 ${itemCode}`, severity: "success" });
  594. await loadExpiryCodes();
  595. } catch (e) {
  596. setSnackbar({
  597. open: true,
  598. message: e instanceof Error ? e.message : "刪除到期日 ZIP 品號失敗",
  599. severity: "error",
  600. });
  601. } finally {
  602. expiryDeleteRef.current = false;
  603. }
  604. };
  605. const applyExpiryRow = (updated: OnPackExpiryItemCodeDto) => {
  606. setExpiryCodes((prev) => prev.map((r) => (r.itemCode === updated.itemCode ? updated : r)));
  607. setNameDrafts((prev) => ({
  608. ...prev,
  609. [updated.itemCode]: updated.printName || updated.defaultPrintName || "",
  610. }));
  611. };
  612. const handleSaveExpiryPrintName = async (itemCode: string) => {
  613. if (expirySaveRef.current.has(itemCode)) return;
  614. expirySaveRef.current.add(itemCode);
  615. try {
  616. const updated = await updateOnPackExpiryCode({
  617. itemCode,
  618. machine: "juice",
  619. printName: (nameDrafts[itemCode] ?? "").trim(),
  620. });
  621. applyExpiryRow(updated);
  622. setSnackbar({ open: true, message: `已儲存 ${itemCode} 列印名稱`, severity: "success" });
  623. } catch (e) {
  624. setSnackbar({
  625. open: true,
  626. message: e instanceof Error ? e.message : "儲存列印名稱失敗",
  627. severity: "error",
  628. });
  629. } finally {
  630. expirySaveRef.current.delete(itemCode);
  631. }
  632. };
  633. const handleToggleUseMinus18 = async (itemCode: string, useMinus18: boolean) => {
  634. if (expiryToggleRef.current.has(itemCode)) return;
  635. expiryToggleRef.current.add(itemCode);
  636. try {
  637. const updated = await updateOnPackExpiryCode({
  638. itemCode,
  639. machine: "juice",
  640. useMinus18,
  641. });
  642. applyExpiryRow(updated);
  643. setSnackbar({
  644. open: true,
  645. message: useMinus18 ? `已改用 ${itemCode} 的 -18 天數` : `已改用 ${itemCode} 的冷藏天數`,
  646. severity: "success",
  647. });
  648. } catch (e) {
  649. setSnackbar({
  650. open: true,
  651. message: e instanceof Error ? e.message : "更新保質期旗標失敗",
  652. severity: "error",
  653. });
  654. } finally {
  655. expiryToggleRef.current.delete(itemCode);
  656. }
  657. };
  658. const juiceExpiryCodeSet = expiryCodeSet(expiryCodes);
  659. const sortedExpiryCodes = useMemo(
  660. () => sortExpiryRows(expiryCodes, expirySortKey, expirySortDir),
  661. [expiryCodes, expirySortKey, expirySortDir],
  662. );
  663. const onExpirySort = (key: ExpirySortKey) => {
  664. if (expirySortKey === key) {
  665. setExpirySortDir((d) => (d === "asc" ? "desc" : "asc"));
  666. } else {
  667. setExpirySortKey(key);
  668. setExpirySortDir("asc");
  669. }
  670. };
  671. return (
  672. <Box sx={{ minHeight: "70vh", display: "flex", flexDirection: "column" }}>
  673. {/* Top: date nav + printer + settings */}
  674. <Paper sx={{ p: 2, mb: 2, backgroundColor: BG_TOP }}>
  675. <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={2}>
  676. <Stack direction="row" alignItems="center" spacing={2}>
  677. <Button variant="outlined" startIcon={<ChevronLeft />} onClick={goPrevDay}>
  678. 前一天
  679. </Button>
  680. <TextField
  681. type="date"
  682. value={planDate}
  683. onChange={(e) => setPlanDate(e.target.value)}
  684. size="small"
  685. sx={{ width: 160 }}
  686. InputLabelProps={{ shrink: true }}
  687. />
  688. <Button variant="outlined" endIcon={<ChevronRight />} onClick={goNextDay}>
  689. 後一天
  690. </Button>
  691. </Stack>
  692. <Stack direction="row" alignItems="center" spacing={2}>
  693. <Button variant="outlined" startIcon={<Settings />} onClick={() => setSettingsOpen(true)}>
  694. 設定
  695. </Button>
  696. {canSeeOnPackAdmin && (
  697. <Button variant="outlined" onClick={() => setTemplatesOpen(true)}>
  698. OnPack 到期日 ZIP
  699. </Button>
  700. )}
  701. <Box
  702. sx={{
  703. px: 1.5,
  704. py: 0.75,
  705. borderRadius: 1,
  706. backgroundColor: printerConnected ? BG_STATUS_OK : BG_STATUS_ERROR,
  707. color: printerConnected ? FG_STATUS_OK : FG_STATUS_ERROR,
  708. fontWeight: 600,
  709. whiteSpace: "nowrap",
  710. }}
  711. title={printerMessage}
  712. >
  713. 列印機:
  714. </Box>
  715. <FormControl size="small" sx={{ minWidth: 180 }}>
  716. <InputLabel>列印機</InputLabel>
  717. <Select value={printer} label="列印機" onChange={handlePrinterChange}>
  718. {PRINTER_OPTIONS.map((opt) => (
  719. <MenuItem key={opt.value} value={opt.value}>
  720. {opt.label}
  721. </MenuItem>
  722. ))}
  723. </Select>
  724. </FormControl>
  725. </Stack>
  726. </Stack>
  727. <Typography variant="body2" sx={{ mt: 1, color: "text.secondary" }}>
  728. {printerMessage}
  729. </Typography>
  730. <Stack direction="row" sx={{ mt: 2 }} spacing={2} flexWrap="wrap" useFlexGap>
  731. <Button
  732. variant="contained"
  733. startIcon={<Download />}
  734. onClick={handleDownloadOnPackQr}
  735. disabled={
  736. loading ||
  737. downloadingOnPack ||
  738. downloadingOnPackExp ||
  739. downloadingOnPackText ||
  740. downloadingOnPackTextExp ||
  741. jobOrders.length === 0
  742. }
  743. >
  744. {downloadingOnPack ? "下載中..." : "下載 OnPack 汁水機 QR code"}
  745. </Button>
  746. <Button
  747. variant="contained"
  748. startIcon={<Download />}
  749. onClick={handleDownloadOnPackQrWithExpiry}
  750. disabled={
  751. loading ||
  752. downloadingOnPack ||
  753. downloadingOnPackExp ||
  754. downloadingOnPackText ||
  755. downloadingOnPackTextExp ||
  756. jobOrders.length === 0
  757. }
  758. >
  759. {downloadingOnPackExp ? "下載中..." : "下載 OnPack 汁水機(含到期日)"}
  760. </Button>
  761. <Button
  762. variant="contained"
  763. color="secondary"
  764. startIcon={<Download />}
  765. onClick={handleDownloadOnPackTextQr}
  766. disabled={
  767. loading ||
  768. downloadingOnPack ||
  769. downloadingOnPackExp ||
  770. downloadingOnPackText ||
  771. downloadingOnPackTextExp ||
  772. jobOrders.length === 0
  773. }
  774. >
  775. {downloadingOnPackText ? "下載中..." : "下載 OnPack2023檸檬機"}
  776. </Button>
  777. {canSeeOnPackAdmin && (
  778. <Button
  779. variant="contained"
  780. color="secondary"
  781. startIcon={<Download />}
  782. onClick={handleDownloadOnPackTextQrWithExpiry}
  783. disabled={
  784. loading ||
  785. downloadingOnPack ||
  786. downloadingOnPackExp ||
  787. downloadingOnPackText ||
  788. downloadingOnPackTextExp ||
  789. jobOrders.length === 0
  790. }
  791. >
  792. {downloadingOnPackTextExp ? "下載中..." : "下載 OnPack2023檸檬機(含到期日)"}
  793. </Button>
  794. )}
  795. </Stack>
  796. </Paper>
  797. {/* Job orders list */}
  798. <Paper sx={{ flex: 1, overflow: "hidden", display: "flex", flexDirection: "column", backgroundColor: BG_LIST }}>
  799. {loading ? (
  800. <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", py: 8 }}>
  801. <CircularProgress />
  802. </Box>
  803. ) : jobOrders.length === 0 ? (
  804. <Box sx={{ py: 8, textAlign: "center" }}>
  805. <Typography color="text.secondary">當日無工單</Typography>
  806. </Box>
  807. ) : (
  808. <Box sx={{ overflow: "auto", flex: 1, p: 2 }}>
  809. <Stack spacing={1}>
  810. {jobOrders.map((jo) => {
  811. const batch = getBatch(jo);
  812. const qtyStr = formatQty(jo.reqQty);
  813. const isSelected = selectedId === jo.id;
  814. const codeKey = (jo.itemCode || "").trim().toUpperCase();
  815. const juiceExpiryOk = juiceExpiryCodeSet.has(codeKey);
  816. const lemonOk = lemonCodeSet.has(codeKey);
  817. return (
  818. <Paper
  819. key={jo.id}
  820. elevation={1}
  821. sx={{
  822. p: 2,
  823. display: "flex",
  824. alignItems: "flex-start",
  825. gap: 2,
  826. cursor: "pointer",
  827. backgroundColor: isSelected ? BG_ROW_SELECTED : BG_ROW,
  828. "&:hover": { backgroundColor: isSelected ? BG_ROW_SELECTED : "#b8d4eb" },
  829. transition: "background-color 0.2s",
  830. }}
  831. onClick={() => handleRowClick(jo)}
  832. >
  833. <Box sx={{ minWidth: 120, flexShrink: 0 }}>
  834. <Typography variant="h6" sx={{ fontSize: "1.1rem" }}>
  835. {batch}
  836. </Typography>
  837. {qtyStr !== "—" && (
  838. <Typography variant="body2" color="text.secondary">
  839. 數量:{qtyStr}
  840. </Typography>
  841. )}
  842. </Box>
  843. <Box sx={{ minWidth: 140, flexShrink: 0 }}>
  844. <Typography variant="h6" sx={{ fontSize: "1.1rem" }}>
  845. {jo.code || "—"}
  846. </Typography>
  847. </Box>
  848. <Box sx={{ minWidth: 140, flexShrink: 0 }}>
  849. <Typography variant="h6" sx={{ fontSize: "1.35rem" }}>
  850. {jo.itemCode || "—"}
  851. </Typography>
  852. <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}>
  853. {juiceExpiryOk ? <Chip size="small" label="汁水機" color="primary" /> : null}
  854. {lemonOk ? <Chip size="small" label="檸檬機" color="secondary" /> : null}
  855. </Stack>
  856. </Box>
  857. <Box sx={{ flex: 1, minWidth: 0 }}>
  858. <Typography variant="h6" sx={{ fontSize: "1.35rem", wordBreak: "break-word" }}>
  859. {jo.itemName || "—"}
  860. </Typography>
  861. </Box>
  862. <Button
  863. size="small"
  864. variant="contained"
  865. startIcon={<Print />}
  866. onClick={(e) => {
  867. e.stopPropagation();
  868. handleRowClick(jo);
  869. }}
  870. >
  871. 列印
  872. </Button>
  873. </Paper>
  874. );
  875. })}
  876. </Stack>
  877. </Box>
  878. )}
  879. </Paper>
  880. {/* Print count dialog (DataFlex) */}
  881. <Dialog open={printDialogOpen} onClose={() => (printing ? null : setPrintDialogOpen(false))} maxWidth="xs" fullWidth>
  882. <DialogTitle>打袋機 DataFlex 列印數量</DialogTitle>
  883. <DialogContent>
  884. <Stack spacing={2} sx={{ mt: 1 }}>
  885. <Typography variant="body1" sx={{ fontWeight: 700 }}>
  886. 列印多少個袋?
  887. </Typography>
  888. <Typography variant="body2" color="text.secondary">
  889. {printContinuous ? "連續 (C)" : `數量: ${printCount}`}
  890. </Typography>
  891. <Stack direction="row" spacing={1} justifyContent="center" flexWrap="wrap">
  892. <Button
  893. size="small"
  894. variant="contained"
  895. onClick={() => {
  896. setPrintContinuous(false);
  897. setPrintCount((c) => c + 50);
  898. }}
  899. disabled={printing}
  900. >
  901. +50
  902. </Button>
  903. <Button
  904. size="small"
  905. variant="contained"
  906. onClick={() => {
  907. setPrintContinuous(false);
  908. setPrintCount((c) => c + 10);
  909. }}
  910. disabled={printing}
  911. >
  912. +10
  913. </Button>
  914. <Button
  915. size="small"
  916. variant="contained"
  917. onClick={() => {
  918. setPrintContinuous(false);
  919. setPrintCount((c) => c + 5);
  920. }}
  921. disabled={printing}
  922. >
  923. +5
  924. </Button>
  925. <Button
  926. size="small"
  927. variant="contained"
  928. onClick={() => {
  929. setPrintContinuous(false);
  930. setPrintCount((c) => c + 1);
  931. }}
  932. disabled={printing}
  933. >
  934. +1
  935. </Button>
  936. <Button
  937. size="small"
  938. variant={printContinuous ? "contained" : "outlined"}
  939. onClick={() => {
  940. setPrintContinuous(true);
  941. }}
  942. disabled={printing}
  943. >
  944. 連續 (C)
  945. </Button>
  946. </Stack>
  947. </Stack>
  948. </DialogContent>
  949. <DialogActions>
  950. <Button onClick={() => setPrintDialogOpen(false)} disabled={printing}>
  951. 取消
  952. </Button>
  953. <Button variant="contained" onClick={() => void confirmPrintDataFlex()} disabled={printing}>
  954. {printing ? <CircularProgress size={16} /> : "確認送出"}
  955. </Button>
  956. </DialogActions>
  957. </Dialog>
  958. {/* Settings dialog */}
  959. <Dialog open={settingsOpen} onClose={() => setSettingsOpen(false)} maxWidth="sm" fullWidth>
  960. <DialogTitle>設定</DialogTitle>
  961. <DialogContent>
  962. <Stack spacing={2} sx={{ mt: 1 }}>
  963. <Typography variant="subtitle2" color="primary">
  964. 打袋機 DataFlex
  965. </Typography>
  966. <TextField
  967. label="IP"
  968. size="small"
  969. value={settings.dabag_ip}
  970. onChange={(e) => setSettings((s) => ({ ...s, dabag_ip: e.target.value }))}
  971. fullWidth
  972. />
  973. <TextField
  974. label="Port"
  975. size="small"
  976. value={settings.dabag_port}
  977. onChange={(e) => setSettings((s) => ({ ...s, dabag_port: e.target.value }))}
  978. fullWidth
  979. />
  980. <Typography variant="subtitle2" color="primary">
  981. 激光機
  982. </Typography>
  983. <TextField
  984. label="IP"
  985. size="small"
  986. value={settings.laser_ip}
  987. onChange={(e) => setSettings((s) => ({ ...s, laser_ip: e.target.value }))}
  988. fullWidth
  989. />
  990. <TextField
  991. label="Port"
  992. size="small"
  993. value={settings.laser_port}
  994. onChange={(e) => setSettings((s) => ({ ...s, laser_port: e.target.value }))}
  995. fullWidth
  996. />
  997. </Stack>
  998. </DialogContent>
  999. <DialogActions>
  1000. <Button onClick={() => setSettingsOpen(false)}>取消</Button>
  1001. <Button
  1002. variant="contained"
  1003. onClick={() => {
  1004. saveSettings(settings);
  1005. setSnackbar({ open: true, message: "設定已儲存", severity: "success" });
  1006. setSettingsOpen(false);
  1007. checkCurrentPrinter();
  1008. }}
  1009. >
  1010. 儲存
  1011. </Button>
  1012. </DialogActions>
  1013. </Dialog>
  1014. <Dialog
  1015. open={templatesOpen && canSeeOnPackAdmin}
  1016. onClose={() => setTemplatesOpen(false)}
  1017. maxWidth="xl"
  1018. fullWidth
  1019. scroll="paper"
  1020. PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }}
  1021. >
  1022. <DialogTitle>OnPack 到期日 ZIP 品號</DialogTitle>
  1023. <DialogContent
  1024. sx={{
  1025. display: "flex",
  1026. flexDirection: "column",
  1027. overflow: "hidden",
  1028. pt: 1,
  1029. }}
  1030. >
  1031. <Stack spacing={1.5} sx={{ flexShrink: 0, mb: 1 }}>
  1032. <Typography variant="subtitle2" color="primary">
  1033. 汁水機({expiryCodes.length})
  1034. </Typography>
  1035. <Typography variant="body2" color="text.secondary">
  1036. 「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。
  1037. 點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。
  1038. </Typography>
  1039. <Stack direction="row" spacing={1} alignItems="center">
  1040. <TextField
  1041. label="新增品號"
  1042. size="small"
  1043. placeholder="例如 PP2211"
  1044. value={expiryCodeInput}
  1045. onChange={(e) => setExpiryCodeInput(e.target.value)}
  1046. onKeyDown={(e) => {
  1047. if (e.key === "Enter") {
  1048. e.preventDefault();
  1049. void handleAddExpiryCode();
  1050. }
  1051. }}
  1052. sx={{ minWidth: 180 }}
  1053. />
  1054. <Button variant="contained" onClick={() => void handleAddExpiryCode()}>
  1055. 加入
  1056. </Button>
  1057. </Stack>
  1058. </Stack>
  1059. {expiryCodesLoading ? (
  1060. <Box sx={{ display: "flex", justifyContent: "center", py: 1 }}>
  1061. <CircularProgress size={20} />
  1062. </Box>
  1063. ) : expiryCodes.length === 0 ? (
  1064. <Typography color="text.secondary">清單空白</Typography>
  1065. ) : (
  1066. <TableContainer sx={{ flex: 1, minHeight: 0, overflow: "auto" }}>
  1067. <Table size="small" stickyHeader>
  1068. <TableHead>
  1069. <TableRow>
  1070. <TableCell sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
  1071. <TableSortLabel
  1072. active={expirySortKey === "itemCode"}
  1073. direction={expirySortKey === "itemCode" ? expirySortDir : "asc"}
  1074. onClick={() => onExpirySort("itemCode")}
  1075. >
  1076. 品號
  1077. </TableSortLabel>
  1078. </TableCell>
  1079. <TableCell sx={{ fontWeight: 700 }}>
  1080. <TableSortLabel
  1081. active={expirySortKey === "name"}
  1082. direction={expirySortKey === "name" ? expirySortDir : "asc"}
  1083. onClick={() => onExpirySort("name")}
  1084. >
  1085. 中文名稱+單位
  1086. </TableSortLabel>
  1087. </TableCell>
  1088. <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
  1089. <TableSortLabel
  1090. active={expirySortKey === "defaultDays"}
  1091. direction={expirySortKey === "defaultDays" ? expirySortDir : "asc"}
  1092. onClick={() => onExpirySort("defaultDays")}
  1093. >
  1094. 冷藏
  1095. </TableSortLabel>
  1096. <Typography variant="caption" display="block" color="text.secondary">
  1097. </Typography>
  1098. </TableCell>
  1099. <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
  1100. <TableSortLabel
  1101. active={expirySortKey === "minus18Days"}
  1102. direction={expirySortKey === "minus18Days" ? expirySortDir : "asc"}
  1103. onClick={() => onExpirySort("minus18Days")}
  1104. >
  1105. -18
  1106. </TableSortLabel>
  1107. <Typography variant="caption" display="block" color="text.secondary">
  1108. </Typography>
  1109. </TableCell>
  1110. <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
  1111. <TableSortLabel
  1112. active={expirySortKey === "useMinus18"}
  1113. direction={expirySortKey === "useMinus18" ? expirySortDir : "asc"}
  1114. onClick={() => onExpirySort("useMinus18")}
  1115. >
  1116. 用 -18
  1117. </TableSortLabel>
  1118. </TableCell>
  1119. <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}>
  1120. <TableSortLabel
  1121. active={expirySortKey === "effectiveDays"}
  1122. direction={expirySortKey === "effectiveDays" ? expirySortDir : "asc"}
  1123. onClick={() => onExpirySort("effectiveDays")}
  1124. >
  1125. 列印
  1126. </TableSortLabel>
  1127. <Typography variant="caption" display="block" color="text.secondary">
  1128. </Typography>
  1129. </TableCell>
  1130. <TableCell align="right" sx={{ fontWeight: 700 }}>
  1131. 操作
  1132. </TableCell>
  1133. </TableRow>
  1134. </TableHead>
  1135. <TableBody>
  1136. {sortedExpiryCodes.map((row) => {
  1137. const draft = nameDrafts[row.itemCode] ?? "";
  1138. const savedName = row.printName || row.defaultPrintName || "";
  1139. const nameDirty = draft.trim() !== savedName.trim();
  1140. const hasShelf = row.defaultDays != null || row.minus18Days != null;
  1141. const canUseMinus18 = row.minus18Days != null && row.minus18Days > 0;
  1142. const missingHint = hasShelf
  1143. ? canUseMinus18
  1144. ? ""
  1145. : "此品號沒有 -18 天數"
  1146. : "未設定保質期,請到設定 → 物品預設保質期新增";
  1147. return (
  1148. <TableRow key={row.itemCode} hover>
  1149. <TableCell sx={{ fontFamily: "monospace", fontWeight: 700, whiteSpace: "nowrap" }}>
  1150. {row.itemCode}
  1151. </TableCell>
  1152. <TableCell sx={{ minWidth: 280 }}>
  1153. <Stack direction="row" spacing={1} alignItems="center">
  1154. <TextField
  1155. size="small"
  1156. value={draft}
  1157. onChange={(e) =>
  1158. setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value }))
  1159. }
  1160. placeholder={row.defaultPrintName || "例如 豬肉水(1包X2千克)"}
  1161. inputProps={{ maxLength: 255 }}
  1162. fullWidth
  1163. />
  1164. <Button
  1165. variant="contained"
  1166. size="small"
  1167. disabled={!nameDirty}
  1168. onClick={() => void handleSaveExpiryPrintName(row.itemCode)}
  1169. >
  1170. 儲存
  1171. </Button>
  1172. </Stack>
  1173. </TableCell>
  1174. <TableCell align="center">
  1175. <Typography
  1176. variant="h6"
  1177. sx={{ fontWeight: 700, lineHeight: 1.2 }}
  1178. color={row.defaultDays == null ? "warning.main" : "text.primary"}
  1179. >
  1180. {daysLabel(row.defaultDays)}
  1181. </Typography>
  1182. </TableCell>
  1183. <TableCell align="center">
  1184. <Typography
  1185. variant="h6"
  1186. sx={{ fontWeight: 700, lineHeight: 1.2 }}
  1187. color={row.minus18Days == null ? "warning.main" : "text.primary"}
  1188. >
  1189. {daysLabel(row.minus18Days)}
  1190. </Typography>
  1191. </TableCell>
  1192. <TableCell align="center">
  1193. <Tooltip title={canUseMinus18 ? "改用 -18 天數列印到期日" : missingHint}>
  1194. <span>
  1195. <Switch
  1196. size="small"
  1197. checked={row.useMinus18 === true}
  1198. disabled={!canUseMinus18}
  1199. onChange={(e) => void handleToggleUseMinus18(row.itemCode, e.target.checked)}
  1200. inputProps={{ "aria-label": `${row.itemCode} 用 -18` }}
  1201. />
  1202. </span>
  1203. </Tooltip>
  1204. </TableCell>
  1205. <TableCell align="center">
  1206. <Typography
  1207. variant="h6"
  1208. sx={{ fontWeight: 800, lineHeight: 1.2 }}
  1209. color={row.effectiveDays == null ? "warning.main" : "primary.main"}
  1210. >
  1211. {daysLabel(row.effectiveDays)}
  1212. </Typography>
  1213. {!hasShelf && (
  1214. <Typography variant="caption" color="warning.main" display="block">
  1215. 未設定
  1216. </Typography>
  1217. )}
  1218. </TableCell>
  1219. <TableCell align="right">
  1220. <Button
  1221. size="small"
  1222. color="error"
  1223. onClick={() => void handleDeleteExpiryCode(row.itemCode)}
  1224. >
  1225. 移除
  1226. </Button>
  1227. </TableCell>
  1228. </TableRow>
  1229. );
  1230. })}
  1231. </TableBody>
  1232. </Table>
  1233. </TableContainer>
  1234. )}
  1235. <Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mt: 1 }}>
  1236. 檸檬機到期日 ZIP 品號稍後加入。
  1237. </Typography>
  1238. </DialogContent>
  1239. <DialogActions>
  1240. <Button onClick={() => setTemplatesOpen(false)}>關閉</Button>
  1241. </DialogActions>
  1242. </Dialog>
  1243. <Snackbar
  1244. open={snackbar.open}
  1245. autoHideDuration={snackbar.duration ?? 3000}
  1246. onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
  1247. anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
  1248. >
  1249. <Alert
  1250. onClose={() => setSnackbar((s) => ({ ...s, open: false }))}
  1251. severity={snackbar.severity ?? "info"}
  1252. variant="filled"
  1253. sx={{ width: "100%", maxWidth: 720 }}
  1254. >
  1255. {snackbar.message}
  1256. </Alert>
  1257. </Snackbar>
  1258. </Box>
  1259. );
  1260. };
  1261. export default BagPrintSearch;