From 4680f3d2d8f5c40c1a78eb01ee1ca6b9fe5baa97 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Thu, 3 Sep 2026 19:21:50 +0800 Subject: [PATCH 1/8] allow admin to setup the items that can use onpack zip file of expiry --- src/components/BagPrint/BagPrintSearch.tsx | 27 +++------------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/src/components/BagPrint/BagPrintSearch.tsx b/src/components/BagPrint/BagPrintSearch.tsx index 64ed9fe6..3f9cd88b 100644 --- a/src/components/BagPrint/BagPrintSearch.tsx +++ b/src/components/BagPrint/BagPrintSearch.tsx @@ -55,6 +55,7 @@ import { import dayjs from "dayjs"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; +import { AUTH, hasAbility } from "@/authorities"; import { NEXT_PUBLIC_API_URL } from "@/config/api"; import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; @@ -77,28 +78,6 @@ const REFRESH_MS = 60 * 1000; const PRINTER_CHECK_MS = 60 * 1000; const PRINTER_RETRY_MS = 30 * 1000; const SETTINGS_KEY = "bagPrint_settings"; -const ONPACK_ADMIN_USERNAME = "2fi"; - -/** Login username from backend JWT `sub` (UserDetails.username). */ -function loginUsernameFromSession(session: SessionWithTokens | null | undefined): string { - const token = session?.accessToken?.trim(); - if (token) { - try { - const parts = token.split("."); - if (parts.length >= 2) { - const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); - const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); - const payload = JSON.parse(atob(padded)) as { sub?: unknown }; - if (typeof payload.sub === "string" && payload.sub.trim()) { - return payload.sub.trim(); - } - } - } catch { - // fall through to display name - } - } - return (session?.user?.name ?? "").trim(); -} const DEFAULT_SETTINGS = { dabag_ip: "", @@ -221,8 +200,8 @@ function sortExpiryRows( const BagPrintSearch: React.FC = () => { const { data: session } = useSession() as { data: SessionWithTokens | null }; - const canSeeOnPackAdmin = - loginUsernameFromSession(session).toLowerCase() === ONPACK_ADMIN_USERNAME; + const abilities = session?.abilities ?? session?.user?.abilities ?? []; + const canSeeOnPackAdmin = hasAbility(abilities, AUTH.ADMIN); const [planDate, setPlanDate] = useState(() => dayjs().format("YYYY-MM-DD")); const [jobOrders, setJobOrders] = useState([]); const [loading, setLoading] = useState(true); From c8c9ae0ee85a2802b0d2ddadd10175341cfa22d8 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 17:37:08 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=20=E9=81=8E=E6=9C=9F=E5=93=81=E8=99=95?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/stockIssue/actions.ts | 15 +- src/app/api/stockIssue/client.ts | 67 ++++ src/components/StockIssue/ExpiryHandleTab.tsx | 334 ++++++++++++++---- .../StockIssue/StockIssueRecordTab.tsx | 63 +++- .../StockIssue/StockIssueSearchPanel.tsx | 24 +- src/i18n/en/stockIssue.json | 18 +- src/i18n/zh/stockIssue.json | 18 +- 7 files changed, 456 insertions(+), 83 deletions(-) create mode 100644 src/app/api/stockIssue/client.ts diff --git a/src/app/api/stockIssue/actions.ts b/src/app/api/stockIssue/actions.ts index 5461e1ec..5b30c36c 100644 --- a/src/app/api/stockIssue/actions.ts +++ b/src/app/api/stockIssue/actions.ts @@ -17,12 +17,17 @@ export interface ExpiryItemResult { storeLocation: string | null; expiryDate: string | null; remainingQty: number; + uomDesc?: string | null; + /** True when expiryDate is today or earlier. */ + canHandle?: boolean; } export interface ExpiryItemFilter { - expiryDate?: string; itemCode?: string; itemName?: string; + lotNo?: string; + /** Inclusive lookahead from today; default 7. */ + daysAhead?: number; } export interface HandleBadItemRequest { @@ -54,6 +59,8 @@ export interface StockIssueHandleRecord { export interface SearchStockIssueRecordParams { startDate?: string; endDate?: string; + handledStartDate?: string; + handledEndDate?: string; itemCode?: string; itemName?: string; lotNo?: string; @@ -61,11 +68,13 @@ export interface SearchStockIssueRecordParams { pageSize?: number; } +/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ export const fetchExpiryItemList = cache(async (filters?: ExpiryItemFilter) => { const params = new URLSearchParams(); - if (filters?.expiryDate) params.set("expiryDate", filters.expiryDate); if (filters?.itemCode) params.set("itemCode", filters.itemCode); if (filters?.itemName) params.set("itemName", filters.itemName); + if (filters?.lotNo) params.set("lotNo", filters.lotNo); + if (filters?.daysAhead != null) params.set("daysAhead", String(filters.daysAhead)); const queryString = params.toString(); const url = `${BASE_API_URL}/pickExecution/issues/expiryItem${queryString ? `?${queryString}` : ""}`; return serverFetchJson(url, { @@ -107,6 +116,8 @@ export async function fetchExpiryItemRecords(params: SearchStockIssueRecordParam const qs = new URLSearchParams(); if (params.startDate) qs.set("startDate", params.startDate); if (params.endDate) qs.set("endDate", params.endDate); + if (params.handledStartDate) qs.set("handledStartDate", params.handledStartDate); + if (params.handledEndDate) qs.set("handledEndDate", params.handledEndDate); if (params.itemCode) qs.set("itemCode", params.itemCode); if (params.itemName) qs.set("itemName", params.itemName); if (params.lotNo) qs.set("lotNo", params.lotNo); diff --git a/src/app/api/stockIssue/client.ts b/src/app/api/stockIssue/client.ts new file mode 100644 index 00000000..264d5963 --- /dev/null +++ b/src/app/api/stockIssue/client.ts @@ -0,0 +1,67 @@ +"use client"; + +import { NEXT_PUBLIC_API_URL } from "@/config/api"; +import { clientAuthFetch } from "@/app/utils/clientAuthFetch"; +import type { ExpiryItemFilter } from "@/app/api/stockIssue/actions"; + +/** Result tab currently shown; backend may filter the workbook by this bucket. */ +export type ExpiryExportBucket = "expired" | "today" | "upcoming"; + +export interface ExportExpiryItemExcelParams extends ExpiryItemFilter { + bucket?: ExpiryExportBucket; +} + +/** + * FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 + * GET /pickExecution/issues/expiryItem/excel + * Query: itemCode, itemName, lotNo, daysAhead, bucket (expired|today|upcoming; omit for all categories) + * Response: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + * Optional Content-Disposition filename. + */ +export async function exportExpiryItemExcel( + filters: ExportExpiryItemExcelParams, +): Promise { + const params = new URLSearchParams(); + if (filters.itemCode) params.set("itemCode", filters.itemCode); + if (filters.itemName) params.set("itemName", filters.itemName); + if (filters.lotNo) params.set("lotNo", filters.lotNo); + if (filters.daysAhead != null) params.set("daysAhead", String(filters.daysAhead)); + if (filters.bucket) params.set("bucket", filters.bucket); + + const queryString = params.toString(); + const url = `${NEXT_PUBLIC_API_URL}/pickExecution/issues/expiryItem/excel${queryString ? `?${queryString}` : ""}`; + + const response = await clientAuthFetch(url, { + method: "GET", + headers: { + Accept: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }, + }); + + if (response.status === 401 || response.status === 403) { + throw new Error("Unauthorized"); + } + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = downloadUrl; + + const contentDisposition = response.headers.get("Content-Disposition"); + let fileName = "expiry-items.xlsx"; + if (contentDisposition?.includes("filename=")) { + fileName = contentDisposition + .split("filename=")[1] + .split(";")[0] + .replace(/"/g, ""); + } + + link.setAttribute("download", fileName); + document.body.appendChild(link); + link.click(); + link.remove(); + window.URL.revokeObjectURL(downloadUrl); +} diff --git a/src/components/StockIssue/ExpiryHandleTab.tsx b/src/components/StockIssue/ExpiryHandleTab.tsx index a582c4e0..c661966d 100644 --- a/src/components/StockIssue/ExpiryHandleTab.tsx +++ b/src/components/StockIssue/ExpiryHandleTab.tsx @@ -11,42 +11,165 @@ import SearchResults, { Column } from "@/components/SearchResults/index"; import { SessionWithTokens } from "@/config/authConfig"; import { batchSubmitExpiryItem, + ExpiryItemFilter, ExpiryItemResult, fetchExpiryItemList, submitExpiryItem, } from "@/app/api/stockIssue/actions"; -import { Box, Button } from "@mui/material"; +import { exportExpiryItemExcel } from "@/app/api/stockIssue/client"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Tab, + Tabs, + Tooltip, + Typography, +} from "@mui/material"; +import FileDownload from "@mui/icons-material/FileDownload"; import { useSession } from "next-auth/react"; type SearchQuery = { itemCode: string; itemName: string; - expiryDate: string; + lotNo: string; + daysAhead: string; }; type SearchParamNames = keyof SearchQuery; +type ResultBucket = "expired" | "today" | "upcoming"; + +const DEFAULT_DAYS_AHEAD = 7; +const MAX_DAYS_AHEAD = 365; + +function parseDaysAhead(raw: string | number | undefined): number { + const n = typeof raw === "number" ? raw : Number.parseInt(String(raw ?? "").trim(), 10); + if (!Number.isFinite(n) || n < 0) return DEFAULT_DAYS_AHEAD; + return Math.min(Math.floor(n), MAX_DAYS_AHEAD); +} + +function parseExpiryDayjs(rawValue: unknown): dayjs.Dayjs | null { + const raw = String(rawValue ?? "").trim(); + if (!raw) return null; + let d: dayjs.Dayjs; + if (raw.includes(",")) { + const parts = raw.split(",").map((s) => parseInt(s.trim(), 10)); + const [y, m, d_] = parts; + if ( + parts.length >= 3 && + y != null && + m != null && + d_ != null && + !Number.isNaN(y) && + !Number.isNaN(m) && + !Number.isNaN(d_) + ) { + d = dayjs(new Date(y, m - 1, d_)); + } else { + d = dayjs(""); + } + } else if (/^\d{4}-\d{2}-\d{2}/.test(raw)) { + d = dayjs(raw.slice(0, 10)); + } else { + let normalized = raw; + if (raw.length === 7) { + normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7); + } else if (raw.length === 6) { + normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6); + } + d = dayjs(normalized, "YYYYMMDD", true); + } + return d.isValid() ? d : null; +} + +function getExpiryBucket( + item: ExpiryItemResult, + daysAhead: number, +): ResultBucket | null { + const d = parseExpiryDayjs(item.expiryDate); + if (!d) return null; + const today = dayjs().startOf("day"); + if (d.isBefore(today, "day")) return "expired"; + if (d.isSame(today, "day")) return "today"; + if (daysAhead > 0 && !d.isAfter(today.add(daysAhead, "day"), "day")) { + return "upcoming"; + } + return null; +} +function canHandleExpiryItem(item: ExpiryItemResult): boolean { + if (typeof item.canHandle === "boolean") return item.canHandle; + const d = parseExpiryDayjs(item.expiryDate); + return d != null && !d.isAfter(dayjs(), "day"); +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ const ExpiryHandleTab: React.FC = () => { const BATCH_CHUNK_SIZE = 20; const { t } = useTranslation("stockIssue"); + const { t: tCommon } = useTranslation("common"); const { data: session } = useSession() as { data: SessionWithTokens | null }; const currentUserId = session?.id ? parseInt(session.id) : undefined; const [expiryItems, setExpiryItems] = useState([]); + const [lastFilters, setLastFilters] = useState({ + daysAhead: DEFAULT_DAYS_AHEAD, + }); + const [hasSearched, setHasSearched] = useState(false); + const [resultTab, setResultTab] = useState("expired"); const [submittingIds, setSubmittingIds] = useState>(new Set()); const [batchSubmitting, setBatchSubmitting] = useState(false); + const [batchConfirmOpen, setBatchConfirmOpen] = useState(false); const [batchProgress, setBatchProgress] = useState<{ done: number; total: number; } | null>(null); const expirySubmitInFlightRef = useRef>(new Set()); const batchSubmitInFlightRef = useRef(false); + const exportInFlightRef = useRef(false); + const [exporting, setExporting] = useState<"filtered" | "all" | null>(null); const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); + const daysAhead = lastFilters.daysAhead ?? DEFAULT_DAYS_AHEAD; + + const itemsByBucket = useMemo(() => { + const expired: ExpiryItemResult[] = []; + const today: ExpiryItemResult[] = []; + const upcoming: ExpiryItemResult[] = []; + for (const item of expiryItems) { + const bucket = getExpiryBucket(item, daysAhead); + if (bucket === "expired") expired.push(item); + else if (bucket === "today") today.push(item); + else if (bucket === "upcoming") upcoming.push(item); + } + return { + expired, + today, + upcoming, + }; + }, [expiryItems, daysAhead]); + + const tabItems = itemsByBucket[resultTab]; + const handleableIds = useMemo( + () => tabItems.filter(canHandleExpiryItem).map((item) => item.id), + [tabItems], + ); + const searchFields: StockIssueSearchField[] = useMemo( () => [ { name: "itemCode", label: t("Item Code"), type: "text" }, { name: "itemName", label: t("Item"), type: "text" }, - { name: "expiryDate", label: t("Expiry Date"), type: "date" }, + { name: "lotNo", label: t("Lot No."), type: "text" }, + { + name: "daysAhead", + label: t("Days ahead"), + type: "number", + defaultValue: String(DEFAULT_DAYS_AHEAD), + min: 0, + max: MAX_DAYS_AHEAD, + }, ], [t], ); @@ -62,6 +185,10 @@ const ExpiryHandleTab: React.FC = () => { alert(t("Item not found")); return; } + if (!canHandleExpiryItem(item)) { + alert(t("Not yet due; cannot dispose until the expiry date")); + return; + } if (expirySubmitInFlightRef.current.has(id)) return; try { @@ -88,7 +215,7 @@ const ExpiryHandleTab: React.FC = () => { const handleSubmitAll = useCallback(async () => { if (!currentUserId) return; if (batchSubmitInFlightRef.current) return; - const allIds = expiryItems.map((item) => item.id); + const allIds = tabItems.filter(canHandleExpiryItem).map((item) => item.id); if (allIds.length === 0) return; batchSubmitInFlightRef.current = true; @@ -114,7 +241,7 @@ const ExpiryHandleTab: React.FC = () => { setBatchProgress(null); batchSubmitInFlightRef.current = false; } - }, [currentUserId, expiryItems, t]); + }, [currentUserId, tabItems, t]); const expiryColumns = useMemo[]>( () => [ @@ -126,52 +253,40 @@ const ExpiryHandleTab: React.FC = () => { name: "expiryDate", label: t("Expiry Date"), renderCell: (item) => { - const raw = String(item.expiryDate ?? "").trim(); - if (!raw) return "—"; - let d; - if (raw.includes(",")) { - const parts = raw.split(",").map((s) => parseInt(s.trim(), 10)); - const [y, m, d_] = parts; - if ( - parts.length >= 3 && - y != null && - m != null && - d_ != null && - !Number.isNaN(y) && - !Number.isNaN(m) && - !Number.isNaN(d_) - ) { - d = dayjs(new Date(y, m - 1, d_)); - } else { - d = dayjs(""); - } - } else { - let normalized = raw; - if (raw.length === 7) { - normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + raw.slice(5, 7); - } else if (raw.length === 6) { - normalized = raw.slice(0, 4) + "0" + raw.slice(4, 5) + "0" + raw.slice(5, 6); - } - d = dayjs(normalized, "YYYYMMDD", true); - } - return d.isValid() ? d.format(OUTPUT_DATE_FORMAT) : raw; + const d = parseExpiryDayjs(item.expiryDate); + return d ? d.format(OUTPUT_DATE_FORMAT) : String(item.expiryDate ?? "").trim() || "—"; }, }, { name: "remainingQty", label: t("Remaining Qty") }, + { + name: "uomDesc", + label: t("UoM"), + renderCell: (item) => item.uomDesc?.trim() || "—", + }, { name: "id", label: t("Action"), - renderCell: (item) => ( - - ), + renderCell: (item) => { + const canHandle = canHandleExpiryItem(item); + const disposing = submittingIds.has(item.id); + const button = ( + + ); + if (canHandle) return button; + return ( + + {button} + + ); + }, }, ], [t, handleSubmitSingle, submittingIds, currentUserId], @@ -180,12 +295,16 @@ const ExpiryHandleTab: React.FC = () => { const handleSearch = useCallback( async (query: Record) => { setPaging((prev) => ({ ...prev, pageNum: 1 })); + const filters: ExpiryItemFilter = { + itemCode: query.itemCode?.trim() || undefined, + itemName: query.itemName?.trim() || undefined, + lotNo: query.lotNo?.trim() || undefined, + daysAhead: parseDaysAhead(query.daysAhead), + }; try { - const result = await fetchExpiryItemList({ - itemCode: query.itemCode?.trim() || undefined, - itemName: query.itemName?.trim() || undefined, - expiryDate: query.expiryDate || undefined, - }); + const result = await fetchExpiryItemList(filters); + setLastFilters(filters); + setHasSearched(true); setExpiryItems(result); } catch (error) { console.error("Failed to search expiry items:", error); @@ -195,20 +314,83 @@ const ExpiryHandleTab: React.FC = () => { [t], ); - const pagedItems = useMemo(() => { - const start = (paging.pageNum - 1) * paging.pageSize; - return expiryItems.slice(start, start + paging.pageSize); - }, [expiryItems, paging]); + const handleExportExcel = useCallback( + async (mode: "filtered" | "all") => { + if (!hasSearched) return; + if (exportInFlightRef.current) return; + exportInFlightRef.current = true; + setExporting(mode); + try { + await exportExpiryItemExcel( + mode === "all" + ? { + daysAhead, + } + : { + ...lastFilters, + bucket: resultTab, + }, + ); + } catch (error) { + console.error("Failed to export expiry items:", error); + alert(t("Failed to export Excel")); + } finally { + setExporting(null); + exportInFlightRef.current = false; + } + }, + [hasSearched, lastFilters, resultTab, daysAhead, t], + ); + + const handleResultTabChange = useCallback( + (_: React.SyntheticEvent, value: string) => { + setResultTab(value as ResultBucket); + setPaging((prev) => ({ ...prev, pageNum: 1 })); + }, + [], + ); return ( - + + + + + + + + - items={pagedItems} + items={tabItems} columns={expiryColumns} pagingController={paging} setPagingController={setPaging} - totalCount={expiryItems.length} + totalCount={tabItems.length} /> + { + if (!batchSubmitting) setBatchConfirmOpen(false); + }} + fullWidth + maxWidth="xs" + > + {t("Confirm batch dispose")} + + + {t("Confirm batch dispose message", { count: handleableIds.length })} + + + + + + + ); }; diff --git a/src/components/StockIssue/StockIssueRecordTab.tsx b/src/components/StockIssue/StockIssueRecordTab.tsx index 145441a4..461b2a39 100644 --- a/src/components/StockIssue/StockIssueRecordTab.tsx +++ b/src/components/StockIssue/StockIssueRecordTab.tsx @@ -22,6 +22,8 @@ type SearchQuery = { lotNo: string; startDate: string; endDate: string; + handledStartDate: string; + handledEndDate: string; }; type SearchParamNames = keyof SearchQuery; @@ -29,6 +31,7 @@ interface Props { kind: RecordKind; } +/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ const StockIssueRecordTab: React.FC = ({ kind }) => { const { t } = useTranslation("stockIssue"); const [items, setItems] = useState([]); @@ -40,27 +43,47 @@ const StockIssueRecordTab: React.FC = ({ kind }) => { lotNo: "", startDate: "", endDate: "", + handledStartDate: "", + handledEndDate: "", }); const hasSearchedRef = useRef(false); const prevPagingRef = useRef(paging); const searchFields: StockIssueSearchField[] = useMemo( - () => [ - { name: "itemCode", label: t("Item Code"), type: "text" }, - { name: "itemName", label: t("Item"), type: "text" }, - { name: "lotNo", label: t("Lot No."), type: "text" }, - { - name: "startDate", - label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"), - type: "date", - mirrorTo: "endDate", - }, - { - name: "endDate", - label: kind === "expiry" ? t("Expiry End Date") : t("End Date"), - type: "date", - }, - ], + () => { + const fields: StockIssueSearchField[] = [ + { name: "itemCode", label: t("Item Code"), type: "text" }, + { name: "itemName", label: t("Item"), type: "text" }, + { name: "lotNo", label: t("Lot No."), type: "text" }, + { + name: "startDate", + label: kind === "expiry" ? t("Expiry Start Date") : t("Start Date"), + type: "date", + mirrorTo: "endDate", + }, + { + name: "endDate", + label: kind === "expiry" ? t("Expiry End Date") : t("End Date"), + type: "date", + }, + ]; + if (kind === "expiry") { + fields.push( + { + name: "handledStartDate", + label: t("Handled Start Date"), + type: "date", + mirrorTo: "handledEndDate", + }, + { + name: "handledEndDate", + label: t("Handled End Date"), + type: "date", + }, + ); + } + return fields; + }, [t, kind], ); @@ -73,6 +96,8 @@ const StockIssueRecordTab: React.FC = ({ kind }) => { lotNo: query.lotNo?.trim() || undefined, startDate: query.startDate || undefined, endDate: query.endDate || undefined, + handledStartDate: query.handledStartDate || undefined, + handledEndDate: query.handledEndDate || undefined, pageNum: page.pageNum - 1, pageSize: page.pageSize, }; @@ -166,7 +191,7 @@ const StockIssueRecordTab: React.FC = ({ kind }) => { }, { name: "uomDesc", - label: t("UOM"), + label: t("UoM"), renderCell: (row) => ( <> {row.uomDesc ?? ""} @@ -179,8 +204,10 @@ const StockIssueRecordTab: React.FC = ({ kind }) => { renderCell: (row) => row.handlerName ?? (row.handlerId != null ? String(row.handlerId) : "—"), }, - { name: "remarks", label: t("Remarks") }, ); + if (kind !== "expiry") { + base.push({ name: "remarks", label: t("Remarks") }); + } return base; }, [t, kind]); diff --git a/src/components/StockIssue/StockIssueSearchPanel.tsx b/src/components/StockIssue/StockIssueSearchPanel.tsx index c0a29983..7c888291 100644 --- a/src/components/StockIssue/StockIssueSearchPanel.tsx +++ b/src/components/StockIssue/StockIssueSearchPanel.tsx @@ -25,7 +25,7 @@ import "dayjs/locale/zh-hk"; import { useCallback, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -export type StockIssueSearchFieldType = "text" | "select" | "date"; +export type StockIssueSearchFieldType = "text" | "select" | "date" | "number"; export interface StockIssueSearchField { name: K; @@ -36,6 +36,9 @@ export interface StockIssueSearchField { getOptionLabel?: (value: string) => string; /** When this date is picked, copy the same value to `mirrorTo`. */ mirrorTo?: K; + defaultValue?: string; + min?: number; + max?: number; } interface Props { @@ -46,6 +49,7 @@ interface Props { disabled?: boolean; } +/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ function StockIssueSearchPanel({ fields, onSearch, @@ -60,7 +64,8 @@ function StockIssueSearchPanel({ return fields.reduce( (acc, field) => { acc[field.name] = - field.type === "select" ? "All" : ""; + field.defaultValue ?? + (field.type === "select" ? "All" : ""); return acc; }, {} as Record, @@ -127,6 +132,21 @@ function StockIssueSearchPanel({ disabled={disabled} /> )} + {field.type === "number" && ( + + )} {field.type === "select" && ( {field.label} diff --git a/src/i18n/en/stockIssue.json b/src/i18n/en/stockIssue.json index 5e6583f8..7ee187b5 100644 --- a/src/i18n/en/stockIssue.json +++ b/src/i18n/en/stockIssue.json @@ -6,15 +6,28 @@ "Bad Item Qty": "Bad Item Qty", "Bad Item Records": "Bad Item Records", "Batch Disposed All": "Batch Disposed All", + "Export Excel": "Export Excel", + "Exporting...": "Exporting...", + "Failed to export Excel": "Failed to export Excel", "Book Qty": "Book Qty", "Cancel": "Cancel", "Code": "Code", "Defective Qty": "Defective Qty", - "Disposed": "Disposed", + "Disposed": "Expiry handle", "Disposing...": "Disposing...", "DO Order Code": "DO Order Code", "End Date": "End Date", "Expiry Date": "Expiry Date", + "Expiry on or before": "Expiry on or before", + "Already expired": "Expiry not yet handle", + "Expires today": "Expires today", + "Expires within 7 days": "Expires within 7 days", + "Expires within n days": "Expires within {{days}} days", + "All expiry items": "All", + "Export all in tab": "All", + "Days ahead": "Days ahead", + "Confirm batch dispose": "Confirm batch dispose", + "Confirm batch dispose message": "Dispose {{count}} lot(s)? Remaining quantity will be fully stocked out.", "Expiry End Date": "Expiry End Date", "Expiry Item": "Expiry Item", "Expiry Item Handle": "Expiry Item Handle", @@ -24,7 +37,10 @@ "Failed to load expiry items": "Failed to load expiry items", "Failed to submit": "Failed to submit", "Failed to submit expiry item": "Failed to submit expiry item", + "Not yet due; cannot dispose until the expiry date": "Not yet due; cannot dispose until the expiry date", "Handled Date": "Handled Date", + "Handled Start Date": "Handled Start Date", + "Handled End Date": "Handled End Date", "Handler": "Handler", "Issue Qty": "Issue Qty", "Item": "Item", diff --git a/src/i18n/zh/stockIssue.json b/src/i18n/zh/stockIssue.json index ca778989..d90a2125 100644 --- a/src/i18n/zh/stockIssue.json +++ b/src/i18n/zh/stockIssue.json @@ -6,15 +6,28 @@ "Bad Item Qty": "不良品數量", "Bad Item Records": "不良品處理紀錄", "Batch Disposed All": "批量處理完成", + "Export Excel": "匯出 Excel", + "Exporting...": "匯出中...", + "Failed to export Excel": "匯出 Excel 失敗", "Book Qty": "帳面庫存", "Cancel": "取消", "Code": "編號", "Defective Qty": "不良數量", - "Disposed": "已處置", + "Disposed": "過期處理", "Disposing...": "處理中...", "DO Order Code": "送貨單編號", "End Date": "結束日期", "Expiry Date": "到期日", + "Expiry on or before": "到期日(含當日及以前)", + "Already expired": "過期尚未處理", + "Expires today": "今日到期", + "Expires within 7 days": "未來 7 日到期", + "Expires within n days": "未來 {{days}} 日到期", + "All expiry items": "全部", + "Export all in tab": "全部", + "Days ahead": "未來天數", + "Confirm batch dispose": "確認批量處置", + "Confirm batch dispose message": "將處置 {{count}} 筆批號,剩餘數量會全部出倉。確定?", "Expiry End Date": "到期日(結束)", "Expiry Item": "過期", "Expiry Item Handle": "過期品處理", @@ -24,7 +37,10 @@ "Failed to load expiry items": "載入過期品失敗", "Failed to submit": "提交失敗", "Failed to submit expiry item": "提交過期品失敗", + "Not yet due; cannot dispose until the expiry date": "尚未到期,到期日當日才可處置", "Handled Date": "處理日期", + "Handled Start Date": "處理日期(開始)", + "Handled End Date": "處理日期(結束)", "Handler": "處理人", "Issue Qty": "問題數量", "Item": "貨品", From 05ebbf496e30a853d1f86427cf7dfac9ac49a5cd Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 17:41:25 +0800 Subject: [PATCH 3/8] =?UTF-8?q?DO=20=E6=94=BE=E5=96=AE=20relate=20created?= =?UTF-8?q?=20need=20write=20in=20username?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/DoDetail/DoDetail.tsx | 22 ++++++------------- src/components/DoSearch/DoSearch.tsx | 15 +++++++++++-- .../DoSearchWorkbench/DoSearchWorkbench.tsx | 12 +++++++++- src/i18n/en/do.json | 3 +++ src/i18n/zh/do.json | 3 ++- 5 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/components/DoDetail/DoDetail.tsx b/src/components/DoDetail/DoDetail.tsx index 496d8ec4..8b5bf3e3 100644 --- a/src/components/DoDetail/DoDetail.tsx +++ b/src/components/DoDetail/DoDetail.tsx @@ -26,6 +26,7 @@ type Props = { workbenchRelease?: boolean; } +/** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */ const DoDetail: React.FC = ({ defaultValues, id, @@ -57,22 +58,13 @@ const DoDetail: React.FC = ({ setSuccessMessage("") if (id) { - // Get current user ID from session - //const currentUserId = session?.id ? parseInt(session.id) : undefined; - - //if (!currentUserId) { - // setServerError("User session not found. Please login again."); - // return; - //} - /* - const response = await releaseDo({ - id: id, - //userId: currentUserId // Pass user ID from session - }) - */ + if (!currentUserId) { + setServerError(t("User session not found")); + return; + } const response = await startWorkbenchBatchReleaseAsyncSingleV2({ doId: id, - userId: currentUserId ?? 0 + userId: currentUserId }) if (response?.code === "STARTED") { setSuccessMessage(t("DO released successfully! Pick orders created.")); @@ -91,7 +83,7 @@ const DoDetail: React.FC = ({ } finally { setIsUploading(false) } - }, [id, formProps, t, setIsUploading, session]) // Add session to dependencies + }, [id, formProps, t, setIsUploading, session, currentUserId, router]) // UPDATE STORE-BASED ASSIGNMENT HANDLERS const handleAssignByStore = useCallback(async (storeId: string) => { diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index d29a4e4a..e4da4268 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -81,6 +81,7 @@ function isTruckLaneSearchMissingEta(truckLanceCode: string, estimatedArrivalDat return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === ""; } +/** FP-MTMS Version Checklist | Functions Ref. No. 77 | v1.0.0 | 2026-09-08 */ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSearch }) => { const apiRef = useGridApiRef(); @@ -509,6 +510,16 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea const handleBatchRelease = useCallback(async (isWorkbench: boolean) => { try { + if (!currentUserId) { + await Swal.fire({ + icon: "error", + title: t("Error"), + text: t("User session not found"), + confirmButtonText: t("OK"), + didOpen: (popup) => applyMainContentAreaSwalOffset(popup), + }); + return; + } const tabFilter = resolveTabFilter(activeTab); const tabTruckKeyword = tabFilter.forceTruckKeyword ?? ""; const effectiveTruckLanceCode = tabTruckKeyword || currentSearchParams.truckLanceCode || ""; @@ -659,12 +670,12 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea if(isWorkbench){ startRes = await startWorkbenchBatchReleaseAsyncV2({ ids: idsToRelease, - userId: currentUserId ?? 1, + userId: currentUserId, mergeExtraIntoLaneTicket, }); } else{ - startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); + startRes = await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId }); } //await startBatchReleaseAsync({ ids: idsToRelease, userId: currentUserId ?? 1 }); const jobId = startRes?.entity?.jobId; diff --git a/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx index b799ad50..39aafb1d 100644 --- a/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx +++ b/src/components/DoSearchWorkbench/DoSearchWorkbench.tsx @@ -61,6 +61,7 @@ function isTruckLaneSearchMissingEta(truckLanceCode: string, estimatedArrivalDat return truckLanceCode.trim() !== "" && estimatedArrivalDate.trim() === ""; } +/** FP-MTMS Version Checklist | Functions Ref. No. 78 | v1.0.0 | 2026-09-08 */ const DoSearchWorkbench: React.FC = ({ filterArgs, searchQuery, @@ -538,6 +539,15 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { const handleBatchRelease = useCallback(async () => { try { + if (!currentUserId) { + await Swal.fire({ + icon: "error", + title: t("Error"), + text: t("User session not found"), + confirmButtonText: t("OK"), + }); + return; + } if ( isTruckLaneSearchMissingEta( currentSearchParams.truckLanceCode ?? "", @@ -657,7 +667,7 @@ const handleSearch = useCallback(async (query: SearchBoxInputs) => { (result.value as { mergeExtraIntoLaneTicket?: boolean } | undefined)?.mergeExtraIntoLaneTicket ?? false; const startRes = await startWorkbenchBatchReleaseAsyncV2({ ids: idsToRelease, - userId: currentUserId ?? 1, + userId: currentUserId, mergeExtraIntoLaneTicket, }); const startEntity = startRes?.entity as { jobId?: string } | undefined; diff --git a/src/i18n/en/do.json b/src/i18n/en/do.json index b21c9bb6..ecae8209 100644 --- a/src/i18n/en/do.json +++ b/src/i18n/en/do.json @@ -166,6 +166,9 @@ "Truck X": "Truck X", "Truck lane search requires date message": "Truck lane search requires date message", "Truck lane search requires date title": "Truck lane search requires date title", + "User session not found": "User session not found. Please login again.", + "Error": "Error", + "OK": "OK", "Warning: Some delivery orders do not have matching trucks for the target date.": "Warning: Some delivery orders do not have matching trucks for the target date.", "Workbench Batch Release": "Workbench Batch Release", "code": "code", diff --git a/src/i18n/zh/do.json b/src/i18n/zh/do.json index e498ca30..a108e5fb 100644 --- a/src/i18n/zh/do.json +++ b/src/i18n/zh/do.json @@ -221,5 +221,6 @@ "Replenishment demo note": "此為前端假資料回應;正式環境將呼叫後端 API。", "Search Delivery Order": "搜尋送貨單", "DO Replenishment": "送貨單補貨", - "Error": "錯誤" + "Error": "錯誤", + "User session not found": "找不到登入使用者,請重新登入。" } From 79ccf5ae6f70af4b64ae744c8892e118476f85b9 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 17:43:38 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=20PO=20=E6=94=B6=E8=B2=A8=20UI=20and=20?= =?UTF-8?q?=E8=B6=85=E9=87=8F=E8=AD=A6=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(main)/po/edit/page.tsx | 4 +-- src/components/PoDetail/PoDetail.tsx | 37 ++++++++++++++++++---- src/components/PoDetail/PoInputGrid.tsx | 2 +- src/components/PoDetail/QcStockInModal.tsx | 2 +- src/components/PoSearch/PoSearch.tsx | 11 ++++--- src/components/Qc/QcStockInModal.tsx | 2 +- src/i18n/en/purchaseOrder.json | 2 +- src/i18n/zh/purchaseOrder.json | 2 +- 8 files changed, 43 insertions(+), 19 deletions(-) diff --git a/src/app/(main)/po/edit/page.tsx b/src/app/(main)/po/edit/page.tsx index e7f0667d..5eeabac3 100644 --- a/src/app/(main)/po/edit/page.tsx +++ b/src/app/(main)/po/edit/page.tsx @@ -16,11 +16,11 @@ type Props = {} & SearchParams; const PoEdit: React.FC = async ({ searchParams }) => { const type = "purchaseOrder"; const { t } = await getServerI18n(type); - console.log(searchParams["id"]); + //console.log(searchParams["id"]); const id = isString(searchParams["id"]) ? parseInt(searchParams["id"]) : undefined; - console.log(id); + //console.log(id); if (!id) { notFound(); } diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index 1b1fb889..36bffeb5 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -253,7 +253,7 @@ interface PolInputResult { dnQty: string, } -/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.1 | 2026-08-12 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.2 | 2026-09-07 */ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { const cameras = useContext(CameraContext); const { data: session } = useSession(); @@ -678,11 +678,29 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { }, 200); }; - const exceedOrderBy10Percent = orderQty > 0 && acceptedQty > orderQty * 1.1; - if (exceedOrderBy10Percent) { + const sils = row.stockInLine ?? []; + const alreadyM18 = sils.reduce( + (acc, sil) => acc + Number(sil.purchaseAcceptedQty ?? 0), + 0, + ); + const alreadyStock = sils.reduce( + (acc, sil) => acc + Number(sil.acceptedQty ?? 0), + 0, + ); + const stockDemand = Number(row.stockUom?.stockQty ?? 0); + const thisBatchStock = + orderQty > 0 && stockDemand > 0 + ? acceptedQty * (stockDemand / orderQty) + : acceptedQty; + const exceedByOrderUnit = + orderQty > 0 && alreadyM18 + acceptedQty > orderQty * 1.1; + const exceedByStockUnit = + stockDemand > 0 && + alreadyStock + thisBatchStock > stockDemand * 1.1; + if (exceedByOrderUnit || exceedByStockUnit) { submitDialogWithWarning(doSubmit, t, { title: t("Confirm submit"), - html: t("This batch quantity exceeds order quantity. Do you still want to submit?"), + html: t("qtyExceedsOrderConfirm"), confirmButtonText: t("Submit"), }); } else { @@ -834,6 +852,11 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { - - - ); }; From 79a4f8564651e58683c1a08dd16af8d90147bac2 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 22:22:03 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=E6=9C=9F=E5=93=81=E5=85=A9=E5=80=8B=20Exce?= =?UTF-8?q?l=20=E6=8C=89=E9=88=95=EF=BC=8F=E5=88=86=E9=A0=81=E6=96=87?= =?UTF-8?q?=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/StockIssue/ExpiryHandleTab.tsx | 187 +++++++++++++----- src/i18n/en/stockIssue.json | 9 +- src/i18n/zh/stockIssue.json | 17 +- 3 files changed, 153 insertions(+), 60 deletions(-) diff --git a/src/components/StockIssue/ExpiryHandleTab.tsx b/src/components/StockIssue/ExpiryHandleTab.tsx index c661966d..fbb12ea2 100644 --- a/src/components/StockIssue/ExpiryHandleTab.tsx +++ b/src/components/StockIssue/ExpiryHandleTab.tsx @@ -24,6 +24,11 @@ import { DialogActions, DialogContent, DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + SelectChangeEvent, Tab, Tabs, Tooltip, @@ -36,17 +41,21 @@ type SearchQuery = { itemCode: string; itemName: string; lotNo: string; - daysAhead: string; }; type SearchParamNames = keyof SearchQuery; type ResultBucket = "expired" | "today" | "upcoming"; const DEFAULT_DAYS_AHEAD = 7; -const MAX_DAYS_AHEAD = 365; +const MIN_DAYS_AHEAD = 1; +const MAX_DAYS_AHEAD = 14; +const DAYS_AHEAD_OPTIONS = Array.from( + { length: MAX_DAYS_AHEAD - MIN_DAYS_AHEAD + 1 }, + (_, i) => MIN_DAYS_AHEAD + i, +); function parseDaysAhead(raw: string | number | undefined): number { const n = typeof raw === "number" ? raw : Number.parseInt(String(raw ?? "").trim(), 10); - if (!Number.isFinite(n) || n < 0) return DEFAULT_DAYS_AHEAD; + if (!Number.isFinite(n) || n < MIN_DAYS_AHEAD) return DEFAULT_DAYS_AHEAD; return Math.min(Math.floor(n), MAX_DAYS_AHEAD); } @@ -105,7 +114,7 @@ function canHandleExpiryItem(item: ExpiryItemResult): boolean { return d != null && !d.isAfter(dayjs(), "day"); } -/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.2 | 2026-09-07 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 74 | v1.0.3 | 2026-09-08 */ const ExpiryHandleTab: React.FC = () => { const BATCH_CHUNK_SIZE = 20; const { t } = useTranslation("stockIssue"); @@ -129,8 +138,10 @@ const ExpiryHandleTab: React.FC = () => { const expirySubmitInFlightRef = useRef>(new Set()); const batchSubmitInFlightRef = useRef(false); const exportInFlightRef = useRef(false); + const searchInFlightRef = useRef(false); const [exporting, setExporting] = useState<"filtered" | "all" | null>(null); const [paging, setPaging] = useState({ pageNum: 1, pageSize: 10 }); + const [daysAheadDraft, setDaysAheadDraft] = useState(String(DEFAULT_DAYS_AHEAD)); const daysAhead = lastFilters.daysAhead ?? DEFAULT_DAYS_AHEAD; @@ -162,14 +173,6 @@ const ExpiryHandleTab: React.FC = () => { { name: "itemCode", label: t("Item Code"), type: "text" }, { name: "itemName", label: t("Item"), type: "text" }, { name: "lotNo", label: t("Lot No."), type: "text" }, - { - name: "daysAhead", - label: t("Days ahead"), - type: "number", - defaultValue: String(DEFAULT_DAYS_AHEAD), - min: 0, - max: MAX_DAYS_AHEAD, - }, ], [t], ); @@ -294,13 +297,17 @@ const ExpiryHandleTab: React.FC = () => { const handleSearch = useCallback( async (query: Record) => { + if (searchInFlightRef.current) return; + const parsedDays = parseDaysAhead(daysAheadDraft); + setDaysAheadDraft(String(parsedDays)); setPaging((prev) => ({ ...prev, pageNum: 1 })); const filters: ExpiryItemFilter = { itemCode: query.itemCode?.trim() || undefined, itemName: query.itemName?.trim() || undefined, lotNo: query.lotNo?.trim() || undefined, - daysAhead: parseDaysAhead(query.daysAhead), + daysAhead: parsedDays, }; + searchInFlightRef.current = true; try { const result = await fetchExpiryItemList(filters); setLastFilters(filters); @@ -309,9 +316,48 @@ const ExpiryHandleTab: React.FC = () => { } catch (error) { console.error("Failed to search expiry items:", error); alert(t("Failed to load expiry items")); + } finally { + searchInFlightRef.current = false; } }, - [t], + [t, daysAheadDraft], + ); + + const applyDaysAhead = useCallback( + async (nextDays: number) => { + const parsedDays = parseDaysAhead(nextDays); + setDaysAheadDraft(String(parsedDays)); + if (parsedDays === daysAhead) return; + if (!hasSearched) { + setLastFilters((prev) => ({ ...prev, daysAhead: parsedDays })); + return; + } + if (searchInFlightRef.current) return; + searchInFlightRef.current = true; + try { + const filters: ExpiryItemFilter = { + ...lastFilters, + daysAhead: parsedDays, + }; + const result = await fetchExpiryItemList(filters); + setLastFilters(filters); + setExpiryItems(result); + setPaging((prev) => ({ ...prev, pageNum: 1 })); + } catch (error) { + console.error("Failed to search expiry items:", error); + alert(t("Failed to load expiry items")); + } finally { + searchInFlightRef.current = false; + } + }, + [daysAhead, hasSearched, lastFilters, t], + ); + + const handleDaysAheadChange = useCallback( + (event: SelectChangeEvent) => { + void applyDaysAhead(parseDaysAhead(event.target.value)); + }, + [applyDaysAhead], ); const handleExportExcel = useCallback( @@ -353,29 +399,37 @@ const ExpiryHandleTab: React.FC = () => { return ( - - - - - - - + + + + - + + + {resultTab === "upcoming" && ( + + {t("Days ahead")} + + + )} + + + + items={tabItems} diff --git a/src/i18n/en/stockIssue.json b/src/i18n/en/stockIssue.json index 7ee187b5..45590bce 100644 --- a/src/i18n/en/stockIssue.json +++ b/src/i18n/en/stockIssue.json @@ -6,7 +6,7 @@ "Bad Item Qty": "Bad Item Qty", "Bad Item Records": "Bad Item Records", "Batch Disposed All": "Batch Disposed All", - "Export Excel": "Export Excel", + "Export Excel": "Export this category", "Exporting...": "Exporting...", "Failed to export Excel": "Failed to export Excel", "Book Qty": "Book Qty", @@ -21,10 +21,11 @@ "Expiry on or before": "Expiry on or before", "Already expired": "Expiry not yet handle", "Expires today": "Expires today", - "Expires within 7 days": "Expires within 7 days", - "Expires within n days": "Expires within {{days}} days", + "Expires within 7 days": "Expires within X days", + "Expires within n days": "Expires within X days", + "Expires within X days": "Expires within X days", "All expiry items": "All", - "Export all in tab": "All", + "Export all in tab": "Export all", "Days ahead": "Days ahead", "Confirm batch dispose": "Confirm batch dispose", "Confirm batch dispose message": "Dispose {{count}} lot(s)? Remaining quantity will be fully stocked out.", diff --git a/src/i18n/zh/stockIssue.json b/src/i18n/zh/stockIssue.json index d90a2125..f9bec61e 100644 --- a/src/i18n/zh/stockIssue.json +++ b/src/i18n/zh/stockIssue.json @@ -6,7 +6,7 @@ "Bad Item Qty": "不良品數量", "Bad Item Records": "不良品處理紀錄", "Batch Disposed All": "批量處理完成", - "Export Excel": "匯出 Excel", + "Export Excel": "匯出目前分類", "Exporting...": "匯出中...", "Failed to export Excel": "匯出 Excel 失敗", "Book Qty": "帳面庫存", @@ -21,13 +21,14 @@ "Expiry on or before": "到期日(含當日及以前)", "Already expired": "過期尚未處理", "Expires today": "今日到期", - "Expires within 7 days": "未來 7 日到期", - "Expires within n days": "未來 {{days}} 日到期", + "Expires within 7 days": "未來 X 日到期", + "Expires within n days": "未來 X 日到期", + "Expires within X days": "未來 X 日到期", "All expiry items": "全部", - "Export all in tab": "全部", + "Export all in tab": "匯出全部", "Days ahead": "未來天數", "Confirm batch dispose": "確認批量處置", - "Confirm batch dispose message": "將處置 {{count}} 筆批號,剩餘數量會全部出倉。確定?", + "Confirm batch dispose message": "將處置 {{count}} 筆批號,數量會全部出倉。確定?", "Expiry End Date": "到期日(結束)", "Expiry Item": "過期", "Expiry Item Handle": "過期品處理", @@ -43,7 +44,7 @@ "Handled End Date": "處理日期(結束)", "Handler": "處理人", "Issue Qty": "問題數量", - "Item": "貨品", + "Item": "貨品名稱", "Item Code": "貨品編號", "Item not found": "找不到貨品", "Item selected": "已選擇貨品", @@ -66,7 +67,7 @@ "Processing...": "處理中...", "Quantity exceeds available quantity": "數量超過可用數量", "Remain available Quantity": "剩餘可用數量", - "Remaining Qty": "剩餘數量", + "Remaining Qty": "數量", "Remark": "備註", "Remarks": "備註", "Reset": "重置", @@ -85,7 +86,7 @@ "Submitting...": "提交中...", "Type": "類型", "Unknown error": "未知錯誤", - "UoM": "單位", + "UoM": "庫存單位", "User ID is required": "需要用戶ID", "Warehouse": "倉庫", "available": "可用", From d5701925f32fb76d43b1e799ad9e28ebfae8e037 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 8 Sep 2026 22:24:58 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=E5=BA=AB=E5=AD=98=E8=AA=BF=E6=95=B4=20Rema?= =?UTF-8?q?rks=20=E5=AF=AB=E5=85=A5=20DB=20=E4=B8=A6=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/stockAdjustment/actions.ts | 14 ++++ .../InventorySearch/InventoryLotLineTable.tsx | 75 +++++++++++++++---- 2 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/app/api/stockAdjustment/actions.ts b/src/app/api/stockAdjustment/actions.ts index cbcb04d5..c42e0c09 100644 --- a/src/app/api/stockAdjustment/actions.ts +++ b/src/app/api/stockAdjustment/actions.ts @@ -16,6 +16,7 @@ export interface StockAdjustmentLineRequest { expiryDate: string; warehouseId: number; uom?: string | null; + remarks?: string | null; } export interface StockAdjustmentRequest { @@ -33,6 +34,19 @@ export interface MessageResponse { errorPosition: string | null; } +export interface StockAdjustmentRemarksResponse { + lotNo: string | null; + remarks: string; +} + +/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ +export const fetchLatestAdjustmentRemarks = async (itemId: number) => { + return serverFetchJson( + `${BASE_API_URL}/stockAdjustment/latestRemarks?itemId=${itemId}`, + { method: "GET" }, + ); +}; + export const submitStockAdjustment = async (data: StockAdjustmentRequest) => { const result = await serverFetchJson( `${BASE_API_URL}/stockAdjustment/submit`, diff --git a/src/components/InventorySearch/InventoryLotLineTable.tsx b/src/components/InventorySearch/InventoryLotLineTable.tsx index fd6165d1..9e3a041f 100644 --- a/src/components/InventorySearch/InventoryLotLineTable.tsx +++ b/src/components/InventorySearch/InventoryLotLineTable.tsx @@ -32,7 +32,7 @@ import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; import dayjs from "dayjs"; import CheckIcon from "@mui/icons-material/Check"; -import { submitStockAdjustment, StockAdjustmentLineRequest } from "@/app/api/stockAdjustment/actions"; +import { submitStockAdjustment, StockAdjustmentLineRequest, fetchLatestAdjustmentRemarks } from "@/app/api/stockAdjustment/actions"; import { useSession } from "next-auth/react"; import { AUTH, hasAbility } from "@/authorities"; @@ -59,7 +59,7 @@ interface Props { onStockAdjustmentSuccess?: () => void | Promise; } -/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.6 | 2026-09-07 */ +/** FP-MTMS Version Checklist | Functions Ref. No. 3 | v1.0.7 | 2026-09-08 */ const InventoryLotLineTable: React.FC = ({ inventoryLotLines, pagingController, setPagingController, totalCount, inventory, filterLotNo, @@ -99,7 +99,11 @@ const InventoryLotLineTable: React.FC = ({ remarks: '', }); const originalAdjustmentLinesRef = useRef([]); + const adjustSaveInFlightRef = useRef(false); + const loadedRemarksByLotRef = useRef>(new Map()); + const remarksFetchGenRef = useRef(0); const [adjustmentEntries, setAdjustmentEntries] = useState([]); + const [isAdjustSaving, setIsAdjustSaving] = useState(false); useEffect(() => { if (stockTransferModalOpen) { fetchWarehouseListClient() @@ -153,9 +157,34 @@ const prevAdjustmentModalOpenRef = useRef(false); })); setAdjustmentEntries(initial); originalAdjustmentLinesRef.current = initial; + loadedRemarksByLotRef.current = new Map(); + const fetchGen = ++remarksFetchGenRef.current; + const itemId = inventory.itemId; + fetchLatestAdjustmentRemarks(itemId) + .then((rows) => { + if (fetchGen !== remarksFetchGenRef.current) return; + const byLot = new Map(); + for (const row of rows ?? []) { + const lot = row.lotNo?.trim(); + const remarks = row.remarks?.trim(); + if (!lot || !remarks || byLot.has(lot)) continue; + byLot.set(lot, remarks); + } + loadedRemarksByLotRef.current = byLot; + const apply = (line: AdjustmentEntry): AdjustmentEntry => { + const lot = line.lotNo?.trim(); + const remarks = (lot && byLot.get(lot)) || line.remarks || ''; + return { ...line, remarks }; + }; + setAdjustmentEntries((prev) => prev.map(apply)); + originalAdjustmentLinesRef.current = originalAdjustmentLinesRef.current.map(apply); + }) + .catch(console.error); } setPendingRemovalLineId(null); setRemovalReasons({}); + } else if (!stockAdjustmentModalOpen) { + remarksFetchGenRef.current += 1; } }, [stockAdjustmentModalOpen, inventory, availableLotLines]); @@ -164,12 +193,15 @@ const prevAdjustmentModalOpenRef = useRef(false); setPendingRemovalLineId(null); setRemovalReasons({}); setAdjustmentEntries( - (availableLotLines ?? []).map((line) => ({ - ...line, - adjustedQty: line.availableQty ?? 0, - originalQty: line.availableQty ?? 0, - remarks: '', - })) + (availableLotLines ?? []).map((line) => { + const lot = line.lotNo?.trim(); + return { + ...line, + adjustedQty: line.availableQty ?? 0, + originalQty: line.availableQty ?? 0, + remarks: (lot && loadedRemarksByLotRef.current.get(lot)) || '', + }; + }) ); }, [availableLotLines]); @@ -241,15 +273,26 @@ const prevAdjustmentModalOpenRef = useRef(false); expiryDate, warehouseId: line.warehouse?.id ?? 0, uom: line.uom ?? null, + remarks: line.remarks?.trim() || null, }; }, []); const handleAdjustmentSave = useCallback(async () => { if (!inventory) return; - const itemCode = inventory.itemCode; - const originalLines = originalAdjustmentLinesRef.current.map((line) => toApiLine(line, itemCode)); - const currentLines = adjustmentEntries.map((line) => toApiLine(line, itemCode)); + if (adjustSaveInFlightRef.current) return; + adjustSaveInFlightRef.current = true; + setIsAdjustSaving(true); try { + const itemCode = inventory.itemCode; + const currentIds = new Set(adjustmentEntries.map((line) => line.id)); + const originalLines = originalAdjustmentLinesRef.current.map((line) => { + const api = toApiLine(line, itemCode); + if (!currentIds.has(line.id)) { + api.remarks = removalReasons[line.id]?.trim() || null; + } + return api; + }); + const currentLines = adjustmentEntries.map((line) => toApiLine(line, itemCode)); setIsUploading(true); await submitStockAdjustment({ itemId: inventory.itemId, @@ -264,8 +307,10 @@ const prevAdjustmentModalOpenRef = useRef(false); msgError(message || t("Save failed")); } finally { setIsUploading(false); + setIsAdjustSaving(false); + adjustSaveInFlightRef.current = false; } - }, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess]); + }, [adjustmentEntries, inventory, t, toApiLine, onStockAdjustmentSuccess, removalReasons]); const handleOpenAddEntry = useCallback(() => { setAddEntryForm({ @@ -857,7 +902,7 @@ const prevAdjustmentModalOpenRef = useRef(false); color="primary" startIcon={} onClick={handleAdjustmentSave} - disabled={!hasAdjustmentChange} + disabled={!hasAdjustmentChange || isAdjustSaving} > {t("Save")} @@ -1004,7 +1049,9 @@ const prevAdjustmentModalOpenRef = useRef(false); }, }} /> - ) : null} + ) : ( + line.remarks || null + )} {pendingRemovalLineId === line.id ? ( From 6145d1bbf8ac63a6630062ba95491703108a8615 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Tue, 8 Sep 2026 23:47:50 +0800 Subject: [PATCH 8/8] fixing for compile --- src/app/api/settings/item/actions.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/app/api/settings/item/actions.ts b/src/app/api/settings/item/actions.ts index f6b91807..2b241dee 100644 --- a/src/app/api/settings/item/actions.ts +++ b/src/app/api/settings/item/actions.ts @@ -138,6 +138,28 @@ export const fetchItemsWithDetails = cache(async (searchParams?: Record) => { + const searchParams = new URLSearchParams(); + if (queryParams) { + Object.entries(queryParams).forEach(([key, value]) => { + if (value !== undefined && value !== null && `${value}` !== "") { + searchParams.set(key, String(value)); + } + }); + } + const queryString = searchParams.toString(); + return serverFetchJson>( + queryString + ? `${BASE_API_URL}/items/getRecordByPage?${queryString}` + : `${BASE_API_URL}/items/getRecordByPage`, + { + method: "GET", + next: { tags: ["items"] }, + }, + ); +}); + export const fetchAllItemsInClient = cache(async () => { return serverFetchJson(`${BASE_API_URL}/items/consumables`, { next: { tags: ["items"] },