|
|
@@ -80,6 +80,19 @@ show_alerts();
|
|
|
?>
|
|
|
<h1>Activity ↔ Item Mappings</h1>
|
|
|
|
|
|
+<style>
|
|
|
+/* Searchable item picker (replaces the unwieldy 100+ entry select) */
|
|
|
+.item-picker{position:relative;}
|
|
|
+.item-list{position:absolute;z-index:20;top:calc(100% + 4px);left:0;right:0;max-height:280px;overflow-y:auto;
|
|
|
+ background:var(--surface);border:1.5px solid var(--border);border-radius:var(--radius);
|
|
|
+ box-shadow:0 8px 24px rgba(0,0,0,.45);padding:.25rem 0;}
|
|
|
+.item-group-label{padding:.4rem .8rem .2rem;font-size:.7rem;font-weight:600;letter-spacing:.08em;
|
|
|
+ text-transform:uppercase;color:var(--accent2);}
|
|
|
+.item-option{padding:.35rem .8rem .35rem 1.3rem;font-size:.88rem;cursor:pointer;}
|
|
|
+.item-option:hover,.item-option.active{background:rgba(232,197,71,.12);color:var(--accent);}
|
|
|
+.item-empty{padding:.5rem .8rem;font-size:.85rem;color:var(--muted);}
|
|
|
+</style>
|
|
|
+
|
|
|
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:flex-start;">
|
|
|
|
|
|
<!-- Activity selector -->
|
|
|
@@ -112,25 +125,34 @@ show_alerts();
|
|
|
<input type="hidden" name="action" value="add">
|
|
|
<input type="hidden" name="activityID" value="<?= $selectedAid ?>">
|
|
|
<div style="flex:1;min-width:200px;">
|
|
|
- <label>Add Item</label>
|
|
|
- <select name="itemID" required style="margin-bottom:0;">
|
|
|
- <option value="">– choose item –</option>
|
|
|
- <?php
|
|
|
- // Group items in optgroups
|
|
|
- $optGroups = [];
|
|
|
- foreach ($allItems as $it) {
|
|
|
- if (in_array($it['itemID'], $mappedItemIds)) continue; // already mapped
|
|
|
- $optGroups[$it['groupName']][] = $it;
|
|
|
- }
|
|
|
- foreach ($optGroups as $grpName => $grpItems):
|
|
|
- ?>
|
|
|
- <optgroup label="<?= htmlspecialchars($grpName) ?>">
|
|
|
- <?php foreach ($grpItems as $it): ?>
|
|
|
- <option value="<?= $it['itemID'] ?>"><?= htmlspecialchars($it['itemName']) ?></option>
|
|
|
+ <label for="item-search">Add Item</label>
|
|
|
+ <div class="item-picker" id="item-picker">
|
|
|
+ <input type="hidden" name="itemID" id="item-id">
|
|
|
+ <input type="text" id="item-search" autocomplete="off" role="combobox"
|
|
|
+ aria-autocomplete="list" aria-expanded="false" aria-controls="item-list"
|
|
|
+ placeholder="Type to search items…" style="margin-bottom:0;">
|
|
|
+ <div class="item-list" id="item-list" role="listbox" hidden>
|
|
|
+ <?php
|
|
|
+ // Group the still-unmapped items; the list is filtered client-side.
|
|
|
+ $optGroups = [];
|
|
|
+ foreach ($allItems as $it) {
|
|
|
+ if (in_array($it['itemID'], $mappedItemIds)) continue; // already mapped
|
|
|
+ $optGroups[$it['groupName']][] = $it;
|
|
|
+ }
|
|
|
+ foreach ($optGroups as $grpName => $grpItems):
|
|
|
+ ?>
|
|
|
+ <div class="item-group">
|
|
|
+ <div class="item-group-label"><?= htmlspecialchars($grpName) ?></div>
|
|
|
+ <?php foreach ($grpItems as $it): ?>
|
|
|
+ <div class="item-option" role="option" tabindex="-1"
|
|
|
+ data-id="<?= $it['itemID'] ?>"
|
|
|
+ data-name="<?= htmlspecialchars($it['itemName']) ?>"><?= htmlspecialchars($it['itemName']) ?></div>
|
|
|
+ <?php endforeach; ?>
|
|
|
+ </div>
|
|
|
<?php endforeach; ?>
|
|
|
- </optgroup>
|
|
|
- <?php endforeach; ?>
|
|
|
- </select>
|
|
|
+ <div class="item-empty" hidden>No matching items.</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
</div>
|
|
|
<div style="width:90px;">
|
|
|
<label>Qty</label>
|
|
|
@@ -178,4 +200,119 @@ show_alerts();
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
+
|
|
|
+<script>
|
|
|
+(function () {
|
|
|
+ var picker = document.getElementById('item-picker');
|
|
|
+ if (!picker) return;
|
|
|
+ var search = document.getElementById('item-search');
|
|
|
+ var hidden = document.getElementById('item-id');
|
|
|
+ var list = document.getElementById('item-list');
|
|
|
+ var empty = list.querySelector('.item-empty');
|
|
|
+ var groups = Array.prototype.slice.call(list.querySelectorAll('.item-group'));
|
|
|
+ var options = Array.prototype.slice.call(list.querySelectorAll('.item-option'));
|
|
|
+ var active = null;
|
|
|
+
|
|
|
+ // Lower-cased and stripped of diacritics, so "muhe" also finds "Mühe".
|
|
|
+ function norm(s) {
|
|
|
+ return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
|
+ }
|
|
|
+ options.forEach(function (o) { o.dataset.search = norm(o.dataset.name); });
|
|
|
+
|
|
|
+ function setActive(el) {
|
|
|
+ if (active) active.classList.remove('active');
|
|
|
+ active = el;
|
|
|
+ if (active) {
|
|
|
+ active.classList.add('active');
|
|
|
+ active.scrollIntoView({block: 'nearest'});
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function visibleOptions() {
|
|
|
+ return options.filter(function (o) { return !o.hidden; });
|
|
|
+ }
|
|
|
+
|
|
|
+ function filter(showAll) {
|
|
|
+ var q = showAll ? '' : norm(search.value.trim());
|
|
|
+ options.forEach(function (o) {
|
|
|
+ o.hidden = q !== '' && o.dataset.search.indexOf(q) === -1;
|
|
|
+ });
|
|
|
+ // Hide a group entirely when none of its items matched.
|
|
|
+ groups.forEach(function (g) {
|
|
|
+ var items = Array.prototype.slice.call(g.querySelectorAll('.item-option'));
|
|
|
+ g.hidden = !items.some(function (o) { return !o.hidden; });
|
|
|
+ });
|
|
|
+ empty.hidden = visibleOptions().length > 0;
|
|
|
+ setActive(visibleOptions()[0] || null);
|
|
|
+ }
|
|
|
+
|
|
|
+ function open() {
|
|
|
+ list.hidden = false;
|
|
|
+ search.setAttribute('aria-expanded', 'true');
|
|
|
+ }
|
|
|
+
|
|
|
+ function close() {
|
|
|
+ list.hidden = true;
|
|
|
+ search.setAttribute('aria-expanded', 'false');
|
|
|
+ setActive(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ function choose(option) {
|
|
|
+ if (!option) return;
|
|
|
+ hidden.value = option.dataset.id;
|
|
|
+ search.value = option.dataset.name;
|
|
|
+ filter();
|
|
|
+ close();
|
|
|
+ }
|
|
|
+
|
|
|
+ search.addEventListener('input', function () {
|
|
|
+ hidden.value = ''; // typing invalidates a previous pick
|
|
|
+ filter();
|
|
|
+ open();
|
|
|
+ });
|
|
|
+ search.addEventListener('focus', function () {
|
|
|
+ // After a pick the field holds the full item name; offer the whole list again.
|
|
|
+ var picked = !!hidden.value;
|
|
|
+ if (picked) search.select();
|
|
|
+ filter(picked);
|
|
|
+ open();
|
|
|
+ });
|
|
|
+ search.addEventListener('keydown', function (e) {
|
|
|
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
|
+ e.preventDefault();
|
|
|
+ if (list.hidden) { filter(!!hidden.value); open(); return; }
|
|
|
+ var vis = visibleOptions();
|
|
|
+ if (!vis.length) return;
|
|
|
+ var i = vis.indexOf(active);
|
|
|
+ i = e.key === 'ArrowDown' ? (i + 1) % vis.length : (i <= 0 ? vis.length : i) - 1;
|
|
|
+ setActive(vis[i]);
|
|
|
+ } else if (e.key === 'Enter') {
|
|
|
+ if (!list.hidden && active) { e.preventDefault(); choose(active); }
|
|
|
+ } else if (e.key === 'Escape') {
|
|
|
+ close();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ list.addEventListener('mousedown', function (e) {
|
|
|
+ var option = e.target.closest('.item-option');
|
|
|
+ if (option) { e.preventDefault(); choose(option); }
|
|
|
+ });
|
|
|
+
|
|
|
+ document.addEventListener('click', function (e) {
|
|
|
+ if (!picker.contains(e.target)) close();
|
|
|
+ });
|
|
|
+
|
|
|
+ search.form.addEventListener('submit', function (e) {
|
|
|
+ if (!hidden.value) {
|
|
|
+ e.preventDefault();
|
|
|
+ search.focus();
|
|
|
+ filter();
|
|
|
+ open();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ filter();
|
|
|
+})();
|
|
|
+</script>
|
|
|
+
|
|
|
<?php admin_foot(); ?>
|