Skip to Content

Search tceq.texas.gov

Questions or Comments: info@tceq.texas.gov

Rebate Grant Tables: Render Table

Rebate Grant Tables: Render Table

text/javascript 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
  });
});