Add SSE live updates for bestellungen

This commit is contained in:
2026-06-16 19:43:19 +02:00
parent c0f819fd3d
commit a463632772
3 changed files with 211 additions and 1 deletions
+25
View File
@@ -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;
}
+71 -1
View File
@@ -124,7 +124,7 @@ function auth_render_bestellungen_large_table(array $bestellungenTable): string
$city = (string) ($row['city'] ?? '');
$country = (string) ($row['country_name'] ?? '');
$html[] = '<div class="' . $rowClass . '" role="row" data-component-part="large-table-row" data-bestellungen-row="true">';
$html[] = '<div class="' . $rowClass . '" role="row" data-component-part="large-table-row" data-bestellungen-row="true" data-order-id="' . (int) ($row['id'] ?? 0) . '">';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html($orderDate) . '</div>';
$html[] = '<div class="sg-large-table__cell" role="cell">';
$html[] = '<a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-order-drawer-open="true" data-order-id="' . (int) ($row['id'] ?? 0) . '" data-order-number="' . auth_escape_html($orderNumber) . '" data-order-date="' . auth_escape_html($orderDate) . '" data-order-total="' . auth_escape_html($totalAmount) . '" data-order-first-name="' . auth_escape_html($firstName) . '" data-order-last-name="' . auth_escape_html($lastName) . '" data-order-street="' . auth_escape_html($street) . '" data-order-house-number="' . auth_escape_html($houseNumber) . '" data-order-zip="' . auth_escape_html($zip) . '" data-order-city="' . auth_escape_html($city) . '" data-order-country="' . auth_escape_html($country) . '">' . auth_escape_html($orderNumber) . '</a>';
@@ -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]');";
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../modules/shared/db.php';
require_once __DIR__ . '/../../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../../modules/erp/bestellungen/service.php';
header('Content-Type: text/event-stream; charset=utf-8');
header('Cache-Control: no-cache, no-transform');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
ignore_user_abort(true);
set_time_limit(0);
if (function_exists('apache_setenv')) {
@apache_setenv('no-gzip', '1');
}
@ini_set('zlib.output_compression', '0');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../../.env'));
try {
$pdo = connect_database($env);
} catch (Throwable $e) {
http_response_code(500);
echo 'Database connection failed';
exit;
}
auth_bootstrap_session();
$currentUser = auth_current_user($pdo);
if ($currentUser === null) {
http_response_code(401);
echo 'Unauthorized';
exit;
}
session_write_close();
while (ob_get_level() > 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);
}
}