Browse Source

補貨V1

production
CANCERYS\kw093 1 week ago
parent
commit
e9d7eb51c9
5 changed files with 365 additions and 15 deletions
  1. +17
    -0
      src/app/api/do/actions.tsx
  2. +66
    -15
      src/components/DoSearch/DoSearch.tsx
  3. +276
    -0
      src/components/DoSearch/batchReleaseReplenishmentHtml.ts
  4. +3
    -0
      src/i18n/en/do.json
  5. +3
    -0
      src/i18n/zh/do.json

+ 17
- 0
src/app/api/do/actions.tsx View File

@@ -730,3 +730,20 @@ export async function fetchDoReplenishmentList(params: {
headers: { "Content-Type": "application/json" },
});
}

export async function fetchDoReplenishmentForBatchRelease(params: {
truckLaneCode?: string;
shopName?: string;
}): Promise<DoReplenishmentRecord[]> {
const query = convertObjToURLSearchParams({
truckLaneCode: params.truckLaneCode?.trim() || undefined,
shopName: params.shopName?.trim() || undefined,
});
return serverFetchJson<DoReplenishmentRecord[]>(
`${BASE_API_URL}/do/replenishment/for-batch-release?${query}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
},
);
}

+ 66
- 15
src/components/DoSearch/DoSearch.tsx View File

@@ -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<string, any>;
@@ -533,12 +551,12 @@ const DoSearch: React.FC<Props> = ({ 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<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
tabFilter.floor,
tabFilter.isExtra,
);
Swal.close();
if (allMatchingDos.length === 0) {
@@ -556,7 +574,8 @@ const DoSearch: React.FC<Props> = ({ 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<Props> = ({ 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: `
<div style="text-align: left;">
<p>${t("Selected Shop(s): ")}${idsToRelease.length}</p>
<p style="font-size: 0.9em; color: #666; margin-top: 8px;">
<div class="do-batch-release-swal-body" style="text-align:left;width:100%;box-sizing:border-box;">
<p style="margin:0 0 4px;text-align:left;">${t("Selected Shop(s): ")}${idsToRelease.length}</p>
<p style="font-size:0.9em;color:#666;margin:0;text-align:left;">
${currentSearchParams.code ? `${t("Code")}: ${currentSearchParams.code} ` : ""}
${currentSearchParams.shopName ? `${t("Shop Name")}: ${currentSearchParams.shopName} ` : ""}
@@ -595,6 +633,7 @@ const DoSearch: React.FC<Props> = ({ filterArgs, searchQuery, onDeliveryOrderSea
? `<p style="font-size:0.95em;color:#666;margin-top:16px;">${t("Merge extra orders into lane batch ticket")}</p>`
: ""
}
${replenishmentSectionHtml}
</div>
`,
showCancelButton: true,
@@ -605,6 +644,9 @@ const DoSearch: React.FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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]);


+ 276
- 0
src/components/DoSearch/batchReleaseReplenishmentHtml.ts View File

@@ -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 `<main>`, 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

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 `<p style="font-size:0.9em;color:#666;margin-top:16px;">${escapeHtml(
t("Batch release no pending replenishment"),
)}</p>`;
}

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) => `<th style="${thStyle}">${escapeHtml(label)}</th>`)
.join("");

const colgroup = showShopColumn
? `<colgroup>
<col style="width:128px" />
<col style="width:148px" />
<col style="width:76px" />
<col />
<col style="width:72px" />
<col style="width:148px" />
</colgroup>`
: `<colgroup>
<col style="width:132px" />
<col style="width:80px" />
<col />
<col style="width:76px" />
<col style="width:152px" />
</colgroup>`;

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
? `<td style="${tdBase}word-break:break-word;" title="${escapeHtml(shopLabel)}">${escapeHtml(shopLabel)}</td>`
: "";
return `<tr>
<td style="${tdBase}white-space:nowrap;">${escapeHtml(row.code)}</td>
${shopCell}
<td style="${tdBase}white-space:nowrap;">${escapeHtml(row.itemNo ?? "—")}</td>
<td style="${tdBase}word-break:break-word;" title="${escapeHtml(itemName)}">${escapeHtml(itemName)}</td>
<td style="${tdBase}white-space:nowrap;">${escapeHtml(qtyLabel)}</td>
<td style="${tdBase}white-space:nowrap;">${escapeHtml(row.sourceDoCode ?? "—")}</td>
</tr>`;
})
.join("");

const scrollMaxHeight = records.length <= 4 ? "none" : "200px";

return `
<div class="do-batch-release-replenish-section" style="margin-top:12px;text-align:left;width:100%;box-sizing:border-box;">
<p style="font-weight:600;margin:0 0 6px;text-align:left;font-size:14px;">${escapeHtml(
t("Batch release pending replenishment", { count: records.length }),
)}</p>
<p style="font-size:13px;color:#666;margin:0 0 8px;text-align:left;">${escapeHtml(
t("Batch release replenishment info only"),
)}</p>
<div class="${BATCH_RELEASE_SWAL_SCROLL_CLASS}" style="max-height:${scrollMaxHeight};overflow:${scrollMaxHeight === "none" ? "visible" : "auto"};border:1px solid #ddd;border-radius:4px;width:100%;box-sizing:border-box;">
<table style="width:100%;table-layout:fixed;border-collapse:collapse;">
${colgroup}
<thead>
<tr style="background:#f5f5f5;">${headerCells}</tr>
</thead>
<tbody>${bodyRows}</tbody>
</table>
</div>
</div>
`;
}

/** 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";
}
}

+ 3
- 0
src/i18n/en/do.json View File

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


+ 3
- 0
src/i18n/zh/do.json View File

@@ -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": "補貨進度追蹤",


Loading…
Cancel
Save