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[] = '';
+ $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;
}