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 = () => { setTemplatesOpen(false)} - maxWidth="md" + maxWidth="xl" fullWidth + scroll="paper" + PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }} > - OnPack 支援品號 / 模板 - - - - 下列為目前可做 OnPack 列印的品號(內建只列 PP 品號)。上傳新套模板後會自動加入清單。下方「刪除」只作用於資料庫檔,內建檔無法從畫面移除。 + OnPack 到期日 ZIP 品號 + + + + 汁水機({expiryCodes.length}) - - - - 汁水機 OnPack({printableCodes(supportedCatalog?.juice).size}) - - - {(supportedCatalog?.juice ?? []).filter((r) => r.printable).map((row) => ( - - {row.itemCode} · {supportLabel(row)} - - ))} - - - - - 檸檬機 OnPack({printableCodes(supportedCatalog?.lemon).size}) - - - {(supportedCatalog?.lemon ?? []).filter((r) => r.printable).map((row) => ( - - {row.itemCode} · {supportLabel(row)} - - ))} - - - - 新增品號:上傳該套 .image / .bmp / .job。汁水機請上傳{" "} - 品號.image;檸檬機請一併上傳模板裡引用的 BMP。 + 「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。 + 點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。 - - 機台 - - - setTemplateItemCode(e.target.value)} - /> - - {templateLoading ? ( - - - - ) : templateFiles.length === 0 ? ( - 資料庫尚無此機台的上傳檔 - ) : ( - - {templateFiles.map((row) => ( - - - {row.itemCode} · {row.fileName} · {Math.max(1, Math.round(row.byteSize / 1024))} KB - - - - ))} - - )} + + + {expiryCodesLoading ? ( + + + + ) : expiryCodes.length === 0 ? ( + 清單空白 + ) : ( + + + + + + onExpirySort("itemCode")} + > + 品號 + + + + onExpirySort("name")} + > + 中文名稱+單位 + + + + onExpirySort("defaultDays")} + > + 冷藏 + + + 天 + + + + onExpirySort("minus18Days")} + > + -18 + + + 天 + + + + onExpirySort("useMinus18")} + > + 用 -18 + + + + onExpirySort("effectiveDays")} + > + 列印 + + + 天 + + + + 操作 + + + + + {sortedExpiryCodes.map((row) => { + const draft = nameDrafts[row.itemCode] ?? ""; + const savedName = row.printName || row.defaultPrintName || ""; + const nameDirty = draft.trim() !== savedName.trim(); + const hasShelf = row.defaultDays != null || row.minus18Days != null; + const canUseMinus18 = row.minus18Days != null && row.minus18Days > 0; + const missingHint = hasShelf + ? canUseMinus18 + ? "" + : "此品號沒有 -18 天數" + : "未設定保質期,請到設定 → 物品預設保質期新增"; + return ( + + + {row.itemCode} + + + + + setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value })) + } + placeholder={row.defaultPrintName || "例如 豬肉水(1包X2千克)"} + inputProps={{ maxLength: 255 }} + fullWidth + /> + + + + + + {daysLabel(row.defaultDays)} + + + + + {daysLabel(row.minus18Days)} + + + + + + void handleToggleUseMinus18(row.itemCode, e.target.checked)} + inputProps={{ "aria-label": `${row.itemCode} 用 -18` }} + /> + + + + + + {daysLabel(row.effectiveDays)} + + {!hasShelf && ( + + 未設定 + + )} + + + + + + ); + })} + +
+
+ )} + + 檸檬機到期日 ZIP 品號稍後加入。 +
@@ -1042,11 +1323,19 @@ const BagPrintSearch: React.FC = () => { setSnackbar((s) => ({ ...s, open: false }))} - message={snackbar.message} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - /> + > + setSnackbar((s) => ({ ...s, open: false }))} + severity={snackbar.severity ?? "info"} + variant="filled" + sx={{ width: "100%", maxWidth: 720 }} + > + {snackbar.message} + + ); }; diff --git a/src/components/Breadcrumb/Breadcrumb.tsx b/src/components/Breadcrumb/Breadcrumb.tsx index 20e30a7a..a5804a95 100644 --- a/src/components/Breadcrumb/Breadcrumb.tsx +++ b/src/components/Breadcrumb/Breadcrumb.tsx @@ -24,6 +24,7 @@ const pathToLabelKey: { [path: string]: string } = { "/settings/user": "nav.settings.user", "/settings/clientMonitor": "nav.settings.clientMonitor", "/settings/items": "nav.settings.items", + "/settings/itemDefaultShelfLife": "nav.settings.itemDefaultShelfLife", "/settings/warehouse": "nav.settings.warehouse", "/settings/qcCategory": "nav.settings.qcCategory", "/settings/bomWeighting": "nav.settings.bomWeighting", diff --git a/src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx b/src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx new file mode 100644 index 00000000..763a54ad --- /dev/null +++ b/src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx @@ -0,0 +1,473 @@ +"use client"; + +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import Add from "@mui/icons-material/Add"; +import DeleteOutline from "@mui/icons-material/DeleteOutline"; +import EditOutlined from "@mui/icons-material/EditOutlined"; +import { + Alert, + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + FormHelperText, + IconButton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Typography, +} from "@mui/material"; +import { + createItemDefaultShelfLife, + deleteItemDefaultShelfLife, + fetchItemDefaultShelfLives, + updateItemDefaultShelfLife, + type ItemDefaultShelfLifeInput, + type ItemDefaultShelfLifeRow, +} from "@/app/api/settings/itemDefaultShelfLife/client"; + +type FormState = { + itemCode: string; + defaultDays: string; + minus18Days: string; + useMinus18: boolean; + openedDays: string; + storageC: string; + remarks: string; +}; + +const emptyForm = (): FormState => ({ + itemCode: "", + defaultDays: "", + minus18Days: "", + useMinus18: false, + openedDays: "", + storageC: "", + remarks: "", +}); + +function parseOptionalDays(raw: string): number | null | "invalid" { + const t = raw.trim(); + if (!t) return null; + if (!/^\d+$/.test(t)) return "invalid"; + return Number(t); +} + +function daysFromForm(form: FormState): { defaultDays: number | null; minus18Days: number | null } | "invalid" { + const defaultDays = parseOptionalDays(form.defaultDays); + const minus18Days = parseOptionalDays(form.minus18Days); + if (defaultDays === "invalid" || minus18Days === "invalid") return "invalid"; + return { defaultDays, minus18Days }; +} + +function effectiveDays(form: FormState): number | null { + const parsed = daysFromForm(form); + if (parsed === "invalid") return null; + const chosen = form.useMinus18 ? parsed.minus18Days : parsed.defaultDays; + return chosen != null && chosen > 0 ? chosen : null; +} + +function expiryPreview(days: number | null): string | null { + if (days == null) return null; + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() + days); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +function toForm(row: ItemDefaultShelfLifeRow): FormState { + return { + itemCode: row.itemCode ?? "", + defaultDays: row.defaultDays != null ? String(row.defaultDays) : "", + minus18Days: row.minus18Days != null ? String(row.minus18Days) : "", + useMinus18: row.useMinus18 === true, + openedDays: row.openedDays != null ? String(row.openedDays) : "", + storageC: row.storageC ?? "", + remarks: row.remarks ?? "", + }; +} + +const ItemDefaultShelfLifeSettings: React.FC = () => { + const { t } = useTranslation("itemDefaultShelfLife"); + const saveInFlightRef = useRef(false); + const deleteInFlightRef = useRef(false); + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [rows, setRows] = useState([]); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(25); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form, setForm] = useState(emptyForm); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setRows(await fetchItemDefaultShelfLives()); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return rows; + return rows.filter((r) => + [r.itemCode, r.itemName, r.remarks].some((v) => v?.toLowerCase().includes(needle)), + ); + }, [query, rows]); + + useEffect(() => { + setPage(0); + }, [query]); + + const paged = useMemo(() => { + const start = page * rowsPerPage; + return filtered.slice(start, start + rowsPerPage); + }, [filtered, page, rowsPerPage]); + + const openCreate = () => { + setEditing(null); + setForm(emptyForm()); + setFormError(null); + setDialogOpen(true); + }; + + const openEdit = (row: ItemDefaultShelfLifeRow) => { + setEditing(row); + setForm(toForm(row)); + setFormError(null); + setDialogOpen(true); + }; + + const closeDialog = () => { + if (saving) return; + setDialogOpen(false); + }; + + const onSave = async () => { + if (saveInFlightRef.current) return; + const code = form.itemCode.trim(); + if (!code) { + setFormError(t("Item code required")); + return; + } + const parsed = daysFromForm(form); + const openedDays = parseOptionalDays(form.openedDays); + if (parsed === "invalid" || openedDays === "invalid") { + setFormError(t("Days invalid")); + return; + } + const payload: ItemDefaultShelfLifeInput = { + itemCode: code, + defaultDays: parsed.defaultDays, + minus18Days: parsed.minus18Days, + useMinus18: form.useMinus18, + openedDays, + storageC: form.storageC.trim() || null, + remarks: form.remarks.trim() || null, + }; + saveInFlightRef.current = true; + setSaving(true); + setFormError(null); + setError(null); + setSuccess(null); + try { + if (editing) { + const updated = await updateItemDefaultShelfLife(editing.id, payload); + setRows((prev) => + prev + .map((r) => (r.id === updated.id ? updated : r)) + .sort((a, b) => a.itemCode.localeCompare(b.itemCode)), + ); + } else { + const created = await createItemDefaultShelfLife(payload); + setRows((prev) => + [...prev.filter((r) => r.id !== created.id), created].sort((a, b) => + a.itemCode.localeCompare(b.itemCode), + ), + ); + } + setSuccess(t("Saved")); + setDialogOpen(false); + } catch (e: unknown) { + setFormError(e instanceof Error ? e.message : String(e)); + } finally { + setSaving(false); + saveInFlightRef.current = false; + } + }; + + const onDelete = async () => { + if (!deleteTarget || deleteInFlightRef.current) return; + deleteInFlightRef.current = true; + setDeleting(true); + setError(null); + setSuccess(null); + try { + const next = await deleteItemDefaultShelfLife(deleteTarget.id); + setRows(next); + setSuccess(t("Deleted")); + setDeleteTarget(null); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setDeleting(false); + deleteInFlightRef.current = false; + } + }; + + const previewDays = effectiveDays(form); + const previewDate = expiryPreview(previewDays); + const from = filtered.length === 0 ? 0 : page * rowsPerPage + 1; + const to = Math.min(filtered.length, (page + 1) * rowsPerPage); + + return ( + + + {t("Intro")} + + {error && {error}} + {success && ( + setSuccess(null)}> + {success} + + )} + + setQuery(e.target.value)} + placeholder={t("Search placeholder")} + sx={{ minWidth: 260, flex: 1 }} + /> + + + {loading ? ( + + + + ) : ( + <> + + + + + {t("Col itemCode")} + {t("Col itemName")} + {t("Col defaultDays")} + {t("Col minus18Days")} + {t("Col useMinus18")} + {t("Col effectiveDays")} + {t("Col openedDays")} + {t("Col storageC")} + {t("Col remarks")} + {t("Col actions")} + + + + {paged.length === 0 ? ( + + + + {rows.length === 0 ? t("Empty") : t("No match")} + + + + ) : ( + paged.map((row) => ( + + {row.itemCode} + {row.itemName || "—"} + {row.defaultDays ?? "—"} + {row.minus18Days ?? "—"} + + + + {row.effectiveDays ?? "—"} + {row.openedDays ?? "—"} + {row.storageC || "—"} + {row.remarks || "—"} + + openEdit(row)}> + + + setDeleteTarget(row)} + > + + + + + )) + )} + +
+
+ + + {t("Showing", { from, to, total: filtered.length })} + + setPage(next)} + rowsPerPage={rowsPerPage} + onRowsPerPageChange={(e) => { + setRowsPerPage(parseInt(e.target.value, 10)); + setPage(0); + }} + rowsPerPageOptions={[25, 50, 100]} + /> + + + )} + + + {editing ? t("Edit title") : t("Add title")} + + + {formError && {formError}} + setForm((s) => ({ ...s, itemCode: e.target.value }))} + disabled={saving} + autoFocus={!editing} + /> + + setForm((s) => ({ ...s, defaultDays: e.target.value }))} + disabled={saving} + fullWidth + /> + setForm((s) => ({ ...s, minus18Days: e.target.value }))} + disabled={saving} + fullWidth + /> + + + setForm((s) => ({ ...s, useMinus18: e.target.checked }))} + disabled={saving} + /> + } + label={t("Use minus18")} + /> + {t("Use minus18 help")} + + + {previewDate + ? t("Expiry preview", { date: previewDate }) + : t("Expiry preview none")} + + + setForm((s) => ({ ...s, openedDays: e.target.value }))} + disabled={saving} + fullWidth + /> + setForm((s) => ({ ...s, storageC: e.target.value }))} + disabled={saving} + inputProps={{ maxLength: 20 }} + fullWidth + /> + + setForm((s) => ({ ...s, remarks: e.target.value }))} + disabled={saving} + inputProps={{ maxLength: 255 }} + multiline + minRows={2} + /> + + + + + + + + + !deleting && setDeleteTarget(null)}> + {t("Delete title")} + + + {t("Delete confirm", { itemCode: deleteTarget?.itemCode ?? "" })} + + + + + + + +
+ ); +}; + +export default ItemDefaultShelfLifeSettings; diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index edafb469..957cb95b 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -40,6 +40,7 @@ import UploadFile from "@mui/icons-material/UploadFile"; import Sync from "@mui/icons-material/Sync"; import Layers from "@mui/icons-material/Layers"; import Devices from "@mui/icons-material/Devices"; +import EventAvailable from "@mui/icons-material/EventAvailable"; import { useTranslation } from "react-i18next"; import { usePathname } from "next/navigation"; import Link from "next/link"; @@ -325,6 +326,12 @@ const NavigationContent: React.FC = () => { labelKey: "nav.settings.items", path: "/settings/items", }, + { + id: "nav.settings.itemDefaultShelfLife", + icon: , + labelKey: "nav.settings.itemDefaultShelfLife", + path: "/settings/itemDefaultShelfLife", + }, { id: "nav.settings.equipment", icon: , diff --git a/src/i18n/en/itemDefaultShelfLife.json b/src/i18n/en/itemDefaultShelfLife.json new file mode 100644 index 00000000..7f90cf3c --- /dev/null +++ b/src/i18n/en/itemDefaultShelfLife.json @@ -0,0 +1,38 @@ +{ + "title": "Item default shelf life", + "Intro": "Manage default shelf-life days by item code for bag / OnPack expiry print. When “Print uses -18” is on, expiry uses -18 days; otherwise chilled days.", + "Search placeholder": "Search item code, name, or remarks", + "Add": "Add", + "Edit": "Edit", + "Delete": "Delete", + "Save": "Save", + "Saving": "Saving", + "Cancel": "Cancel", + "Saved": "Saved", + "Deleted": "Deleted", + "Add title": "Add shelf life", + "Edit title": "Edit shelf life", + "Delete title": "Delete shelf life", + "Delete confirm": "Delete the default shelf life for {{itemCode}}? Bag / OnPack print will no longer show an expiry for this item.", + "Col itemCode": "Item code", + "Col itemName": "Item name", + "Col defaultDays": "Chilled days", + "Col minus18Days": "-18 days", + "Col useMinus18": "Print uses -18", + "Col effectiveDays": "Print days", + "Col openedDays": "Opened days", + "Col storageC": "Storage °C", + "Col remarks": "Remarks", + "Col actions": "Actions", + "Empty": "No rows yet. Use Add to create a shelf-life record.", + "No match": "No rows match the search.", + "Showing": "Showing {{from}}–{{to}} of {{total}}", + "Item code required": "Item code is required.", + "Days invalid": "Days must be 0 or a positive integer.", + "Use minus18": "Print uses -18 days", + "Use minus18 help": "When checked, bag / OnPack expiry uses -18 days; otherwise chilled days.", + "Expiry preview": "Expiry if printed today: {{date}}", + "Expiry preview none": "Expiry if printed today: cannot compute (chosen days missing or not greater than 0)", + "Yes": "Yes", + "No": "No" +} diff --git a/src/i18n/en/navigation.json b/src/i18n/en/navigation.json index 7d852f0e..cb72caca 100644 --- a/src/i18n/en/navigation.json +++ b/src/i18n/en/navigation.json @@ -36,6 +36,7 @@ "nav.settings.user": "User", "nav.settings.clientMonitor": "Device Connection Monitor", "nav.settings.items": "Items", + "nav.settings.itemDefaultShelfLife": "Item default shelf life", "nav.settings.equipment": "Equipment", "nav.settings.warehouse": "Warehouse", "nav.settings.printer": "Printer", diff --git a/src/i18n/zh/itemDefaultShelfLife.json b/src/i18n/zh/itemDefaultShelfLife.json new file mode 100644 index 00000000..e54c8923 --- /dev/null +++ b/src/i18n/zh/itemDefaultShelfLife.json @@ -0,0 +1,38 @@ +{ + "title": "物品預設保質期", + "Intro": "設定各貨品編號的預設保質期,供打袋機/OnPack 列印到期日使用。勾選「列印使用 -18」時,到期日會用 -18 天數,否則用冷藏天數。", + "Search placeholder": "搜尋貨品編號、名稱或備註", + "Add": "新增", + "Edit": "編輯", + "Delete": "刪除", + "Save": "儲存", + "Saving": "儲存中", + "Cancel": "取消", + "Saved": "已儲存", + "Deleted": "已刪除", + "Add title": "新增保質期", + "Edit title": "編輯保質期", + "Delete title": "刪除保質期", + "Delete confirm": "確定刪除 {{itemCode}} 的預設保質期?列印將不再帶出此貨品的到期日。", + "Col itemCode": "貨品編號", + "Col itemName": "物品名稱", + "Col defaultDays": "冷藏天數", + "Col minus18Days": "-18 天數", + "Col useMinus18": "列印使用 -18", + "Col effectiveDays": "列印天數", + "Col openedDays": "開封後天數", + "Col storageC": "儲存溫度", + "Col remarks": "備註", + "Col actions": "操作", + "Empty": "尚無資料。請按「新增」加入貨品保質期。", + "No match": "沒有符合搜尋條件的資料。", + "Showing": "顯示 {{from}}–{{to}}/共 {{total}} 筆", + "Item code required": "請輸入貨品編號。", + "Days invalid": "天數必須為 0 或正整數。", + "Use minus18": "列印使用 -18 天數", + "Use minus18 help": "勾選後,打袋機/OnPack 到期日使用 -18 天數;未勾選則使用冷藏天數。", + "Expiry preview": "今日列印到期日:{{date}}", + "Expiry preview none": "今日列印到期日:無法計算(所選天數未填或不大於 0)", + "Yes": "是", + "No": "否" +} diff --git a/src/i18n/zh/navigation.json b/src/i18n/zh/navigation.json index cd01b472..afbc1d79 100644 --- a/src/i18n/zh/navigation.json +++ b/src/i18n/zh/navigation.json @@ -79,6 +79,7 @@ "nav.settings.importExcel": "Excel 匯入", "nav.settings.importTesting": "匯入測試", "nav.settings.items": "物品", + "nav.settings.itemDefaultShelfLife": "物品預設保質期", "nav.settings.masterDataIssues": "BOM / 物料單位問題", "nav.settings.priceInquiry": "價格查詢", "nav.settings.printer": "列印機",