Sfoglia il codice sorgente

added for the default expiry date

fix負數倉
PC-20260115JRSN\Administrator 4 settimane fa
parent
commit
4e9dfc4100
11 ha cambiato i file con 1224 aggiunte e 179 eliminazioni
  1. +21
    -0
      src/app/(main)/settings/itemDefaultShelfLife/page.tsx
  2. +92
    -4
      src/app/api/bagPrint/actions.ts
  3. +88
    -0
      src/app/api/settings/itemDefaultShelfLife/client.ts
  4. +464
    -175
      src/components/BagPrint/BagPrintSearch.tsx
  5. +1
    -0
      src/components/Breadcrumb/Breadcrumb.tsx
  6. +473
    -0
      src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx
  7. +7
    -0
      src/components/NavigationContent/NavigationContent.tsx
  8. +38
    -0
      src/i18n/en/itemDefaultShelfLife.json
  9. +1
    -0
      src/i18n/en/navigation.json
  10. +38
    -0
      src/i18n/zh/itemDefaultShelfLife.json
  11. +1
    -0
      src/i18n/zh/navigation.json

+ 21
- 0
src/app/(main)/settings/itemDefaultShelfLife/page.tsx Vedi File

@@ -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>
);
}

+ 92
- 4
src/app/api/bagPrint/actions.ts Vedi File

@@ -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" });


+ 88
- 0
src/app/api/settings/itemDefaultShelfLife/client.ts Vedi File

@@ -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);
}

+ 464
- 175
src/components/BagPrint/BagPrintSearch.tsx Vedi File

@@ -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>
);
};


+ 1
- 0
src/components/Breadcrumb/Breadcrumb.tsx Vedi File

@@ -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",


+ 473
- 0
src/components/ItemDefaultShelfLifeSettings/ItemDefaultShelfLifeSettings.tsx Vedi File

@@ -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;

+ 7
- 0
src/components/NavigationContent/NavigationContent.tsx Vedi File

@@ -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 />,


+ 38
- 0
src/i18n/en/itemDefaultShelfLife.json Vedi File

@@ -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"
}

+ 1
- 0
src/i18n/en/navigation.json Vedi File

@@ -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",


+ 38
- 0
src/i18n/zh/itemDefaultShelfLife.json Vedi File

@@ -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": "否"
}

+ 1
- 0
src/i18n/zh/navigation.json Vedi File

@@ -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": "列印機",


Caricamento…
Annulla
Salva