diff --git a/src/app/(main)/settings/itemDefaultShelfLife/page.tsx b/src/app/(main)/settings/itemDefaultShelfLife/page.tsx
new file mode 100644
index 00000000..e4d11586
--- /dev/null
+++ b/src/app/(main)/settings/itemDefaultShelfLife/page.tsx
@@ -0,0 +1,21 @@
+import ItemDefaultShelfLifeSettings from "@/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings";
+import { getServerI18n, I18nProvider } from "@/i18n";
+import { Stack, Typography } from "@mui/material";
+import { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "Item default shelf life",
+};
+
+export default async function ItemDefaultShelfLifePage() {
+ const { t } = await getServerI18n("itemDefaultShelfLife");
+
+ return (
+
+
+ {t("title")}
+
+
+
+ );
+}
diff --git a/src/app/api/bagPrint/actions.ts b/src/app/api/bagPrint/actions.ts
index 5ec95ec8..ad637f2e 100644
--- a/src/app/api/bagPrint/actions.ts
+++ b/src/app/api/bagPrint/actions.ts
@@ -167,10 +167,23 @@ export async function downloadOnPackQrZip(
return res.blob();
}
+export type OnPackZipDownload = {
+ blob: Blob;
+ skippedWithoutExpiry: string[];
+};
+
+function skippedWithoutExpiryFromResponse(res: Response): string[] {
+ const raw = res.headers.get("X-OnPack-Skipped-Expiry") ?? "";
+ return raw
+ .split(",")
+ .map((s) => s.trim().toUpperCase())
+ .filter(Boolean);
+}
+
/** 汁水機 OnPack — same as QR ZIP, plus LOGO_EXP BMP from item_default_shelf_life. */
export async function downloadOnPackQrZipWithExpiry(
request: OnPackQrDownloadRequest,
-): Promise {
+): Promise {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`;
const res = await clientAuthFetch(url, {
method: "POST",
@@ -182,7 +195,10 @@ export async function downloadOnPackQrZipWithExpiry(
throw await zipDownloadError(res);
}
- return res.blob();
+ return {
+ blob: await res.blob(),
+ skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res),
+ };
}
/** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */
@@ -206,7 +222,7 @@ export async function downloadOnPackTextQrZip(
/** OnPack2023 檸檬機 — same as text ZIP, plus TEXT_EXP from item_default_shelf_life. */
export async function downloadOnPackTextQrZipWithExpiry(
request: OnPackQrDownloadRequest,
-): Promise {
+): Promise {
const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`;
const res = await clientAuthFetch(url, {
method: "POST",
@@ -218,7 +234,10 @@ export async function downloadOnPackTextQrZipWithExpiry(
throw await zipDownloadError(res);
}
- return res.blob();
+ return {
+ blob: await res.blob(),
+ skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res),
+ };
}
export type OnPackMachine = "juice" | "lemon";
@@ -251,6 +270,75 @@ export interface OnPackSupportedCatalogDto {
lemon: OnPackSupportedItemDto[];
}
+export interface OnPackExpiryItemCodeDto {
+ machine: string;
+ itemCode: string;
+ printName?: string | null;
+ defaultPrintName?: string | null;
+ defaultDays?: number | null;
+ minus18Days?: number | null;
+ useMinus18?: boolean;
+ effectiveDays?: number | null;
+}
+
+export type OnPackExpiryItemCodeUpdate = {
+ itemCode: string;
+ machine?: OnPackMachine;
+ printName?: string | null;
+ useMinus18?: boolean;
+};
+
+export async function fetchOnPackExpiryCodes(machine: OnPackMachine = "juice"): Promise {
+ const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}`;
+ const res = await clientAuthFetch(url, { method: "GET" });
+ if (!res.ok) {
+ throw await parseBagPrintApiError(res, "讀取到期日 ZIP 品號");
+ }
+ return (await res.json()) as OnPackExpiryItemCodeDto[];
+}
+
+export async function addOnPackExpiryCode(
+ itemCode: string,
+ machine: OnPackMachine = "juice",
+): Promise {
+ const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`;
+ const res = await clientAuthFetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ itemCode, machine }),
+ });
+ if (!res.ok) {
+ throw await parseBagPrintApiError(res, "新增到期日 ZIP 品號");
+ }
+ return (await res.json()) as OnPackExpiryItemCodeDto;
+}
+
+export async function updateOnPackExpiryCode(
+ body: OnPackExpiryItemCodeUpdate,
+): Promise {
+ const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes`;
+ const res = await clientAuthFetch(url, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ throw await parseBagPrintApiError(res, "更新到期日 ZIP 品號");
+ }
+ return (await res.json()) as OnPackExpiryItemCodeDto;
+}
+
+export async function deleteOnPackExpiryCode(
+ itemCode: string,
+ machine: OnPackMachine = "juice",
+): Promise {
+ const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/expiry-codes?machine=${encodeURIComponent(machine)}&itemCode=${encodeURIComponent(itemCode)}`;
+ const res = await clientAuthFetch(url, { method: "DELETE" });
+ if (!res.ok && res.status !== 204) {
+ throw await parseBagPrintApiError(res, "刪除到期日 ZIP 品號");
+ }
+}
+
export async function fetchOnPackSupportedCatalog(): Promise {
const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`;
const res = await clientAuthFetch(url, { method: "GET" });
diff --git a/src/app/api/settings/itemDefaultShelfLife/client.ts b/src/app/api/settings/itemDefaultShelfLife/client.ts
new file mode 100644
index 00000000..03f99b35
--- /dev/null
+++ b/src/app/api/settings/itemDefaultShelfLife/client.ts
@@ -0,0 +1,88 @@
+"use client";
+
+import { clientAuthFetch } from "@/app/utils/clientAuthFetch";
+import { NEXT_PUBLIC_API_URL } from "@/config/api";
+
+const base = NEXT_PUBLIC_API_URL;
+
+export type ItemDefaultShelfLifeRow = {
+ id: number;
+ itemCode: string;
+ itemName?: string | null;
+ defaultDays?: number | null;
+ minus18Days?: number | null;
+ useMinus18: boolean;
+ openedDays?: number | null;
+ storageC?: string | null;
+ remarks?: string | null;
+ effectiveDays?: number | null;
+};
+
+export type ItemDefaultShelfLifeInput = {
+ itemCode: string;
+ defaultDays?: number | null;
+ minus18Days?: number | null;
+ useMinus18: boolean;
+ openedDays?: number | null;
+ storageC?: string | null;
+ remarks?: string | null;
+};
+
+async function parseJson(res: Response): Promise {
+ if (!res.ok) {
+ throw new Error(await readError(res));
+ }
+ return res.json() as Promise;
+}
+
+async function readError(res: Response): Promise {
+ const text = await res.text().catch(() => "");
+ if (!text) return `HTTP ${res.status}`;
+ try {
+ const json = JSON.parse(text) as { message?: string; error?: string };
+ return json.message || json.error || text;
+ } catch {
+ return text;
+ }
+}
+
+export async function fetchItemDefaultShelfLives(
+ q?: string,
+): Promise {
+ const url = new URL(`${base}/itemDefaultShelfLives`);
+ if (q?.trim()) url.searchParams.set("q", q.trim());
+ const res = await clientAuthFetch(url.toString(), { method: "GET" });
+ return parseJson(res);
+}
+
+export async function createItemDefaultShelfLife(
+ data: ItemDefaultShelfLifeInput,
+): Promise {
+ const res = await clientAuthFetch(`${base}/itemDefaultShelfLives`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ return parseJson(res);
+}
+
+export async function updateItemDefaultShelfLife(
+ id: number,
+ data: ItemDefaultShelfLifeInput,
+): Promise {
+ const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ return parseJson(res);
+}
+
+export async function deleteItemDefaultShelfLife(
+ id: number,
+): Promise {
+ const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, {
+ method: "DELETE",
+ });
+ return parseJson(res);
+}
diff --git a/src/components/BagPrint/BagPrintSearch.tsx b/src/components/BagPrint/BagPrintSearch.tsx
index 428b532b..64ed9fe6 100644
--- a/src/components/BagPrint/BagPrintSearch.tsx
+++ b/src/components/BagPrint/BagPrintSearch.tsx
@@ -1,7 +1,8 @@
"use client";
-import React, { useCallback, useEffect, useRef, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
+ Alert,
Box,
Button,
Chip,
@@ -20,6 +21,15 @@ import {
DialogActions,
TextField,
Snackbar,
+ Switch,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ TableSortLabel,
+ Tooltip,
} from "@mui/material";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import ChevronRight from "@mui/icons-material/ChevronRight";
@@ -33,15 +43,14 @@ import {
downloadOnPackQrZipWithExpiry,
downloadOnPackTextQrZip,
downloadOnPackTextQrZipWithExpiry,
- deleteOnPackTemplate,
fetchJobOrders,
+ fetchOnPackExpiryCodes,
+ addOnPackExpiryCode,
+ updateOnPackExpiryCode,
+ deleteOnPackExpiryCode,
fetchOnPackSupportedCatalog,
- listOnPackTemplates,
- uploadOnPackTemplates,
JobOrderListItem,
- OnPackMachine,
- OnPackSupportedCatalogDto,
- OnPackTemplateFileDto,
+ OnPackExpiryItemCodeDto,
} from "@/app/api/bagPrint/actions";
import dayjs from "dayjs";
import { useSession } from "next-auth/react";
@@ -129,15 +138,85 @@ function getBatch(jo: JobOrderListItem): string {
return (jo.lotNo || "—").trim() || "—";
}
-function printableCodes(items: { itemCode: string; printable: boolean }[] | undefined): Set {
- return new Set((items ?? []).filter((r) => r.printable).map((r) => r.itemCode.toUpperCase()));
+function expiryCodeSet(rows: OnPackExpiryItemCodeDto[]): Set {
+ return new Set(rows.map((r) => r.itemCode.trim().toUpperCase()).filter(Boolean));
}
-function supportLabel(row: { inDatabase: boolean; builtin: boolean; printable: boolean }): string {
- if (!row.printable) return "缺模板";
- if (row.inDatabase && row.builtin) return "資料庫+內建";
- if (row.inDatabase) return "資料庫";
- return "內建";
+function daysLabel(value: number | null | undefined): string {
+ return value == null ? "未設" : String(value);
+}
+
+type ExpirySortKey =
+ | "itemCode"
+ | "name"
+ | "defaultDays"
+ | "minus18Days"
+ | "useMinus18"
+ | "effectiveDays";
+
+function displayName(row: OnPackExpiryItemCodeDto): string {
+ return (row.printName || row.defaultPrintName || "").trim();
+}
+
+function cmpText(a: string, b: string): number {
+ return a.localeCompare(b, "zh-Hant", { numeric: true, sensitivity: "base" });
+}
+
+/** Null (未設) sorts first on asc so unset rows are easy to find. */
+function cmpDays(a: number | null | undefined, b: number | null | undefined): number {
+ const av = a == null ? Number.NEGATIVE_INFINITY : a;
+ const bv = b == null ? Number.NEGATIVE_INFINITY : b;
+ return av - bv;
+}
+
+function skippedExpirySnackbar(okMessage: string, skipped: string[]): {
+ open: true;
+ message: string;
+ severity: "success" | "warning";
+ duration: number;
+} {
+ if (skipped.length === 0) {
+ return { open: true, message: okMessage, severity: "success", duration: 3000 };
+ }
+ return {
+ open: true,
+ message: `${okMessage}。以下品號沒有到期日,已略過不入 ZIP:${skipped.join("、")}。請到設定 → 物品預設保質期新增。`,
+ severity: "warning",
+ duration: 10000,
+ };
+}
+
+function sortExpiryRows(
+ rows: OnPackExpiryItemCodeDto[],
+ key: ExpirySortKey,
+ dir: "asc" | "desc",
+): OnPackExpiryItemCodeDto[] {
+ const sign = dir === "asc" ? 1 : -1;
+ return [...rows].sort((a, b) => {
+ let cmp = 0;
+ switch (key) {
+ case "itemCode":
+ cmp = cmpText(a.itemCode, b.itemCode);
+ break;
+ case "name":
+ cmp = cmpText(displayName(a), displayName(b));
+ break;
+ case "defaultDays":
+ cmp = cmpDays(a.defaultDays, b.defaultDays);
+ break;
+ case "minus18Days":
+ cmp = cmpDays(a.minus18Days, b.minus18Days);
+ break;
+ case "useMinus18":
+ cmp = Number(a.useMinus18 === true) - Number(b.useMinus18 === true);
+ break;
+ case "effectiveDays":
+ cmp = cmpDays(a.effectiveDays, b.effectiveDays);
+ break;
+ }
+ if (cmp === 0) cmp = cmpText(a.itemCode, b.itemCode);
+ return cmp * sign;
+ });
}
const BagPrintSearch: React.FC = () => {
@@ -158,16 +237,23 @@ const BagPrintSearch: React.FC = () => {
const [printing, setPrinting] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [templatesOpen, setTemplatesOpen] = useState(false);
- const [templateMachine, setTemplateMachine] = useState("juice");
- const [templateItemCode, setTemplateItemCode] = useState("");
- const [templateFiles, setTemplateFiles] = useState([]);
- const [templateLoading, setTemplateLoading] = useState(false);
- const [templateUploading, setTemplateUploading] = useState(false);
- const [supportedCatalog, setSupportedCatalog] = useState(null);
- const templateUploadRef = useRef(false);
- const templateDeleteRef = useRef(false);
- const templateFileInputRef = useRef(null);
- const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" });
+ const [expiryCodes, setExpiryCodes] = useState([]);
+ const [expiryCodeInput, setExpiryCodeInput] = useState("");
+ const [expiryCodesLoading, setExpiryCodesLoading] = useState(false);
+ const [nameDrafts, setNameDrafts] = useState>({});
+ const [expirySortKey, setExpirySortKey] = useState("itemCode");
+ const [expirySortDir, setExpirySortDir] = useState<"asc" | "desc">("asc");
+ const [lemonCodeSet, setLemonCodeSet] = useState>(() => new Set());
+ const expiryAddRef = useRef(false);
+ const expiryDeleteRef = useRef(false);
+ const expirySaveRef = useRef>(new Set());
+ const expiryToggleRef = useRef>(new Set());
+ const [snackbar, setSnackbar] = useState<{
+ open: boolean;
+ message: string;
+ severity?: "success" | "info" | "warning" | "error";
+ duration?: number;
+ }>({ open: false, message: "" });
const [settings, setSettings] = useState(DEFAULT_SETTINGS);
const [printerConnected, setPrinterConnected] = useState(false);
const [printerMessage, setPrinterMessage] = useState("列印機未連接");
@@ -388,7 +474,7 @@ const BagPrintSearch: React.FC = () => {
downloadingOnPackExpRef.current = true;
setDownloadingOnPackExp(true);
try {
- const blob = await downloadOnPackQrZipWithExpiry({
+ const { blob, skippedWithoutExpiry } = await downloadOnPackQrZipWithExpiry({
jobOrders: onPackJobOrders,
planDate,
});
@@ -402,7 +488,7 @@ const BagPrintSearch: React.FC = () => {
link.remove();
window.URL.revokeObjectURL(url);
- setSnackbar({ open: true, message: "OnPack 汁水機(含到期日)ZIP 已下載", severity: "success" });
+ setSnackbar(skippedExpirySnackbar("OnPack 汁水機(含到期日)ZIP 已下載", skippedWithoutExpiry));
} catch (e) {
setSnackbar({
open: true,
@@ -465,7 +551,7 @@ const BagPrintSearch: React.FC = () => {
downloadingOnPackTextExpRef.current = true;
setDownloadingOnPackTextExp(true);
try {
- const blob = await downloadOnPackTextQrZipWithExpiry({
+ const { blob, skippedWithoutExpiry } = await downloadOnPackTextQrZipWithExpiry({
jobOrders: onPackJobOrders,
planDate,
});
@@ -479,7 +565,7 @@ const BagPrintSearch: React.FC = () => {
link.remove();
window.URL.revokeObjectURL(url);
- setSnackbar({ open: true, message: "OnPack2023檸檬機(含到期日)ZIP 已下載", severity: "success" });
+ setSnackbar(skippedExpirySnackbar("OnPack2023檸檬機(含到期日)ZIP 已下載", skippedWithoutExpiry));
} catch (e) {
setSnackbar({
open: true,
@@ -492,97 +578,166 @@ const BagPrintSearch: React.FC = () => {
}
};
- const loadTemplateFiles = useCallback(async (machine: OnPackMachine) => {
- setTemplateLoading(true);
+ const loadExpiryCodes = useCallback(async (notify = false) => {
+ setExpiryCodesLoading(true);
try {
- const rows = await listOnPackTemplates(machine);
- setTemplateFiles(rows);
- } catch (e) {
- setSnackbar({
- open: true,
- message: e instanceof Error ? e.message : "讀取 OnPack 模板失敗",
- severity: "error",
- });
- } finally {
- setTemplateLoading(false);
- }
- }, []);
-
- const loadSupportedCatalog = useCallback(async (notify = false) => {
- try {
- setSupportedCatalog(await fetchOnPackSupportedCatalog());
+ const rows = await fetchOnPackExpiryCodes("juice");
+ setExpiryCodes(rows);
+ setNameDrafts(
+ Object.fromEntries(
+ rows.map((r) => [r.itemCode, r.printName || r.defaultPrintName || ""]),
+ ),
+ );
} catch (e) {
if (notify) {
setSnackbar({
open: true,
- message: e instanceof Error ? e.message : "讀取 OnPack 支援清單失敗",
+ message: e instanceof Error ? e.message : "讀取到期日 ZIP 品號失敗",
severity: "error",
});
}
+ } finally {
+ setExpiryCodesLoading(false);
}
}, []);
useEffect(() => {
- void loadSupportedCatalog();
- }, [loadSupportedCatalog]);
+ void loadExpiryCodes();
+ }, [loadExpiryCodes]);
+
+ useEffect(() => {
+ void (async () => {
+ try {
+ const catalog = await fetchOnPackSupportedCatalog();
+ setLemonCodeSet(
+ new Set(
+ (catalog.lemon ?? [])
+ .filter((row) => row.printable)
+ .map((row) => row.itemCode.trim().toUpperCase())
+ .filter(Boolean),
+ ),
+ );
+ } catch {
+ /* 檸檬機標籤可沒有;不擋畫面 */
+ }
+ })();
+ }, []);
useEffect(() => {
if (!templatesOpen) return;
- void loadTemplateFiles(templateMachine);
- void loadSupportedCatalog(true);
- }, [templatesOpen, templateMachine, loadTemplateFiles, loadSupportedCatalog]);
+ void loadExpiryCodes(true);
+ }, [templatesOpen, loadExpiryCodes]);
- const handleUploadTemplates = async (fileList: FileList | null) => {
- if (templateUploadRef.current) return;
- const itemCode = templateItemCode.trim();
+ const handleAddExpiryCode = async () => {
+ if (expiryAddRef.current) return;
+ const itemCode = expiryCodeInput.trim();
if (!itemCode) {
setSnackbar({ open: true, message: "請先填寫品號", severity: "error" });
return;
}
- if (!fileList || fileList.length === 0) {
- setSnackbar({ open: true, message: "請選擇 .image / .bmp / .job 檔案", severity: "error" });
- return;
+ expiryAddRef.current = true;
+ try {
+ await addOnPackExpiryCode(itemCode, "juice");
+ setExpiryCodeInput("");
+ setSnackbar({ open: true, message: `已加入到期日 ZIP:${itemCode.toUpperCase()}`, severity: "success" });
+ await loadExpiryCodes();
+ } catch (e) {
+ setSnackbar({
+ open: true,
+ message: e instanceof Error ? e.message : "新增到期日 ZIP 品號失敗",
+ severity: "error",
+ });
+ } finally {
+ expiryAddRef.current = false;
}
- templateUploadRef.current = true;
- setTemplateUploading(true);
+ };
+
+ const handleDeleteExpiryCode = async (itemCode: string) => {
+ if (expiryDeleteRef.current) return;
+ expiryDeleteRef.current = true;
try {
- const result = await uploadOnPackTemplates(templateMachine, itemCode, Array.from(fileList));
+ await deleteOnPackExpiryCode(itemCode, "juice");
+ setSnackbar({ open: true, message: `已從到期日 ZIP 移除 ${itemCode}`, severity: "success" });
+ await loadExpiryCodes();
+ } catch (e) {
setSnackbar({
open: true,
- message: `已儲存 ${result.itemCode}:${result.saved.join("、")}`,
- severity: "success",
+ message: e instanceof Error ? e.message : "刪除到期日 ZIP 品號失敗",
+ severity: "error",
});
- if (templateFileInputRef.current) templateFileInputRef.current.value = "";
- await loadTemplateFiles(templateMachine);
- await loadSupportedCatalog();
+ } finally {
+ expiryDeleteRef.current = false;
+ }
+ };
+
+ const applyExpiryRow = (updated: OnPackExpiryItemCodeDto) => {
+ setExpiryCodes((prev) => prev.map((r) => (r.itemCode === updated.itemCode ? updated : r)));
+ setNameDrafts((prev) => ({
+ ...prev,
+ [updated.itemCode]: updated.printName || updated.defaultPrintName || "",
+ }));
+ };
+
+ const handleSaveExpiryPrintName = async (itemCode: string) => {
+ if (expirySaveRef.current.has(itemCode)) return;
+ expirySaveRef.current.add(itemCode);
+ try {
+ const updated = await updateOnPackExpiryCode({
+ itemCode,
+ machine: "juice",
+ printName: (nameDrafts[itemCode] ?? "").trim(),
+ });
+ applyExpiryRow(updated);
+ setSnackbar({ open: true, message: `已儲存 ${itemCode} 列印名稱`, severity: "success" });
} catch (e) {
setSnackbar({
open: true,
- message: e instanceof Error ? e.message : "上傳 OnPack 模板失敗",
+ message: e instanceof Error ? e.message : "儲存列印名稱失敗",
severity: "error",
});
} finally {
- setTemplateUploading(false);
- templateUploadRef.current = false;
+ expirySaveRef.current.delete(itemCode);
}
};
- const handleDeleteTemplate = async (row: OnPackTemplateFileDto) => {
- if (templateDeleteRef.current) return;
- templateDeleteRef.current = true;
+ const handleToggleUseMinus18 = async (itemCode: string, useMinus18: boolean) => {
+ if (expiryToggleRef.current.has(itemCode)) return;
+ expiryToggleRef.current.add(itemCode);
try {
- await deleteOnPackTemplate(row.id);
- setSnackbar({ open: true, message: `已刪除 ${row.itemCode} / ${row.fileName}`, severity: "success" });
- await loadTemplateFiles(templateMachine);
- await loadSupportedCatalog();
+ const updated = await updateOnPackExpiryCode({
+ itemCode,
+ machine: "juice",
+ useMinus18,
+ });
+ applyExpiryRow(updated);
+ setSnackbar({
+ open: true,
+ message: useMinus18 ? `已改用 ${itemCode} 的 -18 天數` : `已改用 ${itemCode} 的冷藏天數`,
+ severity: "success",
+ });
} catch (e) {
setSnackbar({
open: true,
- message: e instanceof Error ? e.message : "刪除失敗",
+ message: e instanceof Error ? e.message : "更新保質期旗標失敗",
severity: "error",
});
} finally {
- templateDeleteRef.current = false;
+ expiryToggleRef.current.delete(itemCode);
+ }
+ };
+
+ const juiceExpiryCodeSet = expiryCodeSet(expiryCodes);
+ const sortedExpiryCodes = useMemo(
+ () => sortExpiryRows(expiryCodes, expirySortKey, expirySortDir),
+ [expiryCodes, expirySortKey, expirySortDir],
+ );
+
+ const onExpirySort = (key: ExpirySortKey) => {
+ if (expirySortKey === key) {
+ setExpirySortDir((d) => (d === "asc" ? "desc" : "asc"));
+ } else {
+ setExpirySortKey(key);
+ setExpirySortDir("asc");
}
};
@@ -613,7 +768,7 @@ const BagPrintSearch: React.FC = () => {
{canSeeOnPackAdmin && (
)}
{
const qtyStr = formatQty(jo.reqQty);
const isSelected = selectedId === jo.id;
const codeKey = (jo.itemCode || "").trim().toUpperCase();
- const juiceOk = printableCodes(supportedCatalog?.juice).has(codeKey);
- const lemonOk = printableCodes(supportedCatalog?.lemon).has(codeKey);
+ const juiceExpiryOk = juiceExpiryCodeSet.has(codeKey);
+ const lemonOk = lemonCodeSet.has(codeKey);
return (
{
{jo.itemCode || "—"}
- {juiceOk ? : null}
+ {juiceExpiryOk ? : null}
{lemonOk ? : null}
@@ -936,104 +1091,230 @@ const BagPrintSearch: React.FC = () => {