From e9d7eb51c9ffcf1df95ad29a89805d1ed5141c32 Mon Sep 17 00:00:00 2001 From: "CANCERYS\\kw093" Date: Fri, 12 Jun 2026 01:20:52 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A3=9C=E8=B2=A8V1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/do/actions.tsx | 17 ++ src/components/DoSearch/DoSearch.tsx | 81 ++++- .../DoSearch/batchReleaseReplenishmentHtml.ts | 276 ++++++++++++++++++ src/i18n/en/do.json | 3 + src/i18n/zh/do.json | 3 + 5 files changed, 365 insertions(+), 15 deletions(-) create mode 100644 src/components/DoSearch/batchReleaseReplenishmentHtml.ts diff --git a/src/app/api/do/actions.tsx b/src/app/api/do/actions.tsx index 70553c6..c6c6e0e 100644 --- a/src/app/api/do/actions.tsx +++ b/src/app/api/do/actions.tsx @@ -730,3 +730,20 @@ export async function fetchDoReplenishmentList(params: { headers: { "Content-Type": "application/json" }, }); } + +export async function fetchDoReplenishmentForBatchRelease(params: { + truckLaneCode?: string; + shopName?: string; +}): Promise { + const query = convertObjToURLSearchParams({ + truckLaneCode: params.truckLaneCode?.trim() || undefined, + shopName: params.shopName?.trim() || undefined, + }); + return serverFetchJson( + `${BASE_API_URL}/do/replenishment/for-batch-release?${query}`, + { + method: "GET", + headers: { "Content-Type": "application/json" }, + }, + ); +} diff --git a/src/components/DoSearch/DoSearch.tsx b/src/components/DoSearch/DoSearch.tsx index c45300a..d29a4e4 100644 --- a/src/components/DoSearch/DoSearch.tsx +++ b/src/components/DoSearch/DoSearch.tsx @@ -1,7 +1,17 @@ "use client"; import { DoResult } from "@/app/api/do"; -import { DoSearchAll, DoSearchLiteResponse, fetchDoSearch, fetchAllDoSearch, fetchDoSearchList, releaseDo ,startBatchReleaseAsync, getBatchReleaseProgress} from "@/app/api/do/actions"; +import { + DoSearchAll, + DoSearchLiteResponse, + fetchDoSearch, + fetchAllDoSearch, + fetchDoSearchList, + fetchDoReplenishmentForBatchRelease, + releaseDo, + startBatchReleaseAsync, + getBatchReleaseProgress, +} from "@/app/api/do/actions"; import { startWorkbenchBatchReleaseAsyncV2, getWorkbenchBatchReleaseProgress, @@ -38,6 +48,14 @@ import { useSession } from "next-auth/react"; import { SessionWithTokens } from "@/config/authConfig"; import { useDoSearchRowSelection } from "./useDoSearchRowSelection"; import DoReplenishmentTab from "./DoReplenishmentTab"; +import { + applyBatchReleaseSwalLayout, + applyMainContentAreaSwalOffset, + BATCH_RELEASE_SWAL_WIDTH_WITH_REPLENISH, + buildBatchReleaseReplenishmentHtml, + deriveReplenishmentFetchParams, + filterReplenishmentsForBatchDos, +} from "./batchReleaseReplenishmentHtml"; type Props = { filterArgs?: Record; @@ -533,12 +551,12 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea allowOutsideClick: false, allowEscapeKey: false, showConfirmButton: false, - didOpen: () => { + didOpen: (popup) => { + applyMainContentAreaSwalOffset(popup); Swal.showLoading(); - } + }, }); - // 获取所有匹配的记录 const allMatchingDos = await fetchAllDoSearch( currentSearchParams.code || "", currentSearchParams.shopName || "", @@ -548,7 +566,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea tabFilter.floor, tabFilter.isExtra, ); - + Swal.close(); if (allMatchingDos.length === 0) { @@ -556,7 +574,8 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea icon: "warning", title: t("No Records"), text: t("No matching records found for batch release."), - confirmButtonText: t("OK") + confirmButtonText: t("OK"), + didOpen: (popup) => applyMainContentAreaSwalOffset(popup), }); return; } @@ -571,19 +590,38 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea title: t("No Records"), text: t("No delivery orders selected for batch release. Uncheck orders you want to exclude, or search again to reset selection."), confirmButtonText: t("OK"), + didOpen: (popup) => applyMainContentAreaSwalOffset(popup), }); return; } + + const dosForRelease = allMatchingDos.filter((row) => idsToRelease.includes(row.id)); + const replenishmentFetchParams = deriveReplenishmentFetchParams( + dosForRelease, + currentSearchParams.shopName || "", + effectiveTruckLanceCode, + ); + const pendingReplenishments = filterReplenishmentsForBatchDos( + await fetchDoReplenishmentForBatchRelease(replenishmentFetchParams).catch(() => []), + dosForRelease, + ); const showMergeExtraOption = isWorkbench && activeTab === "ETRA"; + const replenishmentSectionHtml = buildBatchReleaseReplenishmentHtml( + pendingReplenishments, + t, + ); + + const hasReplenishmentPreview = pendingReplenishments.length > 0; const result = await Swal.fire({ icon: "question", title: t("Batch Release"), + width: hasReplenishmentPreview ? BATCH_RELEASE_SWAL_WIDTH_WITH_REPLENISH : undefined, html: ` -
-

${t("Selected Shop(s): ")}${idsToRelease.length}

-

+

+

${t("Selected Shop(s): ")}${idsToRelease.length}

+

${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""} ${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""} @@ -595,6 +633,7 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea ? `

${t("Merge extra orders into lane batch ticket")}

` : "" } + ${replenishmentSectionHtml}
`, showCancelButton: true, @@ -605,6 +644,9 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea confirmButtonColor: "#8dba00", denyButtonColor: "#6366f1", cancelButtonColor: "#F04438", + didOpen: (popup) => { + applyBatchReleaseSwalLayout(popup, { wide: hasReplenishmentPreview }); + }, }); if (result.isDismissed) return; @@ -628,7 +670,12 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea const jobId = startRes?.entity?.jobId; if (!jobId) { - await Swal.fire({ icon: "error", title: t("Error"), text: t("Failed to start batch release") }); + await Swal.fire({ + icon: "error", + title: t("Error"), + text: t("Failed to start batch release"), + didOpen: (popup) => applyMainContentAreaSwalOffset(popup), + }); return; } @@ -638,9 +685,10 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea allowOutsideClick: false, allowEscapeKey: false, showConfirmButton: false, - didOpen: () => { + didOpen: (popup) => { + applyMainContentAreaSwalOffset(popup); Swal.showLoading(); - } + }, }); const timer = setInterval(async () => { @@ -669,7 +717,8 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea title: t("Completed"), text: t("Batch release completed successfully."), confirmButtonText: t("Confirm"), - confirmButtonColor: "#8dba00" + confirmButtonColor: "#8dba00", + didOpen: (popup) => applyMainContentAreaSwalOffset(popup), }); if (currentSearchParams && Object.keys(currentSearchParams).length > 0) { @@ -686,7 +735,8 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea icon: "error", title: t("Error"), text: t("An error occurred during batch release"), - confirmButtonText: t("OK") + confirmButtonText: t("OK"), + didOpen: (popup) => applyMainContentAreaSwalOffset(popup), }); } } catch (error) { @@ -695,7 +745,8 @@ const DoSearch: React.FC = ({ filterArgs, searchQuery, onDeliveryOrderSea icon: "error", title: t("Error"), text: t("Failed to fetch matching records"), - confirmButtonText: t("OK") + confirmButtonText: t("OK"), + didOpen: (popup) => applyMainContentAreaSwalOffset(popup), }); } }, [t, currentUserId, currentSearchParams, handleSearch, resolveIdsForBatchRelease, activeTab, resolveTabFilter]); diff --git a/src/components/DoSearch/batchReleaseReplenishmentHtml.ts b/src/components/DoSearch/batchReleaseReplenishmentHtml.ts new file mode 100644 index 0000000..2008c81 --- /dev/null +++ b/src/components/DoSearch/batchReleaseReplenishmentHtml.ts @@ -0,0 +1,276 @@ +import { DoReplenishmentRecord, DoSearchAll } from "@/app/api/do/actions"; +import { NAVIGATION_CONTENT_WIDTH } from "@/config/uiConfig"; +import { TFunction } from "i18next"; + +function normalizeText(value: string | null | undefined): string { + return (value ?? "").trim().toLowerCase(); +} + +function shopTokenFromDoRow(doRow: DoSearchAll): string { + const raw = doRow.shopName?.trim() ?? ""; + if (!raw) return ""; + return normalizeText(raw.split(" - ")[0] || raw); +} + +/** Replenishment must match the same shop + truck lane as a DO in this batch release. */ +export function replenishmentMatchesDoRow( + record: DoReplenishmentRecord, + doRow: DoSearchAll, +): boolean { + const doTruck = normalizeText(doRow.truckLanceCode); + const recordTruck = normalizeText(record.truckLaneCode); + if (doTruck) { + if (!recordTruck || recordTruck !== doTruck) return false; + } + + const doShopToken = shopTokenFromDoRow(doRow); + if (!doShopToken) return false; + + const recordShopCode = normalizeText(record.shopCode); + const recordShopName = normalizeText(record.shopName); + return ( + recordShopCode === doShopToken || + recordShopName.startsWith(doShopToken) || + (recordShopCode.length > 0 && doShopToken.startsWith(recordShopCode)) || + (recordShopCode.length > 0 && recordShopCode.startsWith(doShopToken)) + ); +} + +export function filterReplenishmentsForBatchDos( + records: DoReplenishmentRecord[], + dosForRelease: DoSearchAll[], +): DoReplenishmentRecord[] { + if (dosForRelease.length === 0) return []; + return records.filter((record) => + dosForRelease.some((doRow) => replenishmentMatchesDoRow(record, doRow)), + ); +} + +/** Narrow API query from selected DOs when search box shop/truck is empty. */ +export function deriveReplenishmentFetchParams( + dosForRelease: DoSearchAll[], + searchShopName: string, + searchTruckLaneCode: string, +): { shopName?: string; truckLaneCode?: string } { + const shopFromSearch = searchShopName.trim(); + const truckFromSearch = searchTruckLaneCode.trim(); + if (shopFromSearch || truckFromSearch) { + return { + shopName: shopFromSearch || undefined, + truckLaneCode: truckFromSearch || undefined, + }; + } + + const shopTokens = [ + ...new Set(dosForRelease.map(shopTokenFromDoRow).filter(Boolean)), + ]; + const trucks = [ + ...new Set( + dosForRelease + .map((row) => row.truckLanceCode?.trim()) + .filter((value): value is string => Boolean(value)), + ), + ]; + + if (shopTokens.length === 1 && trucks.length === 1) { + return { shopName: shopTokens[0], truckLaneCode: trucks[0] }; + } + if (shopTokens.length === 1) { + return { shopName: shopTokens[0] }; + } + if (trucks.length === 1) { + return { truckLaneCode: trucks[0] }; + } + return {}; +} + +const BATCH_RELEASE_SWAL_SCROLL_CLASS = "do-batch-release-replenish-scroll"; + +/** Matches MUI `xl` — permanent nav drawer visible at this width and above. */ +export const MUI_XL_BREAKPOINT_PX = 1440; + +/** Keep full-viewport backdrop; shift popup so it centers over `
`, not the sidebar. */ +export function applyMainContentAreaSwalOffset(popup: HTMLElement): void { + const container = popup.closest(".swal2-container"); + if (container instanceof HTMLElement) { + container.style.marginLeft = ""; + container.style.width = ""; + } + + if (!window.matchMedia(`(min-width: ${MUI_XL_BREAKPOINT_PX}px)`).matches) { + popup.style.marginLeft = ""; + return; + } + + popup.style.marginLeft = `calc(${NAVIGATION_CONTENT_WIDTH} / 2)`; +} + +function applyCompactSwalIcon(popup: HTMLElement): void { + const icon = popup.querySelector(".swal2-icon"); + if (icon instanceof HTMLElement) { + icon.style.width = "2.5em"; + icon.style.height = "2.5em"; + icon.style.margin = "0.35em auto 0.25em"; + icon.style.fontSize = "0.75em"; + } +} + +/** ~110% of prior 920px so 100% browser zoom matches previous balance. */ +export const BATCH_RELEASE_SWAL_WIDTH_WITH_REPLENISH = 1024; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function shopKey(record: DoReplenishmentRecord): string { + return (record.shopCode ?? record.shopName ?? "").trim().toLowerCase(); +} + +function shouldShowShopColumn(records: DoReplenishmentRecord[]): boolean { + const keys = new Set(records.map(shopKey).filter(Boolean)); + return keys.size > 1; +} + +export function buildBatchReleaseReplenishmentHtml( + records: DoReplenishmentRecord[], + t: TFunction, +): string { + if (records.length === 0) { + return `

${escapeHtml( + t("Batch release no pending replenishment"), + )}

`; + } + + const showShopColumn = shouldShowShopColumn(records); + const thStyle = + "padding:7px 10px;text-align:left;font-weight:600;border-bottom:1px solid #ddd;white-space:nowrap;font-size:13px;"; + const tdBase = + "padding:7px 10px;text-align:left;vertical-align:top;border-top:1px solid #eee;font-size:13px;line-height:1.35;"; + + const headers: string[] = [t("Replenishment Code")]; + if (showShopColumn) headers.push(t("Shop Name")); + headers.push(t("Item No."), t("Item Name"), t("Replenish Qty"), t("Source DO")); + + const headerCells = headers + .map((label) => `${escapeHtml(label)}`) + .join(""); + + const colgroup = showShopColumn + ? ` + + + + + + + ` + : ` + + + + + + `; + + const bodyRows = records + .map((row) => { + const qtyLabel = row.shortUom + ? `${row.replenishQty} ${row.shortUom}` + : String(row.replenishQty); + const shopLabel = row.shopName ?? row.shopCode ?? "—"; + const itemName = row.itemName ?? "—"; + const shopCell = showShopColumn + ? `${escapeHtml(shopLabel)}` + : ""; + return ` + ${escapeHtml(row.code)} + ${shopCell} + ${escapeHtml(row.itemNo ?? "—")} + ${escapeHtml(itemName)} + ${escapeHtml(qtyLabel)} + ${escapeHtml(row.sourceDoCode ?? "—")} + `; + }) + .join(""); + + const scrollMaxHeight = records.length <= 4 ? "none" : "200px"; + + return ` +
+

${escapeHtml( + t("Batch release pending replenishment", { count: records.length }), + )}

+

${escapeHtml( + t("Batch release replenishment info only"), + )}

+
+ + ${colgroup} + + ${headerCells} + + ${bodyRows} +
+
+
+ `; +} + +/** SweetAlert2 centers html-container by default; fix layout for left-aligned table. */ +export function applyBatchReleaseSwalLayout( + popup: HTMLElement, + options?: { wide?: boolean }, +): void { + applyMainContentAreaSwalOffset(popup); + applyCompactSwalIcon(popup); + + const wide = options?.wide ?? false; + if (!wide) { + return; + } + + popup.style.width = `${BATCH_RELEASE_SWAL_WIDTH_WITH_REPLENISH}px`; + popup.style.maxWidth = `calc(100vw - ${NAVIGATION_CONTENT_WIDTH} - 48px)`; + popup.style.boxSizing = "border-box"; + popup.style.padding = "1.1em 1.35em 1.25em"; + + const title = popup.querySelector(".swal2-title"); + if (title instanceof HTMLElement) { + title.style.padding = "0.4em 0 0"; + title.style.fontSize = "1.35em"; + } + + const htmlContainer = popup.querySelector(".swal2-html-container"); + if (htmlContainer instanceof HTMLElement) { + htmlContainer.style.textAlign = "left"; + htmlContainer.style.overflow = "visible"; + htmlContainer.style.maxHeight = "none"; + htmlContainer.style.margin = "0.25em 0 0"; + htmlContainer.style.padding = "0"; + htmlContainer.style.width = "100%"; + htmlContainer.style.boxSizing = "border-box"; + } + + popup.querySelectorAll(".do-batch-release-swal-body, .do-batch-release-replenish-section").forEach((el) => { + if (el instanceof HTMLElement) { + el.style.width = "100%"; + el.style.boxSizing = "border-box"; + } + }); + + const scrollBox = popup.querySelector(`.${BATCH_RELEASE_SWAL_SCROLL_CLASS}`); + if (scrollBox instanceof HTMLElement) { + scrollBox.style.display = "block"; + scrollBox.style.width = "100%"; + scrollBox.style.boxSizing = "border-box"; + } + + const actions = popup.querySelector(".swal2-actions"); + if (actions instanceof HTMLElement) { + actions.style.marginTop = "0.85em"; + } +} diff --git a/src/i18n/en/do.json b/src/i18n/en/do.json index b164ad3..7da3cb8 100644 --- a/src/i18n/en/do.json +++ b/src/i18n/en/do.json @@ -96,6 +96,9 @@ "Failed to submit replenishment": "Failed to submit replenishment", "Replenishment API not ready": "Replenishment API not ready", "Replenishment submitted successfully": "Replenishment submitted successfully", + "Batch release pending replenishment": "Pending replenishment ({{count}})", + "Batch release no pending replenishment": "No pending replenishment for this search.", + "Batch release replenishment info only": "Pending replenishments matching this search. For information only.", "Replenishment Entry": "Replenishment Entry", "Replenishment item code": "Item Code", "Replenishment Tracking": "Replenishment Tracking", diff --git a/src/i18n/zh/do.json b/src/i18n/zh/do.json index 748a2f7..f826548 100644 --- a/src/i18n/zh/do.json +++ b/src/i18n/zh/do.json @@ -43,6 +43,9 @@ "Failed to submit replenishment": "提交補貨失敗", "Replenishment API not ready": "補貨 API 尚未就緒", "Replenishment submitted successfully": "補貨已提交", + "Batch release pending replenishment": "待放單補貨({{count}} 筆)", + "Batch release no pending replenishment": "此搜尋條件下沒有待放單補貨。", + "Batch release replenishment info only": "以下為符合搜尋條件的待放單補貨,僅供查閱。", "Replenishment Entry": "補貨填表", "Replenishment item code": "貨品編號", "Replenishment Tracking": "補貨進度追蹤",