From a358b79d8f00d88b6c4c59db482262cbf3d667e1 Mon Sep 17 00:00:00 2001 From: "PC-20260115JRSN\\Administrator" Date: Sat, 21 Mar 2026 00:04:40 +0800 Subject: [PATCH 1/2] change the PO with m18 uom and qty, included stock in PO, putaway process and GRN --- src/app/(main)/ps/page.tsx | 4 ++-- src/components/PoDetail/PoDetail.tsx | 7 +++++-- src/components/PoDetail/PoInputGrid.tsx | 24 ++++++++++++++++-------- src/components/PoDetail/PutAwayForm.tsx | 4 ++-- src/components/PoSearch/PoSearch.tsx | 25 +++++++++++++++---------- 5 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/app/(main)/ps/page.tsx b/src/app/(main)/ps/page.tsx index 6c72bc6..81bb48e 100644 --- a/src/app/(main)/ps/page.tsx +++ b/src/app/(main)/ps/page.tsx @@ -210,8 +210,8 @@ export default function ProductionSchedulePage() { } }; - const fromDateDefault = dayjs().subtract(29, "day").format("YYYY-MM-DD"); - const toDateDefault = dayjs().format("YYYY-MM-DD"); + const fromDateDefault = dayjs().subtract(6, "day").format("YYYY-MM-DD"); + const toDateDefault = dayjs().add(1, "day").format("YYYY-MM-DD"); const fetchItemDailyOut = async (force: boolean = false) => { // Avoid starting a new fetch while an import is in progress, diff --git a/src/components/PoDetail/PoDetail.tsx b/src/components/PoDetail/PoDetail.tsx index a7fdb26..7ef386e 100644 --- a/src/components/PoDetail/PoDetail.tsx +++ b/src/components/PoDetail/PoDetail.tsx @@ -416,14 +416,17 @@ const PoDetail: React.FC = ({ po, warehouse, printerCombo }) => { }, []); useEffect(() => { - if (processedQty === row.qty) { + // `processedQty` comes from putAwayLines (stock unit). + // After the fix, `row.qty` is qtyM18 (M18 unit), so compare using stockUom demand. + const targetStockQty = Number(row.stockUom?.stockQty ?? row.qty ?? 0); + if (targetStockQty > 0 && processedQty >= targetStockQty) { setCurrStatus("completed".toUpperCase()); } else if (processedQty > 0) { setCurrStatus("receiving".toUpperCase()); } else { setCurrStatus("pending".toUpperCase()); } - }, [processedQty, row.qty]); + }, [processedQty, row.qty, row.stockUom?.stockQty]); const handleRowSelect = () => { // setSelectedRowId(row.id); diff --git a/src/components/PoDetail/PoInputGrid.tsx b/src/components/PoDetail/PoInputGrid.tsx index bccde84..f2bae05 100644 --- a/src/components/PoDetail/PoInputGrid.tsx +++ b/src/components/PoDetail/PoInputGrid.tsx @@ -153,7 +153,8 @@ function PoInputGrid({ const [btnIsLoading, setBtnIsLoading] = useState(false); const [currQty, setCurrQty] = useState(() => { const total = entries.reduce( - (acc, curr) => acc + (curr.acceptedQty || 0), + // remaining qty (M18 unit) + (acc, curr) => acc + (curr.purchaseAcceptedQty || 0), 0, ); return total; @@ -231,7 +232,8 @@ function PoInputGrid({ itemName: params.row.itemName, // purchaseOrderId: params.row.purchaseOrderId, purchaseOrderLineId: params.row.purchaseOrderLineId, - acceptedQty: params.row.acceptedQty, + // For PO-origin, backend expects M18 qty and converts it to stock qty. + acceptedQty: params.row.purchaseAcceptedQty ?? params.row.acceptedQty, }; const res = await createStockInLine(postData); console.log(res); @@ -516,7 +518,7 @@ function PoInputGrid({ // // flex: 0.6, // }, { - field: "acceptedQty", + field: "purchaseAcceptedQty", headerName: t("acceptedQty"), // flex: 0.5, width: 125, @@ -524,7 +526,7 @@ function PoInputGrid({ // editable: true, // replace with tooltip + content renderCell: (params) => { - const qty = params.row.purchaseAcceptedQty ?? params.row.acceptedQty ?? 0; + const qty = params.row.purchaseAcceptedQty ?? 0; return integerFormatter.format(qty); } }, @@ -818,7 +820,8 @@ function PoInputGrid({ purchaseOrderLineId: itemDetail.id, itemNo: itemDetail.itemNo, itemName: itemDetail.itemName, - acceptedQty: itemDetail.qty - currQty, // this bug + // User inputs qty in M18 unit; backend will convert to stock unit on create. + purchaseAcceptedQty: itemDetail.qty - currQty, uom: itemDetail.uom, status: "draft", }; @@ -840,8 +843,13 @@ function PoInputGrid({ const error: StockInLineEntryError = {}; console.log(newRow); console.log(currQty); - if (newRow.acceptedQty && newRow.acceptedQty > itemDetail.qty) { - error["acceptedQty"] = t("qty cannot be greater than remaining qty"); + if ( + newRow.purchaseAcceptedQty && + newRow.purchaseAcceptedQty > itemDetail.qty + ) { + error["purchaseAcceptedQty"] = t( + "qty cannot be greater than remaining qty", + ); } return Object.keys(error).length > 0 ? error : undefined; }, @@ -872,7 +880,7 @@ function PoInputGrid({ setEntries(newEntries); //update remaining qty const total = newEntries.reduce( - (acc, curr) => acc + (curr.acceptedQty || 0), + (acc, curr) => acc + (curr.purchaseAcceptedQty || 0), 0, ); setCurrQty(total); diff --git a/src/components/PoDetail/PutAwayForm.tsx b/src/components/PoDetail/PutAwayForm.tsx index 732b9db..0aa74b4 100644 --- a/src/components/PoDetail/PutAwayForm.tsx +++ b/src/components/PoDetail/PutAwayForm.tsx @@ -395,7 +395,7 @@ const PutAwayForm: React.FC = ({ itemDetail, warehouse=[], disabled, sugg @@ -403,7 +403,7 @@ const PutAwayForm: React.FC = ({ itemDetail, warehouse=[], disabled, sugg diff --git a/src/components/PoSearch/PoSearch.tsx b/src/components/PoSearch/PoSearch.tsx index ca95ebe..770b306 100644 --- a/src/components/PoSearch/PoSearch.tsx +++ b/src/components/PoSearch/PoSearch.tsx @@ -13,7 +13,7 @@ import { WarehouseResult } from "@/app/api/warehouse"; import NotificationIcon from "@mui/icons-material/NotificationImportant"; import { useSession } from "next-auth/react"; import { defaultPagingController } from "../SearchResults/SearchResults"; -import { fetchPoListClient, testing } from "@/app/api/po/actions"; +import { testing } from "@/app/api/po/actions"; import dayjs from "dayjs"; import { arrayToDateString, dayjsToDateString } from "@/app/utils/formatUtil"; import arraySupport from "dayjs/plugin/arraySupport"; @@ -289,7 +289,20 @@ const PoSearch: React.FC = ({ }; setAutoSyncStatus(null); - const res = await fetchPoListClient(params); + const cleanedQuery: Record = {}; + Object.entries(params).forEach(([k, v]) => { + if (v === undefined || v === null) return; + if (typeof v === "string" && (v as string).trim() === "") return; + cleanedQuery[k] = String(v); + }); + const baseListResp = await clientAuthFetch( + `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams(cleanedQuery).toString()}`, + { method: "GET" }, + ); + if (!baseListResp.ok) { + throw new Error(`PO list fetch failed: ${baseListResp.status}`); + } + const res = await baseListResp.json(); if (!res) return; if (res.records && res.records.length > 0) { @@ -340,14 +353,6 @@ const PoSearch: React.FC = ({ if (syncOk) { setAutoSyncStatus("成功找到PO"); - // Re-fetch /po/list directly from client to avoid cached server action results. - const cleanedQuery: Record = {}; - Object.entries(params).forEach(([k, v]) => { - if (v === undefined || v === null) return; - if (typeof v === "string" && v.trim() === "") return; - cleanedQuery[k] = String(v); - }); - const listResp = await clientAuthFetch( `${NEXT_PUBLIC_API_URL}/po/list?${new URLSearchParams( cleanedQuery, From 87d32c728a6466a2b37aaa2ed98b5311352f2f77 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Sat, 21 Mar 2026 10:10:32 +0800 Subject: [PATCH 2/2] update --- src/app/api/jo/actions.ts | 15 +- .../ProductionProcessList.tsx | 184 +++++++++++++----- .../ProductionProcessPage.tsx | 15 +- src/components/SearchBox/SearchBox.tsx | 6 +- src/i18n/zh/common.json | 2 + 5 files changed, 170 insertions(+), 52 deletions(-) diff --git a/src/app/api/jo/actions.ts b/src/app/api/jo/actions.ts index 73a0caa..8cc5a11 100644 --- a/src/app/api/jo/actions.ts +++ b/src/app/api/jo/actions.ts @@ -779,7 +779,10 @@ export const fetchAllJoborderProductProcessInfo = cache(async (isDrink?: boolean }); export const fetchJoborderProductProcessesPage = cache(async (params: { - date?: string | null; + /** Job order planStart 區間起(YYYY-MM-DD,含當日) */ + planStartFrom?: string | null; + /** Job order planStart 區間迄(YYYY-MM-DD,含當日) */ + planStartTo?: string | null; itemCode?: string | null; jobOrderCode?: string | null; bomIds?: number[] | null; @@ -789,7 +792,8 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { size?: number; }) => { const { - date, + planStartFrom, + planStartTo, itemCode, jobOrderCode, bomIds, @@ -800,7 +804,12 @@ export const fetchJoborderProductProcessesPage = cache(async (params: { } = params; const queryParts: string[] = []; - if (date) queryParts.push(`date=${encodeURIComponent(date)}`); + if (planStartFrom) { + queryParts.push(`planStartFrom=${encodeURIComponent(planStartFrom)}`); + } + if (planStartTo) { + queryParts.push(`planStartTo=${encodeURIComponent(planStartTo)}`); + } if (itemCode) queryParts.push(`itemCode=${encodeURIComponent(itemCode)}`); if (jobOrderCode) queryParts.push(`jobOrderCode=${encodeURIComponent(jobOrderCode)}`); if (bomIds && bomIds.length > 0) queryParts.push(`bomIds=${bomIds.join(",")}`); diff --git a/src/components/ProductionProcess/ProductionProcessList.tsx b/src/components/ProductionProcess/ProductionProcessList.tsx index f713ac6..f8b9651 100644 --- a/src/components/ProductionProcess/ProductionProcessList.tsx +++ b/src/components/ProductionProcess/ProductionProcessList.tsx @@ -45,43 +45,91 @@ import { import { StockInLineInput } from "@/app/api/stockIn"; import { PrinterCombo } from "@/app/api/settings/printer"; import JobPickExecutionsecondscan from "../Jodetail/JobPickExecutionsecondscan"; +export type ProductionProcessListPersistedState = { + planStartFrom: string; + planStartTo: string; + itemCode: string | null; + jobOrderCode: string | null; + filter: "all" | "drink" | "other"; + page: number; + selectedItemCodes: string[]; +}; + interface ProductProcessListProps { onSelectProcess: (jobOrderId: number|undefined, productProcessId: number|undefined) => void; onSelectMatchingStock: (jobOrderId: number|undefined, productProcessId: number|undefined,pickOrderId: number|undefined) => void; printerCombo: PrinterCombo[]; qcReady: boolean; + /** 由父層保存,進入工單詳情再返回時可還原同一組搜尋/分頁 */ + listPersistedState: ProductionProcessListPersistedState; + onListPersistedStateChange: React.Dispatch< + React.SetStateAction + >; } type SearchParam = "date" | "itemCode" | "jobOrderCode" | "processType"; const PAGE_SIZE = 50; -const ProductProcessList: React.FC = ({ onSelectProcess, printerCombo ,onSelectMatchingStock, qcReady}) => { +/** 預設依 JobOrder.planStart 搜尋:今天往前 3 天~往後 3 天(含當日) */ +function defaultPlanStartRange() { + return { + from: dayjs().subtract(0, "day").format("YYYY-MM-DD"), + to: dayjs().add(0, "day").format("YYYY-MM-DD"), + }; +} + +export function createDefaultProductionProcessListPersistedState(): ProductionProcessListPersistedState { + const r = defaultPlanStartRange(); + return { + planStartFrom: r.from, + planStartTo: r.to, + itemCode: null, + jobOrderCode: null, + filter: "all", + page: 0, + selectedItemCodes: [], + }; +} + +const ProductProcessList: React.FC = ({ + onSelectProcess, + printerCombo, + onSelectMatchingStock, + qcReady, + listPersistedState, + onListPersistedStateChange, +}) => { const { t } = useTranslation( ["common", "production","purchaseOrder","dashboard"]); const { data: session } = useSession() as { data: SessionWithTokens | null }; const sessionToken = session as SessionWithTokens | null; const [loading, setLoading] = useState(false); const [processes, setProcesses] = useState([]); - const [page, setPage] = useState(0); const [openModal, setOpenModal] = useState(false); const [modalInfo, setModalInfo] = useState(); const currentUserId = session?.id ? parseInt(session.id) : undefined; type ProcessFilter = "all" | "drink" | "other"; - const [filter, setFilter] = useState("all"); const [suggestedLocationCode, setSuggestedLocationCode] = useState(null); - const [appliedSearch, setAppliedSearch] = useState<{ - date: string; - itemCode: string | null; - jobOrderCode: string | null; - }>(() => ({ - date: dayjs().format("YYYY-MM-DD"), - itemCode: null, - jobOrderCode: null, - })); + const appliedSearch = useMemo( + () => ({ + planStartFrom: listPersistedState.planStartFrom, + planStartTo: listPersistedState.planStartTo, + itemCode: listPersistedState.itemCode, + jobOrderCode: listPersistedState.jobOrderCode, + }), + [ + listPersistedState.planStartFrom, + listPersistedState.planStartTo, + listPersistedState.itemCode, + listPersistedState.jobOrderCode, + ], + ); + const filter = listPersistedState.filter; + const page = listPersistedState.page; + const selectedItemCodes = listPersistedState.selectedItemCodes; const [totalJobOrders, setTotalJobOrders] = useState(0); - const [selectedItemCodes, setSelectedItemCodes] = useState([]); // Generic confirm dialog for actions (update job order / etc.) const [confirmOpen, setConfirmOpen] = useState(false); @@ -172,28 +220,42 @@ const ProductProcessList: React.FC = ({ onSelectProcess setOpenModal(true); }, [t]); - const handleApplySearch = useCallback((inputs: Record) => { - const selectedProcessType = (inputs.processType || "all") as ProcessFilter; - setFilter(selectedProcessType); - setAppliedSearch({ - date: inputs.date || dayjs().format("YYYY-MM-DD"), - itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null, - jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null, - }); - setSelectedItemCodes([]); - setPage(0); - }, []); + const handleApplySearch = useCallback( + (inputs: Record) => { + const selectedProcessType = (inputs.processType || "all") as ProcessFilter; + const fallback = defaultPlanStartRange(); + let from = (inputs.date || "").trim() || fallback.from; + let to = (inputs.dateTo || "").trim() || fallback.to; + if (dayjs(from).isAfter(dayjs(to), "day")) { + [from, to] = [to, from]; + } + onListPersistedStateChange((prev) => ({ + ...prev, + filter: selectedProcessType, + planStartFrom: from, + planStartTo: to, + itemCode: inputs.itemCode?.trim() ? inputs.itemCode.trim() : null, + jobOrderCode: inputs.jobOrderCode?.trim() ? inputs.jobOrderCode.trim() : null, + selectedItemCodes: [], + page: 0, + })); + }, + [onListPersistedStateChange], + ); const handleResetSearch = useCallback(() => { - setFilter("all"); - setAppliedSearch({ - date: dayjs().format("YYYY-MM-DD"), + const r = defaultPlanStartRange(); + onListPersistedStateChange((prev) => ({ + ...prev, + filter: "all", + planStartFrom: r.from, + planStartTo: r.to, itemCode: null, jobOrderCode: null, - }); - setSelectedItemCodes([]); - setPage(0); - }, []); + selectedItemCodes: [], + page: 0, + })); + }, [onListPersistedStateChange]); const fetchProcesses = useCallback(async () => { setLoading(true); @@ -202,7 +264,8 @@ const ProductProcessList: React.FC = ({ onSelectProcess filter === "all" ? undefined : filter === "drink" ? true : false; const data = await fetchJoborderProductProcessesPage({ - date: appliedSearch.date, + planStartFrom: appliedSearch.planStartFrom, + planStartTo: appliedSearch.planStartTo, itemCode: appliedSearch.itemCode, jobOrderCode: appliedSearch.jobOrderCode, qcReady, @@ -220,7 +283,7 @@ const ProductProcessList: React.FC = ({ onSelectProcess } finally { setLoading(false); } - }, [filter, appliedSearch, qcReady, page]); + }, [listPersistedState, qcReady]); useEffect(() => { fetchProcesses(); @@ -308,39 +371,65 @@ const ProductProcessList: React.FC = ({ onSelectProcess return processes.filter((p) => selectedItemCodes.includes(p.itemCode)); }, [processes, selectedItemCodes]); - const searchCriteria: Criterion[] = useMemo( - () => [ + /** Reset 用 ±3 天;preFilled 用目前已套用的條件(與列表查詢一致) */ + const searchCriteria: Criterion[] = useMemo(() => { + const r = defaultPlanStartRange(); + return [ { - type: "date", - label: "Production date", + type: "dateRange", + label: t("Plan start (from)"), + label2: t("Plan start (to)"), paramName: "date", - preFilledValue: dayjs().format("YYYY-MM-DD"), + defaultValue: r.from, + defaultValueTo: r.to, + preFilledValue: { + from: appliedSearch.planStartFrom, + to: appliedSearch.planStartTo, + }, }, { type: "text", label: "Item Code", paramName: "itemCode", + preFilledValue: appliedSearch.itemCode ?? "", }, { type: "text", label: "Job Order Code", paramName: "jobOrderCode", + preFilledValue: appliedSearch.jobOrderCode ?? "", }, { type: "select", label: "Type", paramName: "processType", options: ["all", "drink", "other"], - preFilledValue: "all", + preFilledValue: filter, }, - ], - [], + ]; + }, [appliedSearch, filter, t]); + + /** SearchBox 內部 state 只在掛載時讀 preFilled;套用搜尋後需 remount 才會與 appliedSearch 一致 */ + const searchBoxKey = useMemo( + () => + [ + appliedSearch.planStartFrom, + appliedSearch.planStartTo, + appliedSearch.itemCode ?? "", + appliedSearch.jobOrderCode ?? "", + filter, + ].join("|"), + [appliedSearch, filter], ); - const handleSelectedItemCodesChange = useCallback((e: SelectChangeEvent) => { - const nextValue = e.target.value; - setSelectedItemCodes(typeof nextValue === "string" ? nextValue.split(",") : nextValue); - }, []); + const handleSelectedItemCodesChange = useCallback( + (e: SelectChangeEvent) => { + const nextValue = e.target.value; + const codes = typeof nextValue === "string" ? nextValue.split(",") : nextValue; + onListPersistedStateChange((prev) => ({ ...prev, selectedItemCodes: codes })); + }, + [onListPersistedStateChange], + ); return ( @@ -351,6 +440,7 @@ const ProductProcessList: React.FC = ({ onSelectProcess ) : ( + key={searchBoxKey} criteria={searchCriteria} onSearch={handleApplySearch} onReset={handleResetSearch} @@ -573,7 +663,9 @@ const ProductProcessList: React.FC = ({ onSelectProcess count={totalJobOrders} page={page} rowsPerPage={PAGE_SIZE} - onPageChange={(e, p) => setPage(p)} + onPageChange={(e, p) => + onListPersistedStateChange((prev) => ({ ...prev, page: p })) + } rowsPerPageOptions={[PAGE_SIZE]} /> )} diff --git a/src/components/ProductionProcess/ProductionProcessPage.tsx b/src/components/ProductionProcess/ProductionProcessPage.tsx index 5ffa8f9..ef34f7c 100644 --- a/src/components/ProductionProcess/ProductionProcessPage.tsx +++ b/src/components/ProductionProcess/ProductionProcessPage.tsx @@ -3,7 +3,9 @@ import React, { useState, useEffect, useCallback } from "react"; import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { Box, Tabs, Tab, Stack, Typography, Autocomplete, TextField } from "@mui/material"; -import ProductionProcessList from "@/components/ProductionProcess/ProductionProcessList"; +import ProductionProcessList, { + createDefaultProductionProcessListPersistedState, +} from "@/components/ProductionProcess/ProductionProcessList"; import ProductionProcessDetail from "@/components/ProductionProcess/ProductionProcessDetail"; import ProductionProcessJobOrderDetail from "@/components/ProductionProcess/ProductionProcessJobOrderDetail"; import JobPickExecutionsecondscan from "@/components/Jodetail/JobPickExecutionsecondscan"; @@ -43,6 +45,13 @@ const ProductionProcessPage: React.FC = ({ printerCo pickOrderId: number; } | null>(null); const [tabIndex, setTabIndex] = useState(0); + /** 列表搜尋/分頁:保留在切換工單詳情時,返回後仍為同一條件 */ + const [productionListState, setProductionListState] = useState( + createDefaultProductionProcessListPersistedState, + ); + const [finishedQcListState, setFinishedQcListState] = useState( + createDefaultProductionProcessListPersistedState, + ); const { data: session } = useSession() as { data: SessionWithTokens | null }; const currentUserId = session?.id ? parseInt(session.id) : undefined; @@ -180,6 +189,8 @@ const ProductionProcessPage: React.FC = ({ printerCo { const id = jobOrderId ?? null; if (id !== null) { @@ -200,6 +211,8 @@ const ProductionProcessPage: React.FC = ({ printerCo { const id = jobOrderId ?? null; if (id !== null) { diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index ccd10c5..848fd14 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -40,6 +40,8 @@ interface BaseCriterion { paramName2?: T; // options?: T[] | string[]; defaultValue?: string; + /** 與 `defaultValue` 配對,用於 dateRange / datetimeRange 重置時的結束值 */ + defaultValueTo?: string; preFilledValue?: string | { from?: string; to?: string }; filterObj?: T; handleSelectionChange?: (selectedOptions: T[]) => void; @@ -159,7 +161,7 @@ function SearchBox({ tempCriteria = { ...tempCriteria, [c.paramName]: c.defaultValue ?? "", - [`${c.paramName}To`]: "", + [`${c.paramName}To`]: c.defaultValueTo ?? "", }; } return tempCriteria; @@ -188,7 +190,7 @@ function SearchBox({ {} as Record, ); return {...defaultInputs, ...preFilledCriteria} - }, [defaultInputs]) + }, [defaultInputs, criteria]) const [inputs, setInputs] = useState(preFilledInputs); const [isReset, setIsReset] = useState(false); diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json index 9ee5daf..b2a2354 100644 --- a/src/i18n/zh/common.json +++ b/src/i18n/zh/common.json @@ -17,6 +17,8 @@ "Process & Equipment": "製程與設備", "Sequence": "順序", "Process Name": "製程名稱", + "Plan start (from)": "開始日期(從)", + "Plan start (to)": "開始日期(至)", "Process Description": "說明", "Confirm to Pass this Process?": "確認要通過此工序嗎?", "Equipment Name": "設備",