315 lines
14 KiB
Plaintext
315 lines
14 KiB
Plaintext
<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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||
}
|
||
|
||
function escAttr(s) {
|
||
return String(s).replace(/"/g,""").replace(/'/g,"'");
|
||
}
|
||
|
||
/* ── 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
|
||
? ` · 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 ? ` · ` : ""}
|
||
${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>
|