| @@ -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(); | |||
| } | |||
| 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<Blob> { | |||
| ): Promise<OnPackZipDownload> { | |||
| 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<Blob> { | |||
| ): Promise<OnPackZipDownload> { | |||
| 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<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> { | |||
| const url = `${NEXT_PUBLIC_API_URL}/plastic/onpack-templates/supported`; | |||
| 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"; | |||
| 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<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 = () => { | |||
| @@ -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<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 [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 = () => { | |||
| </Button> | |||
| {canSeeOnPackAdmin && ( | |||
| <Button variant="outlined" onClick={() => setTemplatesOpen(true)}> | |||
| OnPack 模板 | |||
| OnPack 到期日 ZIP | |||
| </Button> | |||
| )} | |||
| <Box | |||
| @@ -731,8 +886,8 @@ const BagPrintSearch: React.FC = () => { | |||
| 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 ( | |||
| <Paper | |||
| key={jo.id} | |||
| @@ -769,7 +924,7 @@ const BagPrintSearch: React.FC = () => { | |||
| {jo.itemCode || "—"} | |||
| </Typography> | |||
| <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} | |||
| </Stack> | |||
| </Box> | |||
| @@ -936,104 +1091,230 @@ const BagPrintSearch: React.FC = () => { | |||
| <Dialog | |||
| open={templatesOpen && canSeeOnPackAdmin} | |||
| onClose={() => setTemplatesOpen(false)} | |||
| maxWidth="md" | |||
| maxWidth="xl" | |||
| 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> | |||
| <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"> | |||
| 新增品號:上傳該套 <code>.image</code> / <code>.bmp</code> / <code>.job</code>。汁水機請上傳{" "} | |||
| <code>品號.image</code>;檸檬機請一併上傳模板裡引用的 BMP。 | |||
| 「下載 OnPack 汁水機(含到期日)」只用此清單。名稱印在 Product 行,過長可改短後按儲存。 | |||
| 點欄位標題可排序;冷藏/-18/列印第一次點擊會把「未設」排到最前。 | |||
| </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> | |||
| {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> | |||
| <DialogActions> | |||
| <Button onClick={() => setTemplatesOpen(false)}>關閉</Button> | |||
| @@ -1042,11 +1323,19 @@ const BagPrintSearch: React.FC = () => { | |||
| <Snackbar | |||
| open={snackbar.open} | |||
| autoHideDuration={3000} | |||
| autoHideDuration={snackbar.duration ?? 3000} | |||
| onClose={() => setSnackbar((s) => ({ ...s, open: false }))} | |||
| message={snackbar.message} | |||
| 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> | |||
| ); | |||
| }; | |||
| @@ -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", | |||
| @@ -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 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: <EventAvailable />, | |||
| labelKey: "nav.settings.itemDefaultShelfLife", | |||
| path: "/settings/itemDefaultShelfLife", | |||
| }, | |||
| { | |||
| id: "nav.settings.equipment", | |||
| 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.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", | |||
| @@ -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.importTesting": "匯入測試", | |||
| "nav.settings.items": "物品", | |||
| "nav.settings.itemDefaultShelfLife": "物品預設保質期", | |||
| "nav.settings.masterDataIssues": "BOM / 物料單位問題", | |||
| "nav.settings.priceInquiry": "價格查詢", | |||
| "nav.settings.printer": "列印機", | |||