From a46363277266a4d61969c2b38275849cee698245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Tue, 16 Jun 2026 19:43:19 +0200 Subject: [PATCH] Add SSE live updates for bestellungen --- modules/erp/bestellungen/service.php | 25 ++++++ modules/shared/auth/ui/home.php | 72 ++++++++++++++++- public/api/realtime/bestellungen.php | 115 +++++++++++++++++++++++++++ 3 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 public/api/realtime/bestellungen.php diff --git a/modules/erp/bestellungen/service.php b/modules/erp/bestellungen/service.php index 712d45a..e4dc0ff 100644 --- a/modules/erp/bestellungen/service.php +++ b/modules/erp/bestellungen/service.php @@ -352,3 +352,28 @@ SQL; 'next_limit' => min($totalCount, $limit + $pageSize), ]; } + +function get_sales_order_realtime_snapshot(PDO $pdo, int $limit = 1000): array +{ + $limit = max(1, min(2000, $limit)); + + $stmt = $pdo->prepare( + 'SELECT id, created_at, updated_at + FROM public.sales_order + ORDER BY updated_at DESC NULLS LAST, id DESC + LIMIT :limit' + ); + $stmt->bindValue(':limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + $rows = []; + foreach ($stmt->fetchAll() as $row) { + $rows[] = [ + 'id' => (int) ($row['id'] ?? 0), + 'created_at' => (string) ($row['created_at'] ?? ''), + 'updated_at' => (string) ($row['updated_at'] ?? ''), + ]; + } + + return $rows; +} diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index f98cf25..bd45820 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -124,7 +124,7 @@ function auth_render_bestellungen_large_table(array $bestellungenTable): string $city = (string) ($row['city'] ?? ''); $country = (string) ($row['country_name'] ?? ''); - $html[] = '
'; + $html[] = '
'; $html[] = '
' . auth_escape_html($orderDate) . '
'; $html[] = '
'; $html[] = ''; @@ -509,6 +509,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " mediaQuery.addEventListener('change', syncMode);"; echo "})();"; echo "let bestellungenTableHandlersInstalled = false;"; + echo "let bestellungenRealtimeSource = null;"; echo "function initBestellungenBindings() {"; echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');"; echo " if (!contentRoot) { return; }"; @@ -677,6 +678,74 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " }"; echo " syncSearchState();"; echo " };"; + echo " const getCurrentQueryParams = () => ({"; + echo " bestellungen_search: getSearchValue(),"; + echo " bestellungen_sort: getSortColumn(),"; + echo " bestellungen_dir: getSortDirection(),"; + echo " bestellungen_limit: getCurrentLimit(),"; + echo " });"; + echo " const getOpenDrawerOrderId = () => {"; + echo " const trigger = Array.from(contentRoot.querySelectorAll('[data-order-drawer-open]')).find((button) => button.getAttribute('aria-expanded') === 'true');"; + echo " return trigger ? (trigger.dataset.orderId || '') : '';"; + echo " };"; + echo " const reloadBestellungenSection = () => loadFragment(getCurrentQueryParams());"; + echo " const reloadBestellungenRow = async (orderId) => {"; + echo " const targetOrderId = String(orderId || '');"; + echo " if (targetOrderId === '') {"; + echo " await reloadBestellungenSection();"; + echo " return;"; + echo " }"; + echo " const response = await fetch(buildFragmentUrl(getCurrentQueryParams()).toString(), { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } });"; + echo " if (!response.ok) {"; + echo " await reloadBestellungenSection();"; + echo " return;"; + echo " }"; + echo " const html = await response.text();"; + echo " const doc = new DOMParser().parseFromString(html, 'text/html');"; + echo " const newRow = Array.from(doc.querySelectorAll('[data-bestellungen-row]')).find((row) => (row.dataset.orderId || '') === targetOrderId) || null;"; + echo " const currentRow = Array.from(contentRoot.querySelectorAll('[data-bestellungen-row]')).find((row) => (row.dataset.orderId || '') === targetOrderId) || null;"; + echo " if (!newRow || !currentRow) {"; + echo " await reloadBestellungenSection();"; + echo " return;"; + echo " }"; + echo " const openDrawerOrderId = getOpenDrawerOrderId();"; + echo " currentRow.outerHTML = newRow.outerHTML;"; + echo " if (openDrawerOrderId === targetOrderId) {"; + echo " const reopenedTrigger = Array.from(contentRoot.querySelectorAll('[data-order-drawer-open]')).find((button) => (button.dataset.orderId || '') === targetOrderId) || null;"; + echo " if (reopenedTrigger) {"; + echo " openDrawer(reopenedTrigger);"; + echo " }"; + echo " }"; + echo " };"; + echo " const connectBestellungenRealtime = () => {"; + echo " if (!window.EventSource) { return null; }"; + echo " if (bestellungenRealtimeSource) {"; + echo " bestellungenRealtimeSource.close();"; + echo " }"; + echo " const source = new EventSource('/api/realtime/bestellungen.php', { withCredentials: true });"; + echo " source.addEventListener('bestellungen.changed', (event) => {"; + echo " const payload = parseJsonRealtimeEvent(event.data);"; + echo " if (!payload) { return; }"; + echo " const kind = String(payload.kind || '');"; + echo " const orderId = payload.orderId !== undefined && payload.orderId !== null ? String(payload.orderId) : '';"; + echo " if (kind === 'updated' && orderId !== '') {"; + echo " void reloadBestellungenRow(orderId);"; + echo " return;"; + echo " }"; + echo " void reloadBestellungenSection();"; + echo " });"; + echo " bestellungenRealtimeSource = source;"; + echo " return source;"; + echo " };"; + echo " const parseJsonRealtimeEvent = (raw) => {"; + echo " if (typeof raw !== 'string' || raw.trim() === '') { return null; }"; + echo " try {"; + echo " const parsed = JSON.parse(raw);"; + echo " return parsed && typeof parsed === 'object' ? parsed : null;"; + echo " } catch (error) {"; + echo " return null;"; + echo " }"; + echo " };"; echo " if (!bestellungenTableHandlersInstalled) {"; echo " bestellungenTableHandlersInstalled = true;"; echo " contentRoot.addEventListener('click', (event) => {"; @@ -727,6 +796,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " });"; echo " }"; echo " bindTable();"; + echo " connectBestellungenRealtime();"; echo "}"; echo "(() => {"; echo " const overlay = document.querySelector('[data-otc-order-overlay]');"; diff --git a/public/api/realtime/bestellungen.php b/public/api/realtime/bestellungen.php new file mode 100644 index 0000000..ae52247 --- /dev/null +++ b/public/api/realtime/bestellungen.php @@ -0,0 +1,115 @@ + 0) { + ob_end_flush(); +} + +$sendEvent = static function (string $eventName, array $payload): void { + echo 'event: ' . $eventName . "\n"; + echo 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n"; + flush(); +}; + +$lastHeartbeatAt = time(); +$state = []; +foreach (get_sales_order_realtime_snapshot($pdo) as $row) { + $state[(int) $row['id']] = (string) $row['updated_at']; +} + +echo ": connected\n\n"; +flush(); + +while (!connection_aborted()) { + try { + $snapshot = get_sales_order_realtime_snapshot($pdo); + $nextState = []; + + foreach ($snapshot as $row) { + $orderId = (int) ($row['id'] ?? 0); + if ($orderId <= 0) { + continue; + } + + $updatedAt = (string) ($row['updated_at'] ?? ''); + $nextState[$orderId] = $updatedAt; + + if (!array_key_exists($orderId, $state)) { + $sendEvent('bestellungen.changed', [ + 'kind' => 'created', + 'orderId' => $orderId, + 'updatedAt' => $updatedAt, + ]); + continue; + } + + if ($state[$orderId] !== $updatedAt) { + $sendEvent('bestellungen.changed', [ + 'kind' => 'updated', + 'orderId' => $orderId, + 'updatedAt' => $updatedAt, + ]); + } + } + + $state = $nextState; + + if ((time() - $lastHeartbeatAt) >= 15) { + echo ": ping\n\n"; + flush(); + $lastHeartbeatAt = time(); + } + + usleep(2000000); + } catch (Throwable $e) { + echo ": error\n\n"; + flush(); + try { + $pdo = connect_database($env); + $state = []; + foreach (get_sales_order_realtime_snapshot($pdo) as $row) { + $state[(int) $row['id']] = (string) $row['updated_at']; + } + } catch (Throwable $reconnectError) { + // Keep the connection open and try again on the next loop. + } + usleep(5000000); + } +}