Rebate Grant Tables: Render Table
Rebate Grant Tables: Render Table
rebate-render-table.js
— 19.6 KB
File contents
// rebate-render-table-1.04.01.js
// Readable labels (kept for clarity / future use)
const grantTableLabels = Object.freeze({
EquipmentType: "Old Vehicle/Equipment Type",
InAreaUsage: "Usage in Area",
HPRange: "HP Range",
YearRange: "Year Range",
IgnitionType: "New Ignition Type",
GrantAmount: "Grant Amount",
MinimumYear: "Minimum Year",
MaximumYear: "Maximum Year",
MinimumHP: "Minimum HP",
MaximumHP: "Maximum HP",
});
const textAlignments = Object.freeze({
LEFT: "left",
CENTER: "Center",
RIGHT: "Right",
});
// Column order (matching property names in grantTables objects)
const grantTableHeaders = [
{
ColumnHeaders: grantTableLabels.EquipmentType,
TextAlignment: textAlignments.LEFT,
},
{
ColumnHeaders: grantTableLabels.InAreaUsage,
TextAlignment: textAlignments.CENTER,
},
{
ColumnHeaders: grantTableLabels.HPRange,
TextAlignment: textAlignments.CENTER,
},
{
ColumnHeaders: grantTableLabels.YearRange,
TextAlignment: textAlignments.CENTER,
},
{
ColumnHeaders: grantTableLabels.IgnitionType,
TextAlignment: textAlignments.LEFT,
},
{
ColumnHeaders: grantTableLabels.GrantAmount,
TextAlignment: textAlignments.RIGHT,
},
];
// Pagination state
let pageSize = 10; // default rows per page
let currentPage = 1;
let filteredData = Array.isArray(grantTables)
? sortGrantTables([...grantTables])
: [];
// Track which equipment types are expanded in the mobile accordion
let accordionState = {}; // { [equipmentType: string]: boolean }
// ---------- Helpers ----------
function getFilterState() {
return {
equipment: $("#filterEquipmentType").val(),
commitment: $("#filterInAreaUsage").val(),
hpRange: $("#filterHPRange").val(),
yearRange: $("#filterSelectedYear").val(),
};
}
function filterRows(baseFilters) {
return grantTables.filter((row) => {
if (
baseFilters.equipment &&
row[grantTableLabels.EquipmentType] !== baseFilters.equipment
) {
return false;
}
if (
baseFilters.commitment &&
row[grantTableLabels.InAreaUsage] !== baseFilters.commitment
) {
return false;
}
if (
baseFilters.hpRange &&
row[grantTableLabels.HPRange] !== baseFilters.hpRange
) {
return false;
}
if (baseFilters.yearRange) {
const selectedYear = parseInt(baseFilters.yearRange, 10);
const minYear = parseInt(row[grantTableLabels.MinimumYear], 10);
const maxYear = parseInt(row[grantTableLabels.MaximumYear], 10);
if (
Number.isNaN(selectedYear) ||
Number.isNaN(minYear) ||
Number.isNaN(maxYear) ||
selectedYear < minYear ||
selectedYear > maxYear
) {
return false;
}
}
return true;
});
}
function sortGrantTables(data) {
return data.sort((a, b) => {
// 1. Equipment Type (asc)
const equipA = a[grantTableLabels.EquipmentType] || "";
const equipB = b[grantTableLabels.EquipmentType] || "";
const equipCompare = equipA.localeCompare(equipB);
if (equipCompare !== 0) return equipCompare;
// 2. Minimum In-Area Commitment (desc)
const commitA = parseInt(a[grantTableLabels.InAreaUsage]) || 0;
const commitB = parseInt(b[grantTableLabels.InAreaUsage]) || 0;
if (commitA !== commitB) return commitB - commitA;
// 3. Minimum HP (asc)
const hpA = parseInt(a[grantTableLabels.HPRange]) || 0;
const hpB = parseInt(b[grantTableLabels.HPRange]) || 0;
if (hpA !== hpB) return hpA - hpB;
// 4. Minimum Year (asc)
const yearA = parseInt(a[grantTableLabels.MinimumYear], 10) || 0;
const yearB = parseInt(b[grantTableLabels.MinimumYear], 10) || 0;
if (yearA !== yearB) return yearA - yearB;
// 5. Emission Type (asc)
const emisA = a[grantTableLabels.IgnitionType] || "";
const emisB = b[grantTableLabels.IgnitionType] || "";
return emisA.localeCompare(emisB);
});
}
function updateYearFilterOptions(currentFilters) {
const baseFilters = { ...currentFilters, yearRange: "" };
const rowsForField = filterRows(baseFilters);
let minYear = Infinity;
let maxYear = -Infinity;
rowsForField.forEach((row) => {
const rowMin = parseInt(row[grantTableLabels.MinimumYear], 10);
const rowMax = parseInt(row[grantTableLabels.MaximumYear], 10);
if (!Number.isNaN(rowMin) && rowMin < minYear) minYear = rowMin;
if (!Number.isNaN(rowMax) && rowMax > maxYear) maxYear = rowMax;
});
const select = $("#filterSelectedYear");
const previousValue = select.val();
select.empty();
select.append($("<option>").attr("value", "").text("All"));
if (minYear !== Infinity && maxYear !== -Infinity) {
for (let year = maxYear; year >= minYear; year--) {
select.append(
$("<option>").attr("value", String(year)).text(String(year))
);
}
}
if (
previousValue &&
!Number.isNaN(parseInt(previousValue, 10)) &&
parseInt(previousValue, 10) >= minYear &&
parseInt(previousValue, 10) <= maxYear
) {
select.val(previousValue);
} else {
select.val("");
}
}
// ---------- Rendering: headers & body ----------
function renderGrantTableHeaders() {
const thead = $("#grantTable thead").empty();
if (!filteredData || filteredData.length === 0) {
return;
}
const headerRow = $("<tr>");
grantTableHeaders.forEach((col) => {
headerRow.append(
$("<th>").text(col.ColumnHeaders).css("text-align", textAlignments.CENTER) // center-align ALL headers
);
});
thead.append(headerRow);
}
function renderGrantTableBody(page) {
const tbody = $("#grantTable tbody").empty();
if (!filteredData || filteredData.length === 0) {
tbody.append(
$("<tr>").append(
$("<td>")
.attr("colspan", grantTableHeaders.length)
.addClass("text-center")
.text("No data")
)
);
return;
}
const start = (page - 1) * pageSize;
const end = start + pageSize;
const pageItems = filteredData.slice(start, end);
pageItems.forEach((item) => {
const row = $("<tr>");
grantTableHeaders.forEach((col) => {
const field = col.ColumnHeaders;
let val = item[field];
if (val == null || val === "") {
val = "—";
}
if (field === grantTableLabels.GrantAmount) {
if (typeof item[field] === "number") {
val = toCurrency(item[field]);
}
}
row.append(
$("<td>").text(val).css("text-align", col.TextAlignment) // APPLY ALIGNMENT HERE
);
});
tbody.append(row);
});
}
// ---------- Pagination (desktop: >= md) ----------
function renderDesktopPagination(page) {
const totalItems = filteredData.length;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const pagination = $("#grantPagination").empty();
const windowSize = 7;
// ---------- First ----------
const firstBtn = $("<li>").addClass(
"page-item" + (page === 1 ? " disabled" : "")
);
const firstLink = $("<a>")
.addClass("page-link")
.attr("href", "#")
.text("First")
.on("click", function (e) {
e.preventDefault();
if (currentPage !== 1) {
currentPage = 1;
renderGrantTable(currentPage);
}
});
firstBtn.append(firstLink);
pagination.append(firstBtn);
// ---------- Prev ----------
const prevBtn = $("<li>").addClass(
"page-item" + (page === 1 ? " disabled" : "")
);
const prevLink = $("<a>")
.addClass("page-link")
.attr("href", "#")
.text("Prev")
.on("click", function (e) {
e.preventDefault();
if (currentPage > 1) {
currentPage--;
renderGrantTable(currentPage);
}
});
prevBtn.append(prevLink);
pagination.append(prevBtn);
// ---------- Page numbers ----------
if (totalPages <= windowSize) {
for (let i = 1; i <= totalPages; i++) {
const pageItem = $("<li>").addClass(
"page-item" + (i === page ? " active" : "")
);
const pageLink = $("<a>")
.addClass("page-link")
.attr("href", "#")
.text(i)
.on("click", function (e) {
e.preventDefault();
currentPage = i;
renderGrantTable(currentPage);
});
pageItem.append(pageLink);
pagination.append(pageItem);
}
} else {
const half = Math.floor(windowSize / 2);
let startPage = page - half;
let endPage = page + half;
if (startPage < 1) {
endPage += 1 - startPage;
startPage = 1;
}
if (endPage > totalPages) {
startPage -= endPage - totalPages;
endPage = totalPages;
}
if (startPage < 1) startPage = 1;
while (endPage - startPage + 1 < windowSize && endPage < totalPages) {
endPage++;
}
while (endPage - startPage + 1 < windowSize && startPage > 1) {
startPage--;
}
const showLeftEllipsis = startPage > 1;
const showRightEllipsis = endPage < totalPages;
if (showLeftEllipsis) {
const leftEllipsis = $("<li>").addClass("page-item");
const link = $("<a>")
.addClass("page-link")
.attr("href", "#")
.text("…")
.on("click", function (e) {
e.preventDefault();
currentPage = Math.max(1, startPage - windowSize);
renderGrantTable(currentPage);
});
leftEllipsis.append(link);
pagination.append(leftEllipsis);
}
for (let i = startPage; i <= endPage; i++) {
const pageItem = $("<li>").addClass(
"page-item" + (i === page ? " active" : "")
);
const pageLink = $("<a>")
.addClass("page-link")
.attr("href", "#")
.text(i)
.on("click", function (e) {
e.preventDefault();
currentPage = i;
renderGrantTable(currentPage);
});
pageItem.append(pageLink);
pagination.append(pageItem);
}
if (showRightEllipsis) {
const rightEllipsis = $("<li>").addClass("page-item");
const link = $("<a>")
.addClass("page-link")
.attr("href", "#")
.text("…")
.on("click", function (e) {
e.preventDefault();
currentPage = Math.min(totalPages, endPage + 1);
renderGrantTable(currentPage);
});
rightEllipsis.append(link);
pagination.append(rightEllipsis);
}
}
// ---------- Next ----------
const nextBtn = $("<li>").addClass(
"page-item" + (page === totalPages ? " disabled" : "")
);
const nextLink = $("<a>")
.addClass("page-link")
.attr("href", "#")
.text("Next")
.on("click", function (e) {
e.preventDefault();
if (currentPage < totalPages) {
currentPage++;
renderGrantTable(currentPage);
}
});
nextBtn.append(nextLink);
pagination.append(nextBtn);
// ---------- Last ----------
const lastBtn = $("<li>").addClass(
"page-item" + (page === totalPages ? " disabled" : "")
);
const lastLink = $("<a>")
.addClass("page-link")
.attr("href", "#")
.text("Last")
.on("click", function (e) {
e.preventDefault();
if (currentPage !== totalPages) {
currentPage = totalPages;
renderGrantTable(currentPage);
}
});
lastBtn.append(lastLink);
pagination.append(lastBtn);
}
function renderMobileAccordion() {
const table = $("#grantTable");
const container = table.closest(".table-responsive");
// Create or reuse the accordion container
let accordion = $("#mobileAccordion");
if (!accordion.length) {
accordion = $('<div id="mobileAccordion" class="accordion mt-3"></div>');
container.append(accordion);
} else {
accordion.empty();
}
if (!filteredData || !filteredData.length) {
accordion.append('<div class="text-center text-muted small">No data</div>');
return;
}
// Wire up state tracking once
if (!accordion.data("state-wired")) {
accordion
.on("shown.bs.collapse", ".accordion-collapse", function () {
const equipKey = $(this).data("equip");
if (equipKey) accordionState[equipKey] = true;
})
.on("hidden.bs.collapse", ".accordion-collapse", function () {
const equipKey = $(this).data("equip");
if (equipKey) accordionState[equipKey] = false;
});
accordion.data("state-wired", true);
}
// Group ALL filtered rows by Equipment Type (no mobile pagination)
const equipmentGroups = {};
filteredData.forEach((row) => {
const equipKey = row[grantTableLabels.EquipmentType] || "Other";
if (!equipmentGroups[equipKey]) equipmentGroups[equipKey] = [];
equipmentGroups[equipKey].push(row);
});
// Helper: build a "rest of fields" table (exclude Equipment Type + In Area Usage)
function buildInnerTable(rows) {
const tbl = $(`
<div class="table-responsive">
<table class="table table-striped table-bordered table-sm align-middle mb-0">
<thead></thead>
<tbody></tbody>
</table>
</div>
`);
const thead = tbl.find("thead");
const tbody = tbl.find("tbody");
// Header row: everything EXCEPT Equipment Type + In Area Usage
const headerRow = $("<tr>");
grantTableHeaders.forEach((col) => {
if (col.ColumnHeaders === grantTableLabels.EquipmentType) return;
if (col.ColumnHeaders === grantTableLabels.InAreaUsage) return;
headerRow.append(
$("<th>")
.text(col.ColumnHeaders)
.css("text-align", textAlignments.CENTER)
.addClass("fw-bold fs-6 text-body-secondary")
);
});
thead.append(headerRow);
// Body rows
rows.forEach((r) => {
const tr = $("<tr>");
grantTableHeaders.forEach((col) => {
if (col.ColumnHeaders === grantTableLabels.EquipmentType) return;
if (col.ColumnHeaders === grantTableLabels.InAreaUsage) return;
const field = col.ColumnHeaders;
let val = r[field];
if (val == null || val === "") val = "—";
tr.append($("<td>").text(val).css("text-align", col.TextAlignment));
});
tbody.append(tr);
});
return tbl;
}
Object.entries(equipmentGroups).forEach(([equip, equipRows], idx) => {
const collapseId = `collapse-${idx}`;
const headingId = `heading-${idx}`;
const isOpen = accordionState[equip] === true;
const item = $(`
<div class="accordion-item">
<h2 class="accordion-header" id="${headingId}">
<button class="accordion-button ${isOpen ? "" : "collapsed"}"
type="button"
data-bs-toggle="collapse"
data-bs-target="#${collapseId}">
${equip}
</button>
</h2>
<div id="${collapseId}"
class="accordion-collapse collapse ${isOpen ? "show" : ""}"
data-bs-parent="#mobileAccordion"
data-equip="${equip}">
<div class="accordion-body p-2">
<div class="accordion-body-scroll"
style="max-height: calc(100vh - 220px); overflow-y: auto;">
</div>
</div>
</div>
</div>
`);
const scrollBody = item.find(".accordion-body-scroll");
// Inside each Equipment Type: group by In Area Usage
const usageGroups = {};
equipRows.forEach((row) => {
const usageKey = row[grantTableLabels.InAreaUsage] || "—";
if (!usageGroups[usageKey]) usageGroups[usageKey] = [];
usageGroups[usageKey].push(row);
});
// Render sections: heading (In Area Usage) + table (rest fields)
Object.entries(usageGroups).forEach(([usage, rows]) => {
scrollBody.append(`
<div class="fw-bold fs-5 text-uppercase mt-4 mb-1">
${grantTableLabels.InAreaUsage}: ${usage}
</div>
`);
scrollBody.append(buildInnerTable(rows));
});
accordion.append(item);
});
}
// ---------- Filters (with "only options that have results") ----------
function initFilters() {
// Initial options based on full dataset
updateAllFilterOptions(getFilterState());
$(
"#filterEquipmentType, #filterInAreaUsage, #filterHPRange, #filterSelectedYear"
).on("change", function () {
applyFilters();
});
$("#resetFilters").on("click", function () {
$("#filterEquipmentType").val("");
$("#filterInAreaUsage").val("");
$("#filterHPRange").val("");
$("#filterSelectedYear").val("");
applyFilters();
});
}
function updateAllFilterOptions(currentFilters) {
updateFilterOptionsForField(
"#filterEquipmentType",
grantTableLabels.EquipmentType,
currentFilters,
"equipment"
);
updateFilterOptionsForField(
"#filterInAreaUsage",
grantTableLabels.InAreaUsage,
currentFilters,
"commitment"
);
updateFilterOptionsForField(
"#filterHPRange",
"HP Range",
currentFilters,
"hpRange"
);
updateYearFilterOptions(currentFilters);
}
function updateFilterOptionsForField(
selector,
fieldName,
currentFilters,
fieldKey
) {
const baseFilters = { ...currentFilters, [fieldKey]: "" };
const rowsForField = filterRows(baseFilters);
const valueSet = new Set();
rowsForField.forEach((row) => {
if (row[fieldName]) {
valueSet.add(row[fieldName]);
}
});
const select = $(selector);
const previousValue = select.val();
select.empty();
select.append($("<option>").attr("value", "").text("All"));
const sortedValues = Array.from(valueSet).sort((a, b) => {
// Special numeric sort ONLY for HP Range
if (fieldName === "HP Range") {
const getFirstNumber = (str) =>
parseInt(str.split("-")[0].trim(), 10) || 0;
return getFirstNumber(a) - getFirstNumber(b);
}
// Default text sort for everything else
return String(a).localeCompare(String(b));
});
sortedValues.forEach((val) => {
select.append($("<option>").attr("value", val).text(val));
});
if (previousValue && valueSet.has(previousValue)) {
select.val(previousValue);
} else {
select.val("");
}
}
function applyFilters() {
const filters = getFilterState();
filteredData = sortGrantTables(filterRows(filters));
currentPage = 1;
// After filtering, recompute options so values that yield no rows are removed
updateAllFilterOptions(filters);
renderGrantTable(currentPage);
}
// ---------- Main entry ----------
function renderGrantTable(page) {
const isMobile = window.innerWidth < 768;
if (isMobile) {
// Hide desktop table & pagination
$("#grantTable").hide();
$("#grantPagination").closest("nav").hide();
// Render mobile accordion (no pagination on mobile)
renderMobileAccordion();
return;
}
// Desktop: show table, hide any mobile accordion
$("#grantTable").show();
$("#grantPagination").closest("nav").show();
$("#mobileAccordion").remove(); // remove mobile view if it exists
renderGrantTableHeaders();
renderGrantTableBody(page);
renderDesktopPagination(page);
}
$(function () {
initFilters();
renderGrantTable(currentPage);
// Re-render on resize so mobile/desktop switch without refresh
let resizeTimer = null;
$(window).on("resize", function () {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(function () {
renderGrantTable(currentPage);
}, 150); // debounce so it doesn't spam while resizing
});
});
