diff --git a/internal/server/static/app.css b/internal/server/static/app.css
index 7600fd1..e6fabbe 100644
--- a/internal/server/static/app.css
+++ b/internal/server/static/app.css
@@ -27,3 +27,26 @@ body {
.btn-list form {
margin: 0;
}
+
+.table-sort {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ color: inherit;
+ cursor: pointer;
+ display: inline-flex;
+ font: inherit;
+ gap: 0.25rem;
+ padding: 0;
+ text-transform: inherit;
+}
+
+.table-sort:hover,
+.table-sort:focus {
+ color: var(--tblr-primary);
+}
+
+.sort-indicator {
+ display: inline-block;
+ min-width: 1ch;
+}
diff --git a/internal/server/static/app.js b/internal/server/static/app.js
new file mode 100644
index 0000000..a5f1650
--- /dev/null
+++ b/internal/server/static/app.js
@@ -0,0 +1,87 @@
+(function () {
+ function sortableValue(row, index, type) {
+ var cell = row.children[index];
+ if (!cell) {
+ return "";
+ }
+ var value = cell.dataset.sortValue || cell.textContent || "";
+ value = value.trim();
+ if (type === "number") {
+ var parsed = Number.parseInt(value, 10);
+ return Number.isNaN(parsed) ? 0 : parsed;
+ }
+ return value.toLocaleLowerCase("pt-BR");
+ }
+
+ function compareRows(index, type, direction) {
+ return function (left, right) {
+ var leftValue = sortableValue(left, index, type);
+ var rightValue = sortableValue(right, index, type);
+ var result;
+ if (type === "number") {
+ result = leftValue - rightValue;
+ } else {
+ result = leftValue.localeCompare(rightValue, "pt-BR", {
+ numeric: true,
+ sensitivity: "base",
+ });
+ }
+ return direction === "desc" ? -result : result;
+ };
+ }
+
+ function updateIndicators(table, activeButton, direction) {
+ table.querySelectorAll(".table-sort").forEach(function (button) {
+ var header = button.closest("th");
+ var indicator = button.querySelector(".sort-indicator");
+ if (header) {
+ header.removeAttribute("aria-sort");
+ }
+ if (indicator) {
+ indicator.textContent = "";
+ }
+ });
+
+ var activeHeader = activeButton.closest("th");
+ if (activeHeader) {
+ activeHeader.setAttribute("aria-sort", direction === "desc" ? "descending" : "ascending");
+ }
+ var activeIndicator = activeButton.querySelector(".sort-indicator");
+ if (activeIndicator) {
+ activeIndicator.textContent = direction === "desc" ? "↓" : "↑";
+ }
+ }
+
+ function sortTable(table, button) {
+ var tbody = table.tBodies[0];
+ if (!tbody) {
+ return;
+ }
+
+ var index = Number.parseInt(button.dataset.sortIndex || "0", 10);
+ var type = button.dataset.sortType || "text";
+ var currentIndex = table.dataset.sortIndex;
+ var currentDirection = table.dataset.sortDirection || "asc";
+ var direction = currentIndex === String(index) && currentDirection === "asc" ? "desc" : "asc";
+
+ Array.from(tbody.rows)
+ .sort(compareRows(index, type, direction))
+ .forEach(function (row) {
+ tbody.appendChild(row);
+ });
+
+ table.dataset.sortIndex = String(index);
+ table.dataset.sortDirection = direction;
+ updateIndicators(table, button, direction);
+ }
+
+ document.addEventListener("DOMContentLoaded", function () {
+ document.querySelectorAll("[data-sortable-table]").forEach(function (table) {
+ table.querySelectorAll(".table-sort").forEach(function (button) {
+ button.addEventListener("click", function () {
+ sortTable(table, button);
+ });
+ });
+ });
+ });
+})();
diff --git a/internal/server/templates/base.html b/internal/server/templates/base.html
index 04e695b..4823952 100644
--- a/internal/server/templates/base.html
+++ b/internal/server/templates/base.html
@@ -69,6 +69,7 @@
+