FPSMS-frontend
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

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