From febf75eb385b21b0060d7e77e21c86a1d7cc3c66 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Mon, 23 Feb 2026 16:38:45 +0800 Subject: [PATCH 01/17] it says it can control the popup keyboard size in tablet --- src/app/global.css | 23 +++++++++++++++++++++++ src/app/layout.tsx | 10 +++++++++- src/components/Logo/Logo.tsx | 6 +++--- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/app/global.css b/src/app/global.css index 261f31c..2b2a85d 100644 --- a/src/app/global.css +++ b/src/app/global.css @@ -29,11 +29,34 @@ body { overscroll-behavior: none; } +/* Tablet/mobile: stable layout when virtual keyboard opens */ +html { + /* Prefer dynamic viewport height so layout can adapt to keyboard (if browser resizes) */ + height: 100%; +} body { + min-height: 100%; + min-height: 100dvh; background-color: var(--background); color: var(--foreground); } +/* Full-height containers: use dvh so keyboard doesn’t squash the layout when overlay is used */ +@media (max-width: 1024px) { + .min-h-screen { + min-height: 100dvh; + } +} + +/* Avoid iOS zoom on input focus (keep inputs ≥16px where possible) */ +@media (max-width: 1024px) { + input, + select, + textarea { + font-size: max(16px, 1rem); + } +} + .app-search-criteria { border-radius: 8px; border: 1px solid var(--border); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index dde610b..027afb7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; // import { detectLanguage } from "@/i18n"; // import ThemeRegistry from "@/theme/ThemeRegistry"; import { detectLanguage } from "../i18n"; @@ -9,6 +9,14 @@ export const metadata: Metadata = { description: "FPSMS - xxxx Management System", }; +/** Tablet/mobile: virtual keyboard overlays content instead of resizing viewport (avoids "half screen gone"). */ +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + interactiveWidget: "overlays-content", +}; + export default async function RootLayout({ children, }: { diff --git a/src/components/Logo/Logo.tsx b/src/components/Logo/Logo.tsx index 8e18be2..0bc601c 100644 --- a/src/components/Logo/Logo.tsx +++ b/src/components/Logo/Logo.tsx @@ -7,7 +7,7 @@ interface Props { } /** - * Logo: 3D-style badge (FP) + MTMS wordmark. + * Logo: 3D-style badge (FP) + FP-MTMS wordmark. * Badge uses gradient and highlight for depth; FP = Food Production, MTMS = system name. */ const Logo: React.FC = ({ height = 44, className = "" }) => { @@ -68,13 +68,13 @@ const Logo: React.FC = ({ height = 44, className = "" }) => { FP - {/* Wordmark: MTMS + subtitle — strong, energetic */} + {/* Wordmark: FP-MTMS + subtitle — strong, energetic */}
- MTMS + FP-MTMS Date: Mon, 23 Feb 2026 17:20:31 +0800 Subject: [PATCH 02/17] update jobmatch --- .../Jodetail/JobPickExecutionForm.tsx | 40 ++++++++------ .../Jodetail/JobPickExecutionsecondscan.tsx | 55 +++++-------------- src/components/Jodetail/JobmatchForm.tsx | 2 +- src/i18n/zh/common.json | 24 +++++++- src/i18n/zh/jo.json | 9 +++ src/i18n/zh/pickOrder.json | 1 + 6 files changed, 70 insertions(+), 61 deletions(-) diff --git a/src/components/Jodetail/JobPickExecutionForm.tsx b/src/components/Jodetail/JobPickExecutionForm.tsx index 6d6a054..42916c3 100644 --- a/src/components/Jodetail/JobPickExecutionForm.tsx +++ b/src/components/Jodetail/JobPickExecutionForm.tsx @@ -91,7 +91,9 @@ const PickExecutionForm: React.FC = ({ const [handlers, setHandlers] = useState>([]); const [verifiedQty, setVerifiedQty] = useState(0); const { data: session } = useSession() as { data: SessionWithTokens | null }; - + const missSet = formData.missQty != null; +const badItemSet = formData.badItemQty != null; +const badPackageSet = (formData as any).badPackageQty != null; const calculateRemainingAvailableQty = useCallback((lot: LotPickData) => { return lot.availableQty || 0; }, []); @@ -162,9 +164,9 @@ useEffect(() => { storeLocation: selectedLot.location, requiredQty: selectedLot.requiredQty, actualPickQty: initialVerifiedQty, - missQty: 0, - badItemQty: 0, - badPackageQty: 0, // Bad Package Qty (frontend only) + missQty: undefined, + badItemQty: undefined, + badPackageQty: undefined, issueRemark: "", pickerName: "", handledBy: undefined, @@ -195,10 +197,10 @@ useEffect(() => { const newErrors: FormErrors = {}; const ap = Number(verifiedQty) || 0; const miss = Number(formData.missQty) || 0; - const badItem = Number(formData.badItemQty) || 0; - const badPackage = Number((formData as any).badPackageQty) || 0; - const totalBad = badItem + badPackage; - const total = ap + miss + totalBad; + const badItem = Number(formData.badItemQty) ?? 0; + const badPackage = Number((formData as any).badPackageQty) ?? 0; + const totalBadQty = badItem + badPackage; + const total = ap + miss + totalBadQty; const availableQty = selectedLot?.availableQty || 0; // 1. Check actualPickQty cannot be negative @@ -231,7 +233,7 @@ useEffect(() => { } // 5. At least one field must have a value - if (ap === 0 && miss === 0 && totalBad === 0) { + if (ap === 0 && miss === 0 && totalBadQty === 0) { newErrors.actualPickQty = t("Enter pick qty or issue qty"); } @@ -288,11 +290,12 @@ useEffect(() => { const submissionData: PickExecutionIssueData = { ...(formData as PickExecutionIssueData), actualPickQty: verifiedQty, - lotId: formData.lotId || selectedLot?.lotId || 0, - lotNo: formData.lotNo || selectedLot?.lotNo || '', - pickOrderCode: formData.pickOrderCode || selectedPickOrderLine?.pickOrderCode || '', - pickerName: session?.user?.name || '', - badItemQty: totalBadQty, + lotId: formData.lotId ?? selectedLot?.lotId ?? 0, + lotNo: formData.lotNo ?? selectedLot?.lotNo ?? '', + pickOrderCode: formData.pickOrderCode ?? selectedPickOrderLine?.pickOrderCode ?? '', + pickerName: session?.user?.name ?? '', + missQty: formData.missQty ?? 0, // 这里:null/undefined → 0 + badItemQty: totalBadQty, // totalBadQty 下面用 ?? 0 算 badReason, }; @@ -397,7 +400,8 @@ useEffect(() => { pattern: "[0-9]*", min: 0, }} - value={formData.missQty || 0} + disabled={badItemSet || badPackageSet} + value={formData.missQty || ""} onChange={(e) => { handleInputChange( "missQty", @@ -421,7 +425,7 @@ useEffect(() => { pattern: "[0-9]*", min: 0, }} - value={formData.badItemQty || 0} + value={formData.badItemQty || ""} onChange={(e) => { const newBadItemQty = e.target.value === "" ? undefined @@ -429,6 +433,7 @@ useEffect(() => { handleInputChange('badItemQty', newBadItemQty); }} error={!!errors.badItemQty} + disabled={missSet || badPackageSet} helperText={errors.badItemQty} variant="outlined" /> @@ -444,7 +449,7 @@ useEffect(() => { pattern: "[0-9]*", min: 0, }} - value={(formData as any).badPackageQty || 0} + value={(formData as any).badPackageQty || ""} onChange={(e) => { handleInputChange( "badPackageQty", @@ -453,6 +458,7 @@ useEffect(() => { : Math.max(0, Number(e.target.value) || 0) ); }} + disabled={missSet || badItemSet} error={!!errors.badItemQty} variant="outlined" /> diff --git a/src/components/Jodetail/JobPickExecutionsecondscan.tsx b/src/components/Jodetail/JobPickExecutionsecondscan.tsx index 823f79c..21163e1 100644 --- a/src/components/Jodetail/JobPickExecutionsecondscan.tsx +++ b/src/components/Jodetail/JobPickExecutionsecondscan.tsx @@ -868,7 +868,8 @@ const JobPickExecution: React.FC = ({ filterArgs, onBack }) => { qty: submitQty, isMissing: false, isBad: false, - reason: undefined + reason: undefined, + userId: currentUserId ?? 0 } ); @@ -881,7 +882,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBack }) => { } catch (error) { console.error("Error submitting second scan quantity:", error); } - }, [fetchJobOrderData]); + }, [fetchJobOrderData, currentUserId]); const handlePickExecutionForm = useCallback((lot: any) => { console.log("=== Pick Execution Form ==="); @@ -1263,55 +1264,24 @@ const JobPickExecution: React.FC = ({ filterArgs, onBack }) => { return requiredQty.toLocaleString()+'('+lot.uomShortDesc+')'; })()} - {/* - - {lot.matchStatus?.toLowerCase() === 'scanned' || - lot.matchStatus?.toLowerCase() === 'completed' ? ( - - - - ) : ( - - {t(" ")} - - )} - - */} + - - ))} @@ -675,6 +672,7 @@ const CompleteJobOrderRecord: React.FC = ({ onPageChange={handlePageChange} onRowsPerPageChange={handlePageSizeChange} rowsPerPageOptions={[5, 10, 25, 50]} + labelRowsPerPage={t("Rows per page")} /> )} diff --git a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx index b569b70..f00ddf5 100644 --- a/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx +++ b/src/components/ProductionProcess/ProductionProcessJobOrderDetail.tsx @@ -422,7 +422,7 @@ const handleRelease = useCallback(async ( jobOrderId: number) => { const productionProcessesLineRemarkTableColumns: GridColDef[] = [ { field: "seqNo", - headerName: t("Seq"), + headerName: t("SEQ"), flex: 0.2, align: "left", headerAlign: "left", diff --git a/src/components/Qc/QcForm.tsx b/src/components/Qc/QcForm.tsx index 10d46d4..46a33a4 100644 --- a/src/components/Qc/QcForm.tsx +++ b/src/components/Qc/QcForm.tsx @@ -232,12 +232,23 @@ const QcForm: React.FC = ({ rows, disabled = false }) => { return ( <> + // autoHeight 'auto'} + initialState={{ + pagination: { paginationModel: { page: 0, pageSize: 100 } }, + }} + pageSizeOptions={[100]} + slotProps={{ + pagination: { + sx: { + display: "none", + }, + }, + }} /> ); diff --git a/src/components/SearchResults/SearchResults.tsx b/src/components/SearchResults/SearchResults.tsx index 923298a..43e692c 100644 --- a/src/components/SearchResults/SearchResults.tsx +++ b/src/components/SearchResults/SearchResults.tsx @@ -198,7 +198,7 @@ function SearchResults({ setCheckboxIds = undefined, onRowClick = undefined, }: Props) { - const { t } = useTranslation("dashboard"); + const { t } = useTranslation(); const [page, setPage] = React.useState(0); const [rowsPerPage, setRowsPerPage] = React.useState(10); diff --git a/src/components/StyledDataGrid/StyledDataGrid.tsx b/src/components/StyledDataGrid/StyledDataGrid.tsx index 7899dc4..0ea456c 100644 --- a/src/components/StyledDataGrid/StyledDataGrid.tsx +++ b/src/components/StyledDataGrid/StyledDataGrid.tsx @@ -1,6 +1,8 @@ import { styled } from "@mui/material"; import { DataGrid ,DataGridProps,zhTW} from "@mui/x-data-grid"; import { forwardRef } from "react"; +import { useTranslation } from "react-i18next"; + const StyledDataGridBase = styled(DataGrid)(({ theme }) => ({ "--unstable_DataGrid-radius": 0, "& .MuiDataGrid-columnHeaders": { @@ -29,12 +31,14 @@ const StyledDataGridBase = styled(DataGrid)(({ theme }) => ({ }, })); const StyledDataGrid = forwardRef((props, ref) => { + const { t } = useTranslation(); return ( diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 5dc8e3b..57d08fe 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -112,6 +112,7 @@ "Today": "今天", "Yesterday": "昨天", + "Two Days Ago": "前天", "Input Equipment is not match with process": "輸入的設備與流程不匹配", "Staff No is required": "員工編號必填", @@ -120,6 +121,8 @@ "Production Date": "生產日期", "QC Check Item": "QC品檢項目", "QC Category": "QC品檢模板", + "QC Item All": "QC 綜合管理", + "qcItemAll": "QC 綜合管理", "qcCategory": "品檢模板", "QC Check Template": "QC檢查模板", "Mail": "郵件", @@ -136,6 +139,7 @@ "Production Date":"生產日期", "QC Check Item":"QC品檢項目", "QC Category":"QC品檢模板", + "QC Item All":"QC 綜合管理", "qcCategory":"品檢模板", "QC Check Template":"QC檢查模板", "QR Code Handle":"二維碼列印及下載", @@ -272,6 +276,7 @@ "Please scan equipment code": "請掃描設備編號", "Equipment Code": "設備編號", "Seq": "步驟", + "SEQ": "步驟", "Item Name": "物料名稱", "Job Order Info": "工單信息", "Matching Stock": "工單對料", diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index a222604..cd05554 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -39,6 +39,7 @@ "DO Order Code": "送貨單編號", "JO Order Code": "工單編號", "Picker Name": "提料員", + "Rows per page": "每頁行數", "rejected": "已拒絕", "miss": "缺貨", diff --git a/src/i18n/zh/jo.json b/src/i18n/zh/jo.json index 5c53546..e726dea 100644 --- a/src/i18n/zh/jo.json +++ b/src/i18n/zh/jo.json @@ -93,6 +93,11 @@ "Bag Code": "包裝袋編號", "Sequence": "序", + "Seq": "步驟", + "SEQ": "步驟", + "Today": "今天", + "Yesterday": "昨天", + "Two Days Ago": "前天", "Item Code": "成品/半成品編號", "Paused": "已暫停", "paused": "已暫停", @@ -347,7 +352,7 @@ "receivedQty": "接收數量", "stock in information": "庫存信息", "No Uom": "沒有單位", - "Print Pick Record": "打印板頭紙", + "Print Pick Record": "打印版頭紙", "Printed Successfully.": "成功列印", "Submit All Scanned": "提交所有已掃描項目", "Submitting...": "提交中...", From 0d0a05ed5597f1a7cd9d613bb61d64d309e1166a Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Mon, 23 Feb 2026 18:12:40 +0800 Subject: [PATCH 04/17] update zh --- src/i18n/zh/common.json | 2 ++ src/i18n/zh/jo.json | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 5dc8e3b..1f82b09 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -470,6 +470,8 @@ "Missing items": "缺少物品", "Total (Verified + Bad + Missing) must equal Required quantity": "總數必須等於需求數量", "Missing item Qty": "缺少物品數量", + "seq": "序號", + "Job Order Pick Execution": "工單提料", "Bad Item Qty": "不良物品數量", "Issue Remark": "問題備註", "At least one issue must be reported": "至少需要報告一個問題", diff --git a/src/i18n/zh/jo.json b/src/i18n/zh/jo.json index 5c53546..f12766a 100644 --- a/src/i18n/zh/jo.json +++ b/src/i18n/zh/jo.json @@ -564,7 +564,9 @@ "Missing item Qty": "缺少物品數量", "Bad Item Qty": "不良物品數量", "Issue Remark": "問題備註", + "seq": "序號", "Handled By": "處理者", + "Job Order Pick Execution": "工單提料", "Finish": "完成" } From f17ed17f878c51157d495e0c5c68a9bf7b505eb0 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Mon, 23 Feb 2026 18:19:01 +0800 Subject: [PATCH 05/17] no message --- src/components/LoginPage/LoginPage.tsx | 5 +- src/components/Logo/Logo.tsx | 79 +++++++++++++++++++------- 2 files changed, 61 insertions(+), 23 deletions(-) diff --git a/src/components/LoginPage/LoginPage.tsx b/src/components/LoginPage/LoginPage.tsx index 73f4c8a..5eb7c30 100644 --- a/src/components/LoginPage/LoginPage.tsx +++ b/src/components/LoginPage/LoginPage.tsx @@ -29,10 +29,11 @@ const LoginPage = () => { display: "flex", alignItems: "flex-end", justifyContent: "center", - svg: { maxHeight: 120 }, + backgroundImage: "linear-gradient(135deg, rgba(59,130,246,0.15) 0%, #f1f5f9 45%, #f8fafc 100%)", + backgroundColor: "#f8fafc", }} > - + diff --git a/src/components/Logo/Logo.tsx b/src/components/Logo/Logo.tsx index 0bc601c..f8bc458 100644 --- a/src/components/Logo/Logo.tsx +++ b/src/components/Logo/Logo.tsx @@ -6,14 +6,18 @@ interface Props { className?: string; } +/** Same logo height everywhere so login and main page look identical. */ +const DEFAULT_LOGO_HEIGHT = 42; + /** - * Logo: 3D-style badge (FP) + FP-MTMS wordmark. - * Badge uses gradient and highlight for depth; FP = Food Production, MTMS = system name. + * Logo: rounded badge (FP) with links motif inside + FP-MTMS wordmark. + * Uses fixed typography so words look the same on login and main page. */ -const Logo: React.FC = ({ height = 44, className = "" }) => { +const Logo: React.FC = ({ height = DEFAULT_LOGO_HEIGHT, className = "" }) => { const size = Math.max(28, height); const badgeSize = Math.round(size * 0.7); - const fontSize = Math.round(size * 0.5); + const titleFontSize = 21; + const subtitleFontSize = 10; const fpSize = badgeSize <= 22 ? 10 : badgeSize <= 28 ? 12 : 14; return ( @@ -22,7 +26,7 @@ const Logo: React.FC = ({ height = 44, className = "" }) => { style={{ display: "flex", flexShrink: 0 }} aria-label="FP-MTMS" > - {/* 3D badge: FP with gradient, top bevel, and soft shadow */} + {/* Badge: rounded square with links motif inside + FP */} = ({ height = 44, className = "" }) => { aria-hidden > - {/* Energetic blue gradient: bright top → deep blue bottom */} - + - {/* Shadow layer - deep blue */} - - {/* Main 3D body */} - - {/* Top bevel (inner 3D) */} - - {/* FP text */} + {/* Shadow */} + + {/* Body */} + + + {/* Links motif inside: small chain links in corners, clear center for FP */} + + + + + + + + + + + {/* FP text – top-right so it doesn’t overlap the links */} = ({ height = 44, className = "" }) => { FP - {/* Wordmark: FP-MTMS + subtitle — strong, energetic */} -
+ {/* Wordmark: fixed typography so login and main page match */} +
FP-MTMS Food Production From 3236f144cddd75a64569c3ad0e06fe30de6bb00e Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Mon, 23 Feb 2026 18:28:35 +0800 Subject: [PATCH 06/17] fix the /ps overlap problem --- src/app/(main)/ps/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/(main)/ps/page.tsx b/src/app/(main)/ps/page.tsx index bca0a75..675380c 100644 --- a/src/app/(main)/ps/page.tsx +++ b/src/app/(main)/ps/page.tsx @@ -287,10 +287,10 @@ export default function ProductionSchedulePage() {
- {/* Detail Modal */} + {/* Detail Modal – z-index above sidebar drawer (1200) so they don't overlap on small windows */} {isDetailOpen && (
@@ -503,7 +503,7 @@ export default function ProductionSchedulePage() { {/* Export Dialog */} {isExportDialogOpen && (
From 42ee4a6d922816e4781e74dae1ada05236986c9d Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Tue, 24 Feb 2026 10:50:27 +0800 Subject: [PATCH 07/17] update --- src/app/api/stockIssue/actions.ts | 3 ++ src/components/General/LoadingComponent.tsx | 2 +- .../InputDataGrid/InputDataGrid.tsx | 2 +- .../Jodetail/FInishedJobOrderRecord.tsx | 2 + .../Jodetail/JobPickExecutionForm.tsx | 7 ++-- .../Jodetail/newJobPickExecution.tsx | 2 +- .../ProductionProcessList.tsx | 2 +- src/components/Qc/QcForm.tsx | 2 +- src/components/StockIssue/SearchPage.tsx | 13 +++++++ src/components/StockIssue/SubmitIssueForm.tsx | 39 +++++++++++++++++-- src/i18n/zh/common.json | 2 +- src/i18n/zh/inventory.json | 7 ++++ src/i18n/zh/jo.json | 2 +- 13 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/app/api/stockIssue/actions.ts b/src/app/api/stockIssue/actions.ts index 790c4fe..5d52d32 100644 --- a/src/app/api/stockIssue/actions.ts +++ b/src/app/api/stockIssue/actions.ts @@ -25,6 +25,7 @@ export interface StockIssueResult { handleStatus: string; handleDate: string | null; handledBy: number | null; + uomDesc: string | null; } export interface ExpiryItemResult { id: number; @@ -178,6 +179,8 @@ export async function submitMissItem(issueId: number, handler: number) { itemDescription: string | null; storeLocation: string | null; issues: IssueDetailItem[]; + bookQty: number; + uomDesc: string | null; } export interface IssueDetailItem { diff --git a/src/components/General/LoadingComponent.tsx b/src/components/General/LoadingComponent.tsx index fc802b2..868d187 100644 --- a/src/components/General/LoadingComponent.tsx +++ b/src/components/General/LoadingComponent.tsx @@ -8,7 +8,7 @@ export const LoadingComponent: React.FC = () => { display="flex" justifyContent="center" alignItems="center" - // autoheight="true" + > diff --git a/src/components/InputDataGrid/InputDataGrid.tsx b/src/components/InputDataGrid/InputDataGrid.tsx index 51ebecf..bab891d 100644 --- a/src/components/InputDataGrid/InputDataGrid.tsx +++ b/src/components/InputDataGrid/InputDataGrid.tsx @@ -370,7 +370,7 @@ function InputDataGrid({ // columns={!checkboxSelection ? _columns : columns} columns={needActions ? _columns : columns} editMode="row" - // autoHeight + sx={{ height: "30vh", "--DataGrid-overlayHeight": "100px", diff --git a/src/components/Jodetail/FInishedJobOrderRecord.tsx b/src/components/Jodetail/FInishedJobOrderRecord.tsx index e076661..5f5a0a8 100644 --- a/src/components/Jodetail/FInishedJobOrderRecord.tsx +++ b/src/components/Jodetail/FInishedJobOrderRecord.tsx @@ -509,9 +509,11 @@ const FInishedJobOrderRecord: React.FC = ({ filterArgs }) => { size="small" sx={{ mb: 1 }} /> + {/* {jobOrderPickOrder.completedItems}/{jobOrderPickOrder.totalItems} {t("items completed")} + */} { // 增加 badPackageQty 判断,确保有坏包装会走 issue 流程 const badPackageQty = Number((formData as any).badPackageQty) || 0; - const isNormalPick = verifiedQty > 0 - && formData.missQty == 0 - && formData.badItemQty == 0 - && badPackageQty == 0; + const isNormalPick = (formData.missQty == null || formData.missQty === 0) + && (formData.badItemQty == null || formData.badItemQty === 0) + && (badPackageQty === 0); if (isNormalPick) { if (onNormalPickSubmit) { diff --git a/src/components/Jodetail/newJobPickExecution.tsx b/src/components/Jodetail/newJobPickExecution.tsx index 4549061..3b9a9d8 100644 --- a/src/components/Jodetail/newJobPickExecution.tsx +++ b/src/components/Jodetail/newJobPickExecution.tsx @@ -1822,7 +1822,7 @@ const JobPickExecution: React.FC = ({ filterArgs, onBackToList }) => { }, [handleSubmitPickQtyWithQty]); const handleSubmitAllScanned = useCallback(async () => { const scannedLots = combinedLotData.filter(lot => - lot.stockOutLineStatus === 'checked' + lot.stockOutLineStatus === 'checked' || lot.stockOutLineStatus === 'partially_completed' ); if (scannedLots.length === 0) { diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index 2190567..956bdbd 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -306,7 +306,7 @@ const ProductProcessList: React.FC = ({ onSelectProcess )} {statusLower === "completed" && ( - )} diff --git a/src/components/Qc/QcForm.tsx b/src/components/Qc/QcForm.tsx index 46a33a4..8fac0e4 100644 --- a/src/components/Qc/QcForm.tsx +++ b/src/components/Qc/QcForm.tsx @@ -232,7 +232,7 @@ const QcForm: React.FC = ({ rows, disabled = false }) => { return ( <> - // autoHeight + = ({ dataList }) => { { name: "itemDescription", label: t("Item") }, { name: "lotNo", label: t("Lot No.") }, { name: "storeLocation", label: t("Location") }, + { + name: "bookQty", + label: t("Book Qty"), + renderCell: (item) => ( + <>{item.bookQty?.toFixed(2) ?? "0"} {item.uomDesc ?? ""} + ), + }, { name: "issueQty", label: t("Miss Qty") }, + { name: "uomDesc", label: t("UoM"), renderCell: (item) => ( + <>{item.uomDesc ?? ""} + ) }, { name: "id", label: t("Action"), @@ -196,6 +206,9 @@ const SearchPage: React.FC = ({ dataList }) => { { name: "lotNo", label: t("Lot No.") }, { name: "storeLocation", label: t("Location") }, { name: "issueQty", label: t("Defective Qty") }, + { name: "uomDesc", label: t("UoM"), renderCell: (item) => ( + <>{item.uomDesc ?? ""} + ) }, { name: "id", label: t("Action"), diff --git a/src/components/StockIssue/SubmitIssueForm.tsx b/src/components/StockIssue/SubmitIssueForm.tsx index 37d6bab..1a891fe 100644 --- a/src/components/StockIssue/SubmitIssueForm.tsx +++ b/src/components/StockIssue/SubmitIssueForm.tsx @@ -49,7 +49,10 @@ const SubmitIssueForm: React.FC = ({ const [submitting, setSubmitting] = useState(false); const [details, setDetails] = useState(null); const [submitQty, setSubmitQty] = useState(""); - + const bookQty = details?.bookQty ?? 0; + const submitQtyNum = parseFloat(submitQty); + const submitQtyValid = !Number.isNaN(submitQtyNum) && submitQtyNum >= 0; + const remainAvailable = submitQtyValid ? Math.max(0, bookQty - submitQtyNum) : bookQty; useEffect(() => { if (open && lotId) { loadDetails(); @@ -121,9 +124,17 @@ const SubmitIssueForm: React.FC = ({ {t("Lot No.")}: {details.lotNo} - + {t("Location")}: {details.storeLocation} + + {t("Book Qty")}:{" "} + {details.bookQty} + + + {t("UoM")}:{" "} + {details.uomDesc ?? ""} + @@ -146,8 +157,8 @@ const SubmitIssueForm: React.FC = ({ {issue.pickerName || "-"} {issueType === "miss" - ? issue.missQty?.toFixed(2) || "0" - : issue.issueQty?.toFixed(2) || "0"} + ? issue.missQty?.toFixed(0) || "0" + : issue.issueQty?.toFixed(0) || "0"} {issue.pickOrderCode} {issue.doOrderCode || "-"} @@ -168,6 +179,26 @@ const SubmitIssueForm: React.FC = ({ inputProps={{ min: 0, step: 0.01 }} sx={{ mt: 2 }} /> + { + const raw = e.target.value; + if (raw === "") { + setSubmitQty(""); + return; + } + const remain = parseFloat(raw); + if (!Number.isNaN(remain) && remain >= 0) { + const newSubmit = Math.max(0, bookQty - remain); + setSubmitQty(newSubmit.toFixed(0)); + } + }} + inputProps={{ min: 0, step: 0.01, readOnly: false }} + sx={{ mt: 2 }} + /> + + + + + setStockAdjustmentModalOpen(false)} + sx={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} +> + + + +{inventory + ? `${t("Edit mode")}: ${inventory.itemCode} ${inventory.itemName}` + : t("Stock Adjustment") +} + + setStockAdjustmentModalOpen(false)}> + + + + + + + + + +{/* List view */} + + + + + {t("Lot No")} + {t("Original Qty")} + {t("Adjusted Qty")} + {t("Difference")} + {t("Stock UoM")} + {t("Expiry Date")} + {t("Location")} + {t("Remarks")} + {t("Action")} + + + + {adjustmentEntries.map((line) => ( + + + + + {line.lotNo?.trim() ? line.lotNo : t("No lot no entered, will be generated by system.")} + {line.isOpeningInventory && ` (${t("Opening Inventory")})`} + + {line.productlotNo && {t("productLotNo")}: {line.productlotNo}} + {line.dnNo && {t("dnNo")}: {line.dnNo}} + + + {line.originalQty ?? 0} + + { + const raw = e.target.value.replace(/\D/g, ''); + if (raw === '') { + handleAdjustmentQtyChange(line.id, 0); + return; + } + const num = parseInt(raw, 10); + if (!Number.isNaN(num) && num >= 0) handleAdjustmentQtyChange(line.id, num); + }} + inputProps={{ style: { textAlign: 'right' } }} + size="small" + sx={{ + width: 120, + '& .MuiInputBase-root': { + display: 'flex', + alignItems: 'center', + height: 56, + }, + '& .MuiInputBase-input': { + fontSize: 16, + textAlign: 'right', + height: 40, + lineHeight: '40px', + paddingTop: 0, + paddingBottom: 0, + boxSizing: 'border-box', + MozAppearance: 'textfield', + }, + '& .MuiInputBase-input::-webkit-outer-spin-button': { + WebkitAppearance: 'none', + margin: 0, + }, + '& .MuiInputBase-input::-webkit-inner-spin-button': { + WebkitAppearance: 'none', + margin: 0, + }, + }} + /> + + + {(() => { + const diff = line.adjustedQty - (line.originalQty ?? 0); + const text = diff > 0 ? `+${diff}` : diff < 0 ? `${diff}` : '±0'; + const color = diff > 0 ? 'success.main' : diff < 0 ? 'error.main' : 'text.secondary'; + return {text}; + })()} + + {line.uom} + {arrayToDateString(line.expiryDate)} + {line.warehouse?.code ?? ""} + + {pendingRemovalLineId === line.id ? ( + handleRemovalReasonChange(line.id, e.target.value)} + sx={{ + width: 160, + maxWidth: '100%', + '& .MuiInputBase-root': { + display: 'flex', + alignItems: 'center', + height: 56, + }, + '& .MuiInputBase-input': { + fontSize: '1rem', + height: 40, + lineHeight: '40px', + paddingTop: 0, + paddingBottom: 0, + boxSizing: 'border-box', + '&::placeholder': { color: '#9e9e9e', opacity: 1 }, + }, + }} + /> + ) : (line.adjustedQty - (line.originalQty ?? 0)) !== 0 ? ( + handleAdjustmentRemarksChange(line.id, e.target.value)} + sx={{ + width: 160, + maxWidth: '100%', + '& .MuiInputBase-root': { + display: 'flex', + alignItems: 'center', + height: 56, + }, + '& .MuiInputBase-input': { + fontSize: '1rem', + height: 40, + lineHeight: '40px', + paddingTop: 0, + paddingBottom: 0, + boxSizing: 'border-box', + '&::placeholder': { color: '#9e9e9e', opacity: 1 }, + }, + }} + /> + ) : null} + + + {pendingRemovalLineId === line.id ? ( + + + + + ) : ( + handleRemoveClick(line.id)} + color="error" + title={t("Remove")} + > + + + )} + + + ))} + +
+
+
+
+ + setAddEntryModalOpen(false)} + sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }} +> + + + {t("Add entry")} + setAddEntryModalOpen(false)}> + + + + setAddEntryForm(f => ({ ...f, qty: Math.max(0, parseInt(e.target.value) || 0) }))} inputProps={{ min: 0 }} /> + + + + + + + setAddEntryForm(f => ({ ...f, expiryDate: value ? dayjs(value).format(INPUT_DATE_FORMAT) : '' }))} slotProps={{ textField: { fullWidth: true, required: true } }} /> + + + + o.code || ''} value={addEntryForm.locationId ? warehouses.find(w => w.id === addEntryForm.locationId) ?? null : null} inputValue={addEntryForm.locationInput} onInputChange={(_, v) => setAddEntryForm(f => ({ ...f, locationInput: v }))} onChange={(_, v) => setAddEntryForm(f => ({ ...f, locationId: v?.id ?? null, locationInput: v?.code ?? '' }))} renderInput={(params) => } /> + + + setAddEntryForm(f => ({ ...f, isOpeningInventory: e.target.checked }))} />} label={t("Opening Inventory")} /> + + + setAddEntryForm(f => ({ ...f, productlotNo: e.target.value }))} sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} /> + + + setAddEntryForm(f => ({ ...f, dnNo: e.target.value }))} sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} /> + + + setAddEntryForm(f => ({ ...f, lotNo: e.target.value }))} sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} /> + + + setAddEntryForm(f => ({ ...f, remarks: e.target.value }))} + multiline + minRows={2} + sx={{ '& .MuiInputBase-input::placeholder': { color: '#9e9e9e', opacity: 1 } }} + /> + + + + + + + + + } export default InventoryLotLineTable; \ No newline at end of file diff --git a/src/components/InventorySearch/InventorySearch.tsx b/src/components/InventorySearch/InventorySearch.tsx index dcb4b2e..3451a93 100644 --- a/src/components/InventorySearch/InventorySearch.tsx +++ b/src/components/InventorySearch/InventorySearch.tsx @@ -10,9 +10,11 @@ import InventoryTable from "./InventoryTable"; import { defaultPagingController } from "../SearchResults/SearchResults"; import InventoryLotLineTable from "./InventoryLotLineTable"; import { SearchInventory, SearchInventoryLotLine, fetchInventories, fetchInventoryLotLines } from "@/app/api/inventory/actions"; +import { PrinterCombo } from "@/app/api/settings/printer"; interface Props { inventories: InventoryResult[]; + printerCombo?: PrinterCombo[]; } type SearchQuery = Partial< @@ -32,7 +34,7 @@ type SearchQuery = Partial< >; type SearchParamNames = keyof SearchQuery; -const InventorySearch: React.FC = ({ inventories }) => { +const InventorySearch: React.FC = ({ inventories, printerCombo }) => { const { t } = useTranslation(["inventory", "common"]); // Inventory @@ -58,6 +60,7 @@ const InventorySearch: React.FC = ({ inventories }) => { currencyName: "", status: "", baseUom: "", + uomShortDesc: "", }), []) const [inputs, setInputs] = useState>(defaultInputs); @@ -248,9 +251,13 @@ const InventorySearch: React.FC = ({ inventories }) => { setPagingController={setInventoryLotLinesPagingController} totalCount={inventoryLotLinesTotalCount} inventory={selectedInventory} + printerCombo={printerCombo ?? []} onStockTransferSuccess={() => refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController) } + onStockAdjustmentSuccess={() => + refetchInventoryLotLineData(selectedInventory?.itemId ?? null, "search", inventoryLotLinesPagingController) + } /> ); diff --git a/src/components/InventorySearch/InventorySearchWrapper.tsx b/src/components/InventorySearch/InventorySearchWrapper.tsx index 63dd5ca..cffbfc5 100644 --- a/src/components/InventorySearch/InventorySearchWrapper.tsx +++ b/src/components/InventorySearch/InventorySearchWrapper.tsx @@ -2,15 +2,19 @@ import React from "react"; import GeneralLoading from "../General/GeneralLoading"; import { fetchInventories } from "@/app/api/inventory"; import InventorySearch from "./InventorySearch"; +import { fetchPrinterCombo } from "@/app/api/settings/printer"; interface SubComponents { Loading: typeof GeneralLoading; } const InventorySearchWrapper: React.FC & SubComponents = async () => { - const [inventories] = await Promise.all([fetchInventories()]); + const [inventories, printerCombo] = await Promise.all([ + fetchInventories(), + fetchPrinterCombo(), + ]); - return ; + return ; }; InventorySearchWrapper.Loading = GeneralLoading; diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index a222604..2148c2a 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -218,6 +218,27 @@ "Target Location": "目標倉位", "Original Qty": "原有數量", "Qty To Be Transferred": "待轉數量", - "Submit": "提交" + "Submit": "提交", + + "Printer": "列印機", + "Print Qty": "列印數量", + "Print": "列印", + "Print sent": "已送出列印", + "Print failed": "列印失敗", + + "Stock Adjustment": "庫存調整", + "Edit mode": "編輯模式", + "Add entry": "新增倉存", + + "productLotNo": "產品批號", + "dnNo": "送貨單編號", + "Optional - system will generate": "選填,系統將自動生成", + "Add": "新增", + "Opening Inventory": "開倉", + "Reason for adjustment": "調整原因", + "No lot no entered, will be generated by system.": "未輸入批號,將由系統生成。", + "Reason for removal": "移除原因", + "Confirm remove": "確認移除", + "Adjusted Qty": "調整後倉存" } From ae3fa7993c1ec2386443989939ae97ea6da7fdce Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Thu, 26 Feb 2026 07:28:00 +0800 Subject: [PATCH 11/17] translation issue --- src/i18n/zh/inventory.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/i18n/zh/inventory.json b/src/i18n/zh/inventory.json index 19b51c3..ae64178 100644 --- a/src/i18n/zh/inventory.json +++ b/src/i18n/zh/inventory.json @@ -214,6 +214,9 @@ "Loading": "加載中", "adj": "調整", "nor": "正常", + "trf": "轉倉", + + "Stock transfer successful": "轉倉成功", "Failed to transfer stock": "轉倉失敗", From 2de29a9a8c8f89442b8b78e523ee636bbce39258 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Fri, 27 Feb 2026 14:54:28 +0800 Subject: [PATCH 12/17] make access right with STOCK can do stock take --- src/components/NavigationContent/NavigationContent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/NavigationContent/NavigationContent.tsx b/src/components/NavigationContent/NavigationContent.tsx index 996a12c..1ea720f 100644 --- a/src/components/NavigationContent/NavigationContent.tsx +++ b/src/components/NavigationContent/NavigationContent.tsx @@ -96,7 +96,7 @@ const NavigationContent: React.FC = () => { { icon: , label: "Stock Take Management", - requiredAbility: [AUTH.STOCK_TAKE, AUTH.ADMIN], + requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.ADMIN], path: "/stocktakemanagement", }, { @@ -120,7 +120,7 @@ const NavigationContent: React.FC = () => { { icon: , label: "Stock Record", - requiredAbility: [AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], + requiredAbility: [AUTH.STOCK, AUTH.STOCK_TAKE, AUTH.STOCK_IN_BIND, AUTH.STOCK_FG, AUTH.ADMIN], path: "/stockRecord", }, ], From a40305f8806a3b55748b877f364faa5b6202f895 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 27 Feb 2026 19:36:40 +0800 Subject: [PATCH 13/17] update stock take --- src/app/api/stockTake/actions.ts | 15 +- src/components/Qc/QcStockInModal.tsx | 42 +- src/components/StockRecord/SearchPage.tsx | 112 ++--- .../StockTakeManagement/ApproverStockTake.tsx | 178 ++++--- .../StockTakeManagement/PickerReStockTake.tsx | 461 ++++++++++-------- .../StockTakeManagement/PickerStockTake.tsx | 278 ++++++----- src/i18n/zh/inventory.json | 2 + 7 files changed, 618 insertions(+), 470 deletions(-) diff --git a/src/app/api/stockTake/actions.ts b/src/app/api/stockTake/actions.ts index e54376a..83403a5 100644 --- a/src/app/api/stockTake/actions.ts +++ b/src/app/api/stockTake/actions.ts @@ -40,6 +40,7 @@ export interface InventoryLotDetailResponse { approverQty: number | null; approverBadQty: number | null; finalQty: number | null; + bookQty: number | null; } export const getInventoryLotDetailsBySection = async ( @@ -207,6 +208,7 @@ export interface BatchSaveApproverStockTakeRecordRequest { stockTakeId: number; stockTakeSection: string; approverId: number; + variancePercentTolerance?: number | null; } export interface BatchSaveApproverStockTakeRecordResponse { @@ -312,7 +314,10 @@ export const getInventoryLotDetailsBySectionNotMatch = async ( ); return response; } - +export interface SearchStockTransactionResult { + records: StockTransactionResponse[]; + total: number; +} export interface SearchStockTransactionRequest { startDate: string | null; endDate: string | null; @@ -345,7 +350,6 @@ export interface StockTransactionListResponse { } export const searchStockTransactions = cache(async (request: SearchStockTransactionRequest) => { - // 构建查询字符串 const params = new URLSearchParams(); if (request.itemCode) params.append("itemCode", request.itemCode); @@ -366,7 +370,10 @@ export const searchStockTransactions = cache(async (request: SearchStockTransact next: { tags: ["Stock Transaction List"] }, } ); - // 确保返回正确的格式 - return response?.records || []; + // 回傳 records 與 total,供分頁正確顯示 + return { + records: response?.records || [], + total: response?.total ?? 0, + }; }); diff --git a/src/components/Qc/QcStockInModal.tsx b/src/components/Qc/QcStockInModal.tsx index 3743a6f..ce55928 100644 --- a/src/components/Qc/QcStockInModal.tsx +++ b/src/components/Qc/QcStockInModal.tsx @@ -68,6 +68,7 @@ interface CommonProps extends Omit { interface Props extends CommonProps { // itemDetail: StockInLine & { qcResult?: PurchaseQcResult[] } & { escResult?: EscalationResult[] }; } + const QcStockInModal: React.FC = ({ open, onClose, @@ -94,6 +95,10 @@ const QcStockInModal: React.FC = ({ () => `qcStockInModal_selectedPrinterId_${session?.id ?? "guest"}`, [session?.id], ); + const labelPrinterCombo = useMemo( + () => (printerCombo || []).filter((p) => p.type === "Label"), + [printerCombo], + ); const getDefaultPrinter = useMemo(() => { if (!printerCombo.length) return undefined; if (typeof window === "undefined") return printerCombo[0]; @@ -102,7 +107,7 @@ const QcStockInModal: React.FC = ({ const matched = savedId ? printerCombo.find(p => p.id === Number(savedId)) : undefined; return matched ?? printerCombo[0]; }, [printerCombo, printerStorageKey]); - const [selectedPrinter, setSelectedPrinter] = useState(printerCombo[0]); + const [selectedPrinter, setSelectedPrinter] = useState(labelPrinterCombo[0]); const [printQty, setPrintQty] = useState(1); const [tabIndex, setTabIndex] = useState(0); @@ -504,6 +509,7 @@ const QcStockInModal: React.FC = ({ // Put away model const [pafRowModesModel, setPafRowModesModel] = useState({}) const [pafRowSelectionModel, setPafRowSelectionModel] = useState([]) + const pafSubmitDisable = useMemo(() => { return Object.entries(pafRowModesModel).length > 0 || Object.entries(pafRowModesModel).some(([key, value], index) => value.mode === GridRowModes.Edit) }, [pafRowModesModel]) @@ -749,21 +755,25 @@ const printQrcode = useCallback( {tabIndex == 1 && ( { - setSelectedPrinter(value) - }} - renderInput={(params) => ( - - )} - /> + disableClearable + options={labelPrinterCombo} + getOptionLabel={(option) => + option.name || option.label || option.code || `Printer ${option.id}` + } + value={selectedPrinter} + onChange={(_, newValue) => { + if (newValue) setSelectedPrinter(newValue); + }} + renderInput={(params) => ( + + )} + /> = ({ dataList: initialDataList }) => { // 当 processedData 变化时更新 filteredList(不更新 pagingController,避免循环) useEffect(() => { setFilteredList(processedData); - setTotalCount(processedData.length); + // 只在初始加载时设置 pageSize if (isInitialMount.current && processedData.length > 0) { setPageSize("all"); @@ -146,55 +146,53 @@ const SearchPage: React.FC = ({ dataList: initialDataList }) => { // API 调用函数(参考 PoSearch 的实现) // API 调用函数(参考 PoSearch 的实现) -const newPageFetch = useCallback( - async ( - pagingController: Record, - filterArgs: Record, - ) => { - setLoading(true); - try { - // 处理空字符串,转换为 null - const itemCode = filterArgs.itemCode?.trim() || null; - const itemName = filterArgs.itemName?.trim() || null; - - // 验证:至少需要 itemCode 或 itemName - if (!itemCode && !itemName) { - console.warn("Search requires at least itemCode or itemName"); + const newPageFetch = useCallback( + async ( + pagingController: Record, + filterArgs: Record, + ) => { + setLoading(true); + try { + const itemCode = filterArgs.itemCode?.trim() || null; + const itemName = filterArgs.itemName?.trim() || null; + + if (!itemCode && !itemName) { + console.warn("Search requires at least itemCode or itemName"); + setDataList([]); + setTotalCount(0); + return; + } + + const params: SearchStockTransactionRequest = { + itemCode: itemCode, + itemName: itemName, + type: filterArgs.type?.trim() || null, + startDate: filterArgs.startDate || null, + endDate: filterArgs.endDate || null, + pageNum: pagingController.pageNum - 1 || 0, + pageSize: pagingController.pageSize || 100, + }; + + const res = await searchStockTransactions(params); + + if (res && typeof res === 'object' && Array.isArray(res.records)) { + setDataList(res.records); + setTotalCount(res.total ?? res.records.length); + } else { + console.error("Invalid response format:", res); + setDataList([]); + setTotalCount(0); + } + } catch (error) { + console.error("Fetch error:", error); setDataList([]); setTotalCount(0); - return; + } finally { + setLoading(false); } - - const params: SearchStockTransactionRequest = { - itemCode: itemCode, - itemName: itemName, - type: filterArgs.type?.trim() || null, - startDate: filterArgs.startDate || null, - endDate: filterArgs.endDate || null, - pageNum: pagingController.pageNum - 1 || 0, - pageSize: pagingController.pageSize || 100, - }; - - console.log("Search params:", params); // 添加调试日志 - - const res = await searchStockTransactions(params); - console.log("Search response:", res); // 添加调试日志 - - if (res && Array.isArray(res)) { - setDataList(res); - } else { - console.error("Invalid response format:", res); - setDataList([]); - } - } catch (error) { - console.error("Fetch error:", error); - setDataList([]); - } finally { - setLoading(false); - } - }, - [], -); + }, + [], + ); // 使用 useRef 来存储上一次的值,避免不必要的 API 调用 const prevPagingControllerRef = useRef(pagingController); @@ -240,13 +238,13 @@ const newPageFetch = useCallback( const newSize = parseInt(event.target.value, 10); if (newSize === -1) { setPageSize("all"); - setPagingController(prev => ({ ...prev, pageSize: filteredList.length, pageNum: 1 })); + setPagingController(prev => ({ ...prev, pageSize: 100, pageNum: 1 })); // 用 100 觸發後端回傳全部 } else if (!isNaN(newSize)) { setPageSize(newSize); setPagingController(prev => ({ ...prev, pageSize: newSize, pageNum: 1 })); } setPage(0); - }, [filteredList.length]); + }, []); const searchCriteria: Criterion[] = useMemo( () => [ @@ -390,29 +388,25 @@ const newPageFetch = useCallback( setPagingController(prev => ({ ...prev, pageNum: 1 })); }, []); - // 计算实际显示的 items(分页) const paginatedItems = useMemo(() => { if (pageSize === "all") { return filteredList; } - const actualPageSize = typeof pageSize === 'number' ? pageSize : 10; - const startIndex = page * actualPageSize; - const endIndex = startIndex + actualPageSize; - return filteredList.slice(startIndex, endIndex); - }, [filteredList, page, pageSize]); + + return filteredList; + }, [filteredList, pageSize]); // 计算传递给 SearchResults 的 pageSize(确保在选项中) const actualPageSizeForTable = useMemo(() => { if (pageSize === "all") { - return filteredList.length; + return totalCount > 0 ? totalCount : filteredList.length; } const size = typeof pageSize === 'number' ? pageSize : 10; - // 如果 size 不在标准选项中,使用 "all" 模式 if (![10, 25, 100].includes(size)) { - return filteredList.length; + return size; } return size; - }, [pageSize, filteredList.length]); + }, [pageSize, filteredList.length, totalCount]); return ( <> diff --git a/src/components/StockTakeManagement/ApproverStockTake.tsx b/src/components/StockTakeManagement/ApproverStockTake.tsx index d2661fb..49d806e 100644 --- a/src/components/StockTakeManagement/ApproverStockTake.tsx +++ b/src/components/StockTakeManagement/ApproverStockTake.tsx @@ -56,8 +56,8 @@ const ApproverStockTake: React.FC = ({ const [inventoryLotDetails, setInventoryLotDetails] = useState([]); const [loadingDetails, setLoadingDetails] = useState(false); - const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(false); - + const [showOnlyWithDifference, setShowOnlyWithDifference] = useState(true); + const [variancePercentTolerance, setVariancePercentTolerance] = useState("5"); // 每个记录的选择状态,key 为 detail.id const [qtySelection, setQtySelection] = useState>({}); const [approverQty, setApproverQty] = useState>({}); @@ -71,7 +71,17 @@ const ApproverStockTake: React.FC = ({ const currentUserId = session?.id ? parseInt(session.id) : undefined; const handleBatchSubmitAllRef = useRef<() => Promise>(); - + const isWithinVarianceTolerance = useCallback(( + difference: number, + bookQty: number, + percentStr: string + ): boolean => { + const percent = parseFloat(percentStr || "0"); + if (isNaN(percent) || percent < 0) return true; // 无效输入时视为全部通过 + if (bookQty === 0) return difference === 0; + const threshold = Math.abs(bookQty) * (percent / 100); + return Math.abs(difference) <= threshold; + }, []); const handleChangePage = useCallback((event: unknown, newPage: number) => { setPage(newPage); }, []); @@ -133,7 +143,7 @@ const ApproverStockTake: React.FC = ({ selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0")) || 0; } - const bookQty = detail.availableQty || 0; + const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); return selectedQty - bookQty; }, [approverQty, approverBadQty]); @@ -159,16 +169,29 @@ const ApproverStockTake: React.FC = ({ // 4. 添加过滤逻辑(在渲染表格之前) const filteredDetails = useMemo(() => { - if (!showOnlyWithDifference) { - return inventoryLotDetails; + let result = inventoryLotDetails; + + if (showOnlyWithDifference) { + const percent = parseFloat(variancePercentTolerance || "0"); + const thresholdPercent = isNaN(percent) || percent < 0 ? 0 : percent; + + result = result.filter(detail => { + // 已完成項目一律顯示 + if (detail.finalQty != null || detail.stockTakeRecordStatus === "completed") { + return true; + } + const selection = qtySelection[detail.id] ?? + (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first"); + const difference = calculateDifference(detail, selection); + const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); + if (bookQty === 0) return difference !== 0; + const threshold = Math.abs(bookQty) * (thresholdPercent / 100); + return Math.abs(difference) > threshold; + }); } - return inventoryLotDetails.filter(detail => { - const selection = qtySelection[detail.id] || (detail.secondStockTakeQty != null && detail.secondStockTakeQty > 0 ? "second" : "first"); - const difference = calculateDifference(detail, selection); - return difference !== 0; - }); - }, [inventoryLotDetails, showOnlyWithDifference, qtySelection, calculateDifference]); + return result; + }, [inventoryLotDetails, showOnlyWithDifference, variancePercentTolerance, qtySelection, calculateDifference]); const handleSaveApproverStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { if (!selectedSession || !currentUserId) { @@ -231,7 +254,22 @@ const ApproverStockTake: React.FC = ({ onSnackbar(t("Approver stock take record saved successfully"), "success"); - await loadDetails(page, pageSize); + // 計算最終數量(合格數) + const goodQty = finalQty - finalBadQty; + + setInventoryLotDetails((prev) => + prev.map((d) => + d.id === detail.id + ? { + ...d, + finalQty: goodQty, + approverQty: selection === "approver" ? finalQty : d.approverQty, + approverBadQty: selection === "approver" ? finalBadQty : d.approverBadQty, + stockTakeRecordStatus: "completed", + } + : d + ) + ); } catch (e: any) { console.error("Save approver stock take record error:", e); let errorMessage = t("Failed to save approver stock take record"); @@ -264,6 +302,11 @@ const ApproverStockTake: React.FC = ({ await updateStockTakeRecordStatusToNotMatch(detail.stockTakeRecordId); onSnackbar(t("Stock take record status updated to not match"), "success"); + setInventoryLotDetails((prev) => + prev.map((d) => + d.id === detail.id ? { ...d, stockTakeRecordStatus: "notMatch" } : d + ) + ); } catch (e: any) { console.error("Update stock take record status error:", e); let errorMessage = t("Failed to update stock take record status"); @@ -284,17 +327,9 @@ const ApproverStockTake: React.FC = ({ setUpdatingStatus(false); // Reload after status update - the useEffect will handle it with current page/pageSize // Or explicitly reload: - setPage((currentPage) => { - setPageSize((currentPageSize) => { - setTimeout(() => { - loadDetails(currentPage, currentPageSize); - }, 0); - return currentPageSize; - }); - return currentPage; - }); + } - }, [selectedSession, t, onSnackbar, loadDetails]); + }, [selectedSession, t, onSnackbar, ]); const handleBatchSubmitAll = useCallback(async () => { if (!selectedSession || !currentUserId) { @@ -309,6 +344,7 @@ const ApproverStockTake: React.FC = ({ stockTakeId: selectedSession.stockTakeId, stockTakeSection: selectedSession.stockTakeSession, approverId: currentUserId, + variancePercentTolerance: parseFloat(variancePercentTolerance || "0") || undefined, }; const result = await batchSaveApproverStockTakeRecords(request); @@ -349,10 +385,10 @@ const ApproverStockTake: React.FC = ({ }, [handleBatchSubmitAll]); const formatNumber = (num: number | null | undefined): string => { - if (num == null) return "0.00"; + if (num == null) return "0"; return num.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2 + minimumFractionDigits: 0, + maximumFractionDigits: 0 }); }; @@ -411,25 +447,30 @@ const ApproverStockTake: React.FC = ({
- - - + {loadingDetails ? ( @@ -454,9 +495,10 @@ const ApproverStockTake: React.FC = ({ {t("Warehouse Location")} {t("Item-lotNo-ExpiryDate")} + {t("UOM")} {t("Stock Take Qty(include Bad Qty)= Available Qty")} {t("Remark")} - {t("UOM")} + {t("Record Status")} {t("Action")} @@ -492,25 +534,27 @@ const ApproverStockTake: React.FC = ({ {detail.expiryDate ? dayjs(detail.expiryDate).format(OUTPUT_DATE_FORMAT) : "-"} - + {detail.uom || "-"} - {detail.finalQty != null ? ( - - {(() => { - const finalDifference = (detail.finalQty || 0) - (detail.availableQty || 0); - const differenceColor = finalDifference > 0 - ? 'error.main' - : finalDifference < 0 - ? 'error.main' - : 'success.main'; - - return ( - - {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(detail.availableQty)} = {formatNumber(finalDifference)} - - ); - })()} - + {detail.finalQty != null ? ( + + {(() => { + // 若有 bookQty(盤點當時帳面),用它來算差異;否則用 availableQty + const bookQtyToUse = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); + const finalDifference = (detail.finalQty || 0) - bookQtyToUse; + const differenceColor = detail.stockTakeRecordStatus === "completed" + ? 'text.secondary' + : finalDifference !== 0 + ? 'error.main' + : 'success.main'; + + return ( + + {t("Difference")}: {formatNumber(detail.finalQty)} - {formatNumber(bookQtyToUse)} = {formatNumber(finalDifference)} + + ); + })()} + ) : ( {hasFirst && ( @@ -581,7 +625,7 @@ const ApproverStockTake: React.FC = ({ disabled={selection !== "approver"} /> - ={(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} + = {formatNumber(parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))} )} @@ -597,12 +641,12 @@ const ApproverStockTake: React.FC = ({ selectedQty = (parseFloat(approverQty[detail.id] || "0") - parseFloat(approverBadQty[detail.id] || "0"))|| 0; } - const bookQty = detail.availableQty || 0; + const bookQty = detail.bookQty != null ? detail.bookQty : (detail.availableQty || 0); const difference = selectedQty - bookQty; - const differenceColor = difference > 0 - ? 'error.main' - : difference < 0 - ? 'error.main' + const differenceColor = detail.stockTakeRecordStatus === "completed" + ? 'text.secondary' + : difference !== 0 + ? 'error.main' : 'success.main'; return ( @@ -621,11 +665,13 @@ const ApproverStockTake: React.FC = ({ - {detail.uom || "-"} + - {detail.stockTakeRecordStatus === "pass" ? ( + {detail.stockTakeRecordStatus === "completed" ? ( + ) : detail.stockTakeRecordStatus === "pass" ? ( + ) : detail.stockTakeRecordStatus === "notMatch" ? ( ) : ( diff --git a/src/components/StockTakeManagement/PickerReStockTake.tsx b/src/components/StockTakeManagement/PickerReStockTake.tsx index 9233ca8..1ff1001 100644 --- a/src/components/StockTakeManagement/PickerReStockTake.tsx +++ b/src/components/StockTakeManagement/PickerReStockTake.tsx @@ -21,7 +21,6 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { AllPickedStockTakeListReponse, - getInventoryLotDetailsBySection, InventoryLotDetailResponse, saveStockTakeRecord, SaveStockTakeRecordRequest, @@ -51,13 +50,13 @@ const PickerReStockTake: React.FC = ({ const [inventoryLotDetails, setInventoryLotDetails] = useState([]); const [loadingDetails, setLoadingDetails] = useState(false); - // 编辑状态 - const [editingRecord, setEditingRecord] = useState(null); - const [firstQty, setFirstQty] = useState(""); - const [secondQty, setSecondQty] = useState(""); - const [firstBadQty, setFirstBadQty] = useState(""); - const [secondBadQty, setSecondBadQty] = useState(""); - const [remark, setRemark] = useState(""); + const [recordInputs, setRecordInputs] = useState>({}); const [saving, setSaving] = useState(false); const [batchSaving, setBatchSaving] = useState(false); const [shortcutInput, setShortcutInput] = useState(""); @@ -115,28 +114,36 @@ const PickerReStockTake: React.FC = ({ } }, [selectedSession, total]); + useEffect(() => { + const inputs: Record = {}; + inventoryLotDetails.forEach((detail) => { + const firstTotal = detail.firstStockTakeQty != null + ? (detail.firstStockTakeQty + (detail.firstBadQty ?? 0)).toString() + : ""; + const secondTotal = detail.secondStockTakeQty != null + ? (detail.secondStockTakeQty + (detail.secondBadQty ?? 0)).toString() + : ""; + inputs[detail.id] = { + firstQty: firstTotal, + secondQty: secondTotal, + firstBadQty: detail.firstBadQty?.toString() || "", + secondBadQty: detail.secondBadQty?.toString() || "", + remark: detail.remarks || "", + }; + }); + setRecordInputs(inputs); + }, [inventoryLotDetails]); + useEffect(() => { loadDetails(page, pageSize); }, [page, pageSize, loadDetails]); - - const handleStartEdit = useCallback((detail: InventoryLotDetailResponse) => { - setEditingRecord(detail); - setFirstQty(detail.firstStockTakeQty?.toString() || ""); - setSecondQty(detail.secondStockTakeQty?.toString() || ""); - setFirstBadQty(detail.firstBadQty?.toString() || ""); - setSecondBadQty(detail.secondBadQty?.toString() || ""); - setRemark(detail.remarks || ""); - }, []); - - const handleCancelEdit = useCallback(() => { - setEditingRecord(null); - setFirstQty(""); - setSecondQty(""); - setFirstBadQty(""); - setSecondBadQty(""); - setRemark(""); - }, []); - + const formatNumber = (num: number | null | undefined): string => { + if (num == null || Number.isNaN(num)) return "0"; + return num.toLocaleString("en-US", { + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }); + }; const handleSaveStockTake = useCallback(async (detail: InventoryLotDetailResponse) => { if (!selectedSession || !currentUserId) { return; @@ -145,41 +152,69 @@ const PickerReStockTake: React.FC = ({ const isFirstSubmit = !detail.stockTakeRecordId || !detail.firstStockTakeQty; const isSecondSubmit = detail.stockTakeRecordId && detail.firstStockTakeQty && !detail.secondStockTakeQty; - const qty = isFirstSubmit ? firstQty : secondQty; - const badQty = isFirstSubmit ? firstBadQty : secondBadQty; - - if (!qty || !badQty) { + // 用戶輸入為 total 和 bad,需計算 available = total - bad(與 PickerStockTake 一致) + const totalQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstQty : recordInputs[detail.id]?.secondQty; + const badQtyStr = isFirstSubmit ? recordInputs[detail.id]?.firstBadQty : recordInputs[detail.id]?.secondBadQty; + + if (!totalQtyStr) { onSnackbar( isFirstSubmit - ? t("Please enter QTY and Bad QTY") - : t("Please enter Second QTY and Bad QTY"), + ? t("Please enter QTY") + : t("Please enter Second QTY"), "error" ); return; } - + + const totalQty = parseFloat(totalQtyStr); + const badQty = parseFloat(badQtyStr || "0") || 0; + + if (Number.isNaN(totalQty)) { + onSnackbar(t("Invalid QTY"), "error"); + return; + } + + const availableQty = totalQty - badQty; + + if (availableQty < 0) { + onSnackbar(t("Available QTY cannot be negative"), "error"); + return; + } + setSaving(true); try { const request: SaveStockTakeRecordRequest = { stockTakeRecordId: detail.stockTakeRecordId || null, inventoryLotLineId: detail.id, - qty: parseFloat(qty), - badQty: parseFloat(badQty), - remark: isSecondSubmit ? (remark || null) : null, + qty: availableQty, + badQty: badQty, + remark: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : null, }; - console.log('handleSaveStockTake: request:', request); - console.log('handleSaveStockTake: selectedSession.stockTakeId:', selectedSession.stockTakeId); - console.log('handleSaveStockTake: currentUserId:', currentUserId); - await saveStockTakeRecord( + const result = await saveStockTakeRecord( request, selectedSession.stockTakeId, currentUserId ); onSnackbar(t("Stock take record saved successfully"), "success"); - handleCancelEdit(); - - await loadDetails(page, pageSize); + + const savedId = result?.id ?? detail.stockTakeRecordId; + setInventoryLotDetails((prev) => + prev.map((d) => + d.id === detail.id + ? { + ...d, + stockTakeRecordId: savedId ?? d.stockTakeRecordId, + firstStockTakeQty: isFirstSubmit ? availableQty : d.firstStockTakeQty, + firstBadQty: isFirstSubmit ? badQty : d.firstBadQty ?? null, + secondStockTakeQty: isSecondSubmit ? availableQty : d.secondStockTakeQty, + secondBadQty: isSecondSubmit ? badQty : d.secondBadQty ?? null, + remarks: isSecondSubmit ? (recordInputs[detail.id]?.remark || null) : d.remarks, + stockTakeRecordStatus: "pass", + } + : d + ) + ); } catch (e: any) { console.error("Save stock take record error:", e); let errorMessage = t("Failed to save stock take record"); @@ -199,15 +234,13 @@ const PickerReStockTake: React.FC = ({ } finally { setSaving(false); } - }, [selectedSession, firstQty, secondQty, firstBadQty, secondBadQty, remark, handleCancelEdit, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); + }, [selectedSession, recordInputs, t, currentUserId, onSnackbar, page, pageSize, loadDetails]); const handleBatchSubmitAll = useCallback(async () => { if (!selectedSession || !currentUserId) { - console.log('handleBatchSubmitAll: Missing selectedSession or currentUserId'); return; } - console.log('handleBatchSubmitAll: Starting batch save...'); setBatchSaving(true); try { const request: BatchSaveStockTakeRecordRequest = { @@ -217,7 +250,6 @@ const PickerReStockTake: React.FC = ({ }; const result = await batchSaveStockTakeRecords(request); - console.log('handleBatchSubmitAll: Result:', result); onSnackbar( t("Batch save completed: {{success}} success, {{errors}} errors", { @@ -273,31 +305,19 @@ const PickerReStockTake: React.FC = ({ const newInput = prev + e.key; if (newInput === '{2fitestall}') { - console.log('✅ Shortcut {2fitestall} detected!'); setTimeout(() => { if (handleBatchSubmitAllRef.current) { - console.log('Calling handleBatchSubmitAll...'); handleBatchSubmitAllRef.current().catch(err => { console.error('Error in handleBatchSubmitAll:', err); }); - } else { - console.error('handleBatchSubmitAllRef.current is null'); } }, 0); return ""; } - if (newInput.length > 15) { - return ""; - } - - if (newInput.length > 0 && !newInput.startsWith('{')) { - return ""; - } - - if (newInput.length > 5 && !newInput.startsWith('{2fi')) { - return ""; - } + if (newInput.length > 15) return ""; + if (newInput.length > 0 && !newInput.startsWith('{')) return ""; + if (newInput.length > 5 && !newInput.startsWith('{2fi')) return ""; return newInput; }); @@ -315,11 +335,15 @@ const PickerReStockTake: React.FC = ({ }, []); const isSubmitDisabled = useCallback((detail: InventoryLotDetailResponse): boolean => { - if (detail.stockTakeRecordStatus === "pass") { + if (selectedSession?.status?.toLowerCase() === "completed") { + return true; + } + const recordStatus = detail.stockTakeRecordStatus?.toLowerCase(); + if (recordStatus === "pass" || recordStatus === "completed") { return true; } return false; - }, []); + }, [selectedSession?.status]); const uniqueWarehouses = Array.from( new Set( @@ -328,6 +352,9 @@ const PickerReStockTake: React.FC = ({ .filter(warehouse => warehouse && warehouse.trim() !== "") ) ).join(", "); + + const defaultInputs = { firstQty: "", secondQty: "", firstBadQty: "", secondBadQty: "", remark: "" }; + return (