render.php 7.5 KB

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