Преглед изворни кода

added delete row functionality

Bernn пре 1 недеља
родитељ
комит
40a22bc073
8 измењених фајлова са 118 додато и 1 уклоњено
  1. 2 0
      assets/app.css
  2. 27 0
      assets/app.js
  3. 65 0
      bin/jobs_delete.php
  4. 5 0
      bin/jobs_partial_ship.php
  5. 5 0
      bin/jobs_update.php
  6. 8 0
      lib/db.php
  7. 3 1
      lib/render.php
  8. 3 0
      log.php

+ 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; }

+ 27 - 0
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);
         }
       });
     });

+ 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 - 1
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);
 }

+ 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) . ')';
     }