render.php 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. <?php
  2. require_once __DIR__ . '/db.php';
  3. function h(?string $s): string {
  4. return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
  5. }
  6. // Render the job table for a given audience.
  7. // $audience: 'ICG' or 'vendor'
  8. // $vendor_id: required when $audience === 'vendor'
  9. function render_jobs_table(string $audience, ?int $vendor_id = null): string {
  10. $pdo = db();
  11. // Filter matches the original: open jobs OR anything touched in the last 4 days.
  12. // Soft-deleted jobs (deleted_at IS NOT NULL) are hidden everywhere.
  13. $where = "j.deleted_at IS NULL AND ((j.status != 'Received') OR (j.updated_at > datetime('now', '-4 days')))";
  14. $params = [];
  15. if ($audience === 'vendor') {
  16. $where = "j.vendor_id = ? AND ($where)";
  17. $params[] = $vendor_id;
  18. }
  19. $sql = "
  20. SELECT j.*, v.slug AS vendor_slug, v.name AS vendor_name,
  21. CASE WHEN j.due_date IS NULL THEN ''
  22. ELSE CAST(CAST(strftime('%m', j.due_date) AS INTEGER) AS TEXT) || '-' ||
  23. CAST(CAST(strftime('%d', j.due_date) AS INTEGER) AS TEXT)
  24. END AS due_short,
  25. (SELECT MAX(changed_at) FROM job_history
  26. WHERE job_id = j.id AND field = 'status' AND new_value = 'Finished') AS finished_at,
  27. (SELECT MAX(changed_at) FROM job_history
  28. WHERE job_id = j.id AND field = 'status' AND new_value = 'Shipped') AS shipped_at,
  29. (SELECT MAX(changed_at) FROM job_history
  30. WHERE job_id = j.id AND field = 'status' AND new_value = 'Received') AS received_at
  31. FROM jobs j
  32. JOIN vendors v ON v.id = j.vendor_id
  33. WHERE $where
  34. ORDER BY (j.ack != '') DESC,
  35. (j.status = '') DESC,
  36. (j.status = 'Finished') DESC,
  37. (j.status = 'Shipped') DESC,
  38. j.due_date IS NULL,
  39. j.due_date ASC,
  40. j.job
  41. ";
  42. $stmt = $pdo->prepare($sql);
  43. $stmt->execute($params);
  44. $rows = $stmt->fetchAll();
  45. ob_start();
  46. ?>
  47. <table class="jobs">
  48. <thead>
  49. <tr>
  50. <th>Job</th>
  51. <th>Material</th>
  52. <th>Description</th>
  53. <th class="right">Qty</th>
  54. <th class="center">Due</th>
  55. <?php if ($audience === 'ICG'): ?><th>Vendor</th><?php endif; ?>
  56. <th class="center">Ack</th>
  57. <th class="center">Status</th>
  58. <th class="center">Action</th>
  59. </tr>
  60. </thead>
  61. <tbody>
  62. <?php if (!$rows): ?>
  63. <tr><td colspan="<?= $audience === 'ICG' ? 9 : 8 ?>" class="empty">No active jobs.</td></tr>
  64. <?php endif; ?>
  65. <?php foreach ($rows as $r):
  66. $editable = ($audience === 'ICG');
  67. $jobClass = strlen($r['job']) > 9 ? 'smaller' : '';
  68. $statusClass = $r['status'] !== '' ? 'status-' . strtolower($r['status']) : '';
  69. $ackClass = '';
  70. $ackLabel = '';
  71. if ($r['ack'] === 'new') { $ackClass = 'ack-new'; $ackLabel = 'NEW'; }
  72. elseif ($r['ack'] === 'changed') { $ackClass = 'ack-changed'; $ackLabel = 'CHANGED'; }
  73. ?>
  74. <tr data-job-id="<?= (int) $r['id'] ?>" class="<?= h($statusClass) ?>">
  75. <td class="<?= h($jobClass) ?>"><?= render_field($r, 'job', $editable) ?></td>
  76. <td><?= render_field($r, 'material', $editable) ?></td>
  77. <td class="center"><?= render_field($r, 'description', $editable) ?></td>
  78. <td class="right"><?= render_field($r, 'qty', $editable) ?></td>
  79. <td class="center"><?= render_due($r, $editable) ?></td>
  80. <?php if ($audience === 'ICG'): ?><td><?= h($r['vendor_name']) ?></td><?php endif; ?>
  81. <td class="center <?= h($ackClass) ?>"><?= h($ackLabel) ?></td>
  82. <td class="center"><?= render_status($r, $audience) ?></td>
  83. <td class="center actions"><?= render_actions($r, $audience) ?></td>
  84. </tr>
  85. <?php endforeach; ?>
  86. </tbody>
  87. </table>
  88. <?php
  89. return ob_get_clean();
  90. }
  91. function render_field(array $r, string $col, bool $editable): string {
  92. $val = (string) ($r[$col] ?? '');
  93. if (!$editable) {
  94. return h($val);
  95. }
  96. return '<span class="editable" data-column="' . h($col) . '" tabindex="0">'
  97. . ($val === '' ? '&mdash;' : h($val))
  98. . '</span>';
  99. }
  100. function render_due(array $r, bool $editable): string {
  101. $display = $r['due_short'] !== '' ? h($r['due_short']) : '&mdash;';
  102. if (!$editable) return $display;
  103. return '<span class="editable" data-column="due_date" data-raw="' . h($r['due_date']) . '" tabindex="0">'
  104. . $display . '</span>';
  105. }
  106. function render_status(array $r, string $audience): string {
  107. $out = h($r['status']);
  108. $stamp = null;
  109. if ($audience === 'ICG') {
  110. if ($r['status'] === 'Finished') $stamp = $r['finished_at'] ?? null;
  111. elseif ($r['status'] === 'Shipped') $stamp = $r['shipped_at'] ?? null;
  112. } else { // vendor
  113. if ($r['status'] === 'Received') $stamp = $r['received_at'] ?? null;
  114. }
  115. if ($stamp) {
  116. $out .= '<div class="status-date">' . h(format_short_date($stamp)) . '</div>';
  117. }
  118. return $out;
  119. }
  120. // Convert a UTC 'YYYY-MM-DD HH:MM:SS' from datetime('now') into 'M/D' in the
  121. // server's local timezone for display.
  122. function format_short_date(?string $ts): string {
  123. if (!$ts) return '';
  124. try {
  125. $dt = new DateTimeImmutable($ts . ' UTC');
  126. return $dt->setTimezone(new DateTimeZone(date_default_timezone_get()))->format('n/j');
  127. } catch (Exception $e) {
  128. return '';
  129. }
  130. }
  131. function render_actions(array $r, string $audience): string {
  132. $btns = [];
  133. if ($audience === 'vendor') {
  134. if ($r['ack'] === 'new' || $r['ack'] === 'changed') {
  135. $btns[] = '<button class="btn btn-ack" data-action="acknowledge">Acknowledge</button>';
  136. }
  137. if ($r['status'] === '') {
  138. $btns[] = '<button class="btn btn-finish" data-action="mark_finished">Mark Finished</button>';
  139. } elseif ($r['status'] === 'Finished') {
  140. $btns[] = '<button class="btn btn-ship" data-action="mark_shipped">Mark Shipped</button>';
  141. }
  142. if (in_array($r['status'], ['', 'Finished'], true) && (int) $r['qty'] > 1) {
  143. $btns[] = '<button class="btn btn-partial" data-action="partial_ship" data-qty="' . (int) $r['qty'] . '">Partial Ship</button>';
  144. }
  145. if (in_array($r['status'], ['Finished', 'Shipped'], true)) {
  146. $btns[] = '<button class="btn btn-undo" data-action="undo">Undo</button>';
  147. }
  148. } else { // ICG
  149. if ($r['status'] === 'Shipped') {
  150. $btns[] = '<button class="btn btn-receive" data-action="mark_received">Mark Received</button>';
  151. }
  152. if ($r['status'] === 'Received') {
  153. $btns[] = '<span class="received">Received</span>';
  154. }
  155. $btns[] = '<button class="btn btn-delete" data-action="delete" title="Delete job">&times;</button>';
  156. }
  157. return implode(' ', $btns);
  158. }
  159. function render_messages(int $vendor_id, ?int $since_id = null): string {
  160. $pdo = db();
  161. if ($since_id !== null) {
  162. $stmt = $pdo->prepare('SELECT * FROM messages WHERE vendor_id = ? AND id > ? ORDER BY id ASC');
  163. $stmt->execute([$vendor_id, $since_id]);
  164. } else {
  165. $stmt = $pdo->prepare('SELECT * FROM messages WHERE vendor_id = ? ORDER BY id ASC');
  166. $stmt->execute([$vendor_id]);
  167. }
  168. $rows = $stmt->fetchAll();
  169. ob_start();
  170. foreach ($rows as $m) {
  171. $klass = $m['author'] === 'ICG' ? 'msg msg-icg' : 'msg msg-vendor';
  172. echo '<div class="' . $klass . '" data-msg-id="' . (int)$m['id'] . '">';
  173. echo '<span class="msg-author">' . h($m['author']) . '</span>';
  174. echo '<span class="msg-time">' . h($m['posted_at']) . '</span>';
  175. echo '<div class="msg-body">' . nl2br(h($m['body'])) . '</div>';
  176. echo '</div>';
  177. }
  178. return ob_get_clean();
  179. }
  180. function max_message_id(int $vendor_id): int {
  181. $stmt = db()->prepare('SELECT COALESCE(MAX(id), 0) FROM messages WHERE vendor_id = ?');
  182. $stmt->execute([$vendor_id]);
  183. return (int) $stmt->fetchColumn();
  184. }