mappings.php 13 KB

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