mappings.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  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. /* Activity selector: one heading per group, activities listed below it, so a long
  85. activity name can wrap without colliding with a per-row category label. */
  86. .activity-picker{flex:0 0 260px;min-width:260px;}
  87. .activity-group{margin-bottom:1rem;}
  88. .activity-group:last-child{margin-bottom:0;}
  89. .activity-group-label{padding:0 .15rem .3rem;font-size:.7rem;font-weight:600;letter-spacing:.08em;
  90. text-transform:uppercase;color:var(--accent2);}
  91. .activity-link{display:block;padding:.4rem .6rem;margin-bottom:.2rem;border-radius:6px;
  92. border:1px solid transparent;font-size:.85rem;line-height:1.35;text-decoration:none;
  93. color:var(--muted);overflow-wrap:anywhere;
  94. transition:color .15s,background .15s,border-color .15s;}
  95. .activity-link:hover{color:var(--text);background:rgba(255,255,255,.03);}
  96. .activity-link.active{background:rgba(232,197,71,.1);color:var(--accent);border-color:rgba(232,197,71,.3);}
  97. </style>
  98. <div style="display:flex;gap:1.5rem;flex-wrap:wrap;align-items:flex-start;">
  99. <!-- Activity selector -->
  100. <div class="card activity-picker">
  101. <h2>Select Activity</h2>
  102. <?php
  103. // The query already orders by group, so grouping keeps the intended sort order.
  104. $activityGroups = [];
  105. foreach ($activities as $a) { $activityGroups[$a['groupName']][] = $a; }
  106. foreach ($activityGroups as $grpName => $grpActivities):
  107. ?>
  108. <div class="activity-group">
  109. <div class="activity-group-label"><?= htmlspecialchars($grpName) ?></div>
  110. <?php foreach ($grpActivities as $a): ?>
  111. <a href="?aid=<?= $a['activityID'] ?>"
  112. class="activity-link<?= $selectedAid==$a['activityID'] ? ' active' : '' ?>"><?= htmlspecialchars($a['activityName']) ?></a>
  113. <?php endforeach; ?>
  114. </div>
  115. <?php endforeach; ?>
  116. </div>
  117. <!-- Mapping manager -->
  118. <div style="flex:1;min-width:300px;">
  119. <?php if ($selectedAid):
  120. // Get the activity name
  121. $actName = '';
  122. foreach ($activities as $a) { if ($a['activityID'] == $selectedAid) $actName = $a['activityName']; }
  123. ?>
  124. <div class="card">
  125. <h2 style="margin-bottom:1rem">Items for: <span style="color:var(--accent)"><?= htmlspecialchars($actName) ?></span></h2>
  126. <!-- Add new mapping -->
  127. <form method="POST" style="display:flex;gap:.5rem;align-items:flex-end;margin-bottom:1.25rem;flex-wrap:wrap;">
  128. <input type="hidden" name="action" value="add">
  129. <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
  130. <div style="flex:1;min-width:200px;">
  131. <label for="item-search">Add Item</label>
  132. <div class="item-picker" id="item-picker">
  133. <input type="hidden" name="itemID" id="item-id">
  134. <input type="text" id="item-search" autocomplete="off" role="combobox"
  135. aria-autocomplete="list" aria-expanded="false" aria-controls="item-list"
  136. placeholder="Type to search items…" style="margin-bottom:0;">
  137. <div class="item-list" id="item-list" role="listbox" hidden>
  138. <?php
  139. // Group the still-unmapped items; the list is filtered client-side.
  140. $optGroups = [];
  141. foreach ($allItems as $it) {
  142. if (in_array($it['itemID'], $mappedItemIds)) continue; // already mapped
  143. $optGroups[$it['groupName']][] = $it;
  144. }
  145. foreach ($optGroups as $grpName => $grpItems):
  146. ?>
  147. <div class="item-group">
  148. <div class="item-group-label"><?= htmlspecialchars($grpName) ?></div>
  149. <?php foreach ($grpItems as $it): ?>
  150. <div class="item-option" role="option" tabindex="-1"
  151. data-id="<?= $it['itemID'] ?>"
  152. data-name="<?= htmlspecialchars($it['itemName']) ?>"><?= htmlspecialchars($it['itemName']) ?></div>
  153. <?php endforeach; ?>
  154. </div>
  155. <?php endforeach; ?>
  156. <div class="item-empty" hidden>No matching items.</div>
  157. </div>
  158. </div>
  159. </div>
  160. <div style="width:90px;">
  161. <label>Qty</label>
  162. <input type="number" name="quantity" value="1" min="1" style="margin-bottom:0;width:100%;">
  163. </div>
  164. <button type="submit" class="btn btn-primary">➕ Add</button>
  165. </form>
  166. <!-- Current mapped items -->
  167. <?php if (empty($mappedItems)): ?>
  168. <p style="color:var(--muted);font-size:.85rem">No items mapped yet.</p>
  169. <?php else: ?>
  170. <table class="tbl">
  171. <thead><tr><th>#</th><th>Item</th><th>Group</th><th>Qty</th><th></th></tr></thead>
  172. <tbody>
  173. <?php foreach ($mappedItems as $it): ?>
  174. <tr>
  175. <td style="color:var(--muted)"><?= $it['itemID'] ?></td>
  176. <td><?= htmlspecialchars($it['itemName']) ?></td>
  177. <td><span class="badge badge-group"><?= htmlspecialchars($it['groupName']) ?></span></td>
  178. <td>
  179. <form method="POST" style="display:flex;gap:.3rem;align-items:center;">
  180. <input type="hidden" name="action" value="update">
  181. <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
  182. <input type="hidden" name="itemID" value="<?= $it['itemID'] ?>">
  183. <input type="number" name="quantity" value="<?= (int)$it['quantity'] ?>" min="1" style="margin:0;width:70px;">
  184. <button class="btn btn-sm btn-primary">💾</button>
  185. </form>
  186. </td>
  187. <td style="text-align:right">
  188. <form method="POST" onsubmit="return confirm('Remove this mapping?')">
  189. <input type="hidden" name="action" value="remove">
  190. <input type="hidden" name="activityID" value="<?= $selectedAid ?>">
  191. <input type="hidden" name="itemID" value="<?= $it['itemID'] ?>">
  192. <button class="btn btn-sm btn-danger">Remove</button>
  193. </form>
  194. </td>
  195. </tr>
  196. <?php endforeach; ?>
  197. </tbody>
  198. </table>
  199. <?php endif; ?>
  200. </div>
  201. <?php endif; ?>
  202. </div>
  203. </div>
  204. <script>
  205. (function () {
  206. var picker = document.getElementById('item-picker');
  207. if (!picker) return;
  208. var search = document.getElementById('item-search');
  209. var hidden = document.getElementById('item-id');
  210. var list = document.getElementById('item-list');
  211. var empty = list.querySelector('.item-empty');
  212. var groups = Array.prototype.slice.call(list.querySelectorAll('.item-group'));
  213. var options = Array.prototype.slice.call(list.querySelectorAll('.item-option'));
  214. var active = null;
  215. // Lower-cased and stripped of diacritics, so "muhe" also finds "Mühe".
  216. function norm(s) {
  217. return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
  218. }
  219. options.forEach(function (o) { o.dataset.search = norm(o.dataset.name); });
  220. function setActive(el) {
  221. if (active) active.classList.remove('active');
  222. active = el;
  223. if (active) {
  224. active.classList.add('active');
  225. active.scrollIntoView({block: 'nearest'});
  226. }
  227. }
  228. function visibleOptions() {
  229. return options.filter(function (o) { return !o.hidden; });
  230. }
  231. function filter(showAll) {
  232. var q = showAll ? '' : norm(search.value.trim());
  233. options.forEach(function (o) {
  234. o.hidden = q !== '' && o.dataset.search.indexOf(q) === -1;
  235. });
  236. // Hide a group entirely when none of its items matched.
  237. groups.forEach(function (g) {
  238. var items = Array.prototype.slice.call(g.querySelectorAll('.item-option'));
  239. g.hidden = !items.some(function (o) { return !o.hidden; });
  240. });
  241. empty.hidden = visibleOptions().length > 0;
  242. setActive(visibleOptions()[0] || null);
  243. }
  244. function open() {
  245. list.hidden = false;
  246. search.setAttribute('aria-expanded', 'true');
  247. }
  248. function close() {
  249. list.hidden = true;
  250. search.setAttribute('aria-expanded', 'false');
  251. setActive(null);
  252. }
  253. function choose(option) {
  254. if (!option) return;
  255. hidden.value = option.dataset.id;
  256. search.value = option.dataset.name;
  257. filter();
  258. close();
  259. }
  260. search.addEventListener('input', function () {
  261. hidden.value = ''; // typing invalidates a previous pick
  262. filter();
  263. open();
  264. });
  265. search.addEventListener('focus', function () {
  266. // After a pick the field holds the full item name; offer the whole list again.
  267. var picked = !!hidden.value;
  268. if (picked) search.select();
  269. filter(picked);
  270. open();
  271. });
  272. search.addEventListener('keydown', function (e) {
  273. if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
  274. e.preventDefault();
  275. if (list.hidden) { filter(!!hidden.value); open(); return; }
  276. var vis = visibleOptions();
  277. if (!vis.length) return;
  278. var i = vis.indexOf(active);
  279. i = e.key === 'ArrowDown' ? (i + 1) % vis.length : (i <= 0 ? vis.length : i) - 1;
  280. setActive(vis[i]);
  281. } else if (e.key === 'Enter') {
  282. if (!list.hidden && active) { e.preventDefault(); choose(active); }
  283. } else if (e.key === 'Escape') {
  284. close();
  285. }
  286. });
  287. list.addEventListener('mousedown', function (e) {
  288. var option = e.target.closest('.item-option');
  289. if (option) { e.preventDefault(); choose(option); }
  290. });
  291. document.addEventListener('click', function (e) {
  292. if (!picker.contains(e.target)) close();
  293. });
  294. search.form.addEventListener('submit', function (e) {
  295. if (!hidden.value) {
  296. e.preventDefault();
  297. search.focus();
  298. filter();
  299. open();
  300. }
  301. });
  302. filter();
  303. })();
  304. </script>
  305. <?php admin_foot(); ?>