NonInventPurchasingSystem/CPRNIMS.WebApps/Views/Shared/PagesView/Inventory/_InventoryHelpers.cshtml
2026-06-15 16:41:50 +08:00

315 lines
14 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
window.InventoryHelpers = (function () {
"use strict";
/* ── String helpers ──────────────────────────────── */
function splitAggr(raw) {
if (!raw) return [];
return raw.replace(/<br\s*\/?>/gi, ",").split(",").map(s => s.trim()).filter(Boolean);
}
function escHtml(s) {
return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function escAttr(s) {
return String(s).replace(/"/g,"&quot;").replace(/'/g,"&#39;");
}
/* ── Pagination helpers ──────────────────────────── */
function buildPageRange(cur, total) {
if (total <= 7) return Array.from({length: total}, (_, i) => i + 1);
if (cur <= 4) return [1, 2, 3, 4, 5, "…", total];
if (cur >= total - 3) return [1, "…", total-4, total-3, total-2, total-1, total];
return [1, "…", cur-1, cur, cur+1, "…", total];
}
function mkPageBtn(html, disabled, active) {
const b = document.createElement("button");
b.className = "inv-pg-btn" + (active ? " active" : "");
b.innerHTML = html; b.disabled = !!disabled;
return b;
}
function renderPagination(container, infoEl, state, onPageChange) {
const { page, pageSize, totalCount } = state;
const totalPages = Math.ceil(totalCount / pageSize) || 1;
const from = Math.min((page - 1) * pageSize + 1, totalCount);
const to = Math.min(page * pageSize, totalCount);
infoEl.textContent = totalCount
? `Showing ${from.toLocaleString()}${to.toLocaleString()} of ${totalCount.toLocaleString()}`
: "No records";
container.innerHTML = "";
const prev = mkPageBtn('<i class="fas fa-chevron-left"></i>', page <= 1);
prev.addEventListener("click", () => { if (page > 1) onPageChange(page - 1); });
container.appendChild(prev);
buildPageRange(page, totalPages).forEach(p => {
if (p === "…") {
const d = document.createElement("span");
d.className = "inv-pg-btn"; d.style.cursor = "default"; d.textContent = "…";
container.appendChild(d); return;
}
const b = mkPageBtn(p, false, p === page);
b.addEventListener("click", () => onPageChange(p));
container.appendChild(b);
});
const next = mkPageBtn('<i class="fas fa-chevron-right"></i>', page >= totalPages);
next.addEventListener("click", () => { if (page < totalPages) onPageChange(page + 1); });
container.appendChild(next);
}
/* ── Generic searchable dropdown factory ─────────────────────────────
*
* Works for BOTH Departments and ClientNames (or any list).
* CSS class prefixes are passed in so each dropdown is fully independent.
*
* param wrap HTMLElement — the root wrapper element
* param onChange Function — called with the selected value string
* param opts Object — optional overrides:
* allLabel : string label for "All" option (default "All")
* icon : string FA icon class (default "fa-th-large / fa-store")
* cssPrefix : string CSS class prefix (default "inv-dep")
*
* CSS classes expected inside `wrap`:
* {prefix}-trigger, {prefix}-dropdown, {prefix}-lbl,
* {prefix}-searchbox > input, {prefix}-list
*
* Returns: { setItems(list), getCurrent() }
──────────────────────────────────────────────────────────────────── */
function initSearchDropdown(wrap, onChange, opts) {
opts = opts || {};
const prefix = opts.cssPrefix || "inv-dep";
const allLabel = opts.allLabel || "All";
const allIcon = opts.allIcon || "fas fa-th-large";
const itemIcon = opts.itemIcon || "fas fa-store";
const trigger = wrap.querySelector(`.${prefix}-trigger`);
const dropdown = wrap.querySelector(`.${prefix}-dropdown`);
const label = wrap.querySelector(`.${prefix}-lbl`);
const search = wrap.querySelector(`.${prefix}-searchbox input`);
const list = wrap.querySelector(`.${prefix}-list`);
if (!trigger || !dropdown || !label || !search || !list) {
//console.warn("initSearchDropdown: one or more required elements not found in", wrap);
return { setItems: () => {}, getCurrent: () => "" };
}
let allItems = [];
let current = "";
let isOpen = false; // ← tracks whether dropdown is visible
// ── AbortController lets us remove the document listener in one call ──
const ac = new AbortController();
trigger.addEventListener("click", () => {
isOpen = dropdown.classList.toggle("open");
trigger.classList.toggle("open", isOpen);
if (isOpen) {
search.value = "";
renderOpts(allItems);
search.focus();
}
});
document.addEventListener("click", e => {
if (!wrap.contains(e.target)) {
isOpen = false;
dropdown.classList.remove("open");
trigger.classList.remove("open");
}
}, { signal: ac.signal });
search.addEventListener("input", () => {
const q = search.value.trim().toLowerCase();
renderOpts(q ? allItems.filter(s => s.toLowerCase().includes(q)) : allItems);
});
list.addEventListener("click", e => {
const opt = e.target.closest("[data-value]");
if (!opt) return;
current = opt.dataset.value;
label.textContent = current || allLabel;
isOpen = false;
dropdown.classList.remove("open");
trigger.classList.remove("open");
onChange(current);
});
// ── Watch for wrap being removed from DOM → abort the document listener ──
const observer = new MutationObserver(() => {
if (!document.contains(wrap)) {
ac.abort(); // removes the document click listener
observer.disconnect();
}
});
observer.observe(document.body, { childList: true, subtree: true });
function renderOpts(items) {
// ── Only exclude current when the dropdown is open (user is browsing) ──
// ── When called from setItems (background refresh), show everything ─
const switchable = (isOpen && current)
? items.filter(n => n !== current)
: items;
const clearRow = `<div class="${prefix}-opt${current === "" ? " active" : ""}" data-value="">
<i class="${allIcon}"></i> ${escHtml(allLabel)}
${isOpen && current !== ""
? `<span style="margin-left:auto;font-size:.72rem;color:var(--text-muted);opacity:.7">clear</span>`
: ""}
</div>`;
if (!switchable.length) {
list.innerHTML = clearRow +
`<div style="padding:12px;text-align:center;font-size:.85rem;color:#6b8890">
${isOpen && current ? "No other departments" : "No results found"}
</div>`;
return;
}
list.innerHTML = clearRow + switchable.map(n =>
`<div class="${prefix}-opt${!isOpen && n === current ? " active" : ""}"
data-value="${escAttr(n)}">
<i class="${itemIcon}"></i> ${escHtml(n)}
</div>`
).join("");
}
function setItems(newList) {
allItems = (newList || []).filter(Boolean);
renderOpts(allItems); // isOpen is false here → full list rendered, active class applied
}
return { setItems, getCurrent: () => current };
}
/* ── Convenience wrappers (keep back-compat names) ───────────────── */
function initDepartmentDropdown(wrap, onChange) {
return initSearchDropdown(wrap, onChange, {
cssPrefix: "inv-dep",
allLabel: "All Departments",
allIcon: "fas fa-th-large",
itemIcon: "fas fa-store"
});
}
/* ── Card HTML builder ───────────────────────────── */
function buildCardHtml(item, footerHtml) {
const DESC_THRESHOLD = 80; // chars — expand toggle only if longer than this
const desc = (item.ItemDescription ?? "").trim();
const hasLongDesc = desc.length > DESC_THRESHOLD;
const fmt = v => v != null ? Number(v).toLocaleString("en-US", {
minimumFractionDigits: 2, maximumFractionDigits: 2
}) : "—";
const createdDate = item.createdDate
? new Date(item.createdDate).toLocaleDateString("en-US",
{ year: "numeric", month: "short", day: "numeric" })
: "—";
const lotPart = item.LotNo
? ` &nbsp;·&nbsp; Lot: ${escHtml(item.LotNo)}`
: "";
const descBlock = !desc ? "" : hasLongDesc
? `<div class="inv-desc-wrap">
<div class="inv-desc-header" onclick="
this.querySelector('.inv-desc-caret').classList.toggle('open');
this.nextElementSibling.classList.toggle('open');">
<span class="inv-desc-lbl">
<i class="fas fa-align-left"></i> Description
</span>
<i class="fas fa-chevron-down inv-desc-caret"></i>
</div>
<div class="inv-desc-body">${escHtml(desc)}</div>
</div>`
: `<div class="inv-desc-wrap">
<div style="padding:8px 11px;">
<span class="inv-desc-lbl" style="margin-bottom:4px;">
<i class="fas fa-align-left"></i> Description
</span>
<div style="font-size:.82rem;color:var(--text-dark);line-height:1.5;margin-top:4px;">
${escHtml(desc)}
</div>
</div>
</div>`;
return `
<div class="inv-card">
<div class="inv-card-hd">
<div class="inv-card-code">ITEMNO #${escHtml(String(item.itemNo))}${lotPart}</div>
<div class="inv-card-name">${escHtml(item.itemName ?? "—")}</div>
${(item.department || item.itemCategoryName) ? `
<div class="inv-card-sub">
${item.department ? `<i class="fas fa-building"></i> ${escHtml(item.department)}` : ""}
${item.department && item.itemCategoryName ? `&nbsp;·&nbsp;` : ""}
${item.itemCategoryName ? `<i class="fas fa-tag"></i> ${escHtml(item.itemCategoryName)}` : ""}
</div>
<div class="inv-card-sub">
<i class="fas fa-clock"></i>
${escHtml(createdDate ?? "—")}
</div>
<div class="inv-card-sub">
<i class="fas fa-qrcode"></i>
${escHtml(item.projectCode ?? "—")}
</div>
` : ""}
</div>
<div class="inv-card-body">
<div class="inv-agg-row">
<div class="inv-agg-badge">
<span class="inv-agg-lbl"><i class="fas fa-arrow-alt-circle-down"></i> Qty In</span>
<span class="inv-agg-val">${fmt(item.qtyIn)}</span>
</div>
<div class="inv-agg-badge">
<span class="inv-agg-lbl"><i class="fas fa-arrow-alt-circle-up"></i> Qty Out</span>
<span class="inv-agg-val">${fmt(item.qtyOut)}</span>
</div>
</div>
<div class="inv-agg-row">
<div class="inv-agg-badge">
<span class="inv-agg-lbl"><i class="fas fa-layer-group"></i> On Hand</span>
<span class="inv-agg-val">${fmt(item.qtyOnHand)}</span>
</div>
<div class="inv-agg-badge">
<span class="inv-agg-lbl"><i class="fas fa-history"></i> Remaining</span>
<span class="inv-agg-val">${fmt(item.remainingQty)}</span>
</div>
</div>
${descBlock}
<div>
<div class="inv-item-lbl"><i class="fas fa-box"></i> Item</div>
<div class="inv-item-row">
<span class="inv-item-name" title="${escAttr(item.itemName ?? "")}">
${escHtml(item.itemName ?? "—")}
</span>
<span class="inv-item-qty">
<i class="fas fa-cubes"></i> ${fmt(item.qtyIn)}
</span>
</div>
</div>
</div>
<div class="inv-card-ft">${footerHtml(item)}</div>
</div>`;
}
return {
splitAggr,
escHtml,
escAttr,
buildPageRange,
mkPageBtn,
renderPagination,
buildCardHtml,
initSearchDropdown,
initDepartmentDropdown,
};
})();
</script>