|
|
|
@@ -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, "&") |
|
|
|
.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 `<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"; |
|
|
|
} |
|
|
|
} |