Jelajahi Sumber

Show mapping counts on the items page and make the list sortable

The items table said nothing about how an item is used, so deciding
whether one is safe to delete or rename meant going to the mappings
page and reading every activity. A Mappings column now carries the
count from activity_item_map, as a badge, or a muted 0 for items no
activity uses at all -- the ones actually worth spotting.

With 35 items and growing, the fixed group-then-sort-order sequence was
also the only way to read the table. The five named columns are now
clickable headers cycling ascending, descending and back to the
server's order; that third state exists because group, sort order and
ID together are not reproducible from any single column, so without it
there is no way back. Rows carry their sort keys as data attributes, so
the comparator never parses the view/edit markup in the cells, and ties
fall back to the server order -- sorting by Group still lists each
group's items in their configured sequence. Names use Intl.Collator, so
case and umlauts sort the way they read.

The chosen sort is kept in sessionStorage: saving an item redirects
back to items.php, and without that the table snapped back to the
default after every edit. Storage access is guarded, so a private
window loses the persistence rather than the sorting. The arrow
indicator is driven off aria-sort, and headers are focusable and take
Enter and Space.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sebastian 3 minggu lalu
induk
melakukan
b46705174a
1 mengubah file dengan 110 tambahan dan 6 penghapusan
  1. 110 6
      admin/items.php

+ 110 - 6
admin/items.php

@@ -37,7 +37,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
 
 $igroups = $pdo->query("SELECT * FROM item_groups ORDER BY sortOrder,groupID")->fetchAll();
 $items   = $pdo->query("
-    SELECT i.*, ig.groupName
+    SELECT i.*, ig.groupName,
+           (SELECT COUNT(*) FROM activity_item_map m WHERE m.itemID = i.itemID) AS mappingCount
     FROM items i
     JOIN item_groups ig ON ig.groupID = i.groupID
     ORDER BY ig.sortOrder, i.sortOrder, i.itemID
@@ -66,6 +67,18 @@ show_alerts();
 .dup-hint-float{position:absolute;z-index:30;top:calc(100% + 4px);left:0;margin:0;
                 min-width:100%;width:max-content;max-width:min(420px,60vw);
                 background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.45);}
+
+/* Clickable column headers. The arrow is driven by aria-sort, so the visual
+   state and the one screen readers announce can never drift apart. */
+.th-sort{cursor:pointer;user-select:none;}
+.th-sort:hover{color:var(--text);}
+.th-sort:focus-visible{outline:2px solid var(--accent2);outline-offset:-2px;}
+.th-sort::after{content:'\2195';margin-left:.35rem;opacity:.3;}
+.th-sort[aria-sort="ascending"]{color:var(--accent);}
+.th-sort[aria-sort="ascending"]::after{content:'\2191';opacity:1;}
+.th-sort[aria-sort="descending"]{color:var(--accent);}
+.th-sort[aria-sort="descending"]::after{content:'\2193';opacity:1;}
+.count-zero{color:var(--muted);}
 </style>
 
 <div class="card">
@@ -107,20 +120,32 @@ show_alerts();
 </form>
 <?php endforeach; ?>
 
-<table class="tbl tbl-fixed">
+<table class="tbl tbl-fixed" id="items-table">
   <colgroup>
     <col style="width:60px">
     <col>
     <col style="width:180px">
-    <col style="width:100px">
+    <col style="width:90px">
+    <col style="width:120px">
     <col style="width:200px">
   </colgroup>
-  <thead><tr><th>#</th><th>Name</th><th>Group</th><th>Sort</th><th></th></tr></thead>
+  <thead><tr>
+    <th class="th-sort" data-sort-key="id"    data-sort-type="num"  tabindex="0" aria-sort="none">#</th>
+    <th class="th-sort" data-sort-key="name"  data-sort-type="text" tabindex="0" aria-sort="none">Name</th>
+    <th class="th-sort" data-sort-key="group" data-sort-type="text" tabindex="0" aria-sort="none">Group</th>
+    <th class="th-sort" data-sort-key="order" data-sort-type="num"  tabindex="0" aria-sort="none">Sort</th>
+    <th class="th-sort" data-sort-key="maps"  data-sort-type="num"  tabindex="0" aria-sort="none">Mappings</th>
+    <th></th>
+  </tr></thead>
   <tbody>
   <?php foreach ($items as $item):
-    $id = $item['itemID'];
+    $id   = $item['itemID'];
+    $maps = (int)$item['mappingCount'];
   ?>
-  <tr id="row-<?= $id ?>" data-editing="false">
+  <tr id="row-<?= $id ?>" data-editing="false"
+      data-id="<?= $id ?>" data-name="<?= htmlspecialchars($item['itemName']) ?>"
+      data-group="<?= htmlspecialchars($item['groupName']) ?>"
+      data-order="<?= (int)$item['sortOrder'] ?>" data-maps="<?= $maps ?>">
     <td style="color:var(--muted)"><?= $id ?></td>
     <td>
       <div class="view"><?= htmlspecialchars($item['itemName']) ?></div>
@@ -147,6 +172,13 @@ show_alerts();
       <div class="view"><?= $item['sortOrder'] ?></div>
       <div class="edit"><input type="number" name="sortOrder" form="edit-form-<?= $id ?>" value="<?= (int)$item['sortOrder'] ?>" min="0"></div>
     </td>
+    <td>
+      <?php if ($maps > 0): ?>
+      <span class="badge badge-group" title="Used in <?= $maps ?> activity mapping<?= $maps === 1 ? '' : 's' ?>"><?= $maps ?></span>
+      <?php else: ?>
+      <span class="count-zero" title="Not used in any activity">0</span>
+      <?php endif; ?>
+    </td>
     <td>
       <div class="view actions-buttons">
         <button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>
@@ -179,6 +211,78 @@ function cancelEdit(id) {
 }
 </script>
 
+<script>
+// Column sorting. Each row carries its sort keys as data attributes, so the
+// comparison never has to dig through the view/edit markup in the cells.
+(function () {
+  var table = document.getElementById('items-table');
+  if (!table || !table.tBodies.length) return;
+
+  var tbody = table.tBodies[0];
+  var rows  = Array.prototype.slice.call(tbody.rows);
+  // The order the server sent - group, then sort order, then ID - is not
+  // reproducible from any single column, so it is kept as the third state of
+  // the cycle rather than being lost after the first click.
+  rows.forEach(function (row, i) { row.dataset.defaultPos = i; });
+
+  var headers = Array.prototype.slice.call(table.querySelectorAll('th.th-sort'));
+  var STORE   = 'packit.items.sort';
+  var collator = new Intl.Collator(undefined, {sensitivity: 'base', numeric: true});
+
+  function compare(th, a, b) {
+    var k = th.dataset.sortKey;
+    return th.dataset.sortType === 'num'
+      ? (+a.dataset[k]) - (+b.dataset[k])
+      : collator.compare(a.dataset[k], b.dataset[k]);
+  }
+
+  // dir: 1 ascending, -1 descending, 0 back to the server's order.
+  function apply(th, dir) {
+    headers.forEach(function (h) { h.setAttribute('aria-sort', 'none'); });
+    if (th && dir) {
+      th.setAttribute('aria-sort', dir > 0 ? 'ascending' : 'descending');
+    }
+
+    rows.slice().sort(function (a, b) {
+      // Equal keys keep the server's order, so sorting by Group still lists
+      // each group's items in their configured sequence.
+      return (dir && compare(th, a, b) * dir) || (a.dataset.defaultPos - b.dataset.defaultPos);
+    }).forEach(function (row) { tbody.appendChild(row); });
+
+    try {
+      if (th && dir) {
+        sessionStorage.setItem(STORE, th.dataset.sortKey + ':' + dir);
+      } else {
+        sessionStorage.removeItem(STORE);
+      }
+    } catch (e) { /* private mode - sorting still works, it just will not stick */ }
+  }
+
+  function toggle(th) {
+    var state = th.getAttribute('aria-sort');
+    apply(th, state === 'ascending' ? -1 : state === 'descending' ? 0 : 1);
+  }
+
+  headers.forEach(function (th) {
+    th.addEventListener('click', function () { toggle(th); });
+    th.addEventListener('keydown', function (e) {
+      if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
+        e.preventDefault();
+        toggle(th);
+      }
+    });
+  });
+
+  // Saving an item redirects back to this page, so the chosen order is
+  // restored instead of snapping back to the default on every edit.
+  try {
+    var saved = (sessionStorage.getItem(STORE) || '').split(':');
+    var th = headers.filter(function (h) { return h.dataset.sortKey === saved[0]; })[0];
+    if (th) apply(th, saved[1] === '-1' ? -1 : 1);
+  } catch (e) { /* nothing stored, or storage blocked */ }
+})();
+</script>
+
 <script>
 // Duplicate hints: purely advisory. They never block a save - they only show
 // which existing items resemble the name being typed, both on the add form and