app.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  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. btn.disabled = true;
  65. postForm('bin/jobs_update.php', { job_id: jobId, action })
  66. .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
  67. .then(res => {
  68. if (!res.ok || res.body.trim() !== 'Success') {
  69. alert('Update failed: ' + res.body);
  70. }
  71. return reloadTable();
  72. })
  73. .catch(err => alert('Network error: ' + err.message));
  74. }
  75. function beginEdit(e) {
  76. const span = this;
  77. if (span.querySelector('input')) return;
  78. const col = span.dataset.column;
  79. const original = (span.dataset.raw !== undefined) ? span.dataset.raw : span.textContent.trim();
  80. const initial = original === '—' ? '' : original;
  81. const input = document.createElement('input');
  82. input.type = 'text';
  83. input.className = 'inline-edit';
  84. input.value = initial;
  85. span.innerHTML = '';
  86. span.appendChild(input);
  87. input.focus();
  88. input.select();
  89. let done = false;
  90. const commit = (save) => {
  91. if (done) return;
  92. done = true;
  93. const val = input.value;
  94. if (!save || val === initial) {
  95. renderValue(span, original, col);
  96. return;
  97. }
  98. span.classList.add('saving');
  99. const row = span.closest('tr');
  100. postForm('bin/jobs_update.php', {
  101. job_id: row.dataset.jobId,
  102. column: col,
  103. value: val,
  104. })
  105. .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
  106. .then(res => {
  107. span.classList.remove('saving');
  108. if (!res.ok || res.body.trim() !== 'Success') {
  109. span.classList.add('error');
  110. alert('Save failed: ' + res.body);
  111. renderValue(span, original, col);
  112. } else {
  113. return reloadTable();
  114. }
  115. })
  116. .catch(err => {
  117. span.classList.remove('saving');
  118. span.classList.add('error');
  119. alert('Network error: ' + err.message);
  120. renderValue(span, original, col);
  121. });
  122. };
  123. input.addEventListener('blur', () => commit(true));
  124. input.addEventListener('keydown', ev => {
  125. if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
  126. if (ev.key === 'Escape') { ev.preventDefault(); commit(false); }
  127. });
  128. }
  129. function renderValue(span, val, col) {
  130. if (col === 'due_date') span.dataset.raw = val;
  131. span.textContent = (val === '' || val === null) ? '—' : val;
  132. }
  133. // -------- messages --------
  134. function reloadAllThreads() {
  135. return Promise.all($$('.thread').map(reloadThread));
  136. }
  137. function reloadThread(thread) {
  138. const slug = thread.dataset.vendor;
  139. const list = thread.querySelector('.thread-list');
  140. const since = list.dataset.maxId || 0;
  141. return get('bin/messages_list.php', { vendor: slug, since })
  142. .then(r => r.text().then(html => ({
  143. html,
  144. maxId: r.headers.get('X-Max-Id') || since,
  145. })))
  146. .then(({ html, maxId }) => {
  147. if (html.trim()) {
  148. if (since === '0' || since === 0) list.innerHTML = '';
  149. list.insertAdjacentHTML('beforeend', html);
  150. list.scrollTop = list.scrollHeight;
  151. }
  152. list.dataset.maxId = maxId;
  153. const empty = list.querySelector('.msg-empty');
  154. if (empty && list.querySelector('.msg')) empty.remove();
  155. });
  156. }
  157. function wireCompose() {
  158. $$('.thread').forEach(thread => {
  159. const form = thread.querySelector('.thread-compose');
  160. if (!form) return;
  161. form.addEventListener('submit', e => {
  162. e.preventDefault();
  163. const input = form.querySelector('input[name=body]');
  164. const body = input.value.trim();
  165. if (!body) return;
  166. const slug = thread.dataset.vendor;
  167. input.disabled = true;
  168. postForm('bin/messages_post.php', { vendor: slug, body })
  169. .then(r => r.text().then(t => ({ ok: r.ok, body: t, maxId: r.headers.get('X-Msg-Id') })))
  170. .then(res => {
  171. input.disabled = false;
  172. if (!res.ok) { alert('Post failed: ' + res.body); return; }
  173. input.value = '';
  174. input.focus();
  175. const list = thread.querySelector('.thread-list');
  176. const empty = list.querySelector('.msg-empty');
  177. if (empty) empty.remove();
  178. list.insertAdjacentHTML('beforeend', res.body);
  179. list.dataset.maxId = res.maxId || list.dataset.maxId;
  180. list.scrollTop = list.scrollHeight;
  181. })
  182. .catch(err => {
  183. input.disabled = false;
  184. alert('Network error: ' + err.message);
  185. });
  186. });
  187. });
  188. }
  189. // -------- add job (ICG only) --------
  190. function wireAddJob() {
  191. const btn = $('#add-job');
  192. if (!btn) return;
  193. btn.addEventListener('click', () => {
  194. const select = $('#add-job-vendor');
  195. const vendor = select ? select.value : 'bill';
  196. postForm('bin/jobs_add.php', { vendor, ajax: '1' })
  197. .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
  198. .then(res => {
  199. if (!res.ok || res.body.trim() !== 'Success') {
  200. alert('Add failed: ' + res.body);
  201. return;
  202. }
  203. return reloadTable();
  204. })
  205. .catch(err => alert('Network error: ' + err.message));
  206. });
  207. }
  208. // -------- polling --------
  209. function isUserBusy() {
  210. const a = document.activeElement;
  211. if (!a) return false;
  212. const tag = a.tagName;
  213. return tag === 'INPUT' || tag === 'TEXTAREA' || a.isContentEditable;
  214. }
  215. function tick() {
  216. if (isUserBusy()) return;
  217. reloadTable();
  218. reloadAllThreads();
  219. }
  220. document.addEventListener('DOMContentLoaded', () => {
  221. wireAddJob();
  222. wireCompose();
  223. reloadTable();
  224. reloadAllThreads();
  225. const interval = PDQ.pollMs || 60000;
  226. setInterval(tick, interval);
  227. });
  228. })();