|
|
@@ -0,0 +1,253 @@
|
|
|
+// Front-end for PDQ / vendor pages.
|
|
|
+//
|
|
|
+// Globals provided by the page via <script> before this file:
|
|
|
+// PDQ.actor 'ICG' or vendor slug
|
|
|
+// PDQ.audience 'ICG' or 'vendor'
|
|
|
+// PDQ.vendors [{slug, name}, ...] (ICG view only; vendor view: just its own)
|
|
|
+// PDQ.pollMs ms between auto-refresh
|
|
|
+
|
|
|
+(function () {
|
|
|
+ if (typeof window.PDQ === 'undefined') window.PDQ = {};
|
|
|
+ const PDQ = window.PDQ;
|
|
|
+
|
|
|
+ const $ = (sel, root = document) => root.querySelector(sel);
|
|
|
+ const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
|
|
+
|
|
|
+ // Adds actor/vendor params to AJAX calls so the server can identify us.
|
|
|
+ function authParams() {
|
|
|
+ if (PDQ.audience === 'ICG') return { actor: 'ICG' };
|
|
|
+ return { v: PDQ.actor };
|
|
|
+ }
|
|
|
+
|
|
|
+ function postForm(url, data) {
|
|
|
+ const body = new URLSearchParams({ ...authParams(), ...data });
|
|
|
+ return fetch(url, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
|
+ body,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function get(url, params = {}) {
|
|
|
+ const u = new URL(url, window.location.href);
|
|
|
+ Object.entries({ ...authParams(), ...params })
|
|
|
+ .forEach(([k, v]) => u.searchParams.set(k, v));
|
|
|
+ return fetch(u.toString());
|
|
|
+ }
|
|
|
+
|
|
|
+ // -------- jobs table --------
|
|
|
+
|
|
|
+ function reloadTable() {
|
|
|
+ return get('bin/jobs_table.php')
|
|
|
+ .then(r => r.text())
|
|
|
+ .then(html => {
|
|
|
+ $('#jobs-table').innerHTML = html;
|
|
|
+ wireTable();
|
|
|
+ stampSyncTime();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function stampSyncTime() {
|
|
|
+ const d = new Date();
|
|
|
+ const el = $('#sync-time');
|
|
|
+ if (!el) return;
|
|
|
+ el.textContent = 'Last sync: ' + d.toLocaleTimeString();
|
|
|
+ }
|
|
|
+
|
|
|
+ function wireTable() {
|
|
|
+ $$('#jobs-table .btn[data-action]').forEach(btn => {
|
|
|
+ btn.addEventListener('click', onAction);
|
|
|
+ });
|
|
|
+ $$('#jobs-table .editable').forEach(el => {
|
|
|
+ el.addEventListener('dblclick', beginEdit);
|
|
|
+ el.addEventListener('keydown', e => {
|
|
|
+ if (e.key === 'Enter') { e.preventDefault(); beginEdit.call(el, e); }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function onAction(e) {
|
|
|
+ const btn = e.currentTarget;
|
|
|
+ const row = btn.closest('tr');
|
|
|
+ const jobId = row.dataset.jobId;
|
|
|
+ const action = btn.dataset.action;
|
|
|
+ btn.disabled = true;
|
|
|
+ postForm('bin/jobs_update.php', { job_id: jobId, action })
|
|
|
+ .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
|
|
|
+ .then(res => {
|
|
|
+ if (!res.ok || res.body.trim() !== 'Success') {
|
|
|
+ alert('Update failed: ' + res.body);
|
|
|
+ }
|
|
|
+ return reloadTable();
|
|
|
+ })
|
|
|
+ .catch(err => alert('Network error: ' + err.message));
|
|
|
+ }
|
|
|
+
|
|
|
+ function beginEdit(e) {
|
|
|
+ const span = this;
|
|
|
+ if (span.querySelector('input')) return;
|
|
|
+ const col = span.dataset.column;
|
|
|
+ const original = (span.dataset.raw !== undefined) ? span.dataset.raw : span.textContent.trim();
|
|
|
+ const initial = original === '—' ? '' : original;
|
|
|
+
|
|
|
+ const input = document.createElement('input');
|
|
|
+ input.type = 'text';
|
|
|
+ input.className = 'inline-edit';
|
|
|
+ input.value = initial;
|
|
|
+ span.innerHTML = '';
|
|
|
+ span.appendChild(input);
|
|
|
+ input.focus();
|
|
|
+ input.select();
|
|
|
+
|
|
|
+ let done = false;
|
|
|
+ const commit = (save) => {
|
|
|
+ if (done) return;
|
|
|
+ done = true;
|
|
|
+ const val = input.value;
|
|
|
+ if (!save || val === initial) {
|
|
|
+ renderValue(span, original, col);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ span.classList.add('saving');
|
|
|
+ const row = span.closest('tr');
|
|
|
+ postForm('bin/jobs_update.php', {
|
|
|
+ job_id: row.dataset.jobId,
|
|
|
+ column: col,
|
|
|
+ value: val,
|
|
|
+ })
|
|
|
+ .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
|
|
|
+ .then(res => {
|
|
|
+ span.classList.remove('saving');
|
|
|
+ if (!res.ok || res.body.trim() !== 'Success') {
|
|
|
+ span.classList.add('error');
|
|
|
+ alert('Save failed: ' + res.body);
|
|
|
+ renderValue(span, original, col);
|
|
|
+ } else {
|
|
|
+ return reloadTable();
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ span.classList.remove('saving');
|
|
|
+ span.classList.add('error');
|
|
|
+ alert('Network error: ' + err.message);
|
|
|
+ renderValue(span, original, col);
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ input.addEventListener('blur', () => commit(true));
|
|
|
+ input.addEventListener('keydown', ev => {
|
|
|
+ if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); }
|
|
|
+ if (ev.key === 'Escape') { ev.preventDefault(); commit(false); }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderValue(span, val, col) {
|
|
|
+ if (col === 'due_date') span.dataset.raw = val;
|
|
|
+ span.textContent = (val === '' || val === null) ? '—' : val;
|
|
|
+ }
|
|
|
+
|
|
|
+ // -------- messages --------
|
|
|
+
|
|
|
+ function reloadAllThreads() {
|
|
|
+ return Promise.all($$('.thread').map(reloadThread));
|
|
|
+ }
|
|
|
+
|
|
|
+ function reloadThread(thread) {
|
|
|
+ const slug = thread.dataset.vendor;
|
|
|
+ const list = thread.querySelector('.thread-list');
|
|
|
+ const since = list.dataset.maxId || 0;
|
|
|
+ return get('bin/messages_list.php', { vendor: slug, since })
|
|
|
+ .then(r => r.text().then(html => ({
|
|
|
+ html,
|
|
|
+ maxId: r.headers.get('X-Max-Id') || since,
|
|
|
+ })))
|
|
|
+ .then(({ html, maxId }) => {
|
|
|
+ if (html.trim()) {
|
|
|
+ if (since === '0' || since === 0) list.innerHTML = '';
|
|
|
+ list.insertAdjacentHTML('beforeend', html);
|
|
|
+ list.scrollTop = list.scrollHeight;
|
|
|
+ }
|
|
|
+ list.dataset.maxId = maxId;
|
|
|
+ const empty = list.querySelector('.msg-empty');
|
|
|
+ if (empty && list.querySelector('.msg')) empty.remove();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function wireCompose() {
|
|
|
+ $$('.thread').forEach(thread => {
|
|
|
+ const form = thread.querySelector('.thread-compose');
|
|
|
+ if (!form) return;
|
|
|
+ form.addEventListener('submit', e => {
|
|
|
+ e.preventDefault();
|
|
|
+ const input = form.querySelector('input[name=body]');
|
|
|
+ const body = input.value.trim();
|
|
|
+ if (!body) return;
|
|
|
+ const slug = thread.dataset.vendor;
|
|
|
+ input.disabled = true;
|
|
|
+ postForm('bin/messages_post.php', { vendor: slug, body })
|
|
|
+ .then(r => r.text().then(t => ({ ok: r.ok, body: t, maxId: r.headers.get('X-Msg-Id') })))
|
|
|
+ .then(res => {
|
|
|
+ input.disabled = false;
|
|
|
+ if (!res.ok) { alert('Post failed: ' + res.body); return; }
|
|
|
+ input.value = '';
|
|
|
+ input.focus();
|
|
|
+ const list = thread.querySelector('.thread-list');
|
|
|
+ const empty = list.querySelector('.msg-empty');
|
|
|
+ if (empty) empty.remove();
|
|
|
+ list.insertAdjacentHTML('beforeend', res.body);
|
|
|
+ list.dataset.maxId = res.maxId || list.dataset.maxId;
|
|
|
+ list.scrollTop = list.scrollHeight;
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ input.disabled = false;
|
|
|
+ alert('Network error: ' + err.message);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // -------- add job (ICG only) --------
|
|
|
+
|
|
|
+ function wireAddJob() {
|
|
|
+ const btn = $('#add-job');
|
|
|
+ if (!btn) return;
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ const select = $('#add-job-vendor');
|
|
|
+ const vendor = select ? select.value : 'bill';
|
|
|
+ postForm('bin/jobs_add.php', { vendor, ajax: '1' })
|
|
|
+ .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
|
|
|
+ .then(res => {
|
|
|
+ if (!res.ok || res.body.trim() !== 'Success') {
|
|
|
+ alert('Add failed: ' + res.body);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ return reloadTable();
|
|
|
+ })
|
|
|
+ .catch(err => alert('Network error: ' + err.message));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // -------- polling --------
|
|
|
+
|
|
|
+ function isUserBusy() {
|
|
|
+ const a = document.activeElement;
|
|
|
+ if (!a) return false;
|
|
|
+ const tag = a.tagName;
|
|
|
+ return tag === 'INPUT' || tag === 'TEXTAREA' || a.isContentEditable;
|
|
|
+ }
|
|
|
+
|
|
|
+ function tick() {
|
|
|
+ if (isUserBusy()) return;
|
|
|
+ reloadTable();
|
|
|
+ reloadAllThreads();
|
|
|
+ }
|
|
|
+
|
|
|
+ document.addEventListener('DOMContentLoaded', () => {
|
|
|
+ wireAddJob();
|
|
|
+ wireCompose();
|
|
|
+ reloadTable();
|
|
|
+ reloadAllThreads();
|
|
|
+ const interval = PDQ.pollMs || 60000;
|
|
|
+ setInterval(tick, interval);
|
|
|
+ });
|
|
|
+})();
|