| @@ -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 ( | |||||
| <I18nProvider namespaces={["itemDefaultShelfLife", "navigation", "common"]}> | |||||
| <Stack spacing={2}> | |||||
| <Typography variant="h4">{t("title")}</Typography> | |||||
| <ItemDefaultShelfLifeSettings /> | |||||
| </Stack> | |||||
| </I18nProvider> | |||||
| ); | |||||
| } | |||||
| @@ -167,10 +167,23 @@ export async function downloadOnPackQrZip( | |||||
| return res.blob(); | 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. */ | /** 汁水機 OnPack — same as QR ZIP, plus LOGO_EXP BMP from item_default_shelf_life. */ | ||||
| export async function downloadOnPackQrZipWithExpiry( | export async function downloadOnPackQrZipWithExpiry( | ||||
| request: OnPackQrDownloadRequest, | request: OnPackQrDownloadRequest, | ||||
| ): Promise<Blob> { | |||||
| ): Promise<OnPackZipDownload> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-with-expiry`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| method: "POST", | method: "POST", | ||||
| @@ -182,7 +195,10 @@ export async function downloadOnPackQrZipWithExpiry( | |||||
| throw await zipDownloadError(res); | throw await zipDownloadError(res); | ||||
| } | } | ||||
| return res.blob(); | |||||
| return { | |||||
| blob: await res.blob(), | |||||
| skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res), | |||||
| }; | |||||
| } | } | ||||
| /** OnPack2023 檸檬機 — text QR template (`onpack2030_2`), no separate .bmp */ | /** 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. */ | /** OnPack2023 檸檬機 — same as text ZIP, plus TEXT_EXP from item_default_shelf_life. */ | ||||
| export async function downloadOnPackTextQrZipWithExpiry( | export async function downloadOnPackTextQrZipWithExpiry( | ||||
| request: OnPackQrDownloadRequest, | request: OnPackQrDownloadRequest, | ||||
| ): Promise<Blob> { | |||||
| ): Promise<OnPackZipDownload> { | |||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/download-onpack-qr-text-with-expiry`; | ||||
| const res = await clientAuthFetch(url, { | const res = await clientAuthFetch(url, { | ||||
| method: "POST", | method: "POST", | ||||
| @@ -218,7 +234,10 @@ export async function downloadOnPackTextQrZipWithExpiry( | |||||
| throw await zipDownloadError(res); | throw await zipDownloadError(res); | ||||
| } | } | ||||
| return res.blob(); | |||||
| return { | |||||
| blob: await res.blob(), | |||||
| skippedWithoutExpiry: skippedWithoutExpiryFromResponse(res), | |||||
| }; | |||||
| } | } | ||||
| export type OnPackMachine = "juice" | "lemon"; | export type OnPackMachine = "juice" | "lemon"; | ||||
| @@ -251,6 +270,75 @@ export interface OnPackSupportedCatalogDto { | |||||
| lemon: OnPackSupportedItemDto[]; | 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<OnPackExpiryItemCodeDto[]> { | |||||
| 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<OnPackExpiryItemCodeDto> { | |||||
| 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<OnPackExpiryItemCodeDto> { | |||||
| 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<void> { | |||||
| 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<OnPackSupportedCatalogDto> { | export async function fetchOnPackSupportedCatalog(): Promise<OnPackSupportedCatalogDto> { | ||||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`; | const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`; | ||||
| const res = await clientAuthFetch(url, { method: "GET" }); | const res = await clientAuthFetch(url, { method: "GET" }); | ||||
| @@ -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<T>(res: Response): Promise<T> { | |||||
| if (!res.ok) { | |||||
| throw new Error(await readError(res)); | |||||
| } | |||||
| return res.json() as Promise<T>; | |||||
| } | |||||
| async function readError(res: Response): Promise<string> { | |||||
| 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<ItemDefaultShelfLifeRow[]> { | |||||
| 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<ItemDefaultShelfLifeRow[]>(res); | |||||
| } | |||||
| export async function createItemDefaultShelfLife( | |||||
| data: ItemDefaultShelfLifeInput, | |||||
| ): Promise<ItemDefaultShelfLifeRow> { | |||||
| const res = await clientAuthFetch(`${base}/itemDefaultShelfLives`, { | |||||
| method: "POST", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify(data), | |||||
| }); | |||||
| return parseJson<ItemDefaultShelfLifeRow>(res); | |||||
| } | |||||
| export async function updateItemDefaultShelfLife( | |||||
| id: number, | |||||
| data: ItemDefaultShelfLifeInput, | |||||
| ): Promise<ItemDefaultShelfLifeRow> { | |||||
| const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, { | |||||
| method: "PUT", | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| body: JSON.stringify(data), | |||||
| }); | |||||
| return parseJson<ItemDefaultShelfLifeRow>(res); | |||||
| } | |||||
| export async function deleteItemDefaultShelfLife( | |||||
| id: number, | |||||
| ): Promise<ItemDefaultShelfLifeRow[]> { | |||||
| const res = await clientAuthFetch(`${base}/itemDefaultShelfLives/${id}`, { | |||||
| method: "DELETE", | |||||
| }); | |||||
| return parseJson<ItemDefaultShelfLifeRow[]>(res); | |||||
| } | |||||
| @@ -1,7 +1,8 @@ | |||||
| "use client"; | "use client"; | ||||
| import React, { useCallback, useEffect, useRef, useState } from "react"; | |||||
| import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; | |||||
| import { | import { | ||||
| Alert, | |||||
| Box, | Box, | ||||
| Button, | Button, | ||||
| Chip, | Chip, | ||||
| @@ -20,6 +21,15 @@ import { | |||||
| DialogActions, | DialogActions, | ||||
| TextField, | TextField, | ||||
| Snackbar, | Snackbar, | ||||
| Switch, | |||||
| Table, | |||||
| TableBody, | |||||
| TableCell, | |||||
| TableContainer, | |||||
| TableHead, | |||||
| TableRow, | |||||
| TableSortLabel, | |||||
| Tooltip, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import ChevronLeft from "@mui/icons-material/ChevronLeft"; | import ChevronLeft from "@mui/icons-material/ChevronLeft"; | ||||
| import ChevronRight from "@mui/icons-material/ChevronRight"; | import ChevronRight from "@mui/icons-material/ChevronRight"; | ||||
| @@ -33,15 +43,14 @@ import { | |||||
| downloadOnPackQrZipWithExpiry, | downloadOnPackQrZipWithExpiry, | ||||
| downloadOnPackTextQrZip, | downloadOnPackTextQrZip, | ||||
| downloadOnPackTextQrZipWithExpiry, | downloadOnPackTextQrZipWithExpiry, | ||||
| deleteOnPackTemplate, | |||||
| fetchJobOrders, | fetchJobOrders, | ||||
| fetchOnPackExpiryCodes, | |||||
| addOnPackExpiryCode, | |||||
| updateOnPackExpiryCode, | |||||
| deleteOnPackExpiryCode, | |||||
| fetchOnPackSupportedCatalog, | fetchOnPackSupportedCatalog, | ||||
| listOnPackTemplates, | |||||
| uploadOnPackTemplates, | |||||
| JobOrderListItem, | JobOrderListItem, | ||||
| OnPackMachine, | |||||
| OnPackSupportedCatalogDto, | |||||
| OnPackTemplateFileDto, | |||||
| OnPackExpiryItemCodeDto, | |||||
| } from "@/app/api/bagPrint/actions"; | } from "@/app/api/bagPrint/actions"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { useSession } from "next-auth/react"; | import { useSession } from "next-auth/react"; | ||||
| @@ -129,15 +138,85 @@ function getBatch(jo: JobOrderListItem): string { | |||||
| return (jo.lotNo || "—").trim() || "—"; | return (jo.lotNo || "—").trim() || "—"; | ||||
| } | } | ||||
| function printableCodes(items: { itemCode: string; printable: boolean }[] | undefined): Set<string> { | |||||
| return new Set((items ?? []).filter((r) => r.printable).map((r) => r.itemCode.toUpperCase())); | |||||
| function expiryCodeSet(rows: OnPackExpiryItemCodeDto[]): Set<string> { | |||||
| 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 = () => { | const BagPrintSearch: React.FC = () => { | ||||
| @@ -158,16 +237,23 @@ const BagPrintSearch: React.FC = () => { | |||||
| const [printing, setPrinting] = useState(false); | const [printing, setPrinting] = useState(false); | ||||
| const [settingsOpen, setSettingsOpen] = useState(false); | const [settingsOpen, setSettingsOpen] = useState(false); | ||||
| const [templatesOpen, setTemplatesOpen] = useState(false); | const [templatesOpen, setTemplatesOpen] = useState(false); | ||||
| const [templateMachine, setTemplateMachine] = useState<OnPackMachine>("juice"); | |||||
| const [templateItemCode, setTemplateItemCode] = useState(""); | |||||
| const [templateFiles, setTemplateFiles] = useState<OnPackTemplateFileDto[]>([]); | |||||
| const [templateLoading, setTemplateLoading] = useState(false); | |||||
| const [templateUploading, setTemplateUploading] = useState(false); | |||||
| const [supportedCatalog, setSupportedCatalog] = useState<OnPackSupportedCatalogDto | null>(null); | |||||
| const templateUploadRef = useRef(false); | |||||
| const templateDeleteRef = useRef(false); | |||||
| const templateFileInputRef = useRef<HTMLInputElement | null>(null); | |||||
| const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity?: "success" | "info" | "error" }>({ open: false, message: "" }); | |||||
| const [expiryCodes, setExpiryCodes] = useState<OnPackExpiryItemCodeDto[]>([]); | |||||
| const [expiryCodeInput, setExpiryCodeInput] = useState(""); | |||||
| const [expiryCodesLoading, setExpiryCodesLoading] = useState(false); | |||||
| const [nameDrafts, setNameDrafts] = useState<Record<string, string>>({}); | |||||
| const [expirySortKey, setExpirySortKey] = useState<ExpirySortKey>("itemCode"); | |||||
| const [expirySortDir, setExpirySortDir] = useState<"asc" | "desc">("asc"); | |||||
| const [lemonCodeSet, setLemonCodeSet] = useState<Set<string>>(() => new Set()); | |||||
| const expiryAddRef = useRef(false); | |||||
| const expiryDeleteRef = useRef(false); | |||||
| const expirySaveRef = useRef<Set<string>>(new Set()); | |||||
| const expiryToggleRef = useRef<Set<string>>(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 [settings, setSettings] = useState(DEFAULT_SETTINGS); | ||||
| const [printerConnected, setPrinterConnected] = useState(false); | const [printerConnected, setPrinterConnected] = useState(false); | ||||
| const [printerMessage, setPrinterMessage] = useState("列印機未連接"); | const [printerMessage, setPrinterMessage] = useState("列印機未連接"); | ||||
| @@ -388,7 +474,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| downloadingOnPackExpRef.current = true; | downloadingOnPackExpRef.current = true; | ||||
| setDownloadingOnPackExp(true); | setDownloadingOnPackExp(true); | ||||
| try { | try { | ||||
| const blob = await downloadOnPackQrZipWithExpiry({ | |||||
| const { blob, skippedWithoutExpiry } = await downloadOnPackQrZipWithExpiry({ | |||||
| jobOrders: onPackJobOrders, | jobOrders: onPackJobOrders, | ||||
| planDate, | planDate, | ||||
| }); | }); | ||||
| @@ -402,7 +488,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| link.remove(); | link.remove(); | ||||
| window.URL.revokeObjectURL(url); | window.URL.revokeObjectURL(url); | ||||
| setSnackbar({ open: true, message: "OnPack 汁水機(含到期日)ZIP 已下載", severity: "success" }); | |||||
| setSnackbar(skippedExpirySnackbar("OnPack 汁水機(含到期日)ZIP 已下載", skippedWithoutExpiry)); | |||||
| } catch (e) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| @@ -465,7 +551,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| downloadingOnPackTextExpRef.current = true; | downloadingOnPackTextExpRef.current = true; | ||||
| setDownloadingOnPackTextExp(true); | setDownloadingOnPackTextExp(true); | ||||
| try { | try { | ||||
| const blob = await downloadOnPackTextQrZipWithExpiry({ | |||||
| const { blob, skippedWithoutExpiry } = await downloadOnPackTextQrZipWithExpiry({ | |||||
| jobOrders: onPackJobOrders, | jobOrders: onPackJobOrders, | ||||
| planDate, | planDate, | ||||
| }); | }); | ||||
| @@ -479,7 +565,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| link.remove(); | link.remove(); | ||||
| window.URL.revokeObjectURL(url); | window.URL.revokeObjectURL(url); | ||||
| setSnackbar({ open: true, message: "OnPack2023檸檬機(含到期日)ZIP 已下載", severity: "success" }); | |||||
| setSnackbar(skippedExpirySnackbar("OnPack2023檸檬機(含到期日)ZIP 已下載", skippedWithoutExpiry)); | |||||
| } catch (e) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | 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 { | 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) { | } catch (e) { | ||||
| if (notify) { | if (notify) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "讀取 OnPack 支援清單失敗", | |||||
| message: e instanceof Error ? e.message : "讀取到期日 ZIP 品號失敗", | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| } | } | ||||
| } finally { | |||||
| setExpiryCodesLoading(false); | |||||
| } | } | ||||
| }, []); | }, []); | ||||
| useEffect(() => { | 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(() => { | useEffect(() => { | ||||
| if (!templatesOpen) return; | 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) { | if (!itemCode) { | ||||
| setSnackbar({ open: true, message: "請先填寫品號", severity: "error" }); | setSnackbar({ open: true, message: "請先填寫品號", severity: "error" }); | ||||
| return; | 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 { | 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({ | setSnackbar({ | ||||
| open: true, | 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) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "上傳 OnPack 模板失敗", | |||||
| message: e instanceof Error ? e.message : "儲存列印名稱失敗", | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } 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 { | 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) { | } catch (e) { | ||||
| setSnackbar({ | setSnackbar({ | ||||
| open: true, | open: true, | ||||
| message: e instanceof Error ? e.message : "刪除失敗", | |||||
| message: e instanceof Error ? e.message : "更新保質期旗標失敗", | |||||
| severity: "error", | severity: "error", | ||||
| }); | }); | ||||
| } finally { | } 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 = () => { | |||||
| </Button> | </Button> | ||||
| {canSeeOnPackAdmin && ( | {canSeeOnPackAdmin && ( | ||||
| <Button variant="outlined" onClick={() => setTemplatesOpen(true)}> | <Button variant="outlined" onClick={() => setTemplatesOpen(true)}> | ||||
| OnPack 模板 | |||||
| OnPack 到期日 ZIP | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| <Box | <Box | ||||
| @@ -731,8 +886,8 @@ const BagPrintSearch: React.FC = () => { | |||||
| const qtyStr = formatQty(jo.reqQty); | const qtyStr = formatQty(jo.reqQty); | ||||
| const isSelected = selectedId === jo.id; | const isSelected = selectedId === jo.id; | ||||
| const codeKey = (jo.itemCode || "").trim().toUpperCase(); | 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 ( | return ( | ||||
| <Paper | <Paper | ||||
| key={jo.id} | key={jo.id} | ||||
| @@ -769,7 +924,7 @@ const BagPrintSearch: React.FC = () => { | |||||
| {jo.itemCode || "—"} | {jo.itemCode || "—"} | ||||
| </Typography> | </Typography> | ||||
| <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}> | <Stack direction="row" spacing={0.5} flexWrap="wrap" useFlexGap sx={{ mt: 0.5 }}> | ||||
| {juiceOk ? <Chip size="small" label="汁水機" color="primary" /> : null} | |||||
| {juiceExpiryOk ? <Chip size="small" label="汁水機" color="primary" /> : null} | |||||
| {lemonOk ? <Chip size="small" label="檸檬機" color="secondary" /> : null} | {lemonOk ? <Chip size="small" label="檸檬機" color="secondary" /> : null} | ||||
| </Stack> | </Stack> | ||||
| </Box> | </Box> | ||||
| @@ -936,104 +1091,230 @@ const BagPrintSearch: React.FC = () => { | |||||
| <Dialog | <Dialog | ||||
| open={templatesOpen && canSeeOnPackAdmin} | open={templatesOpen && canSeeOnPackAdmin} | ||||
| onClose={() => setTemplatesOpen(false)} | onClose={() => setTemplatesOpen(false)} | ||||
| maxWidth="md" | |||||
| maxWidth="xl" | |||||
| fullWidth | fullWidth | ||||
| scroll="paper" | |||||
| PaperProps={{ sx: { height: "90vh", maxHeight: "90vh" } }} | |||||
| > | > | ||||
| <DialogTitle>OnPack 支援品號 / 模板</DialogTitle> | |||||
| <DialogContent> | |||||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| 下列為目前可做 OnPack 列印的品號(內建只列 PP 品號)。上傳新套模板後會自動加入清單。下方「刪除」只作用於資料庫檔,內建檔無法從畫面移除。 | |||||
| <DialogTitle>OnPack 到期日 ZIP 品號</DialogTitle> | |||||
| <DialogContent | |||||
| sx={{ | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| overflow: "hidden", | |||||
| pt: 1, | |||||
| }} | |||||
| > | |||||
| <Stack spacing={1.5} sx={{ flexShrink: 0, mb: 1 }}> | |||||
| <Typography variant="subtitle2" color="primary"> | |||||
| 汁水機({expiryCodes.length}) | |||||
| </Typography> | </Typography> | ||||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2} alignItems="flex-start"> | |||||
| <Box sx={{ flex: 1, minWidth: 0 }}> | |||||
| <Typography variant="subtitle2" color="primary" sx={{ mb: 0.5 }}> | |||||
| 汁水機 OnPack({printableCodes(supportedCatalog?.juice).size}) | |||||
| </Typography> | |||||
| <Box sx={{ maxHeight: 180, overflow: "auto", pr: 1 }}> | |||||
| {(supportedCatalog?.juice ?? []).filter((r) => r.printable).map((row) => ( | |||||
| <Typography key={`j-${row.itemCode}`} variant="body2" sx={{ fontFamily: "monospace" }}> | |||||
| {row.itemCode} · {supportLabel(row)} | |||||
| </Typography> | |||||
| ))} | |||||
| </Box> | |||||
| </Box> | |||||
| <Box sx={{ flex: 1, minWidth: 0 }}> | |||||
| <Typography variant="subtitle2" color="secondary" sx={{ mb: 0.5 }}> | |||||
| 檸檬機 OnPack({printableCodes(supportedCatalog?.lemon).size}) | |||||
| </Typography> | |||||
| <Box sx={{ maxHeight: 180, overflow: "auto", pr: 1 }}> | |||||
| {(supportedCatalog?.lemon ?? []).filter((r) => r.printable).map((row) => ( | |||||
| <Typography key={`l-${row.itemCode}`} variant="body2" sx={{ fontFamily: "monospace" }}> | |||||
| {row.itemCode} · {supportLabel(row)} | |||||
| </Typography> | |||||
| ))} | |||||
| </Box> | |||||
| </Box> | |||||
| </Stack> | |||||
| <Typography variant="body2" color="text.secondary"> | <Typography variant="body2" color="text.secondary"> | ||||
| 新增品號:上傳該套 <code>.image</code> / <code>.bmp</code> / <code>.job</code>。汁水機請上傳{" "} | |||||
| <code>品號.image</code>;檸檬機請一併上傳模板裡引用的 BMP。 | |||||
| 「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。 | |||||
| 點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。 | |||||
| </Typography> | </Typography> | ||||
| <FormControl size="small" sx={{ minWidth: 200 }}> | |||||
| <InputLabel>機台</InputLabel> | |||||
| <Select | |||||
| value={templateMachine} | |||||
| label="機台" | |||||
| onChange={(e) => setTemplateMachine(e.target.value as OnPackMachine)} | |||||
| > | |||||
| <MenuItem value="juice">汁水機</MenuItem> | |||||
| <MenuItem value="lemon">檸檬機</MenuItem> | |||||
| </Select> | |||||
| </FormControl> | |||||
| <TextField | |||||
| label="品號" | |||||
| size="small" | |||||
| placeholder="例如 PP2211" | |||||
| value={templateItemCode} | |||||
| onChange={(e) => setTemplateItemCode(e.target.value)} | |||||
| /> | |||||
| <Button variant="contained" component="label" disabled={templateUploading}> | |||||
| {templateUploading ? "上傳中..." : "選擇檔案並上傳"} | |||||
| <input | |||||
| ref={templateFileInputRef} | |||||
| type="file" | |||||
| hidden | |||||
| multiple | |||||
| accept=".image,.bmp,.job" | |||||
| onChange={(e) => { | |||||
| void handleUploadTemplates(e.target.files); | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <TextField | |||||
| label="新增品號" | |||||
| size="small" | |||||
| placeholder="例如 PP2211" | |||||
| value={expiryCodeInput} | |||||
| onChange={(e) => setExpiryCodeInput(e.target.value)} | |||||
| onKeyDown={(e) => { | |||||
| if (e.key === "Enter") { | |||||
| e.preventDefault(); | |||||
| void handleAddExpiryCode(); | |||||
| } | |||||
| }} | }} | ||||
| sx={{ minWidth: 180 }} | |||||
| /> | /> | ||||
| </Button> | |||||
| {templateLoading ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", py: 2 }}> | |||||
| <CircularProgress size={24} /> | |||||
| </Box> | |||||
| ) : templateFiles.length === 0 ? ( | |||||
| <Typography color="text.secondary">資料庫尚無此機台的上傳檔</Typography> | |||||
| ) : ( | |||||
| <Stack spacing={0.5} sx={{ maxHeight: 320, overflow: "auto" }}> | |||||
| {templateFiles.map((row) => ( | |||||
| <Stack | |||||
| key={row.id} | |||||
| direction="row" | |||||
| alignItems="center" | |||||
| justifyContent="space-between" | |||||
| spacing={1} | |||||
| sx={{ py: 0.5, borderBottom: "1px solid #e0e0e0" }} | |||||
| > | |||||
| <Typography variant="body2" sx={{ fontFamily: "monospace" }}> | |||||
| {row.itemCode} · {row.fileName} · {Math.max(1, Math.round(row.byteSize / 1024))} KB | |||||
| </Typography> | |||||
| <Button size="small" color="error" onClick={() => void handleDeleteTemplate(row)}> | |||||
| 刪除 | |||||
| </Button> | |||||
| </Stack> | |||||
| ))} | |||||
| </Stack> | |||||
| )} | |||||
| <Button variant="contained" onClick={() => void handleAddExpiryCode()}> | |||||
| 加入 | |||||
| </Button> | |||||
| </Stack> | |||||
| </Stack> | </Stack> | ||||
| {expiryCodesLoading ? ( | |||||
| <Box sx={{ display: "flex", justifyContent: "center", py: 1 }}> | |||||
| <CircularProgress size={20} /> | |||||
| </Box> | |||||
| ) : expiryCodes.length === 0 ? ( | |||||
| <Typography color="text.secondary">清單空白</Typography> | |||||
| ) : ( | |||||
| <TableContainer sx={{ flex: 1, minHeight: 0, overflow: "auto" }}> | |||||
| <Table size="small" stickyHeader> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "itemCode"} | |||||
| direction={expirySortKey === "itemCode" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("itemCode")} | |||||
| > | |||||
| 品號 | |||||
| </TableSortLabel> | |||||
| </TableCell> | |||||
| <TableCell sx={{ fontWeight: 700 }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "name"} | |||||
| direction={expirySortKey === "name" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("name")} | |||||
| > | |||||
| 中文名稱+單位 | |||||
| </TableSortLabel> | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "defaultDays"} | |||||
| direction={expirySortKey === "defaultDays" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("defaultDays")} | |||||
| > | |||||
| 冷藏 | |||||
| </TableSortLabel> | |||||
| <Typography variant="caption" display="block" color="text.secondary"> | |||||
| 天 | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "minus18Days"} | |||||
| direction={expirySortKey === "minus18Days" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("minus18Days")} | |||||
| > | |||||
| -18 | |||||
| </TableSortLabel> | |||||
| <Typography variant="caption" display="block" color="text.secondary"> | |||||
| 天 | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "useMinus18"} | |||||
| direction={expirySortKey === "useMinus18" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("useMinus18")} | |||||
| > | |||||
| 用 -18 | |||||
| </TableSortLabel> | |||||
| </TableCell> | |||||
| <TableCell align="center" sx={{ fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| <TableSortLabel | |||||
| active={expirySortKey === "effectiveDays"} | |||||
| direction={expirySortKey === "effectiveDays" ? expirySortDir : "asc"} | |||||
| onClick={() => onExpirySort("effectiveDays")} | |||||
| > | |||||
| 列印 | |||||
| </TableSortLabel> | |||||
| <Typography variant="caption" display="block" color="text.secondary"> | |||||
| 天 | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="right" sx={{ fontWeight: 700 }}> | |||||
| 操作 | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {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 ( | |||||
| <TableRow key={row.itemCode} hover> | |||||
| <TableCell sx={{ fontFamily: "monospace", fontWeight: 700, whiteSpace: "nowrap" }}> | |||||
| {row.itemCode} | |||||
| </TableCell> | |||||
| <TableCell sx={{ minWidth: 280 }}> | |||||
| <Stack direction="row" spacing={1} alignItems="center"> | |||||
| <TextField | |||||
| size="small" | |||||
| value={draft} | |||||
| onChange={(e) => | |||||
| setNameDrafts((prev) => ({ ...prev, [row.itemCode]: e.target.value })) | |||||
| } | |||||
| placeholder={row.defaultPrintName || "例如 豬肉水(1包X2千克)"} | |||||
| inputProps={{ maxLength: 255 }} | |||||
| fullWidth | |||||
| /> | |||||
| <Button | |||||
| variant="contained" | |||||
| size="small" | |||||
| disabled={!nameDirty} | |||||
| onClick={() => void handleSaveExpiryPrintName(row.itemCode)} | |||||
| > | |||||
| 儲存 | |||||
| </Button> | |||||
| </Stack> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Typography | |||||
| variant="h6" | |||||
| sx={{ fontWeight: 700, lineHeight: 1.2 }} | |||||
| color={row.defaultDays == null ? "warning.main" : "text.primary"} | |||||
| > | |||||
| {daysLabel(row.defaultDays)} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Typography | |||||
| variant="h6" | |||||
| sx={{ fontWeight: 700, lineHeight: 1.2 }} | |||||
| color={row.minus18Days == null ? "warning.main" : "text.primary"} | |||||
| > | |||||
| {daysLabel(row.minus18Days)} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Tooltip title={canUseMinus18 ? "改用 -18 天數列印到期日" : missingHint}> | |||||
| <span> | |||||
| <Switch | |||||
| size="small" | |||||
| checked={row.useMinus18 === true} | |||||
| disabled={!canUseMinus18} | |||||
| onChange={(e) => void handleToggleUseMinus18(row.itemCode, e.target.checked)} | |||||
| inputProps={{ "aria-label": `${row.itemCode} 用 -18` }} | |||||
| /> | |||||
| </span> | |||||
| </Tooltip> | |||||
| </TableCell> | |||||
| <TableCell align="center"> | |||||
| <Typography | |||||
| variant="h6" | |||||
| sx={{ fontWeight: 800, lineHeight: 1.2 }} | |||||
| color={row.effectiveDays == null ? "warning.main" : "primary.main"} | |||||
| > | |||||
| {daysLabel(row.effectiveDays)} | |||||
| </Typography> | |||||
| {!hasShelf && ( | |||||
| <Typography variant="caption" color="warning.main" display="block"> | |||||
| 未設定 | |||||
| </Typography> | |||||
| )} | |||||
| </TableCell> | |||||
| <TableCell align="right"> | |||||
| <Button | |||||
| size="small" | |||||
| color="error" | |||||
| onClick={() => void handleDeleteExpiryCode(row.itemCode)} | |||||
| > | |||||
| 移除 | |||||
| </Button> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ); | |||||
| })} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| )} | |||||
| <Typography variant="body2" color="text.secondary" sx={{ flexShrink: 0, mt: 1 }}> | |||||
| 檸檬機到期日 ZIP 品號稍後加入。 | |||||
| </Typography> | |||||
| </DialogContent> | </DialogContent> | ||||
| <DialogActions> | <DialogActions> | ||||
| <Button onClick={() => setTemplatesOpen(false)}>關閉</Button> | <Button onClick={() => setTemplatesOpen(false)}>關閉</Button> | ||||
| @@ -1042,11 +1323,19 @@ const BagPrintSearch: React.FC = () => { | |||||
| <Snackbar | <Snackbar | ||||
| open={snackbar.open} | open={snackbar.open} | ||||
| autoHideDuration={3000} | |||||
| autoHideDuration={snackbar.duration ?? 3000} | |||||
| onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | ||||
| message={snackbar.message} | |||||
| anchorOrigin={{ vertical: "bottom", horizontal: "center" }} | anchorOrigin={{ vertical: "bottom", horizontal: "center" }} | ||||
| /> | |||||
| > | |||||
| <Alert | |||||
| onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | |||||
| severity={snackbar.severity ?? "info"} | |||||
| variant="filled" | |||||
| sx={{ width: "100%", maxWidth: 720 }} | |||||
| > | |||||
| {snackbar.message} | |||||
| </Alert> | |||||
| </Snackbar> | |||||
| </Box> | </Box> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -24,6 +24,7 @@ const pathToLabelKey: { [path: string]: string } = { | |||||
| "/settings/user": "nav.settings.user", | "/settings/user": "nav.settings.user", | ||||
| "/settings/clientMonitor": "nav.settings.clientMonitor", | "/settings/clientMonitor": "nav.settings.clientMonitor", | ||||
| "/settings/items": "nav.settings.items", | "/settings/items": "nav.settings.items", | ||||
| "/settings/itemDefaultShelfLife": "nav.settings.itemDefaultShelfLife", | |||||
| "/settings/warehouse": "nav.settings.warehouse", | "/settings/warehouse": "nav.settings.warehouse", | ||||
| "/settings/qcCategory": "nav.settings.qcCategory", | "/settings/qcCategory": "nav.settings.qcCategory", | ||||
| "/settings/bomWeighting": "nav.settings.bomWeighting", | "/settings/bomWeighting": "nav.settings.bomWeighting", | ||||
| @@ -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<string | null>(null); | |||||
| const [success, setSuccess] = useState<string | null>(null); | |||||
| const [rows, setRows] = useState<ItemDefaultShelfLifeRow[]>([]); | |||||
| const [query, setQuery] = useState(""); | |||||
| const [page, setPage] = useState(0); | |||||
| const [rowsPerPage, setRowsPerPage] = useState(25); | |||||
| const [dialogOpen, setDialogOpen] = useState(false); | |||||
| const [editing, setEditing] = useState<ItemDefaultShelfLifeRow | null>(null); | |||||
| const [form, setForm] = useState<FormState>(emptyForm); | |||||
| const [formError, setFormError] = useState<string | null>(null); | |||||
| const [saving, setSaving] = useState(false); | |||||
| const [deleteTarget, setDeleteTarget] = useState<ItemDefaultShelfLifeRow | null>(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 ( | |||||
| <Stack spacing={2}> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Intro")} | |||||
| </Typography> | |||||
| {error && <Alert severity="error">{error}</Alert>} | |||||
| {success && ( | |||||
| <Alert severity="success" onClose={() => setSuccess(null)}> | |||||
| {success} | |||||
| </Alert> | |||||
| )} | |||||
| <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap alignItems="center"> | |||||
| <TextField | |||||
| size="small" | |||||
| value={query} | |||||
| onChange={(e) => setQuery(e.target.value)} | |||||
| placeholder={t("Search placeholder")} | |||||
| sx={{ minWidth: 260, flex: 1 }} | |||||
| /> | |||||
| <Button variant="contained" startIcon={<Add />} onClick={openCreate}> | |||||
| {t("Add")} | |||||
| </Button> | |||||
| </Stack> | |||||
| {loading ? ( | |||||
| <Box display="flex" justifyContent="center" py={4}> | |||||
| <CircularProgress /> | |||||
| </Box> | |||||
| ) : ( | |||||
| <> | |||||
| <TableContainer sx={{ maxHeight: 640 }}> | |||||
| <Table size="small" stickyHeader> | |||||
| <TableHead> | |||||
| <TableRow> | |||||
| <TableCell>{t("Col itemCode")}</TableCell> | |||||
| <TableCell>{t("Col itemName")}</TableCell> | |||||
| <TableCell align="right">{t("Col defaultDays")}</TableCell> | |||||
| <TableCell align="right">{t("Col minus18Days")}</TableCell> | |||||
| <TableCell>{t("Col useMinus18")}</TableCell> | |||||
| <TableCell align="right">{t("Col effectiveDays")}</TableCell> | |||||
| <TableCell align="right">{t("Col openedDays")}</TableCell> | |||||
| <TableCell>{t("Col storageC")}</TableCell> | |||||
| <TableCell>{t("Col remarks")}</TableCell> | |||||
| <TableCell align="right">{t("Col actions")}</TableCell> | |||||
| </TableRow> | |||||
| </TableHead> | |||||
| <TableBody> | |||||
| {paged.length === 0 ? ( | |||||
| <TableRow> | |||||
| <TableCell colSpan={10}> | |||||
| <Typography color="text.secondary"> | |||||
| {rows.length === 0 ? t("Empty") : t("No match")} | |||||
| </Typography> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| ) : ( | |||||
| paged.map((row) => ( | |||||
| <TableRow key={row.id} hover> | |||||
| <TableCell>{row.itemCode}</TableCell> | |||||
| <TableCell>{row.itemName || "—"}</TableCell> | |||||
| <TableCell align="right">{row.defaultDays ?? "—"}</TableCell> | |||||
| <TableCell align="right">{row.minus18Days ?? "—"}</TableCell> | |||||
| <TableCell> | |||||
| <Chip | |||||
| size="small" | |||||
| label={row.useMinus18 ? t("Yes") : t("No")} | |||||
| color={row.useMinus18 ? "warning" : "default"} | |||||
| variant={row.useMinus18 ? "filled" : "outlined"} | |||||
| /> | |||||
| </TableCell> | |||||
| <TableCell align="right">{row.effectiveDays ?? "—"}</TableCell> | |||||
| <TableCell align="right">{row.openedDays ?? "—"}</TableCell> | |||||
| <TableCell>{row.storageC || "—"}</TableCell> | |||||
| <TableCell>{row.remarks || "—"}</TableCell> | |||||
| <TableCell align="right"> | |||||
| <IconButton size="small" aria-label={t("Edit")} onClick={() => openEdit(row)}> | |||||
| <EditOutlined fontSize="small" /> | |||||
| </IconButton> | |||||
| <IconButton | |||||
| size="small" | |||||
| aria-label={t("Delete")} | |||||
| onClick={() => setDeleteTarget(row)} | |||||
| > | |||||
| <DeleteOutline fontSize="small" /> | |||||
| </IconButton> | |||||
| </TableCell> | |||||
| </TableRow> | |||||
| )) | |||||
| )} | |||||
| </TableBody> | |||||
| </Table> | |||||
| </TableContainer> | |||||
| <Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap"> | |||||
| <Typography variant="body2" color="text.secondary"> | |||||
| {t("Showing", { from, to, total: filtered.length })} | |||||
| </Typography> | |||||
| <TablePagination | |||||
| component="div" | |||||
| count={filtered.length} | |||||
| page={page} | |||||
| onPageChange={(_, next) => setPage(next)} | |||||
| rowsPerPage={rowsPerPage} | |||||
| onRowsPerPageChange={(e) => { | |||||
| setRowsPerPage(parseInt(e.target.value, 10)); | |||||
| setPage(0); | |||||
| }} | |||||
| rowsPerPageOptions={[25, 50, 100]} | |||||
| /> | |||||
| </Stack> | |||||
| </> | |||||
| )} | |||||
| <Dialog open={dialogOpen} onClose={closeDialog} fullWidth maxWidth="sm"> | |||||
| <DialogTitle>{editing ? t("Edit title") : t("Add title")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <Stack spacing={2} sx={{ mt: 1 }}> | |||||
| {formError && <Alert severity="error">{formError}</Alert>} | |||||
| <TextField | |||||
| required | |||||
| label={t("Col itemCode")} | |||||
| value={form.itemCode} | |||||
| onChange={(e) => setForm((s) => ({ ...s, itemCode: e.target.value }))} | |||||
| disabled={saving} | |||||
| autoFocus={!editing} | |||||
| /> | |||||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2}> | |||||
| <TextField | |||||
| label={t("Col defaultDays")} | |||||
| value={form.defaultDays} | |||||
| onChange={(e) => setForm((s) => ({ ...s, defaultDays: e.target.value }))} | |||||
| disabled={saving} | |||||
| fullWidth | |||||
| /> | |||||
| <TextField | |||||
| label={t("Col minus18Days")} | |||||
| value={form.minus18Days} | |||||
| onChange={(e) => setForm((s) => ({ ...s, minus18Days: e.target.value }))} | |||||
| disabled={saving} | |||||
| fullWidth | |||||
| /> | |||||
| </Stack> | |||||
| <Box> | |||||
| <FormControlLabel | |||||
| control={ | |||||
| <Checkbox | |||||
| checked={form.useMinus18} | |||||
| onChange={(e) => setForm((s) => ({ ...s, useMinus18: e.target.checked }))} | |||||
| disabled={saving} | |||||
| /> | |||||
| } | |||||
| label={t("Use minus18")} | |||||
| /> | |||||
| <FormHelperText>{t("Use minus18 help")}</FormHelperText> | |||||
| </Box> | |||||
| <Typography variant="body2" color={previewDate ? "text.secondary" : "warning.main"}> | |||||
| {previewDate | |||||
| ? t("Expiry preview", { date: previewDate }) | |||||
| : t("Expiry preview none")} | |||||
| </Typography> | |||||
| <Stack direction={{ xs: "column", sm: "row" }} spacing={2}> | |||||
| <TextField | |||||
| label={t("Col openedDays")} | |||||
| value={form.openedDays} | |||||
| onChange={(e) => setForm((s) => ({ ...s, openedDays: e.target.value }))} | |||||
| disabled={saving} | |||||
| fullWidth | |||||
| /> | |||||
| <TextField | |||||
| label={t("Col storageC")} | |||||
| value={form.storageC} | |||||
| onChange={(e) => setForm((s) => ({ ...s, storageC: e.target.value }))} | |||||
| disabled={saving} | |||||
| inputProps={{ maxLength: 20 }} | |||||
| fullWidth | |||||
| /> | |||||
| </Stack> | |||||
| <TextField | |||||
| label={t("Col remarks")} | |||||
| value={form.remarks} | |||||
| onChange={(e) => setForm((s) => ({ ...s, remarks: e.target.value }))} | |||||
| disabled={saving} | |||||
| inputProps={{ maxLength: 255 }} | |||||
| multiline | |||||
| minRows={2} | |||||
| /> | |||||
| </Stack> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={closeDialog} disabled={saving}> | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button variant="contained" onClick={onSave} disabled={saving}> | |||||
| {saving ? t("Saving") : t("Save")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| <Dialog open={!!deleteTarget} onClose={() => !deleting && setDeleteTarget(null)}> | |||||
| <DialogTitle>{t("Delete title")}</DialogTitle> | |||||
| <DialogContent> | |||||
| <Typography> | |||||
| {t("Delete confirm", { itemCode: deleteTarget?.itemCode ?? "" })} | |||||
| </Typography> | |||||
| </DialogContent> | |||||
| <DialogActions> | |||||
| <Button onClick={() => setDeleteTarget(null)} disabled={deleting}> | |||||
| {t("Cancel")} | |||||
| </Button> | |||||
| <Button color="error" variant="contained" onClick={onDelete} disabled={deleting}> | |||||
| {t("Delete")} | |||||
| </Button> | |||||
| </DialogActions> | |||||
| </Dialog> | |||||
| </Stack> | |||||
| ); | |||||
| }; | |||||
| export default ItemDefaultShelfLifeSettings; | |||||
| @@ -40,6 +40,7 @@ import UploadFile from "@mui/icons-material/UploadFile"; | |||||
| import Sync from "@mui/icons-material/Sync"; | import Sync from "@mui/icons-material/Sync"; | ||||
| import Layers from "@mui/icons-material/Layers"; | import Layers from "@mui/icons-material/Layers"; | ||||
| import Devices from "@mui/icons-material/Devices"; | import Devices from "@mui/icons-material/Devices"; | ||||
| import EventAvailable from "@mui/icons-material/EventAvailable"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { usePathname } from "next/navigation"; | import { usePathname } from "next/navigation"; | ||||
| import Link from "next/link"; | import Link from "next/link"; | ||||
| @@ -325,6 +326,12 @@ const NavigationContent: React.FC = () => { | |||||
| labelKey: "nav.settings.items", | labelKey: "nav.settings.items", | ||||
| path: "/settings/items", | path: "/settings/items", | ||||
| }, | }, | ||||
| { | |||||
| id: "nav.settings.itemDefaultShelfLife", | |||||
| icon: <EventAvailable />, | |||||
| labelKey: "nav.settings.itemDefaultShelfLife", | |||||
| path: "/settings/itemDefaultShelfLife", | |||||
| }, | |||||
| { | { | ||||
| id: "nav.settings.equipment", | id: "nav.settings.equipment", | ||||
| icon: <Build />, | icon: <Build />, | ||||
| @@ -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" | |||||
| } | |||||
| @@ -36,6 +36,7 @@ | |||||
| "nav.settings.user": "User", | "nav.settings.user": "User", | ||||
| "nav.settings.clientMonitor": "Device Connection Monitor", | "nav.settings.clientMonitor": "Device Connection Monitor", | ||||
| "nav.settings.items": "Items", | "nav.settings.items": "Items", | ||||
| "nav.settings.itemDefaultShelfLife": "Item default shelf life", | |||||
| "nav.settings.equipment": "Equipment", | "nav.settings.equipment": "Equipment", | ||||
| "nav.settings.warehouse": "Warehouse", | "nav.settings.warehouse": "Warehouse", | ||||
| "nav.settings.printer": "Printer", | "nav.settings.printer": "Printer", | ||||
| @@ -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": "否" | |||||
| } | |||||
| @@ -79,6 +79,7 @@ | |||||
| "nav.settings.importExcel": "Excel 匯入", | "nav.settings.importExcel": "Excel 匯入", | ||||
| "nav.settings.importTesting": "匯入測試", | "nav.settings.importTesting": "匯入測試", | ||||
| "nav.settings.items": "物品", | "nav.settings.items": "物品", | ||||
| "nav.settings.itemDefaultShelfLife": "物品預設保質期", | |||||
| "nav.settings.masterDataIssues": "BOM / 物料單位問題", | "nav.settings.masterDataIssues": "BOM / 物料單位問題", | ||||
| "nav.settings.priceInquiry": "價格查詢", | "nav.settings.priceInquiry": "價格查詢", | ||||
| "nav.settings.printer": "列印機", | "nav.settings.printer": "列印機", | ||||