From bbec87abfa54286e9c3cb45798ad8669b197e74b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Sat, 19 Sep 2026 11:10:26 +0200 Subject: [PATCH] Ergaenze Markt-Durchfuehrungstabelle im Portal --- .../market_management_hermes_mcp_api.md | 4 +- modules/market_management/service.php | 19 ++- modules/shared/auth/ui/home.php | 120 +++++++++++++++++- public/index.php | 26 +++- 4 files changed, 164 insertions(+), 5 deletions(-) diff --git a/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md b/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md index 9c86a33..baaed8b 100644 --- a/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md +++ b/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md @@ -72,9 +72,9 @@ Das Ergebnis steht bei `tools/call` als JSON im Textinhalt des ersten `municipality_reference_id`, `date_from`, `date_to`, `include_archived`, `sort`, `sort_direction`, `limit`, `offset` - `market_management.search_occurrences` - - optional: `market_definition_id`, `status`, `place`, `address`, + - optional: `search`, `market_definition_id`, `status`, `place`, `address`, `municipality_reference_id`, `date_from`, `date_to`, `include_archived`, - `limit`, `offset` + `sort`, `sort_direction`, `limit`, `offset` - `market_management.get_market` - erforderlich: `market_id` - optional: `include_archived` diff --git a/modules/market_management/service.php b/modules/market_management/service.php index e6f6e60..1bff4f7 100644 --- a/modules/market_management/service.php +++ b/modules/market_management/service.php @@ -260,9 +260,26 @@ function market_management_search_occurrences(PDO $pdo, array $input): array $where = []; $params = [':limit' => min(100, max(1, (int) ($input['limit'] ?? 50))), ':offset' => max(0, (int) ($input['offset'] ?? 0))]; $includeArchived = (bool) ($input['include_archived'] ?? false); + $search = market_management_optional_string($input, 'search'); + $sort = (string) ($input['sort'] ?? 'earliest_date'); + $sortDirection = strtolower((string) ($input['sort_direction'] ?? 'asc')) === 'desc' ? 'DESC' : 'ASC'; + $sortColumn = match ($sort) { + 'market_name' => 'm.name', + 'place' => 'o.place', + 'status' => 'o.status', + default => 'MIN(d.date)', + }; if (!$includeArchived) { $where[] = 'o.archived_at IS NULL'; } + if ($search !== null) { + $where[] = '(m.name ILIKE :search_name OR o.place ILIKE :search_place OR o.status ILIKE :search_status OR EXISTS (SELECT 1 FROM market_occurrence_day sd WHERE sd.occurrence_id = o.id AND TO_CHAR(sd.date, \'DD.MM.YYYY\') ILIKE :search_date))'; + $searchValue = '%' . $search . '%'; + $params[':search_name'] = $searchValue; + $params[':search_place'] = $searchValue; + $params[':search_status'] = $searchValue; + $params[':search_date'] = $searchValue; + } if (array_key_exists('market_definition_id', $input)) { $where[] = 'o.market_definition_id = :market_definition_id'; $params[':market_definition_id'] = market_management_require_positive_id($input['market_definition_id'], 'market_definition_id'); @@ -298,7 +315,7 @@ function market_management_search_occurrences(PDO $pdo, array $input): array LEFT JOIN market_occurrence_day d ON d.occurrence_id = o.id WHERE ' . ($where === [] ? 'TRUE' : implode(' AND ', $where)) . ' GROUP BY o.id, m.name, m.category_id, c.code, c.name - ORDER BY earliest_date NULLS LAST, o.id ASC + ORDER BY ' . $sortColumn . ' ' . $sortDirection . ' NULLS LAST, o.id ASC LIMIT :limit OFFSET :offset' ); foreach ($params as $key => $value) { diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index b925835..9c20ce3 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -50,6 +50,97 @@ function auth_format_bestellung_total(?float $value): string return 'CHF ' . number_format($value, 2, '.', '\''); } +function auth_format_market_date(?string $earliest, ?string $latest): string +{ + if ($earliest === null || $earliest === '') { + return ''; + } + + $start = (new DateTimeImmutable($earliest))->format('d.m.Y'); + if ($latest === null || $latest === '' || $latest === $earliest) { + return $start; + } + + return $start . '–' . (new DateTimeImmutable($latest))->format('d.m.Y'); +} + +function auth_market_status_label(string $status): string +{ + return [ + 'open' => 'Offen', + 'waitlist' => 'Warteliste', + 'registered' => 'Angemeldet', + 'confirmation_pending' => 'Bestätigung ausstehend', + 'confirmed' => 'Bestätigt', + 'cancelled' => 'Abgesagt', + 'completed' => 'Abgeschlossen', + ][$status] ?? $status; +} + +function auth_render_markets_large_table(array $marketTable): string +{ + $rows = $marketTable['rows'] ?? $marketTable; + $search = (string) ($marketTable['search'] ?? ''); + $sortColumn = (string) ($marketTable['sort_column'] ?? 'earliest_date'); + $sortDirection = strtoupper((string) ($marketTable['sort_direction'] ?? 'ASC')); + $limit = (int) ($marketTable['limit'] ?? 20); + $pageSize = (int) ($marketTable['page_size'] ?? 20); + $hasMore = (bool) ($marketTable['has_more'] ?? (count($rows) >= $limit)); + $sortColumns = [ + 'market_name' => 'Name der Veranstaltung', + 'earliest_date' => 'Durchführung', + 'place' => 'Ort', + 'status' => 'Status', + ]; + + $html = []; + $html[] = '
'; + $html[] = '
'; + $html[] = '
'; + $html[] = '
Marktdurchführungen
'; + $html[] = ''; + $html[] = ''; + $html[] = ''; + $html[] = ''; + $html[] = '
'; + $html[] = '
'; + $headerIndex = 0; + foreach ($sortColumns as $column => $label) { + $isActive = $column === $sortColumn; + $headerDirection = $isActive ? strtolower($sortDirection) : 'none'; + $iconDirection = $isActive ? strtolower($sortDirection) : 'ascending'; + $targetDirection = $isActive ? ($sortDirection === 'ASC' ? 'DESC' : 'ASC') : ($column === 'earliest_date' ? 'ASC' : 'ASC'); + $ariaLabel = $isActive ? $label . ' ' . ($sortDirection === 'ASC' ? 'aufsteigend sortiert' : 'absteigend sortiert') : $label . ' sortieren'; + $html[] = '
'; + $headerIndex++; + } + $html[] = '
'; + + foreach ($rows as $index => $row) { + $rowClass = 'sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row'; + if ($index % 2 === 1) { + $rowClass .= ' sg-large-table__row--striped-light'; + } + $html[] = '
'; + $html[] = '
' . auth_escape_html((string) ($row['market_name'] ?? '')) . '
'; + $html[] = '
' . auth_escape_html(auth_format_market_date($row['earliest_date'] ?? null, $row['latest_date'] ?? null)) . '
'; + $html[] = '
' . auth_escape_html((string) ($row['place'] ?? '')) . '
'; + $html[] = '
' . auth_escape_html(auth_market_status_label((string) ($row['status'] ?? ''))) . '
'; + $html[] = '
'; + } + + if ($hasMore) { + $html[] = ''; + } + $html[] = '
'; + return implode('', $html); +} + +function auth_render_markets_content(array $marketTable): string +{ + return '
' . auth_render_markets_large_table($marketTable) . '
'; +} + function auth_render_bestellungen_large_table(array $bestellungenTable): string { $rows = $bestellungenTable['rows'] ?? []; @@ -487,7 +578,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar . ''; } -function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $lagerProducts = [], array $lagerArtikel = [], array $lagerManufacturers = []): void +function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $marketOccurrencesTable = [], array $lagerProducts = [], array $lagerArtikel = [], array $lagerManufacturers = []): void { $otcProductRows = ''; foreach ($otcProducts as $product) { @@ -522,10 +613,12 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); $bestellungenContent = auth_render_bestellungen_content($bestellungenTable); + $marketsContent = auth_render_markets_content($marketOccurrencesTable); $lagerContent = auth_render_lager_content($lagerProducts, $lagerArtikel, $lagerManufacturers, auth_csrf_token()); $moduleContentCards = json_encode( [ 'Bestellungen' => $bestellungenContent, + 'Märkte' => $marketsContent, 'Lager' => $lagerContent, 'Artikel' => auth_render_lager_content([], $lagerArtikel, $lagerManufacturers, auth_csrf_token(), 'Artikel'), ], @@ -732,6 +825,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " if (contentTitle) { contentTitle.textContent = activeEntry; }"; echo " if (contentBody) { contentBody.innerHTML = portalModuleContentCards[activeEntry] || ''; }"; echo " if (activeEntry === 'Bestellungen') { initBestellungenBindings(); }"; + echo " if (activeEntry === 'Märkte') { initMarketsBindings(); }"; echo " if (activeEntry === 'Lager' || activeEntry === 'Artikel') { initLagerBindings(); }"; echo " menu.innerHTML = '';"; echo " entries.forEach((entry, index) => {"; @@ -753,6 +847,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " if (contentTitle) { contentTitle.textContent = entryName; }"; echo " if (contentBody) { contentBody.innerHTML = portalModuleContentCards[entryName] || ''; }"; echo " if (entryName === 'Bestellungen') { initBestellungenBindings(); }"; + echo " if (entryName === 'Märkte') { initMarketsBindings(); }"; echo " if (entryName === 'Lager' || entryName === 'Artikel') { initLagerBindings(); }"; echo " });"; echo " });"; @@ -907,6 +1002,29 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best echo " const lagerTotalObserver = new MutationObserver(syncLagerDisplayedTotal);"; echo " lagerTotalObserver.observe(contentRoot, { childList: true, subtree: true, characterData: true });"; echo "}"; + echo "let marketsBindingsInstalled = false;"; + echo "function initMarketsBindings() {"; + echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');"; + echo " if (!contentRoot || !contentRoot.querySelector('[data-markets-large-table]')) { return; }"; + echo " const getTable = () => contentRoot.querySelector('[data-markets-large-table]');"; + echo " let searchTimer = null;"; + echo " const getSearchInput = () => getTable()?.querySelector('[data-markets-search-input]') || null;"; + echo " const getSearchValue = () => (getSearchInput()?.value || '').trim();"; + echo " const getSortColumn = () => getTable()?.dataset.marketsSortColumn || 'earliest_date';"; + echo " const getSortDirection = () => (getTable()?.dataset.marketsSortDirection || 'ASC').toUpperCase();"; + echo " const getCurrentLimit = () => parseInt(getTable()?.dataset.marketsLimit || '20', 10);"; + echo " const getPageSize = () => parseInt(getTable()?.dataset.marketsPageSize || '20', 10);"; + echo " const buildUrl = (params) => { const url = new URL(window.location.href); Object.entries(params).forEach(([key, value]) => { if (value === null || value === undefined || value === '') { url.searchParams.delete(key); } else { url.searchParams.set(key, String(value)); } }); return url; };"; + echo " const replaceFragment = (html) => { const doc = new DOMParser().parseFromString(html, 'text/html'); const fragment = doc.querySelector('[data-markets-content]'); if (!fragment) { return false; } contentRoot.innerHTML = fragment.innerHTML; return Boolean(getTable()); };"; + echo " const loadFragment = async (params) => { if (searchTimer) { window.clearTimeout(searchTimer); searchTimer = null; } const input = getSearchInput(); const wasFocused = input ? document.activeElement === input : false; const response = await fetch(buildUrl({ ...params, market_fragment: '1' }).toString(), { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) { return; } const replaced = replaceFragment(await response.text()); if (!replaced) { return; } window.history.replaceState({}, '', buildUrl(params)); bindTable(); if (wasFocused) { const nextInput = getSearchInput(); nextInput?.focus({ preventScroll: true }); if (nextInput) { nextInput.setSelectionRange(nextInput.value.length, nextInput.value.length); } } };"; + echo " const syncSearchState = () => { const input = getSearchInput(); const wrap = input?.closest('[data-component=\'single-line-input\']'); if (!input || !wrap) { return; } const hasValue = input.value.trim() !== ''; wrap.setAttribute('data-has-value', String(hasValue)); wrap.dataset.componentState = hasValue ? 'active' : 'inactive-selectable'; };"; + echo " const bindTable = () => { const table = getTable(); if (!table) { return; } const input = getSearchInput(); const clearButton = table.querySelector('[data-markets-search-clear]'); if (input && input.dataset.marketsBound !== 'true') { input.dataset.marketsBound = 'true'; input.addEventListener('input', () => { syncSearchState(); if (searchTimer) { window.clearTimeout(searchTimer); } searchTimer = window.setTimeout(() => { void loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getPageSize() }); }, 500); }); } if (clearButton && clearButton.dataset.marketsBound !== 'true') { clearButton.dataset.marketsBound = 'true'; clearButton.addEventListener('click', () => { const currentInput = getSearchInput(); if (currentInput) { currentInput.value = ''; } syncSearchState(); void loadFragment({ market_search: '', market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getPageSize() }); }); } syncSearchState(); };"; + echo " if (!marketsBindingsInstalled) {"; + echo " marketsBindingsInstalled = true;"; + echo " document.addEventListener('click', (event) => { const sortButton = event.target.closest('[data-markets-sort-button]'); if (sortButton) { event.preventDefault(); void loadFragment({ market_search: getSearchValue(), market_sort: sortButton.dataset.marketsSortColumn || getSortColumn(), market_dir: sortButton.dataset.marketsSortDirection || getSortDirection(), market_limit: getPageSize() }); return; } const loadMore = event.target.closest('[data-markets-load-more-trigger]'); if (loadMore) { event.preventDefault(); void loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getCurrentLimit() + getPageSize() }); } });"; + echo " }"; + echo " bindTable();"; + echo "}"; echo "function initBestellungenBindings() {"; echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');"; echo " if (!contentRoot) { return; }"; diff --git a/public/index.php b/public/index.php index 0db9cb2..ebdc263 100644 --- a/public/index.php +++ b/public/index.php @@ -8,6 +8,7 @@ require_once __DIR__ . '/../modules/erp/bestellungen/service.php'; require_once __DIR__ . '/../modules/erp/lager/service.php'; require_once __DIR__ . '/../modules/erp/kontakte/service.php'; require_once __DIR__ . '/../modules/erp/artikel-mapping/service.php'; +require_once __DIR__ . '/../modules/market_management/service.php'; $env = expand_env_values(parse_env_file(__DIR__ . '/../.env')); $pdo = connect_database($env); @@ -58,11 +59,34 @@ if ($currentUser !== null) { 'limit' => (int) ($_GET['bestellungen_limit'] ?? 20), 'page_size' => 20, ]); + $marketSearch = (string) ($_GET['market_search'] ?? ''); + $marketSort = (string) ($_GET['market_sort'] ?? 'earliest_date'); + $marketDirection = strtoupper((string) ($_GET['market_dir'] ?? 'ASC')); + $marketLimit = min(100, max(1, (int) ($_GET['market_limit'] ?? 20))); + $marketOccurrencesTable = [ + 'rows' => market_management_search_occurrences($pdo, [ + 'search' => $marketSearch, + 'sort' => $marketSort, + 'sort_direction' => $marketDirection, + 'limit' => $marketLimit, + 'offset' => 0, + ]), + 'search' => $marketSearch, + 'sort_column' => $marketSort, + 'sort_direction' => $marketDirection, + 'limit' => $marketLimit, + 'page_size' => 20, + ]; + $marketOccurrencesTable['has_more'] = count($marketOccurrencesTable['rows']) >= $marketLimit; if ((string) ($_GET['bestellungen_fragment'] ?? '') === '1') { echo auth_render_bestellungen_content($bestellungenTable); exit; } - render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel, $lagerManufacturers); + if ((string) ($_GET['market_fragment'] ?? '') === '1') { + echo auth_render_markets_content($marketOccurrencesTable); + exit; + } + render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $marketOccurrencesTable, $lagerProducts, $lagerArtikel, $lagerManufacturers); exit; }