app.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. // Front-end for PDQ / vendor pages.
  2. //
  3. // Globals provided by the page via <script> before this file:
  4. // PDQ.actor 'ICG' or vendor slug
  5. // PDQ.audience 'ICG' or 'vendor'
  6. // PDQ.vendors [{slug, name}, ...] (ICG view only; vendor view: just its own)
  7. // PDQ.pollMs ms between auto-refresh
  8. (function () {
  9. if (typeof window.PDQ === 'undefined') window.PDQ = {};
  10. const PDQ = window.PDQ;
  11. const $ = (sel, root = document) => root.querySelector(sel);
  12. const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  13. // Adds actor/vendor params to AJAX calls so the server can identify us.
  14. function authParams() {
  15. if (PDQ.audience === 'ICG') return { actor: 'ICG' };
  16. return { v: PDQ.actor };
  17. }
  18. function postForm(url, data) {
  19. const body = new URLSearchParams({ ...authParams(), ...data });
  20. return fetch(url, {
  21. method: 'POST',
  22. headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  23. body,
  24. });
  25. }
  26. function get(url, params = {}) {
  27. const u = new URL(url, window.location.href);
  28. Object.entries({ ...authParams(), ...params })
  29. .forEach(([k, v]) => u.searchParams.set(k, v));
  30. return fetch(u.toString());
  31. }
  32. // -------- jobs table --------
  33. function reloadTable() {
  34. return get('bin/jobs_table.php')
  35. .then(r => r.text())
  36. .then(html => {
  37. $('#jobs-table').innerHTML = html;
  38. wireTable();
  39. stampSyncTime();
  40. });
  41. }
  42. function stampSyncTime() {
  43. const d = new Date();
  44. const el = $('#sync-time');
  45. if (!el) return;
  46. el.textContent = 'Last sync: ' + d.toLocaleTimeString();
  47. }
  48. function wireTable() {
  49. $$('#jobs-table .btn[data-action]').forEach(btn => {
  50. btn.addEventListener('click', onAction);
  51. });
  52. $$('#jobs-table .editable').forEach(el => {
  53. el.addEventListener('dblclick', beginEdit);
  54. el.addEventListener('keydown', e => {
  55. if (e.key === 'Enter') { e.preventDefault(); beginEdit.call(el, e); }
  56. });
  57. });
  58. }
  59. function onAction(e) {
  60. const btn = e.currentTarget;
  61. const row = btn.closest('tr');
  62. const jobId = row.dataset.jobId;
  63. const action = btn.dataset.action;
  64. if (action === 'partial_ship') return onPartialShip(btn, row, jobId);
  65. btn.disabled = true;
  66. postForm('bin/jobs_update.php', { job_id: jobId, action })
  67. .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
  68. .then(res => {
  69. if (!res.ok || res.body.trim() !== 'Success') {
  70. alert('Update failed: ' + res.body);
  71. }
  72. return reloadTable();
  73. })
  74. .catch(err => alert('Network error: ' + err.message));
  75. }
  76. function onPartialShip(btn, row, jobId) {
  77. const currentQty = parseInt(btn.dataset.qty, 10);
  78. const raw = window.prompt(
  79. 'How many are shipping now?' + (currentQty ? ' (out of ' + currentQty + ')' : '')
  80. );
  81. if (raw === null) return;
  82. const partial = parseInt(String(raw).trim(), 10);
  83. if (!Number.isInteger(partial) || partial <= 0) {
  84. alert('Enter a whole number greater than zero.');
  85. return;
  86. }
  87. if (currentQty && partial >= currentQty) {
  88. alert('Partial quantity must be less than ' + currentQty + '. Use Mark Shipped to ship the whole job.');
  89. return;
  90. }
  91. btn.disabled = true;
  92. postForm('bin/jobs_partial_ship.php', { job_id: jobId, partial_qty: partial })
  93. .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
  94. .then(res => {
  95. if (!res.ok || res.body.trim() !== 'Success') {
  96. alert('Partial ship failed: ' + res.body);
  97. }
  98. return reloadTable();
  99. })
  100. .catch(err => alert('Network error: ' + err.message));
  101. }
  102. function beginEdit(e) {
  103. const span = this;
  104. if (span.querySelector('input')) return;
  105. const col = span.dataset.column;
  106. const original = (span.dataset.raw !== undefined) ? span.dataset.raw : span.textContent.trim();
  107. const initial = original === '—' ? '' : original;
  108. const input = document.createElement('input');
  109. input.type = 'text';
  110. input.className = 'inline-edit';
  111. input.value = initial;
  112. span.innerHTML = '';
  113. span.appendChild(input);
  114. input.focus();
  115. input.select();
  116. let done = false;
  117. const commit = (save) => {
  118. if (done) return;
  119. done = true;
  120. const val = input.value;
  121. if (!save || val === initial) {
  122. renderValue(span, original, col);
  123. return;
  124. }
  125. span.classList.add('saving');
  126. const row = span.closest('tr');
  127. postForm('bin/jobs_update.php', {
  128. job_id: row.dataset.jobId,
  129. column: col,
  130. value: val,
  131. })
  132. .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
  133. .then(res => {
  134. span.classList.remove('saving');
  135. if (!res.ok || res.body.trim() !== 'Success') {
  136. span.classList.add('error');
  137. alert('Save failed: ' + res.body);
  138. renderValue(span, original, col);
  139. } else {
  140. return reloadTable();
  141. }
  142. })
  143. .catch(err => {
  144. span.classList.remove('saving');
  145. span.classList.add('error');
  146. alert('Network error: ' + err.message);
  147. renderValue(span, original, col);
  148. });
  149. };
  150. input.addEventListener('blur', () => commit(true));
  151. input.addEventListener('keydown', ev => {
  152. if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
  153. if (ev.key === 'Escape') { ev.preventDefault(); commit(false); }
  154. });
  155. }
  156. function renderValue(span, val, col) {
  157. if (col === 'due_date') span.dataset.raw = val;
  158. span.textContent = (val === '' || val === null) ? '—' : val;
  159. }
  160. // -------- messages --------
  161. function reloadAllThreads() {
  162. return Promise.all($$('.thread').map(reloadThread));
  163. }
  164. function reloadThread(thread) {
  165. const slug = thread.dataset.vendor;
  166. const list = thread.querySelector('.thread-list');
  167. const since = list.dataset.maxId || 0;
  168. return get('bin/messages_list.php', { vendor: slug, since })
  169. .then(r => r.text().then(html => ({
  170. html,
  171. maxId: r.headers.get('X-Max-Id') || since,
  172. })))
  173. .then(({ html, maxId }) => {
  174. if (html.trim()) {
  175. if (since === '0' || since === 0) list.innerHTML = '';
  176. list.insertAdjacentHTML('beforeend', html);
  177. list.scrollTop = list.scrollHeight;
  178. }
  179. list.dataset.maxId = maxId;
  180. const empty = list.querySelector('.msg-empty');
  181. if (empty && list.querySelector('.msg')) empty.remove();
  182. });
  183. }
  184. function wireCompose() {
  185. $$('.thread').forEach(thread => {
  186. const form = thread.querySelector('.thread-compose');
  187. if (!form) return;
  188. form.addEventListener('submit', e => {
  189. e.preventDefault();
  190. const input = form.querySelector('input[name=body]');
  191. const body = input.value.trim();
  192. if (!body) return;
  193. const slug = thread.dataset.vendor;
  194. input.disabled = true;
  195. postForm('bin/messages_post.php', { vendor: slug, body })
  196. .then(r => r.text().then(t => ({ ok: r.ok, body: t, maxId: r.headers.get('X-Msg-Id') })))
  197. .then(res => {
  198. input.disabled = false;
  199. if (!res.ok) { alert('Post failed: ' + res.body); return; }
  200. input.value = '';
  201. input.focus();
  202. const list = thread.querySelector('.thread-list');
  203. const empty = list.querySelector('.msg-empty');
  204. if (empty) empty.remove();
  205. list.insertAdjacentHTML('beforeend', res.body);
  206. list.dataset.maxId = res.maxId || list.dataset.maxId;
  207. list.scrollTop = list.scrollHeight;
  208. })
  209. .catch(err => {
  210. input.disabled = false;
  211. alert('Network error: ' + err.message);
  212. });
  213. });
  214. });
  215. }
  216. // -------- add job (ICG only) --------
  217. // While this is non-null, polling skips auto-reload so the in-progress
  218. // compose form isn't ripped out from under the user.
  219. let composeRowId = null;
  220. function wireAddJob() {
  221. const btn = $('#add-job');
  222. if (!btn) return;
  223. btn.addEventListener('click', () => {
  224. const select = $('#add-job-vendor');
  225. const vendor = select ? select.value : 'bill';
  226. postForm('bin/jobs_add.php', { vendor, ajax: '1' })
  227. .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
  228. .then(res => {
  229. const newId = parseInt((res.body || '').trim(), 10);
  230. if (!res.ok || !Number.isInteger(newId) || newId <= 0) {
  231. alert('Add failed: ' + res.body);
  232. return;
  233. }
  234. return reloadTable().then(() => {
  235. const row = $('#jobs-table tr[data-job-id="' + newId + '"]');
  236. if (row) composeRow(row);
  237. });
  238. })
  239. .catch(err => alert('Network error: ' + err.message));
  240. });
  241. }
  242. // Open every editable cell of `rowEl` as an <input> simultaneously so the
  243. // user can Tab between them. Each input's initial value is captured; on
  244. // exit (Escape, Enter past the last cell, or focus leaving the row) only
  245. // the changed fields are saved in parallel, then the table reloads once.
  246. function composeRow(rowEl) {
  247. const jobId = parseInt(rowEl.dataset.jobId, 10);
  248. composeRowId = jobId;
  249. const cells = Array.from(rowEl.querySelectorAll('.editable'));
  250. const inputs = cells.map(span => {
  251. const col = span.dataset.column;
  252. const raw = span.dataset.raw !== undefined ? span.dataset.raw : span.textContent.trim();
  253. const initial = raw === '—' ? '' : raw;
  254. const input = document.createElement('input');
  255. input.type = 'text';
  256. input.className = 'inline-edit';
  257. input.value = initial;
  258. input.dataset.column = col;
  259. input.dataset.initial = initial;
  260. // The span owns tabindex=0 so it can be entered from the keyboard, but
  261. // while it hosts a live input we want Tab to jump straight to the input.
  262. span.setAttribute('tabindex', '-1');
  263. span.innerHTML = '';
  264. span.appendChild(input);
  265. return input;
  266. });
  267. inputs.forEach((input, i) => {
  268. input.addEventListener('keydown', e => {
  269. if (e.key === 'Escape') {
  270. e.preventDefault();
  271. finishCompose(rowEl, /*save=*/false);
  272. } else if (e.key === 'Enter') {
  273. e.preventDefault();
  274. if (i + 1 < inputs.length) {
  275. inputs[i + 1].focus();
  276. inputs[i + 1].select();
  277. } else {
  278. finishCompose(rowEl, true);
  279. }
  280. }
  281. });
  282. });
  283. // Detect focus genuinely leaving the row (not just hopping between its inputs).
  284. rowEl.addEventListener('focusout', () => {
  285. setTimeout(() => {
  286. if (composeRowId === jobId && !rowEl.contains(document.activeElement)) {
  287. finishCompose(rowEl, true);
  288. }
  289. }, 50);
  290. });
  291. if (inputs[0]) { inputs[0].focus(); inputs[0].select(); }
  292. }
  293. function finishCompose(rowEl, save) {
  294. if (composeRowId === null) return;
  295. const jobId = composeRowId;
  296. composeRowId = null;
  297. const inputs = Array.from(rowEl.querySelectorAll('input.inline-edit'));
  298. const dirty = save ? inputs.filter(i => i.value !== i.dataset.initial) : [];
  299. // Serialize the saves so they queue against the SQLite writer lock
  300. // politely instead of all racing at once.
  301. const failed = [];
  302. const runNext = (i) => {
  303. if (i >= dirty.length) return Promise.resolve();
  304. const input = dirty[i];
  305. return postForm('bin/jobs_update.php', {
  306. job_id: jobId,
  307. column: input.dataset.column,
  308. value: input.value,
  309. })
  310. .then(r => r.text().then(t => {
  311. if (!r.ok || t.trim() !== 'Success') {
  312. failed.push(input.dataset.column + ': ' + t);
  313. }
  314. }))
  315. .then(() => runNext(i + 1));
  316. };
  317. runNext(0)
  318. .then(() => {
  319. if (failed.length) alert('Some saves failed:\n' + failed.join('\n'));
  320. return reloadTable();
  321. })
  322. .catch(err => {
  323. alert('Network error: ' + err.message);
  324. reloadTable();
  325. });
  326. }
  327. // -------- polling --------
  328. function isUserBusy() {
  329. const a = document.activeElement;
  330. if (!a) return false;
  331. const tag = a.tagName;
  332. return tag === 'INPUT' || tag === 'TEXTAREA' || a.isContentEditable;
  333. }
  334. function tick() {
  335. if (isUserBusy()) return;
  336. reloadTable();
  337. reloadAllThreads();
  338. }
  339. // -------- long-poll (real-time push) --------
  340. //
  341. // The server holds the connection open until job_history or messages get
  342. // a row beyond our since markers, then immediately tells us what changed.
  343. // We reload only the affected piece. The setInterval timer below remains
  344. // as a safety net for transient network/server failures.
  345. let lpHistoryId = (PDQ.initialHistoryId | 0) || 0;
  346. let lpMessageMaxIds = Object.assign({}, PDQ.initialMessageMaxIds || {});
  347. let lpAbort = null;
  348. let lpStopped = false;
  349. function buildSinceM() {
  350. if (PDQ.audience === 'vendor') {
  351. return String(lpMessageMaxIds[PDQ.vendorId] || 0);
  352. }
  353. return Object.entries(lpMessageMaxIds).map(([k, v]) => k + ':' + v).join(',');
  354. }
  355. function startLongPoll() {
  356. if (lpStopped) return;
  357. lpAbort = new AbortController();
  358. const url = new URL('bin/events_poll.php', window.location.href);
  359. Object.entries(authParams()).forEach(([k, v]) => url.searchParams.set(k, v));
  360. url.searchParams.set('since_h', String(lpHistoryId));
  361. url.searchParams.set('since_m', buildSinceM());
  362. fetch(url.toString(), { signal: lpAbort.signal })
  363. .then(r => r.json())
  364. .then(data => {
  365. // Always update the since markers so the long-poll moves forward and
  366. // doesn't re-fire the same event in a tight loop.
  367. if (typeof data.history === 'number') lpHistoryId = data.history;
  368. if (data.messages && typeof data.messages === 'object') {
  369. Object.entries(data.messages).forEach(([vid, maxid]) => {
  370. lpMessageMaxIds[vid] = maxid;
  371. });
  372. }
  373. // Skip the actual reload if the user is editing — would tear out
  374. // their input. The 60s tick() and the next change will catch them
  375. // up once they're done.
  376. const busy = isUserBusy() || composeRowId !== null;
  377. if (!busy && data.history_changed) reloadTable();
  378. if (!busy && data.changed_vendors && data.changed_vendors.length) reloadAllThreads();
  379. startLongPoll(); // immediately reopen
  380. })
  381. .catch(err => {
  382. if (err.name === 'AbortError') return;
  383. // Network blip / server hiccup: back off briefly and retry. The 60s
  384. // tick() will keep things fresh in the meantime.
  385. setTimeout(startLongPoll, 2000);
  386. });
  387. }
  388. document.addEventListener('DOMContentLoaded', () => {
  389. wireAddJob();
  390. wireCompose();
  391. reloadTable();
  392. reloadAllThreads();
  393. startLongPoll();
  394. const interval = PDQ.pollMs || 60000;
  395. setInterval(tick, interval);
  396. });
  397. })();