1
0

2 Commits 8abcbad21e ... 8b161683ba

Autor SHA1 Nachricht Datum
  Bernn 8b161683ba removed long http polling. Just refreshes on interval vor 1 Woche
  Bernn 40a22bc073 added delete row functionality vor 1 Woche
11 geänderte Dateien mit 121 neuen und 184 gelöschten Zeilen
  1. 1 9
      PDQ.php
  2. 2 0
      assets/app.css
  3. 28 56
      assets/app.js
  4. 0 97
      bin/events_poll.php
  5. 65 0
      bin/jobs_delete.php
  6. 5 0
      bin/jobs_partial_ship.php
  7. 5 0
      bin/jobs_update.php
  8. 8 0
      lib/db.php
  9. 3 17
      lib/render.php
  10. 3 0
      log.php
  11. 1 5
      vendor.php

+ 1 - 9
PDQ.php

@@ -4,12 +4,6 @@ require_once __DIR__ . '/lib/render.php';
 
 $actor = current_actor('ICG');
 $vendors = all_vendors();
-
-$initialMessageMaxIds = [];
-foreach ($vendors as $v) {
-    $initialMessageMaxIds[(int) $v['id']] = max_message_id((int) $v['id']);
-}
-$initialHistoryId = max_history_id();
 ?><!doctype html>
 <html lang="en">
 <head>
@@ -21,9 +15,7 @@ window.PDQ = {
     actor: 'ICG',
     audience: 'ICG',
     vendors: <?= json_encode(array_map(fn($v) => ['slug' => $v['slug'], 'name' => $v['name']], $vendors)) ?>,
-    pollMs: 60000,
-    initialHistoryId: <?= (int) $initialHistoryId ?>,
-    initialMessageMaxIds: <?= json_encode((object) $initialMessageMaxIds) ?>
+    pollMs: 180000
 };
 </script>
 <script src="assets/app.js" defer></script>

+ 2 - 0
assets/app.css

@@ -123,6 +123,8 @@ input.inline-edit {
 .btn-ship   { background: var(--c-info); }
 .btn-partial{ background: #ff9800; }
 .btn-undo   { background: #757575; padding: 0.25rem 0.55rem; font-size: 0.8rem; }
+.btn-delete { background: transparent; color: var(--c-danger); border: 1px solid transparent; padding: 0.1rem 0.45rem; font-size: 1.1rem; line-height: 1; }
+.btn-delete:hover { background: var(--c-danger); color: #fff; border-color: var(--c-danger); }
 .btn-receive{ background: var(--c-accent-strong); }
 .btn-add    { background: var(--c-accent-strong); padding: 0.5rem 1.25rem; font-size: 1rem; }
 .btn-post   { background: var(--c-accent-strong); padding: 0.5rem 1rem; font-size: 1rem; }

+ 28 - 56
assets/app.js

@@ -72,6 +72,7 @@
     const jobId = row.dataset.jobId;
     const action = btn.dataset.action;
     if (action === 'partial_ship') return onPartialShip(btn, row, jobId);
+    if (action === 'delete')       return onDelete(btn, row, jobId);
 
     btn.disabled = true;
     postForm('bin/jobs_update.php', { job_id: jobId, action })
@@ -85,6 +86,22 @@
       .catch(err => alert('Network error: ' + err.message));
   }
 
+  function onDelete(btn, row, jobId) {
+    const jobCell = row.querySelector('[data-column="job"]');
+    const label = jobCell ? (jobCell.textContent.trim() || '#' + jobId) : '#' + jobId;
+    if (!window.confirm('Delete job ' + label + '? This cannot be undone from the UI.')) return;
+    btn.disabled = true;
+    postForm('bin/jobs_delete.php', { job_id: jobId })
+      .then(r => r.text().then(t => ({ ok: r.ok, body: t })))
+      .then(res => {
+        if (!res.ok || res.body.trim() !== 'Success') {
+          alert('Delete failed: ' + res.body);
+        }
+        return reloadTable();
+      })
+      .catch(err => alert('Network error: ' + err.message));
+  }
+
   function onPartialShip(btn, row, jobId) {
     const currentQty = parseInt(btn.dataset.qty, 10);
     const raw = window.prompt(
@@ -272,6 +289,12 @@
     const jobId = parseInt(rowEl.dataset.jobId, 10);
     composeRowId = jobId;
 
+    // Pull the row's delete button out of the tab order so Tab from the last
+    // field doesn't land on it. (The table reload after finishCompose
+    // rebuilds the row without this attribute, restoring normal tabbing.)
+    const deleteBtn = rowEl.querySelector('.btn-delete');
+    if (deleteBtn) deleteBtn.setAttribute('tabindex', '-1');
+
     const cells = Array.from(rowEl.querySelectorAll('.editable'));
     const inputs = cells.map(span => {
       const col = span.dataset.column;
@@ -304,6 +327,10 @@
           } else {
             finishCompose(rowEl, true);
           }
+        } else if (e.key === 'Tab' && !e.shiftKey && i === inputs.length - 1) {
+          // Tabbing forward off the last field (Due) commits the new row.
+          e.preventDefault();
+          finishCompose(rowEl, true);
         }
       });
     });
@@ -373,67 +400,12 @@
     reloadAllThreads();
   }
 
-  // -------- long-poll (real-time push) --------
-  //
-  // The server holds the connection open until job_history or messages get
-  // a row beyond our since markers, then immediately tells us what changed.
-  // We reload only the affected piece. The setInterval timer below remains
-  // as a safety net for transient network/server failures.
-
-  let lpHistoryId = (PDQ.initialHistoryId | 0) || 0;
-  let lpMessageMaxIds = Object.assign({}, PDQ.initialMessageMaxIds || {});
-  let lpAbort = null;
-  let lpStopped = false;
-
-  function buildSinceM() {
-    if (PDQ.audience === 'vendor') {
-      return String(lpMessageMaxIds[PDQ.vendorId] || 0);
-    }
-    return Object.entries(lpMessageMaxIds).map(([k, v]) => k + ':' + v).join(',');
-  }
-
-  function startLongPoll() {
-    if (lpStopped) return;
-    lpAbort = new AbortController();
-    const url = new URL('bin/events_poll.php', window.location.href);
-    Object.entries(authParams()).forEach(([k, v]) => url.searchParams.set(k, v));
-    url.searchParams.set('since_h', String(lpHistoryId));
-    url.searchParams.set('since_m', buildSinceM());
-
-    fetch(url.toString(), { signal: lpAbort.signal })
-      .then(r => r.json())
-      .then(data => {
-        // Always update the since markers so the long-poll moves forward and
-        // doesn't re-fire the same event in a tight loop.
-        if (typeof data.history === 'number') lpHistoryId = data.history;
-        if (data.messages && typeof data.messages === 'object') {
-          Object.entries(data.messages).forEach(([vid, maxid]) => {
-            lpMessageMaxIds[vid] = maxid;
-          });
-        }
-        // Skip the actual reload if the user is editing — would tear out
-        // their input. The 60s tick() and the next change will catch them
-        // up once they're done.
-        const busy = isUserBusy() || composeRowId !== null;
-        if (!busy && data.history_changed) reloadTable();
-        if (!busy && data.changed_vendors && data.changed_vendors.length) reloadAllThreads();
-        startLongPoll(); // immediately reopen
-      })
-      .catch(err => {
-        if (err.name === 'AbortError') return;
-        // Network blip / server hiccup: back off briefly and retry. The 60s
-        // tick() will keep things fresh in the meantime.
-        setTimeout(startLongPoll, 2000);
-      });
-  }
-
   document.addEventListener('DOMContentLoaded', () => {
     wireAddJob();
     wireCompose();
     reloadTable();
     reloadAllThreads();
-    startLongPoll();
-    const interval = PDQ.pollMs || 60000;
+    const interval = PDQ.pollMs || 180000;
     setInterval(tick, interval);
   });
 })();

+ 0 - 97
bin/events_poll.php

@@ -1,97 +0,0 @@
-<?php
-// Long-poll endpoint. Holds the connection open for up to ~28 seconds until
-// either (a) the job_history or messages tables receive a row beyond the
-// caller's since markers, or (b) it times out. Either way returns the current
-// max IDs so the client can update its trackers.
-//
-// One PHP-FPM worker is parked per connected client while this runs. For 2-3
-// users that's fine; if more users start using this app, raise pm.max_children.
-
-require_once __DIR__ . '/../lib/identity.php';
-
-[$actor, $vendor_id] = resolve_request_actor();
-
-$since_h = (int) ($_GET['since_h'] ?? 0);
-
-// Messages "since" markers.
-//   Vendor caller: a single int (their own vendor's last seen message id).
-//   ICG caller:    "vid:id,vid:id" pairs (one per vendor thread on PDQ.php).
-$since_m = [];
-if ($actor === 'ICG') {
-    foreach (array_filter(explode(',', (string) ($_GET['since_m'] ?? ''))) as $pair) {
-        $p = explode(':', $pair);
-        if (count($p) === 2) $since_m[(int) $p[0]] = (int) $p[1];
-    }
-} else {
-    $since_m[$vendor_id] = (int) ($_GET['since_m'] ?? 0);
-}
-
-// PHP's default max_execution_time is 30s; give ourselves slightly more so the
-// loop can finish writing the response before the deadline.
-@set_time_limit(45);
-
-$pdo = db();
-$vfilter = $actor === 'ICG' ? null : $vendor_id;
-$deadline = microtime(true) + 28.0;
-
-header('Content-Type: application/json');
-
-while (true) {
-    [$cur_h, $cur_m] = events_snapshot($pdo, $vfilter);
-
-    $history_changed = $cur_h > $since_h;
-    $changed_vendors = [];
-    foreach ($cur_m as $vid => $maxid) {
-        if ($maxid > ($since_m[$vid] ?? 0)) $changed_vendors[] = $vid;
-    }
-
-    if ($history_changed || $changed_vendors) {
-        echo json_encode([
-            'history'          => $cur_h,
-            'messages'         => $cur_m,
-            'history_changed'  => $history_changed,
-            'changed_vendors'  => $changed_vendors,
-        ]);
-        return;
-    }
-    if (microtime(true) >= $deadline) {
-        echo json_encode([
-            'history'  => $cur_h,
-            'messages' => $cur_m,
-            'timeout'  => true,
-        ]);
-        return;
-    }
-    // Bail early if the client has gone away (cheap check — flush updates the
-    // connection state). Otherwise sleep 1s and loop.
-    if (connection_aborted()) return;
-    usleep(1000000);
-}
-
-function events_snapshot(PDO $pdo, ?int $vendor_filter): array {
-    if ($vendor_filter === null) {
-        $h = (int) $pdo->query('SELECT COALESCE(MAX(id), 0) FROM job_history')->fetchColumn();
-        $m = [];
-        foreach ($pdo->query('SELECT vendor_id, COALESCE(MAX(id), 0) AS maxid FROM messages GROUP BY vendor_id')->fetchAll() as $r) {
-            $m[(int) $r['vendor_id']] = (int) $r['maxid'];
-        }
-        // Vendors with zero messages get a 0 entry so the client tracks them.
-        foreach ($pdo->query('SELECT id FROM vendors WHERE active = 1')->fetchAll() as $v) {
-            if (!isset($m[(int) $v['id']])) $m[(int) $v['id']] = 0;
-        }
-    } else {
-        $stmt = $pdo->prepare(
-            'SELECT COALESCE(MAX(h.id), 0)
-               FROM job_history h
-               JOIN jobs j ON j.id = h.job_id
-              WHERE j.vendor_id = ?'
-        );
-        $stmt->execute([$vendor_filter]);
-        $h = (int) $stmt->fetchColumn();
-
-        $stmt = $pdo->prepare('SELECT COALESCE(MAX(id), 0) FROM messages WHERE vendor_id = ?');
-        $stmt->execute([$vendor_filter]);
-        $m = [$vendor_filter => (int) $stmt->fetchColumn()];
-    }
-    return [$h, $m];
-}

+ 65 - 0
bin/jobs_delete.php

@@ -0,0 +1,65 @@
+<?php
+// Soft-delete a job. ICG only. Sets jobs.deleted_at and logs a 'deleted'
+// event so the audit log captures who removed it and when.
+
+require_once __DIR__ . '/../lib/identity.php';
+require_once __DIR__ . '/../lib/jobs.php';
+
+[$actor, ] = resolve_request_actor();
+
+if ($actor !== 'ICG') {
+    http_response_code(403);
+    echo 'Only ICG can delete jobs';
+    return;
+}
+
+$job_id = (int) ($_POST['job_id'] ?? 0);
+if ($job_id <= 0) {
+    http_response_code(400);
+    echo 'Bad job_id';
+    return;
+}
+
+$pdo = db();
+$stmt = $pdo->prepare('SELECT * FROM jobs WHERE id = ?');
+$stmt->execute([$job_id]);
+$job = $stmt->fetch();
+if (!$job) {
+    http_response_code(404);
+    echo 'Job not found';
+    return;
+}
+if (!empty($job['deleted_at'])) {
+    // Already deleted — idempotent success.
+    echo 'Success';
+    return;
+}
+
+// BEGIN IMMEDIATE + retry, same pattern as apply_job_change.
+$attempts = 0;
+while (true) {
+    $attempts++;
+    try {
+        $pdo->exec('BEGIN IMMEDIATE');
+        break;
+    } catch (PDOException $e) {
+        if ($attempts >= 50 || !is_busy_error($e)) throw $e;
+        usleep(100000);
+    }
+}
+
+try {
+    $upd = $pdo->prepare("UPDATE jobs SET deleted_at = datetime('now'), updated_at = datetime('now') WHERE id = ?");
+    $upd->execute([$job_id]);
+
+    $hist = $pdo->prepare(
+        'INSERT INTO job_history(job_id, field, old_value, new_value, actor) VALUES (?, ?, ?, ?, ?)'
+    );
+    $hist->execute([$job_id, 'deleted', null, $job['job'], $actor]);
+
+    $pdo->exec('COMMIT');
+    echo 'Success';
+} catch (Throwable $e) {
+    $pdo->exec('ROLLBACK');
+    throw $e;
+}

+ 5 - 0
bin/jobs_partial_ship.php

@@ -28,6 +28,11 @@ if (!$job) {
     echo 'Job not found';
     return;
 }
+if (!empty($job['deleted_at'])) {
+    http_response_code(410);
+    echo 'Job has been deleted';
+    return;
+}
 
 if ((int) $job['vendor_id'] !== $vendor_id) {
     http_response_code(403);

+ 5 - 0
bin/jobs_update.php

@@ -24,6 +24,11 @@ if (!$job) {
     echo 'Job not found';
     return;
 }
+if (!empty($job['deleted_at'])) {
+    http_response_code(410);
+    echo 'Job has been deleted';
+    return;
+}
 
 // Vendor-side requests are scoped to their own jobs.
 if ($actor !== 'ICG' && (int) $job['vendor_id'] !== $vendor_id) {

+ 8 - 0
lib/db.php

@@ -87,6 +87,14 @@ function db_migrate(PDO $pdo): void {
             ->execute(['hiep', 'Hiep']);
         $pdo->exec('PRAGMA user_version = 2');
     }
+
+    if ($version < 3) {
+        // Soft delete: a non-null deleted_at hides the row everywhere but
+        // keeps job_history rows joinable so the audit log still resolves
+        // the job number, material, etc.
+        $pdo->exec('ALTER TABLE jobs ADD COLUMN deleted_at TEXT');
+        $pdo->exec('PRAGMA user_version = 3');
+    }
 }
 
 function find_vendor_by_slug(string $slug): ?array {

+ 3 - 17
lib/render.php

@@ -12,7 +12,8 @@ function render_jobs_table(string $audience, ?int $vendor_id = null): string {
     $pdo = db();
 
     // Filter matches the original: open jobs OR anything touched in the last 4 days.
-    $where = "(j.status != 'Received') OR (j.updated_at > datetime('now', '-4 days'))";
+    // Soft-deleted jobs (deleted_at IS NOT NULL) are hidden everywhere.
+    $where = "j.deleted_at IS NULL AND ((j.status != 'Received') OR (j.updated_at > datetime('now', '-4 days')))";
     $params = [];
     if ($audience === 'vendor') {
         $where = "j.vendor_id = ? AND ($where)";
@@ -162,6 +163,7 @@ function render_actions(array $r, string $audience): string {
         if ($r['status'] === 'Received') {
             $btns[] = '<span class="received">Received</span>';
         }
+        $btns[] = '<button class="btn btn-delete" data-action="delete" title="Delete job">&times;</button>';
     }
     return implode(' ', $btns);
 }
@@ -193,19 +195,3 @@ function max_message_id(int $vendor_id): int {
     $stmt->execute([$vendor_id]);
     return (int) $stmt->fetchColumn();
 }
-
-// Scoped to a vendor's jobs when $vendor_id is given (so a vendor doesn't get
-// woken up by another vendor's history). ICG callers pass null for global max.
-function max_history_id(?int $vendor_id = null): int {
-    if ($vendor_id === null) {
-        return (int) db()->query('SELECT COALESCE(MAX(id), 0) FROM job_history')->fetchColumn();
-    }
-    $stmt = db()->prepare(
-        'SELECT COALESCE(MAX(h.id), 0)
-           FROM job_history h
-           JOIN jobs j ON j.id = h.job_id
-          WHERE j.vendor_id = ?'
-    );
-    $stmt->execute([$vendor_id]);
-    return (int) $stmt->fetchColumn();
-}

+ 3 - 0
log.php

@@ -47,6 +47,9 @@ function fmt_event(array $h): string {
     if ($f === 'created') {
         return 'Created (vendor: ' . h((string) $new) . ')';
     }
+    if ($f === 'deleted') {
+        return 'Deleted';
+    }
     if ($f === 'partial_ship') {
         return 'Partial ship: split off ' . h((string) $new) . ' (was qty ' . h((string) $old) . ')';
     }

+ 1 - 5
vendor.php

@@ -6,7 +6,6 @@ $slug = $_GET['v'] ?? '';
 $vendor = current_actor_from_vendor($slug);
 $vid = (int) $vendor['id'];
 $maxId = max_message_id($vid);
-$initialHistoryId = max_history_id($vid);
 ?><!doctype html>
 <html lang="en">
 <head>
@@ -17,11 +16,8 @@ $initialHistoryId = max_history_id($vid);
 window.PDQ = {
     actor: <?= json_encode($vendor['slug']) ?>,
     audience: 'vendor',
-    vendorId: <?= (int) $vid ?>,
     vendors: [<?= json_encode(['slug' => $vendor['slug'], 'name' => $vendor['name']]) ?>],
-    pollMs: 60000,
-    initialHistoryId: <?= (int) $initialHistoryId ?>,
-    initialMessageMaxIds: <?= json_encode((object) [$vid => $maxId]) ?>
+    pollMs: 180000
 };
 </script>
 <script src="assets/app.js" defer></script>