| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355 |
- // 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) --------
- // While this is non-null, polling skips auto-reload so the in-progress
- // compose form isn't ripped out from under the user.
- let composeRowId = null;
- 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 => {
- const newId = parseInt((res.body || '').trim(), 10);
- if (!res.ok || !Number.isInteger(newId) || newId <= 0) {
- alert('Add failed: ' + res.body);
- return;
- }
- return reloadTable().then(() => {
- const row = $('#jobs-table tr[data-job-id="' + newId + '"]');
- if (row) composeRow(row);
- });
- })
- .catch(err => alert('Network error: ' + err.message));
- });
- }
- // Open every editable cell of `rowEl` as an <input> simultaneously so the
- // user can Tab between them. Each input's initial value is captured; on
- // exit (Escape, Enter past the last cell, or focus leaving the row) only
- // the changed fields are saved in parallel, then the table reloads once.
- function composeRow(rowEl) {
- const jobId = parseInt(rowEl.dataset.jobId, 10);
- composeRowId = jobId;
- const cells = Array.from(rowEl.querySelectorAll('.editable'));
- const inputs = cells.map(span => {
- const col = span.dataset.column;
- const raw = span.dataset.raw !== undefined ? span.dataset.raw : span.textContent.trim();
- const initial = raw === '—' ? '' : raw;
- const input = document.createElement('input');
- input.type = 'text';
- input.className = 'inline-edit';
- input.value = initial;
- input.dataset.column = col;
- input.dataset.initial = initial;
- // The span owns tabindex=0 so it can be entered from the keyboard, but
- // while it hosts a live input we want Tab to jump straight to the input.
- span.setAttribute('tabindex', '-1');
- span.innerHTML = '';
- span.appendChild(input);
- return input;
- });
- inputs.forEach((input, i) => {
- input.addEventListener('keydown', e => {
- if (e.key === 'Escape') {
- e.preventDefault();
- finishCompose(rowEl, /*save=*/false);
- } else if (e.key === 'Enter') {
- e.preventDefault();
- if (i + 1 < inputs.length) {
- inputs[i + 1].focus();
- inputs[i + 1].select();
- } else {
- finishCompose(rowEl, true);
- }
- }
- });
- });
- // Detect focus genuinely leaving the row (not just hopping between its inputs).
- rowEl.addEventListener('focusout', () => {
- setTimeout(() => {
- if (composeRowId === jobId && !rowEl.contains(document.activeElement)) {
- finishCompose(rowEl, true);
- }
- }, 50);
- });
- if (inputs[0]) { inputs[0].focus(); inputs[0].select(); }
- }
- function finishCompose(rowEl, save) {
- if (composeRowId === null) return;
- const jobId = composeRowId;
- composeRowId = null;
- const inputs = Array.from(rowEl.querySelectorAll('input.inline-edit'));
- const dirty = save ? inputs.filter(i => i.value !== i.dataset.initial) : [];
- // Serialize the saves so they queue against the SQLite writer lock
- // politely instead of all racing at once.
- const failed = [];
- const runNext = (i) => {
- if (i >= dirty.length) return Promise.resolve();
- const input = dirty[i];
- return postForm('bin/jobs_update.php', {
- job_id: jobId,
- column: input.dataset.column,
- value: input.value,
- })
- .then(r => r.text().then(t => {
- if (!r.ok || t.trim() !== 'Success') {
- failed.push(input.dataset.column + ': ' + t);
- }
- }))
- .then(() => runNext(i + 1));
- };
- runNext(0)
- .then(() => {
- if (failed.length) alert('Some saves failed:\n' + failed.join('\n'));
- return reloadTable();
- })
- .catch(err => {
- alert('Network error: ' + err.message);
- reloadTable();
- });
- }
- // -------- 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);
- });
- })();
|