mappings.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. <?php
  2. require_once __DIR__ . '/auth.php';
  3. require_once __DIR__ . '/layout.php';
  4. require_admin();
  5. $pdo = get_pdo();
  6. // Handle add/remove mapping
  7. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  8. $action = $_POST['action'] ?? '';
  9. $aid = (int)($_POST['activityID'] ?? 0);
  10. $iid = (int)($_POST['itemID'] ?? 0);
  11. if ($action === 'add' && $aid > 0 && $iid > 0) {
  12. $qty = max(1, (int)($_POST['quantity'] ?? 1));
  13. try {
  14. $pdo->prepare("INSERT IGNORE INTO activity_item_map (activityID,itemID,quantity) VALUES (?,?,?)")
  15. ->execute([$aid, $iid, $qty]);
  16. flash('success', 'Mapping added.');
  17. } catch (Exception $e) {
  18. flash('error', 'Could not add mapping.');
  19. }
  20. }
  21. if ($action === 'update' && $aid > 0 && $iid > 0) {
  22. $qty = max(1, (int)($_POST['quantity'] ?? 1));
  23. $pdo->prepare("UPDATE activity_item_map SET quantity=? WHERE activityID=? AND itemID=?")
  24. ->execute([$qty, $aid, $iid]);
  25. flash('success', 'Quantity updated.');
  26. }
  27. if ($action === 'remove' && $aid > 0 && $iid > 0) {
  28. $pdo->prepare("DELETE FROM activity_item_map WHERE activityID=? AND itemID=?")
  29. ->execute([$aid, $iid]);
  30. flash('success', 'Mapping removed.');
  31. }
  32. header('Location: mappings.php' . (isset($_GET['aid']) ? '?aid=' . (int)$_GET['aid'] : ''));
  33. exit;
  34. }
  35. // Load all activities and items for selects
  36. $activities = $pdo->query("
  37. SELECT a.activityID, a.activityName, ag.groupName
  38. FROM activities a
  39. JOIN activity_groups ag ON ag.groupID = a.groupID
  40. ORDER BY ag.sortOrder, a.sortOrder, a.activityID
  41. ")->fetchAll();
  42. $allItems = $pdo->query("
  43. SELECT i.itemID, i.itemName, ig.groupName
  44. FROM items i
  45. JOIN item_groups ig ON ig.groupID = i.groupID
  46. ORDER BY ig.sortOrder, i.itemName, i.itemID
  47. ")->fetchAll();
  48. // Selected activity filter
  49. $selectedAid = isset($_GET['aid']) ? (int)$_GET['aid'] : 0;
  50. if (!$selectedAid && !empty($activities)) {
  51. $selectedAid = $activities[0]['activityID'];
  52. }
  53. // Get current mappings for selected activity
  54. $mappedItemIds = [];
  55. $mappedItems = [];
  56. if ($selectedAid) {
  57. $stmt = $pdo->prepare("
  58. SELECT i.itemID, i.itemName, ig.groupName, aim.quantity
  59. FROM activity_item_map aim
  60. JOIN items i ON i.itemID = aim.itemID
  61. JOIN item_groups ig ON ig.groupID = i.groupID
  62. WHERE aim.activityID = ?
  63. ORDER BY ig.sortOrder, i.sortOrder
  64. ");
  65. $stmt->execute([$selectedAid]);
  66. $mappedItems = $stmt->fetchAll();
  67. $mappedItemIds = array_column($mappedItems, 'itemID');
  68. }
  69. admin_head('Mappings', 'mappings.php');
  70. show_alerts();
  71. ?>
  72. <h1>Activity ↔ Item Mappings</h1>
  73. <style>
  74. /* Searchable item picker (replaces the unwieldy 100+ entry select) */
  75. .item-picker{position:relative;}
  76. .item-list{position:absolute;z-index:20;top:calc(100% + 4px);left:0;right:0;max-height:280px;overflow-y:auto;
  77. background:var(--surface);border:1.5px solid var(--border);border-radius:var(--radius);
  78. box-shadow:0 8px 24px rgba(0,0,0,.45);padding:.25rem 0;}
  79. .item-group-label{padding:.4rem .8rem .2rem;font-size:.7rem;font-weight:600;letter-spacing:.08em;
  80. text-transform:uppercase;color:var(--accent2);}
  81. .item-option{padding:.35rem .8rem .35rem 1.3rem;font-size:.88rem;cursor:pointer;}
  82. .item-option:hover,.item-option.active{background:rgba(232,197,71,.12);color:var(--accent);}
  83. .item-empty{padding:.5rem .8rem;font-size:.85rem;color:var(--muted);}
  84. </style>
  85. <div style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:flex-start;">
  86. <!-- Activity selector -->
  87. <div class="card" style="min-width:220px;flex:0 0 220px;">
  88. <h2>Select Activity</h2>
  89. <?php foreach ($activities as $a): ?>
  90. <a href="?aid=<?= $a['activityID'] ?>"
  91. style="display:block;padding:.45rem .6rem;border-radius:6px;margin-bottom:.25rem;font-size:.85rem;text-decoration:none;
  92. background:<?= $selectedAid==$a['activityID'] ? 'rgba(232,197,71,.1)' : 'transparent' ?>;
  93. color:<?= $selectedAid==$a['activityID'] ? 'var(--accent)' : 'var(--muted)' ?>;
  94. border:1px solid <?= $selectedAid==$a['activityID'] ? 'rgba(232,197,71,.3)' : 'transparent' ?>;">
  95. <?= htmlspecialchars($a['activityName']) ?>
  96. <span style="float:right;font-size:.72rem;color:var(--muted)"><?= htmlspecialchars($a['groupName']) ?></span>
  97. </a>
  98. <?php endforeach; ?>
  99. </div>
  100. <!-- Mapping manager -->
  101. <div style="flex:1;min-width:300px;">
  102. <?php if ($selectedAid):
  103. // Get the activity name
  104. $actName = '';
  105. foreach ($activities as $a) { if ($a['activityID'] == $selectedAid) $actName = $a['activityName']; }
  106. ?>
  107. <div class="card">
  108. <h2 style="margin-bottom:1rem">Items for: <span style="color:var(--accent)"><?= htmlspecialchars($actName) ?></span></h2>
  109. <!-- Add new mapping -->
  110. <form method="POST" style="display:flex;gap:.5rem;align-items:flex-end;margin-bottom:1.25rem;flex-wrap:wrap;">
  111. <input type="hidden" name="action" value="add">
  112. <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
  113. <div style="flex:1;min-width:200px;">
  114. <label for="item-search">Add Item</label>
  115. <div class="item-picker" id="item-picker">
  116. <input type="hidden" name="itemID" id="item-id">
  117. <input type="text" id="item-search" autocomplete="off" role="combobox"
  118. aria-autocomplete="list" aria-expanded="false" aria-controls="item-list"
  119. placeholder="Type to search items…" style="margin-bottom:0;">
  120. <div class="item-list" id="item-list" role="listbox" hidden>
  121. <?php
  122. // Group the still-unmapped items; the list is filtered client-side.
  123. $optGroups = [];
  124. foreach ($allItems as $it) {
  125. if (in_array($it['itemID'], $mappedItemIds)) continue; // already mapped
  126. $optGroups[$it['groupName']][] = $it;
  127. }
  128. foreach ($optGroups as $grpName => $grpItems):
  129. ?>
  130. <div class="item-group">
  131. <div class="item-group-label"><?= htmlspecialchars($grpName) ?></div>
  132. <?php foreach ($grpItems as $it): ?>
  133. <div class="item-option" role="option" tabindex="-1"
  134. data-id="<?= $it['itemID'] ?>"
  135. data-name="<?= htmlspecialchars($it['itemName']) ?>"><?= htmlspecialchars($it['itemName']) ?></div>
  136. <?php endforeach; ?>
  137. </div>
  138. <?php endforeach; ?>
  139. <div class="item-empty" hidden>No matching items.</div>
  140. </div>
  141. </div>
  142. </div>
  143. <div style="width:90px;">
  144. <label>Qty</label>
  145. <input type="number" name="quantity" value="1" min="1" style="margin-bottom:0;width:100%;">
  146. </div>
  147. <button type="submit" class="btn btn-primary">➕ Add</button>
  148. </form>
  149. <!-- Current mapped items -->
  150. <?php if (empty($mappedItems)): ?>
  151. <p style="color:var(--muted);font-size:.85rem">No items mapped yet.</p>
  152. <?php else: ?>
  153. <table class="tbl">
  154. <thead><tr><th>#</th><th>Item</th><th>Group</th><th>Qty</th><th></th></tr></thead>
  155. <tbody>
  156. <?php foreach ($mappedItems as $it): ?>
  157. <tr>
  158. <td style="color:var(--muted)"><?= $it['itemID'] ?></td>
  159. <td><?= htmlspecialchars($it['itemName']) ?></td>
  160. <td><span class="badge badge-group"><?= htmlspecialchars($it['groupName']) ?></span></td>
  161. <td>
  162. <form method="POST" style="display:flex;gap:.3rem;align-items:center;">
  163. <input type="hidden" name="action" value="update">
  164. <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
  165. <input type="hidden" name="itemID" value="<?= $it['itemID'] ?>">
  166. <input type="number" name="quantity" value="<?= (int)$it['quantity'] ?>" min="1" style="margin:0;width:70px;">
  167. <button class="btn btn-sm btn-primary">💾</button>
  168. </form>
  169. </td>
  170. <td style="text-align:right">
  171. <form method="POST" onsubmit="return confirm('Remove this mapping?')">
  172. <input type="hidden" name="action" value="remove">
  173. <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
  174. <input type="hidden" name="itemID" value="<?= $it['itemID'] ?>">
  175. <button class="btn btn-sm btn-danger">Remove</button>
  176. </form>
  177. </td>
  178. </tr>
  179. <?php endforeach; ?>
  180. </tbody>
  181. </table>
  182. <?php endif; ?>
  183. </div>
  184. <?php endif; ?>
  185. </div>
  186. </div>
  187. <script>
  188. (function () {
  189. var picker = document.getElementById('item-picker');
  190. if (!picker) return;
  191. var search = document.getElementById('item-search');
  192. var hidden = document.getElementById('item-id');
  193. var list = document.getElementById('item-list');
  194. var empty = list.querySelector('.item-empty');
  195. var groups = Array.prototype.slice.call(list.querySelectorAll('.item-group'));
  196. var options = Array.prototype.slice.call(list.querySelectorAll('.item-option'));
  197. var active = null;
  198. // Lower-cased and stripped of diacritics, so "muhe" also finds "Mühe".
  199. function norm(s) {
  200. return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
  201. }
  202. options.forEach(function (o) { o.dataset.search = norm(o.dataset.name); });
  203. function setActive(el) {
  204. if (active) active.classList.remove('active');
  205. active = el;
  206. if (active) {
  207. active.classList.add('active');
  208. active.scrollIntoView({block: 'nearest'});
  209. }
  210. }
  211. function visibleOptions() {
  212. return options.filter(function (o) { return !o.hidden; });
  213. }
  214. function filter(showAll) {
  215. var q = showAll ? '' : norm(search.value.trim());
  216. options.forEach(function (o) {
  217. o.hidden = q !== '' && o.dataset.search.indexOf(q) === -1;
  218. });
  219. // Hide a group entirely when none of its items matched.
  220. groups.forEach(function (g) {
  221. var items = Array.prototype.slice.call(g.querySelectorAll('.item-option'));
  222. g.hidden = !items.some(function (o) { return !o.hidden; });
  223. });
  224. empty.hidden = visibleOptions().length > 0;
  225. setActive(visibleOptions()[0] || null);
  226. }
  227. function open() {
  228. list.hidden = false;
  229. search.setAttribute('aria-expanded', 'true');
  230. }
  231. function close() {
  232. list.hidden = true;
  233. search.setAttribute('aria-expanded', 'false');
  234. setActive(null);
  235. }
  236. function choose(option) {
  237. if (!option) return;
  238. hidden.value = option.dataset.id;
  239. search.value = option.dataset.name;
  240. filter();
  241. close();
  242. }
  243. search.addEventListener('input', function () {
  244. hidden.value = ''; // typing invalidates a previous pick
  245. filter();
  246. open();
  247. });
  248. search.addEventListener('focus', function () {
  249. // After a pick the field holds the full item name; offer the whole list again.
  250. var picked = !!hidden.value;
  251. if (picked) search.select();
  252. filter(picked);
  253. open();
  254. });
  255. search.addEventListener('keydown', function (e) {
  256. if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
  257. e.preventDefault();
  258. if (list.hidden) { filter(!!hidden.value); open(); return; }
  259. var vis = visibleOptions();
  260. if (!vis.length) return;
  261. var i = vis.indexOf(active);
  262. i = e.key === 'ArrowDown' ? (i + 1) % vis.length : (i <= 0 ? vis.length : i) - 1;
  263. setActive(vis[i]);
  264. } else if (e.key === 'Enter') {
  265. if (!list.hidden && active) { e.preventDefault(); choose(active); }
  266. } else if (e.key === 'Escape') {
  267. close();
  268. }
  269. });
  270. list.addEventListener('mousedown', function (e) {
  271. var option = e.target.closest('.item-option');
  272. if (option) { e.preventDefault(); choose(option); }
  273. });
  274. document.addEventListener('click', function (e) {
  275. if (!picker.contains(e.target)) close();
  276. });
  277. search.form.addEventListener('submit', function (e) {
  278. if (!hidden.value) {
  279. e.preventDefault();
  280. search.focus();
  281. filter();
  282. open();
  283. }
  284. });
  285. filter();
  286. })();
  287. </script>
  288. <?php admin_foot(); ?>