Compare commits

...
29 changed files with 1326 additions and 261 deletions

No files matched your search

@@ -25,14 +25,14 @@ Der Token wird nicht in dieser Dokumentation oder im Repository gespeichert.
## Dokumentlinks in Marktdurchführungen
Wenn Hermes Informationen aus einer Datei verarbeitet und diese in einer
Marktdurchführung speichert, muss Hermes die Quelldatei als vollständige
HTTPS-URL in das Feld `notes` aufnehmen. Der bestehende Text darf erhalten
bleiben; die Position im Freitext ist frei wählbar.
Marktdurchführung speichert, muss Hermes die Quelldatei als Link im Feld
`notes` aufnehmen. Der bestehende Text darf erhalten bleiben; die Position im
Freitext ist frei wählbar.
Die URL verwendet dieses Format:
Der Link verwendet dieses Format:
```text
https://erpnaurua.imhochrain.ch/files.php?path=<RFC-3986-url-encodierter-relativer-Pfad>
<a href="https://erpnaurua.imhochrain.ch/files.php?path=<RFC-3986-url-encodierter-relativer-Pfad>">Dokumentname</a>
```
Der Parameter `path` ist relativ zum Ordner `Hermes Naurua` und enthält nicht
@@ -41,12 +41,19 @@ Beispiel:
```text
Quelldatei: Hermes Naurua/Marktmanagement/2026 Bellevue Noël/Standmietvertrag_Naurua.pdf
Link: https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2F2026%20Bellevue%20No%C3%ABl%2FStandmietvertrag_Naurua.pdf
Link: <a href="https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2F2026%20Bellevue%20No%C3%ABl%2FStandmietvertrag_Naurua.pdf">Standmietvertrag_Naurua.pdf</a>
```
Leerzeichen, Umlaute und sonstige Sonderzeichen müssen vollständig URL-encodiert
werden. Die ERP-Anzeige macht ausschließlich diese Dokument-URLs klickbar. Der
Link ist schreibgeschützt und erfordert eine aktive ERP-Anmeldung.
Leerzeichen, Umlaute und sonstige Sonderzeichen im URL-Parameter müssen
vollständig URL-encodiert werden. Im Feld `notes` sind ausschließlich `<a>`
für solche internen Dokumentlinks sowie `<br>` und `<strong>` erlaubt. Andere
HTML-Tags und nicht erlaubte URL-Schemata werden entfernt. Externe `http://`-
und `https://`-Links sind erlaubt und öffnen in einem neuen Tab. Der Link ist
schreibgeschützt und erfordert bei internen Dokumenten eine aktive ERP-Anmeldung.
Hermes muss diesen HTML-String unverändert als Feldwert senden: keine Markdown-
Notation, keine Backticks und keine HTML-Entity-Kodierung wie `&lt;`, `&gt;` oder
`&quot;`. Der MCP-Transport erwartet den HTML-Inhalt im JSON-String und escaped
ihn nicht als HTML.
Die Route benötigt auf dem ERP-Webserver die Konfiguration
`HERMES_DOCUMENT_ROOT=/volume1/Hermes/Hermes Naurua`. Dieser lokale Pfad ist
@@ -69,9 +69,14 @@ Modulberechtigung.
- Alle Schreibvorgänge laufen über die interne Modul-API.
- Hermes darf bei einem neuen Markt nur eine bestehende Kategorie referenzieren.
- Wenn Hermes Informationen aus einer Datei verarbeitet und in einer
Marktdurchführung speichert, muss die Quelldatei als vollständige HTTPS-URL
im Feld `notes` referenziert werden. Die Position im bestehenden Freitext ist
frei wählbar.
Marktdurchführung speichert, muss die Quelldatei als interner `<a>`-Link im
Feld `notes` referenziert werden. Zusätzlich sind in mehrzeiligen Feldern
`<br>` und `<strong>` erlaubt. Die Position im bestehenden Freitext ist frei
wählbar. Externe `http://`- und `https://`-Links sind zulässig und öffnen in
einem neuen Tab; andere HTML-Tags und URL-Schemata sind nicht zulässig. Hermes
sendet den HTML-String unverändert; Markdown, Backticks und HTML-Entities wie
`&lt;`, `&gt;` oder `&quot;` werden nicht verwendet. Der MCP-Transport escaped
den HTML-Inhalt nicht.
- Unsichere Erkennung wird nicht durch eine zusätzliche App-Freigabelogik
behandelt; die fachliche Qualitätssicherung liegt im Hermes-Training.
@@ -0,0 +1,27 @@
# Mobile Detail
## Zweck
`SharedMobileDetail` ist ein fachlich neutrales Frontend-Pattern fuer mobile
Listen-/Detailansichten. Es ersetzt auf kleinen Viewports den sichtbaren
Drawer durch eine vollflaechige Detailansicht.
## Verantwortung
Das Pattern kapselt ausschliesslich technische Interaktion:
- Verschieben des vorhandenen Detail-DOMs in einen mobilen Host
- Anzeigen und Entfernen des mobilen Hosts
- Browser-History und `popstate`-kompatibler Zurueck-Navigation
- Zuruecksetzen des Body-Locks und des globalen Drawer-Zustands
- Zuruecksetzen der Drawer-Attribute beim Verlassen der Detailansicht
Fachliche Darstellung, API-Aufrufe, Validierung und Speichern bleiben im
owning Modul.
## Nutzung
Fachliche Bereiche erzeugen einen Controller mit eigenem Host-/Drawer-
Attribut, eigener Body-Klasse und einem bestehenden Styleguide-
Navigationskarten-Markup fuer die Zurueck-Navigation. Der Controller darf
keine fachlichen Daten oder fachlichen Zustandsentscheidungen enthalten.
+1 -1
View File
@@ -1 +1 @@
2026.05.18.1
2026.05.18.2
@@ -1,41 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Komponente E-Mail Footer</title>
<link rel="stylesheet" href="../styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Komponente – E-Mail Footer</h1>
<section id="component-email-footer">
<p class="sg-preview-label">Komponente: E-Mail Footer</p>
<p class="sg-preview-label">E-Mail Footer not subscribed</p>
<div class="sg-preview-area">
<article
class="sg-card sg-card--content-card sg-card--footer-card"
data-component="content-card"
data-component-context="email-footer"
data-component-variant="not-subscribed"
aria-label="E-Mail Footer not subscribed"
>
<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body">
<div class="sg-text-layout-pattern__sample sg-text-layout-pattern__two-column" data-pattern-part="text-block-two-column">
<p class="sg-body sg-text-layout-pattern__column">
Footer links: Platzhaltertext fuer allgemeine Hinweise, Navigation oder Kontaktinformationen im zweispaltigen Layout.
</p>
<p class="sg-body sg-text-layout-pattern__column">
Footer rechts: Platzhaltertext fuer ergaenzende Angaben, rechtliche Hinweise oder sekundäre Footer-Inhalte im gleichen Raster.
</p>
</div>
</div>
</article>
</div>
</section>
</body>
</html>
@@ -1,37 +0,0 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Komponente E-Mail Header</title>
<link rel="stylesheet" href="../styleguide.css">
<link rel="stylesheet" href="../email-styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Komponente – E-Mail Header</h1>
<section id="component-email-header">
<p class="sg-preview-label">Komponente: E-Mail Header</p>
<div class="sg-preview-area">
<div class="sg-email-preview-viewport">
<div class="sg-email-preview-body">
<header
class="sg-email-header"
aria-label="E-Mail Header"
data-component="email-header"
data-component-variant="logo-only"
data-derived-from="portal-header-without-options-row"
>
<div class="sg-email-header__main" data-component-part="main">
<p class="sg-email-header__brand" data-component-part="brand">ValueStockFinder</p>
</div>
</header>
</div>
</div>
</div>
</section>
</body>
</html>
+18 -2
View File
@@ -31,6 +31,7 @@
<tr><td>white</td><td>#FFFFFF</td><td>Standardfläche von Buttons, Eingabefeldern, Pulldown-Triggern, Checkboxen, Radios, Toggle-Tracks und Card-Trennern.</td></tr>
<tr><td>signal-green</td><td>#009101</td><td>Positive Score-Balken und positive Chart-Werte.</td></tr>
<tr><td>signal-green-light</td><td>#65A665</td><td>Hellgrüne Variante für positive Signal- und Notification-Flächen.</td></tr>
<tr><td>signal-green-red</td><td>#C0624D</td><td>Rotgrüne Variante für positive oder negative Bewertungsdarstellungen in Notifications.</td></tr>
<tr><td>signal-yellow</td><td>#9C7A00</td><td>Neutrale Score-Balken, neutrale Chart-Werte und Warnwerte in Tabellen.</td></tr>
<tr><td>ampel-hellgelb</td><td>#D3B962</td><td>Untersegmente in der Pattern-Notification-Variante der Object Card.</td></tr>
<tr><td>signal-red</td><td>#9B3B2F</td><td>Negative Score-Balken und negative Chart-Werte.</td></tr>
@@ -38,13 +39,29 @@
<tr><td>font-light</td><td>#FFFFFF</td><td>Schrift auf dunklen Flächen, Portalheader, Card-Headern, Prozessbuttons und ausgewählten Tab-Buttons.</td></tr>
<tr><td>font-hyperlink</td><td>#FF6900</td><td>Links in Texten, Prozessbuttons und Page-Navigations-Cards.</td></tr>
<tr><td>process-inactive</td><td>#FFAE79</td><td>Inaktive Hintergrundfläche des Prozessbuttons.</td></tr>
<tr><td>background-purple</td><td>#373C4A</td><td>Globaler Portal- und E-Mail-Hintergrund.</td></tr>
<tr><td>background-purple</td><td>#373C4A</td><td>Globaler Portalhintergrund.</td></tr>
<tr><td>background-purple-light</td><td>#656C7D</td><td>Group Cards und gruppierende Card-Flächen.</td></tr>
<tr><td>transparent</td><td>transparent</td><td>Transparenter Foundation-Wert für semantische Overlay-/Box-Varianten ohne Eigenfläche.</td></tr>
</tbody>
</table>
</section>
<section id="foundations-card-text">
<h2 class="sg-sub-heading sg-text-on-dark">Card Semantic Tokens</h2>
<table class="sg-foundation-table sg-table-label">
<thead>
<tr>
<th>Name</th>
<th>Wert</th>
<th>Verwendung</th>
</tr>
</thead>
<tbody>
<tr><td>text-card-signal</td><td>font-light</td><td>Weiße Schrift auf allen farbigen Signal-Segmenten, unabhängig vom übergeordneten Card-Kontext.</td></tr>
</tbody>
</table>
</section>
<section id="foundations-states">
<h2 class="sg-sub-heading sg-text-on-dark">States</h2>
<table class="sg-foundation-table sg-table-label">
@@ -156,7 +173,6 @@
<tr><td>input-label-width</td><td>9rem</td><td>Desktop-Breite der Label-Spalte für gruppierte Formularzeilen mit Pulldowns, Slidern, Radio-Feldern, Checkbox-Feldern sowie ein- und mehrzeiligen Eingabefeldern.</td></tr>
<tr><td>input-field-desktop-width</td><td>400px</td><td>Fixe Desktop-Breite von ein- und mehrzeiligen Eingabefeldern in der Input-Component-Preview.</td></tr>
<tr><td>input-field-max-width</td><td>600px</td><td>Maximale Breite von ein- und mehrzeiligen Eingabefeldern (ohne Suchfeld).</td></tr>
<tr><td>email-body-max-width</td><td>600px</td><td>Maximale Breite des Inhaltsbereichs von E-Mail-Templates innerhalb des E-Mail-Viewports.</td></tr>
<tr><td>object-card-height</td><td>600px</td><td>Fixe Höhe der Object Card im Desktop-Layout (u. a. für Company Card).</td></tr>
<tr><td>scrollable-card-width</td><td>600px</td><td>Standardbreite der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
<tr><td>scrollable-card-height</td><td>600px</td><td>Standardhöhe der eigenständigen Scrollable Card; kann im konsumierenden Portal über den semantischen Layout-Token angepasst werden.</td></tr>
+1
View File
@@ -48,6 +48,7 @@
<li><a href="./patterns/navigation-card.html">Navigation Card</a></li>
<li><a href="./patterns/left-navigation.html">Left Navigation</a></li>
<li><a href="./patterns/notifications.html">Notifications</a></li>
<li><a href="./patterns/history-graph.html">History Graph</a></li>
<li><a href="./patterns/content-cards-group.html">Content Cards Group</a></li>
<li><a href="./patterns/card-gruppe-mit-tastennavigation.html">Card Gruppe mit Tastennavigation</a></li>
<li><a href="./patterns/formular-mit-abschnitten.html">Formular mit Abschnitten</a></li>
+120
View File
@@ -0,0 +1,120 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Styleguide – Pattern History Graph</title>
<link rel="stylesheet" href="../styleguide.css">
</head>
<body>
<h1 class="sg-main-heading">Pattern – History Graph</h1>
<section id="pattern-history-graph">
<p class="sg-preview-label">Pattern: History Graph</p>
<div class="sg-preview-area sg-history-graph" aria-label="Verlauf von Ereignissen">
<ol class="sg-history-graph__events">
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Aktuellstes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green" data-component-part="card-header">
<p class="sg-body">Aktuellstes Ereignis mit einem einzelnen farbigen Segment.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Zweites Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-yellow" data-component-part="card-header">
<p class="sg-body">Ereignis mit mehreren unabhängig gefärbten Segmenten.</p>
</div>
<div class="sg-card-segment sg-card-segment--body sg-card-segment--white" data-component-part="card-body">
<p class="sg-body">Der Inhalt, zum Beispiel Datum und Beschreibung, wird individuell durch das konsumierende Portal definiert.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Ältestes dargestelltes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-red" data-component-part="card-header">
<p class="sg-body">Ältestes dargestelltes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Viertes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green-light" data-component-part="card-header">
<p class="sg-body">Viertes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Fünftes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-yellow" data-component-part="card-header">
<p class="sg-body">Fünftes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Sechstes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green" data-component-part="card-header">
<p class="sg-body">Sechstes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Siebtes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-red" data-component-part="card-header">
<p class="sg-body">Siebtes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Achtes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green-light" data-component-part="card-header">
<p class="sg-body">Achtes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Neuntes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-yellow" data-component-part="card-header">
<p class="sg-body">Neuntes Ereignis.</p>
</div>
</article>
</li>
<li class="sg-history-graph__event">
<article class="sg-card sg-history-graph__card" data-component="card" data-pattern="history-graph-event" aria-label="Zehntes Ereignis">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green" data-component-part="card-header">
<p class="sg-body">Zehntes Ereignis.</p>
</div>
</article>
</li>
</ol>
<!-- Wird nur gerendert, wenn weitere Ereignisse geladen werden können. -->
<div class="sg-history-graph__navigation">
<div class="sg-navigation-card-layout">
<div class="sg-navigation-card-block">
<article class="sg-card" data-component="card" data-pattern="navigation-card" aria-label="Weitere Ereignisse laden">
<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-pattern-part="navigation-card-segment">
<div class="sg-navigation-card-center">
<a class="sg-hyperlink" href="#" data-component="hyperlink">Mehr laden</a>
</div>
</div>
</article>
</div>
</div>
</div>
</div>
</section>
</body>
</html>
@@ -142,6 +142,27 @@
</div>
</article>
<p class="sg-preview-label">Pattern: Notification with title - green-red rating</p>
<article class="sg-card sg-card--notification-signal-green-red sg-notifications-pattern__card" data-pattern="notification-with-title" data-pattern-variant="green-red-rating" data-component="notification-card">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green-red sg-notifications-pattern__text-segment sg-notifications-pattern__title-segment" data-component-part="card-header">
<p class="sg-body">
<span class="sg-strong">Bewertung</span>
</p>
</div>
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-green-red sg-notifications-pattern__text-segment" data-component-part="card-header">
<p class="sg-body">
Positive oder negative Bewertungsdarstellung zur Illustration.
</p>
</div>
<div class="sg-card-segment sg-card-segment--body sg-card-segment--signal-green-red" data-component-part="card-body">
<button class="sg-interaction-element sg-button sg-button--active" type="button" data-component="button" data-component-state="active">
zur Bewertung
</button>
</div>
</article>
<p class="sg-preview-label">Pattern: Notification with title - yellow</p>
<article class="sg-card sg-notifications-pattern__card" data-pattern="notification-with-title" data-pattern-variant="yellow" data-component="notification-card">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--signal-yellow sg-notifications-pattern__text-segment sg-notifications-pattern__title-segment" data-component-part="card-header">
+48 -1
View File
@@ -17,7 +17,7 @@
</p>
<p class="sg-table-label" style="color: var(--text-options-row-description);">
Mobile: rechte Optionen werden zum oberen Segment, linke Optionen zum unteren Segment; linke Optionen brechen innerhalb der verfügbaren Breite um.
Mobile: Pulldowns stehen mit maximal zwei Elementen pro Zeile; ein Suchfeld steht immer allein in einer 100-%-Zeile.
</p>
<div class="sg-options-row" aria-label="Optionszeile" data-pattern="options-row">
@@ -83,6 +83,20 @@
</div>
</div>
<div class="sg-pulldown-demo" data-open="false" data-align="left" data-selection-mode="single" data-component="pulldown" data-component-context="options-row" data-component-state="inactive-selectable">
<button class="sg-interaction-element sg-pulldown sg-pulldown-demo__trigger" type="button" aria-expanded="false" aria-label="Very long English label for market selection" data-component-part="pulldown-trigger" data-label-base="Very long English market selection label">
Very long English market selection label
</button>
<div class="sg-pulldown-panel" aria-label="Opened market selection" data-component-part="pulldown-panel">
<ul class="sg-pulldown-option-list" aria-label="Market options">
<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option>
<span>United Kingdom</span>
</li>
</ul>
</div>
</div>
<div class="sg-search-field-row">
<span class="sg-input-single-line-wrap sg-search-field-input" data-has-value="false" data-component="single-line-input" data-component-context="options-row" data-component-state="inactive-selectable">
<input
@@ -120,6 +134,39 @@
</div>
</section>
<section id="pattern-options-row-mobile-cases">
<p class="sg-preview-label">Pattern: Options Row – Mobile-Fälle</p>
<p class="sg-table-label" style="color: var(--text-options-row-description);">
Prüffälle: kein Pulldown, ein Pulldown, Suchfeld ohne Ergebniszähler sowie lange deutsche Beschriftung.
</p>
<div class="sg-options-row sg-options-row--left-only" aria-label="Optionszeile ohne Pulldown" data-pattern="options-row-mobile-no-pulldown">
<div class="sg-options-row__left" data-pattern-part="options-row-primary-actions">
<div class="sg-search-field-row">
<span class="sg-input-single-line-wrap sg-search-field-input" data-has-value="false" data-component="single-line-input" data-component-context="options-row" data-component-state="inactive-selectable">
<input class="sg-interaction-element sg-input-single-line" type="text" placeholder="Suche ohne Ergebniszähler" aria-label="Suche ohne Ergebniszähler">
<button class="sg-input-clear-button" type="button" aria-label="Eingabe löschen">×</button>
</span>
</div>
</div>
</div>
<div class="sg-options-row sg-options-row--left-only" aria-label="Optionszeile mit einem Pulldown" data-pattern="options-row-mobile-one-pulldown">
<div class="sg-options-row__left" data-pattern-part="options-row-primary-actions">
<div class="sg-pulldown-demo" data-open="false" data-align="left" data-selection-mode="single" data-component="pulldown" data-component-context="options-row" data-component-state="inactive-selectable">
<button class="sg-interaction-element sg-pulldown sg-pulldown-demo__trigger" type="button" aria-expanded="false" aria-label="Sehr lange deutsche Beschriftung für eine Auswahl" data-component-part="pulldown-trigger" data-label-base="Sehr lange deutsche Beschriftung für eine Auswahl">
Sehr lange deutsche Beschriftung für eine Auswahl
</button>
<div class="sg-pulldown-panel" aria-label="Geöffnete Auswahl" data-component-part="pulldown-panel">
<ul class="sg-pulldown-option-list" aria-label="Auswahloptionen">
<li class="sg-pulldown-option" role="checkbox" aria-checked="false" data-pulldown-option><span>Option</span></li>
</ul>
</div>
</div>
</div>
</div>
</section>
<section id="pattern-options-row-left-only">
<p class="sg-preview-label">Pattern: Options row left only</p>
+293 -92
View File
@@ -1,10 +1,12 @@
(function initTooltipOverlayModule() {
const getViewportWidth = () => {
if (window.visualViewport && typeof window.visualViewport.width === 'number') {
return window.visualViewport.width;
}
return window.innerWidth;
};
let tooltipId = 0;
const states = new WeakMap();
const tooltipStates = new Set();
const getViewport = () => ({
width: window.visualViewport?.width || window.innerWidth,
height: window.visualViewport?.height || window.innerHeight,
});
const getPixelValue = (propertyName) => {
const rootStyles = getComputedStyle(document.documentElement);
@@ -21,144 +23,343 @@
return value;
};
const setTooltipState = (wrap, isOpen) => {
const trigger = wrap.querySelector('[data-component-part="tooltip-trigger"]');
const panel = wrap.querySelector('[data-component-part="tooltip-panel"]');
if (!trigger || !panel) {
return;
const getElements = (wrap) => ({
trigger: wrap.querySelector('[data-component-part="tooltip-trigger"]'),
panel: wrap.querySelector('[data-component-part="tooltip-panel"]'),
});
const ensureAccessibility = (state) => {
const { trigger, panel } = state;
panel.setAttribute('role', 'tooltip');
if (!panel.id) {
tooltipId += 1;
panel.id = `sg-tooltip-panel-${tooltipId}`;
}
const describedBy = (trigger.getAttribute('aria-describedby') || '')
.split(/\s+/)
.filter(Boolean);
if (!describedBy.includes(panel.id)) {
trigger.setAttribute('aria-describedby', [...describedBy, panel.id].join(' '));
}
const hasAccessibleName = Boolean(
trigger.getAttribute('aria-label')
|| trigger.getAttribute('aria-labelledby')
|| trigger.getAttribute('title')
|| trigger.textContent.trim(),
);
if (!hasAccessibleName && panel.textContent.trim()) {
trigger.setAttribute('aria-label', panel.textContent.trim());
}
if (!trigger.matches('a,button,input,select,textarea,[tabindex]')) {
trigger.setAttribute('tabindex', '0');
}
};
const setOpenState = (state, isOpen) => {
const { wrap, panel, trigger } = state;
wrap.dataset.tooltipOpen = String(isOpen);
panel.hidden = !isOpen;
trigger.setAttribute('aria-expanded', String(isOpen));
if (trigger.matches('button,[role="button"],[aria-expanded]')) {
trigger.setAttribute('aria-expanded', String(isOpen));
}
};
const positionTooltip = (wrap) => {
const trigger = wrap.querySelector('[data-component-part="tooltip-trigger"]');
const panel = wrap.querySelector('[data-component-part="tooltip-panel"]');
if (!trigger || !panel || panel.hidden) {
const restorePanel = (state) => {
const { panel, placeholder } = state;
panel.hidden = true;
panel.removeAttribute('data-tooltip-portal');
panel.removeAttribute('data-tooltip-placement');
panel.style.removeProperty('left');
panel.style.removeProperty('top');
panel.style.removeProperty('visibility');
panel.style.removeProperty('--tooltip-arrow-offset');
if (placeholder.parentNode) {
placeholder.parentNode.insertBefore(panel, placeholder.nextSibling);
}
};
const movePanelToPortal = (state) => {
const { panel } = state;
if (panel.parentNode !== document.body) {
document.body.appendChild(panel);
}
panel.setAttribute('data-tooltip-portal', 'true');
panel.hidden = false;
panel.style.visibility = 'hidden';
};
const setArrowOffset = (state, placement, panelLeft, panelTop, panelWidth, panelHeight) => {
const { trigger, panel } = state;
const triggerRect = trigger.getBoundingClientRect();
const offset = placement === 'left' || placement === 'right'
? triggerRect.top + (triggerRect.height / 2) - panelTop
: triggerRect.left + (triggerRect.width / 2) - panelLeft;
const maxOffset = placement === 'left' || placement === 'right' ? panelHeight : panelWidth;
panel.style.setProperty('--tooltip-arrow-offset', `${Math.max(0, Math.min(offset, maxOffset))}px`);
};
const positionTooltip = (state) => {
const { trigger, panel } = state;
if (panel.hidden || panel.parentNode !== document.body) {
return;
}
const viewportWidth = getViewportWidth();
const viewport = getViewport();
const safeInset = getPixelValue('--spacing-large');
const triggerRect = trigger.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const gap = getPixelValue('--spacing-small');
const triggerRect = trigger.getBoundingClientRect();
const panelWidth = panel.offsetWidth;
const panelHeight = panel.offsetHeight;
const centeredLeft = triggerRect.left + (triggerRect.width / 2) - (panelWidth / 2);
const centeredRight = centeredLeft + panelWidth;
const hasBottomSpace = triggerRect.bottom + gap + panelHeight <= viewport.height - safeInset;
const hasTopSpace = triggerRect.top - gap - panelHeight >= safeInset;
const hasLeftSpace = triggerRect.left - gap - panelWidth >= safeInset;
const hasRightSpace = triggerRect.right + gap + panelWidth <= viewport.width - safeInset;
let placement = 'bottom';
if (triggerRect.right + (panelRect.width / 2) > viewportWidth - safeInset) {
if (centeredRight > viewport.width - safeInset && hasLeftSpace) {
placement = 'left';
} else if (triggerRect.left - (panelRect.width / 2) < safeInset) {
} else if (centeredLeft < safeInset && hasRightSpace) {
placement = 'right';
} else if (!hasBottomSpace && hasTopSpace) {
placement = 'top';
}
wrap.dataset.tooltipPlacement = placement;
let panelLeft = centeredLeft;
let panelTop = triggerRect.bottom + gap;
if (placement === 'bottom') {
const nextPanelRect = panel.getBoundingClientRect();
if (nextPanelRect.bottom > window.innerHeight - safeInset
&& triggerRect.top - panelRect.height - gap >= safeInset) {
wrap.dataset.tooltipPlacement = 'top';
if (placement === 'top') {
panelTop = triggerRect.top - gap - panelHeight;
} else if (placement === 'left') {
panelLeft = triggerRect.left - gap - panelWidth;
panelTop = triggerRect.top + (triggerRect.height / 2) - (panelHeight / 2);
} else if (placement === 'right') {
panelLeft = triggerRect.right + gap;
panelTop = triggerRect.top + (triggerRect.height / 2) - (panelHeight / 2);
}
panelLeft = Math.max(safeInset, Math.min(panelLeft, viewport.width - safeInset - panelWidth));
panelTop = Math.max(safeInset, Math.min(panelTop, viewport.height - safeInset - panelHeight));
panel.dataset.tooltipPlacement = placement;
state.wrap.dataset.tooltipPlacement = placement;
panel.style.left = `${Math.round(panelLeft)}px`;
panel.style.top = `${Math.round(panelTop)}px`;
setArrowOffset(state, placement, panelLeft, panelTop, panelWidth, panelHeight);
panel.style.visibility = 'visible';
};
const openTooltip = (state) => {
tooltipStates.forEach((otherState) => {
if (otherState !== state) {
closeTooltip(otherState);
}
});
movePanelToPortal(state);
setOpenState(state, true);
positionTooltip(state);
};
const closeTooltip = (state) => {
state.pinned = false;
state.hoverTrigger = false;
state.hoverPanel = false;
state.focused = false;
state.dismissed = false;
setOpenState(state, false);
restorePanel(state);
};
const closeIfInactive = (state) => {
if (!state.pinned && !state.hoverTrigger && !state.hoverPanel && !state.focused) {
setOpenState(state, false);
restorePanel(state);
}
};
const closeTooltip = (wrap) => {
wrap.dataset.tooltipPinned = 'false';
wrap.dataset.tooltipHover = 'false';
wrap.dataset.tooltipFocus = 'false';
wrap.dataset.tooltipDismissed = 'false';
setTooltipState(wrap, false);
};
const togglePinned = (state) => {
if (state.pinned) {
state.pinned = false;
state.dismissed = true;
setOpenState(state, false);
restorePanel(state);
return;
}
const openTooltip = (wrap) => {
setTooltipState(wrap, true);
positionTooltip(wrap);
state.pinned = true;
state.dismissed = false;
openTooltip(state);
};
const initTooltip = (wrap) => {
if (wrap.dataset.tooltipInit === 'true') {
if (states.has(wrap)) {
return;
}
const elements = getElements(wrap);
if (!elements.trigger || !elements.panel || !elements.panel.parentNode) {
return;
}
const placeholder = document.createComment('sg-tooltip-panel-placeholder');
elements.panel.parentNode.insertBefore(placeholder, elements.panel);
const state = {
wrap,
trigger: elements.trigger,
panel: elements.panel,
placeholder,
pinned: false,
hoverTrigger: false,
hoverPanel: false,
focused: false,
dismissed: false,
};
wrap.dataset.tooltipInit = 'true';
wrap.dataset.tooltipPlacement = 'bottom';
wrap.dataset.tooltipPinned = 'false';
wrap.dataset.tooltipHover = 'false';
wrap.dataset.tooltipFocus = 'false';
wrap.dataset.tooltipDismissed = 'false';
setTooltipState(wrap, false);
states.set(wrap, state);
tooltipStates.add(state);
ensureAccessibility(state);
setOpenState(state, false);
const trigger = wrap.querySelector('[data-component-part="tooltip-trigger"]');
if (!trigger) {
return;
}
wrap.addEventListener('pointerenter', () => {
if (wrap.dataset.tooltipDismissed === 'true') {
wrap.addEventListener('pointerenter', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
wrap.dataset.tooltipHover = 'true';
openTooltip(wrap);
if (state.dismissed) {
return;
}
state.hoverTrigger = true;
openTooltip(state);
});
wrap.addEventListener('pointerleave', () => {
wrap.dataset.tooltipHover = 'false';
wrap.dataset.tooltipDismissed = 'false';
if (wrap.dataset.tooltipPinned !== 'true' && wrap.dataset.tooltipFocus !== 'true') {
setTooltipState(wrap, false);
wrap.addEventListener('pointerleave', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
state.hoverTrigger = false;
state.dismissed = false;
if (!state.panel.contains(event.relatedTarget)) {
closeIfInactive(state);
}
});
state.panel.addEventListener('pointerenter', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
if (!state.dismissed) {
state.hoverPanel = true;
openTooltip(state);
}
});
state.panel.addEventListener('pointerleave', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
state.hoverPanel = false;
state.dismissed = false;
closeIfInactive(state);
});
wrap.addEventListener('focusin', () => {
if (wrap.dataset.tooltipDismissed === 'true') {
return;
if (!state.dismissed) {
state.focused = true;
openTooltip(state);
}
wrap.dataset.tooltipFocus = 'true';
openTooltip(wrap);
});
wrap.addEventListener('focusout', (event) => {
if (wrap.contains(event.relatedTarget)) {
if (wrap.contains(event.relatedTarget) || state.panel.contains(event.relatedTarget)) {
return;
}
wrap.dataset.tooltipFocus = 'false';
wrap.dataset.tooltipDismissed = 'false';
if (wrap.dataset.tooltipPinned !== 'true' && wrap.dataset.tooltipHover !== 'true') {
setTooltipState(wrap, false);
}
state.focused = false;
state.dismissed = false;
closeIfInactive(state);
});
trigger.addEventListener('click', (event) => {
event.stopPropagation();
const isPinned = wrap.dataset.tooltipPinned === 'true';
wrap.dataset.tooltipPinned = String(!isPinned);
state.trigger.addEventListener('click', (event) => {
togglePinned(state);
});
if (isPinned) {
wrap.dataset.tooltipDismissed = 'true';
setTooltipState(wrap, false);
return;
state.trigger.addEventListener('keydown', (event) => {
const isNativeInteractive = state.trigger.matches('a,button,input,select,textarea');
if (!isNativeInteractive && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
togglePinned(state);
}
wrap.dataset.tooltipDismissed = 'false';
openTooltip(wrap);
});
};
document.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip);
document.addEventListener('click', (event) => {
document.querySelectorAll('.sg-tooltip-wrap').forEach((wrap) => {
if (!wrap.contains(event.target)) {
closeTooltip(wrap);
}
});
});
const repositionOpenTooltips = () => {
document.querySelectorAll('.sg-tooltip-wrap[data-tooltip-open="true"]').forEach(positionTooltip);
const initializeWithin = (root) => {
if (root.nodeType === Node.DOCUMENT_NODE) {
root.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip);
return;
}
if (root.nodeType !== Node.ELEMENT_NODE) {
return;
}
const owningWrap = root.closest('.sg-tooltip-wrap');
if (owningWrap) {
initTooltip(owningWrap);
}
if (root.matches('.sg-tooltip-wrap')) {
initTooltip(root);
}
root.querySelectorAll('.sg-tooltip-wrap').forEach(initTooltip);
};
window.addEventListener('resize', repositionOpenTooltips);
window.addEventListener('scroll', repositionOpenTooltips, true);
const bootstrap = () => {
window.sgInitTooltips = (options = {}) => {
const root = options.root || document.body;
initializeWithin(root);
};
window.sgInitTooltips();
const observer = new MutationObserver((records) => {
records.forEach((record) => {
record.addedNodes.forEach(initializeWithin);
});
});
observer.observe(document.body, { childList: true, subtree: true });
const closeFromOutside = (event) => {
tooltipStates.forEach((state) => {
if (!state.wrap.contains(event.target) && !state.panel.contains(event.target)) {
closeTooltip(state);
}
});
};
document.addEventListener('pointerdown', closeFromOutside, true);
document.addEventListener('touchstart', closeFromOutside, true);
document.addEventListener('click', closeFromOutside, true);
const repositionOpenTooltips = () => {
document.querySelectorAll('.sg-tooltip-wrap[data-tooltip-open="true"]').forEach((wrap) => {
const state = states.get(wrap);
if (state) {
positionTooltip(state);
}
});
};
window.addEventListener('resize', repositionOpenTooltips);
window.addEventListener('scroll', repositionOpenTooltips, true);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bootstrap, { once: true });
} else {
bootstrap();
}
})();
+1
View File
@@ -3,6 +3,7 @@
@import "./styles/03-typography-helpers.css";
@import "./styles/10-components-interactive-elements.css";
@import "./styles/47-patterns-file-upload.css";
@import "./styles/48-patterns-history-graph.css";
@import "./styles/20-patterns-portal-header.css";
@import "./styles/21-patterns-options-row.css";
@import "./styles/22-patterns-object-card.css";
+2 -2
View File
@@ -16,6 +16,7 @@
--color-process-inactive: #FFAE79;
--color-signal-green: #009101;
--color-signal-green-light: #65A665;
--color-signal-green-red: #C0624D;
--color-signal-yellow: #9C7A00;
--color-ampel-hellgelb: #D3B962;
--color-signal-red: #9B3B2F;
@@ -37,6 +38,7 @@
--surface-card-header-muted: var(--color-darkbrown);
--divider-card-segment: var(--color-white);
--text-card-header: var(--color-font-light);
--text-card-signal: var(--color-font-light);
--text-card-body: var(--color-font-dark);
--text-card-transparent: var(--color-font-light);
--layout-card-body-content-justify: flex-start;
@@ -144,7 +146,6 @@
--layout-preview-align-items: flex-start;
/* Semantic pattern tokens: Portal header */
--surface-portal-background: var(--color-background-purple);
--surface-portal-header: var(--color-darkblue);
--surface-portal-header-tab: var(--color-midblue);
--surface-portal-header-tab-active: var(--color-light-grey);
@@ -310,7 +311,6 @@
--dimension-input-label-width: 9rem;
--dimension-input-field-desktop-width: 400px;
--dimension-input-field-max-width: 600px;
--dimension-email-body-max-width: 600px;
--dimension-search-field-width: 15.3rem;
--dimension-public-content-width: 100%;
--dimension-layer-pulldown-panel: 40;
+1 -1
View File
@@ -8,7 +8,7 @@ body {
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
color: var(--color-font-dark);
background: var(--surface-portal-background);
background: var(--color-background-purple);
margin: 0;
padding: var(--spacing-small);
}
@@ -1240,9 +1240,7 @@
}
.sg-tooltip-wrap {
position: relative;
display: inline-flex;
vertical-align: middle;
display: contents;
}
.sg-tooltip-panel {
@@ -1328,6 +1326,47 @@
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"] {
position: fixed;
top: 0;
left: 0;
z-index: var(--layer-pulldown-panel);
max-width: calc(100vw - (2 * var(--spacing-large)));
transform: none;
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="bottom"]::before {
top: calc(var(--spacing-small) * -1);
left: var(--tooltip-arrow-offset);
border-width: 0 var(--spacing-small) var(--spacing-small);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="top"]::before {
bottom: calc(var(--spacing-small) * -1);
left: var(--tooltip-arrow-offset);
border-width: var(--spacing-small) var(--spacing-small) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="left"]::before {
top: var(--tooltip-arrow-offset);
right: calc(var(--spacing-small) * -1);
border-width: var(--spacing-small) 0 var(--spacing-small) var(--spacing-small);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="right"]::before {
top: var(--tooltip-arrow-offset);
left: calc(var(--spacing-small) * -1);
border-width: var(--spacing-small) var(--spacing-small) var(--spacing-small) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-pulldown-panel {
position: absolute;
top: calc(100% + var(--spacing-small));
@@ -91,19 +91,39 @@
.sg-options-row--left-only .sg-options-row__left {
order: 0;
width: 100%;
padding: 0;
box-shadow: none;
}
.sg-options-row__left > .sg-pulldown-demo,
.sg-options-row__left > .sg-input-single-line-wrap,
.sg-options-row__left > .sg-search-field-row {
flex: 1 1 calc(50% - var(--layout-options-row-group-gap));
.sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
min-width: 0;
}
.sg-options-row__left:has(> .sg-pulldown-demo ~ .sg-pulldown-demo) > .sg-pulldown-demo {
flex-basis: calc(50% - (var(--layout-options-row-group-gap) / 2));
}
.sg-options-row--left-only .sg-options-row__left > .sg-pulldown-demo {
flex-basis: 100%;
}
.sg-options-row__left > .sg-search-field-row {
flex: 0 0 auto;
flex: 0 0 100%;
width: 100%;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row > .sg-search-field-input {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row .sg-input-single-line {
width: 100%;
min-width: 0;
}
.sg-options-row__left > .sg-input-single-line-wrap .sg-input-single-line,
+14 -4
View File
@@ -136,22 +136,27 @@
.sg-card-segment--signal-red {
background: var(--color-signal-red);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green {
background: var(--color-signal-green);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green-light {
background: var(--color-signal-green-light);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green-red {
background: var(--color-signal-green-red);
color: var(--text-card-signal);
}
.sg-card-segment--signal-yellow {
background: var(--color-signal-yellow);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--gray {
@@ -171,6 +176,11 @@
--text-card-body: var(--color-font-light);
}
.sg-card.sg-card--notification-signal-green-red {
--text-card-header: var(--color-font-light);
--text-card-body: var(--color-font-light);
}
.sg-card--content-card {
--surface-card-header-primary: var(--surface-content-block-card-title);
--surface-card-body: var(--surface-content-block-card-content);
@@ -0,0 +1,45 @@
/* ========================================================= */
/* Pattern: History Graph */
/* ========================================================= */
.sg-history-graph {
--layout-history-graph-connector-width: var(--score-marker-width);
--surface-history-graph-connector: var(--chart-marker-line);
width: 100%;
}
.sg-history-graph__events {
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.sg-history-graph__event {
position: relative;
width: 100%;
}
.sg-history-graph__event:not(:last-child)::after {
position: absolute;
top: 100%;
left: 50%;
width: var(--layout-history-graph-connector-width);
height: var(--spacing-large);
background: var(--surface-history-graph-connector);
content: "";
transform: translateX(-50%);
}
.sg-history-graph__card {
width: 100%;
max-width: var(--layout-object-card-min-width);
margin-inline: auto;
}
.sg-history-graph__navigation {
width: 100%;
}
+3 -3
View File
@@ -78,10 +78,10 @@ function market_management_mcp_tools(): array
['name' => 'market_management.update_market', 'description' => 'Markt inklusive Veranstalter und Website-URL ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'organizer_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]],
['name' => 'market_management.archive_market', 'description' => 'Markt archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer']]]],
['name' => 'market_management.reactivate_market', 'description' => 'Markt reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer']]]],
['name' => 'market_management.list_categories', 'description' => 'Bestehende Kategorien lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
['name' => 'market_management.list_market_organizers', 'description' => 'Aktive Veranstalter lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
['name' => 'market_management.list_categories', 'description' => 'Bestehende Kategorien lesen', 'inputSchema' => ['type' => 'object', 'properties' => (object) []]],
['name' => 'market_management.list_market_organizers', 'description' => 'Aktive Veranstalter lesen', 'inputSchema' => ['type' => 'object', 'properties' => (object) []]],
['name' => 'market_management.create_market_organizer', 'description' => 'Veranstalter mit optionalen Kontaktdaten anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string'], 'email' => ['type' => ['string', 'null'], 'format' => 'email'], 'phone' => ['type' => ['string', 'null']]]]],
['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object', 'properties' => (object) []]],
['name' => 'market_management.create_municipality', 'description' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string']]]],
['name' => 'market_management.get_overview', 'description' => 'Marktübersicht mit serverseitigen Filtern lesen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
['name' => 'market_management.create_occurrence', 'description' => 'Marktdurchführung mit mindestens einem Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id', 'days'], 'properties' => ['market_definition_id' => ['type' => 'integer'], 'status' => ['type' => 'string'], 'days' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => 'object', 'required' => ['date'], 'properties' => $dayProperties]] + $occurrenceProperties]]],
+131 -4
View File
@@ -18,6 +18,126 @@ const MARKET_OCCURRENCE_DAY_INHERITED_FIELDS = [
'delivery_start_time', 'delivery_end_time',
];
const MARKET_MANAGEMENT_MULTILINE_FIELDS = [
'address', 'notes', 'other_infrastructure', 'own_stand_requirements',
'travel_address', 'parking_options', 'delivery_access', 'storage_options',
'travel_notes', 'cancellation_terms', 'revenue_share_terms',
'other_participation_conditions', 'observations', 'cost_items_text',
'description',
];
function market_management_is_allowed_link_url(string $url): bool
{
$parts = parse_url($url);
if (!is_array($parts)
|| !in_array(($parts['scheme'] ?? ''), ['http', 'https'], true)
|| ($parts['host'] ?? '') === ''
|| isset($parts['user'], $parts['pass'])) {
return false;
}
if (($parts['host'] ?? '') !== 'erpnaurua.imhochrain.ch' || ($parts['path'] ?? '') !== '/files.php') {
return true;
}
if (isset($parts['port'], $parts['fragment']) || !preg_match('/^path=([^&]+)$/', (string) ($parts['query'] ?? ''), $matches)) {
return false;
}
$relativePath = rawurldecode($matches[1]);
if ($relativePath === '' || str_contains($relativePath, "\0") || str_contains($relativePath, '\\') || str_starts_with($relativePath, '/')) {
return false;
}
foreach (explode('/', $relativePath) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}
function market_management_sanitize_multiline_html(?string $value): ?string
{
if ($value === null || $value === '') {
return $value;
}
$tokens = preg_split('/(<\/?a\b[^>]*>|<br\s*\/?>|<\/?strong\s*>)/iu', $value, -1, PREG_SPLIT_DELIM_CAPTURE);
if ($tokens === false) {
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
$result = '';
$anchorOpen = false;
foreach ($tokens as $token) {
if (preg_match('/^<br\s*\/?>$/iu', $token)) {
$result .= '<br>';
continue;
}
if (preg_match('/^<strong\s*>$/iu', $token)) {
$result .= '<strong>';
continue;
}
if (preg_match('/^<\/strong\s*>$/iu', $token)) {
$result .= '</strong>';
continue;
}
if (preg_match('/^<\/a\s*>$/iu', $token)) {
if ($anchorOpen) {
$result .= '</a>';
$anchorOpen = false;
} else {
$result .= htmlspecialchars($token, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
continue;
}
if (preg_match('/^<a\b[^>]*\bhref\s*=\s*(["\'])(.*?)\1[^>]*>$/isu', $token, $matches)
&& market_management_is_allowed_link_url($matches[2])) {
if ($anchorOpen) {
$result .= '</a>';
}
$href = htmlspecialchars($matches[2], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$result .= '<a class="sg-hyperlink" href="' . $href . '" target="_blank" rel="noopener noreferrer">';
$anchorOpen = true;
continue;
}
$result .= htmlspecialchars($token, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
if ($anchorOpen) {
$result .= '</a>';
}
return $result;
}
function market_management_sanitize_multiline_fields(array &$record): void
{
foreach (MARKET_MANAGEMENT_MULTILINE_FIELDS as $field) {
if (array_key_exists($field, $record) && is_string($record[$field])) {
$record[$field] = market_management_sanitize_multiline_html($record[$field]);
}
}
}
function market_management_sanitize_occurrence_payload(array &$occurrence): void
{
market_management_sanitize_multiline_fields($occurrence);
foreach ($occurrence['days'] ?? [] as &$day) {
if (is_array($day)) {
market_management_sanitize_multiline_fields($day);
}
}
unset($day);
foreach ($occurrence['cost_items'] ?? [] as &$costItem) {
if (is_array($costItem)) {
market_management_sanitize_multiline_fields($costItem);
}
}
unset($costItem);
}
function market_management_list_organizers(PDO $pdo): array
{
return $pdo->query(
@@ -482,6 +602,7 @@ function market_management_get_market(PDO $pdo, int $marketId, bool $includeArch
$costs = $pdo->prepare('SELECT * FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC');
$costs->execute([':occurrence_id' => $occurrenceId]);
$occurrence['cost_items'] = $costs->fetchAll();
market_management_sanitize_occurrence_payload($occurrence);
}
unset($occurrence);
return $market;
@@ -721,6 +842,9 @@ function market_management_write_fields(array $input, array $definitions): array
'nullable_id' => market_management_optional_id($input, $field),
default => throw new LogicException('Unbekannter Feldtyp.'),
};
if ($type === 'string' && in_array($field, MARKET_MANAGEMENT_MULTILINE_FIELDS, true)) {
$value = market_management_sanitize_multiline_html($value);
}
$fields[] = $field . ' = :' . $field;
$params[':' . $field] = $value;
}
@@ -982,6 +1106,7 @@ function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $inc
break;
}
}
market_management_sanitize_occurrence_payload($occurrence);
$costSummary = $pdo->prepare(
'SELECT COALESCE(SUM(gross_amount), 0) AS gross_amount,
COALESCE(SUM(net_amount), 0) AS net_amount,
@@ -1330,7 +1455,7 @@ function market_management_create_cost_item(PDO $pdo, array $input): array
$stmt->execute([
':occurrence_id' => $occurrenceId,
':cost_type_id' => $costTypeId,
':description' => market_management_optional_string($input, 'description'),
':description' => market_management_sanitize_multiline_html(market_management_optional_string($input, 'description')),
':gross_amount' => $grossAmount,
':tax_status' => $taxStatus,
':tax_rate' => $taxRate,
@@ -1381,7 +1506,9 @@ function market_management_update_cost_item(PDO $pdo, int $costItemId, array $in
$sets[] = $field . ' = :' . $field;
$params[':' . $field] = $type === 'number'
? market_management_optional_number($input, $field)
: market_management_optional_string($input, $field);
: ($field === 'description'
? market_management_sanitize_multiline_html(market_management_optional_string($input, $field))
: market_management_optional_string($input, $field));
}
if (isset($params[':tax_status']) && !in_array($params[':tax_status'], ['tax_free', 'taxable'], true)) {
throw new InvalidArgumentException('Ungültiger Steuerstatus.');
@@ -1418,7 +1545,7 @@ function market_management_update_occurrence_simple_costs(PDO $pdo, int $occurre
$item = market_management_create_cost_item($pdo, [
'occurrence_id' => $occurrenceId,
'cost_type_id' => market_management_get_cost_type_id($pdo, 'stand_fee'),
'description' => array_key_exists('cost_items_text', $input) ? market_management_optional_string($input, 'cost_items_text') : null,
'description' => array_key_exists('cost_items_text', $input) ? market_management_sanitize_multiline_html(market_management_optional_string($input, 'cost_items_text')) : null,
'gross_amount' => $grossAmount,
'tax_status' => $taxStatus,
'tax_rate' => $taxRate,
@@ -1429,7 +1556,7 @@ function market_management_update_occurrence_simple_costs(PDO $pdo, int $occurre
$costFields['gross_amount'] = market_management_optional_number($input, 'gross_cost');
}
if (array_key_exists('cost_items_text', $input)) {
$costFields['description'] = market_management_optional_string($input, 'cost_items_text');
$costFields['description'] = market_management_sanitize_multiline_html(market_management_optional_string($input, 'cost_items_text'));
}
if (array_key_exists('tax_status', $input)) {
$costFields['tax_status'] = trim((string) $input['tax_status']);
+18 -10
View File
@@ -931,6 +931,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
echo '</section>';
echo '<script src="/scripts/help-icon-overlays.js"></script>';
echo '<script src="/scripts/shared-editable-action-card.js?v=20260920-2"></script>';
echo '<script src="/scripts/shared-mobile-detail.js?v=20260925-1"></script>';
echo '<script>';
echo 'const portalModuleNavigation = ' . $moduleNavigation . ';';
echo 'const portalModuleContentCards = ' . $moduleContentCards . ';';
@@ -1071,15 +1072,15 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
echo " lagerBindingsInstalled = true;";
echo " let lagerMobileDetailHost = null;";
echo " let lagerMobileDetailOpen = false;";
echo " const lagerMobileDetail = window.SharedMobileDetail?.create({ contentRoot, bodyClass: 'sg-lager-mobile-detail-open', hostClass: 'sg-lager-mobile-detail-host', hostDataAttribute: 'data-lager-mobile-detail-host', drawerDataAttribute: 'data-lager-mobile-drawer', backMarkup: '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\"><div class=\"sg-card-segment sg-card-segment--body\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-lager-detail-back>zurück</button></div></div></article></div></div>', historyKey: 'lagerView' });";
echo " const renderLagerMobileDetail = (sourceDrawer, trigger) => { const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; sourceDrawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; sourceDrawer.querySelector('[data-lager-drawer-name-value]').textContent = name; sourceDrawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; sourceDrawer.querySelector('[data-lager-drawer-value-value]').textContent = value; sourceDrawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); const newLotForm = sourceDrawer.querySelector('[data-lager-drawer-topic=\"new-lot-form\"]'); if (newLotForm) { newLotForm.hidden = true; } const newLotAction = sourceDrawer.querySelector('[data-lager-drawer-topic=\"new-lot-action\"]'); if (newLotAction) { newLotAction.hidden = false; } if (type === 'Produkt') { const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = sourceDrawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? (trigger.dataset.lagerDrawerForecast || 'Keine Prognose') : 'Keine akute Warnung.'; } const warningTitle = sourceDrawer.querySelector('[data-lager-drawer-warning-title]'); if (warningTitle) { warningTitle.textContent = getLagerWarningTitle(trigger); } sourceDrawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-current-qty]').textContent = trigger.dataset.lagerDrawerCurrentQty || '0'; sourceDrawer.querySelector('[data-lager-drawer-qty-in]').textContent = trigger.dataset.lagerDrawerQtyIn || '0'; sourceDrawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; sourceDrawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const productId = trigger.closest('[data-lager-row]')?.dataset.lagerId || ''; sourceDrawer.querySelectorAll('[data-lager-historical-product]').forEach((product) => { product.hidden = product.dataset.lagerHistoricalProduct !== productId; }); sourceDrawer.querySelectorAll('[data-lager-open-product]').forEach((product) => { product.hidden = product.dataset.lagerOpenProduct !== productId; product.querySelectorAll('[data-lager-open-edit-form]').forEach((form) => { form.hidden = true; }); product.querySelectorAll('[data-lager-open-card]').forEach((readView) => { readView.hidden = false; }); }); resetLagerNewLotConfirmation(); resetLagerOpenLotConfirmations(); sourceDrawer.dataset.lagerProductId = productId; populateLagerActiveLotForm(sourceDrawer, trigger); } const host = document.createElement('div'); host.className = 'sg-lager-mobile-detail-host'; host.dataset.lagerMobileDetailHost = 'true'; host.innerHTML = '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\" aria-label=\"Navigation Detailansicht zurück oben\"><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\" data-pattern-part=\"navigation-card-segment\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-lager-detail-back>zurück</button></div></div></article></div></div>'; const mobileDrawer = sourceDrawer; mobileDrawer.dataset.lagerMobileDrawer = 'true'; mobileDrawer.dataset.open = 'true'; mobileDrawer.setAttribute('aria-hidden', 'false'); host.appendChild(sourceDrawer); return host; };";
echo " const restoreLagerMobileDetail = () => {";
echo " if (lagerMobileDetailHost && lagerMobileDetailHost.parentNode) { const mobileDrawer = lagerMobileDetailHost.querySelector('[data-lager-mobile-drawer]'); if (mobileDrawer) { contentRoot.appendChild(mobileDrawer); mobileDrawer.removeAttribute('data-lager-mobile-drawer'); mobileDrawer.dataset.open = 'false'; mobileDrawer.setAttribute('aria-hidden', 'true'); } lagerMobileDetailHost.parentNode.removeChild(lagerMobileDetailHost); }";
echo " lagerMobileDetailHost = null; lagerMobileDetailOpen = false; document.body.classList.remove('sg-lager-mobile-detail-open');";
echo " lagerMobileDetail?.restore(); lagerMobileDetailHost = null; lagerMobileDetailOpen = false;";
echo " };";
echo " const openLagerMobileDetail = (trigger) => {";
echo " restoreLagerMobileDetail(); const sourceDrawer = contentRoot.querySelector('[data-lager-drawer]'); lagerMobileDetailHost = sourceDrawer ? renderLagerMobileDetail(sourceDrawer, trigger) : null; const mobileDrawer = lagerMobileDetailHost?.querySelector('[data-lager-mobile-drawer]'); if (mobileDrawer) { const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; const virtualLotValue = mobileDrawer.querySelector('[data-lager-drawer-virtual-lot-value]'); if (virtualLotValue) { virtualLotValue.textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } }";
echo " if (lagerMobileDetailHost) { document.body.appendChild(lagerMobileDetailHost); document.body.classList.add('sg-lager-mobile-detail-open'); }";
echo " lagerMobileDetailOpen = true; window.history.pushState({ ...(window.history.state || {}), lagerView: 'detail' }, '', window.location.href);";
echo " if (lagerMobileDetailHost && mobileDrawer) { lagerMobileDetail?.mount(mobileDrawer, trigger, lagerMobileDetailHost); if (window.SharedEditableActionCard) { window.SharedEditableActionCard.init(lagerMobileDetailHost); } }";
echo " lagerMobileDetailOpen = true;";
echo " };";
echo " const closeLagerMobileDetail = () => {";
echo " if (!lagerMobileDetailOpen) { return; }";
@@ -1152,7 +1153,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
echo " const getCurrentLimit = () => parseInt(getTable()?.dataset.marketsLimit || '20', 10);";
echo " const getPageSize = () => parseInt(getTable()?.dataset.marketsPageSize || '20', 10);";
echo " const marketEscape = (value) => String(value ?? '').replace(/[&<>\"']/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;', \"'\": '&#39;' }[character] || character));";
echo " const marketDisplay = (value) => { if (value === null || value === undefined || value === '') { return 'Nicht hinterlegt'; } const escaped = marketEscape(value); const linked = escaped.replace(/https:\\/\\/erpnaurua\\.imhochrain\\.ch\\/files\\.php\\?path=[^\\s<]+/g, (url) => '<a class=\"sg-hyperlink\" href=\"' + url + '\" target=\"_blank\" rel=\"noopener\">' + url + '</a>'); return linked.replace(/\\r?\\n/g, '<br>'); };";
echo " const marketDisplay = (value) => { if (value === null || value === undefined || value === '') { return 'Nicht hinterlegt'; } const escaped = marketEscape(value); const safeMarkup = escaped.replace(/&lt;br&gt;/g, '<br>').replace(/&lt;strong&gt;/g, '<strong>').replace(/&lt;\\/strong&gt;/g, '</strong>').replace(/&lt;a class=&quot;sg-hyperlink&quot; href=&quot;(https?:\\/\\/.*?)&quot; target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;&gt;/g, '<a class=\"sg-hyperlink\" href=\"$1\" target=\"_blank\" rel=\"noopener noreferrer\">').replace(/&lt;\\/a&gt;/g, '</a>'); return safeMarkup.replace(/\\r?\\n/g, '<br>'); };";
echo " const marketBoolean = (value) => value === true || value === 'true' || value === 1 || value === '1' ? 'Ja' : value === false || value === 'false' || value === 0 || value === '0' ? 'Nein' : 'Nicht hinterlegt';";
echo " const marketMoney = (value) => value === null || value === undefined || value === '' ? 'Nicht hinterlegt' : 'CHF ' + Number(value).toFixed(2);";
echo " const marketContactName = (contact) => { if (!contact) { return 'Nicht hinterlegt'; } return marketDisplay([contact.first_name, contact.last_name].filter(Boolean).join(' ')); };";
@@ -1180,9 +1181,10 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
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 " let marketMobileDetailHost = null; let marketOpeningMobileDetail = false; let marketDetailOpen = false;";
echo " const marketMobileDetail = window.SharedMobileDetail?.create({ contentRoot, bodyClass: 'sg-market-mobile-detail-open', hostClass: 'sg-market-mobile-detail-host', hostDataAttribute: 'data-market-mobile-detail-host', drawerDataAttribute: 'data-market-mobile-drawer', backMarkup: '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\"><div class=\"sg-card-segment sg-card-segment--body\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-market-detail-back>zurück</button></div></div></article></div></div>', historyKey: 'marketView' });";
echo " const getMarketDrawer = () => document.querySelector('[data-market-mobile-drawer]') || contentRoot.querySelector('[data-market-drawer]');";
echo " const getMarketBackdrop = () => contentRoot.querySelector('[data-market-drawer-backdrop]');";
echo " const setMarketDrawerState = (open) => { const drawer = getMarketDrawer(); const backdrop = getMarketBackdrop(); if (!drawer || !backdrop) { return; } drawer.dataset.open = String(open); drawer.setAttribute('aria-hidden', String(!open)); backdrop.dataset.open = String(open); backdrop.setAttribute('aria-hidden', String(!open)); document.body.classList.toggle('sg-card-list-page-drawer-open', open); };";
echo " const setMarketDrawerState = (open) => { const drawer = getMarketDrawer(); const backdrop = getMarketBackdrop(); if (!drawer || !backdrop) { return; } const drawerOpen = Boolean(open); const backdropOpen = drawerOpen && !marketDetailOpen; drawer.dataset.open = String(drawerOpen); drawer.setAttribute('aria-hidden', String(!drawerOpen)); backdrop.dataset.open = String(backdropOpen); backdrop.setAttribute('aria-hidden', String(!backdropOpen)); document.body.classList.toggle('sg-card-list-page-drawer-open', backdropOpen); };";
echo " const applyMarketTabs = (root) => { const group = root.querySelector('[data-pattern=\"card-gruppe-mit-tastennavigation\"] [role=\"tablist\"]'); if (group) { group.dataset.component = 'tab-navigation'; group.dataset.componentSize = 'large'; group.querySelectorAll('[data-market-day-tab]').forEach((tab) => { tab.dataset.componentPart = 'tab-button'; tab.classList.remove('sg-button--active'); }); } root.querySelectorAll('[data-market-day-tab]').forEach((tab) => { if (tab.dataset.marketTabBound === 'true') { return; } tab.dataset.marketTabBound = 'true'; tab.addEventListener('click', () => { const tabGroup = tab.closest('[data-pattern=\"card-gruppe-mit-tastennavigation\"]'); tabGroup?.querySelectorAll('[data-market-day-tab]').forEach((candidate) => candidate.setAttribute('aria-selected', String(candidate === tab))); tabGroup?.querySelectorAll('[role=\"tabpanel\"]').forEach((panel) => { panel.hidden = panel.id !== tab.getAttribute('aria-controls'); }); tab.focus(); }); }); };";
echo " const syncMarketDayAddButton = (root = contentRoot) => { if (!root.querySelector('[data-market-inherit-row]')) { enhanceMarketDayInheritance(root); } const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; const days = Array.isArray(payload?.occurrence?.days) ? payload.occurrence.days.filter((day) => !day.__draft).slice().sort((left, right) => String(left.date).localeCompare(String(right.date))) : []; const nextDate = (date) => { const value = new Date(String(date) + 'T00:00:00Z'); value.setUTCDate(value.getUTCDate() + 1); return value.toISOString().slice(0, 10); }; root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=\"day\"]').forEach((form) => { const input = form.querySelector('[name=\"date\"]'); if (!input) { return; } const index = days.findIndex((day) => String(day.id) === String(form.dataset.marketEditId)); const minimum = form.dataset.marketEditDraft === 'true' ? days.at(-1)?.date : index > 0 ? days[index - 1].date : ''; input.min = minimum ? nextDate(minimum) : ''; }); const addButton = root.querySelector('[data-market-day-add]'); if (!addButton) { return; } const editing = Array.from(root.querySelectorAll('[data-market-edit-form]')).some((form) => form.closest('[data-shared-editable-card-form]')?.hidden === false); addButton.disabled = editing; addButton.setAttribute('aria-disabled', String(editing)); addButton.classList.toggle('sg-button--active', !editing); addButton.classList.toggle('sg-button--inactive', editing); };";
echo " const enhanceMarketDayInheritance = (root) => { const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; const days = Array.isArray(payload?.occurrence?.days) ? payload.occurrence.days.filter((day) => !day.__draft).slice().sort((left, right) => String(left.date).localeCompare(String(right.date))) : []; const firstDayId = days[0]?.id; root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=\"day\"]').forEach((form) => { const dayId = form.dataset.marketEditId; if (!dayId || String(dayId) === String(firstDayId)) { return; } const dateGroup = form.querySelector('[name=\"date\"]')?.closest('.sg-form-sections-card__field-group'); const observationsGroup = form.querySelector('[name=\"observations\"]')?.closest('.sg-form-sections-card__field-group'); if (!dateGroup || !observationsGroup) { return; } let row = form.querySelector('[data-market-inherit-row]'); if (!row) { row = document.createElement('div'); row.className = 'sg-form-sections-card__field-group'; row.dataset.marketInheritRow = 'true'; row.innerHTML = '<label class=\"sg-checkbox-field-option sg-body\" data-component=\"checkbox-field\" data-component-state=\"default\"><button class=\"sg-checkbox-field\" type=\"button\" role=\"checkbox\" aria-checked=\"false\" aria-label=\"Ab hier alle Werte aus Tag 1 übernehmen\" data-market-inherit-toggle><span class=\"sg-checkbox-field__mark\" aria-hidden=\"true\">✓</span></button><span>Ab hier alle Werte aus Tag 1 übernehmen</span><input type=\"hidden\" name=\"inherit_from_first_day\" value=\"0\"></label>'; observationsGroup.after(row); row.querySelector('[data-market-inherit-toggle]').addEventListener('click', (event) => { event.stopImmediatePropagation(); const toggle = row.querySelector('[data-market-inherit-toggle]'); const checked = toggle.getAttribute('aria-checked') !== 'true'; toggle.setAttribute('aria-checked', String(checked)); toggle.classList.toggle('sg-form-active', checked); row.querySelector('[name=\"inherit_from_first_day\"]').value = checked ? '1' : '0'; form.querySelectorAll('input, select, textarea').forEach((control) => { if (control.name !== 'date' && control.name !== 'inherit_from_first_day' && control.name !== 'observations') { control.disabled = checked; control.setAttribute('aria-disabled', String(checked)); control.classList.toggle('sg-form-disabled', checked); control.classList.toggle('sg-input-single-line--disabled', checked && control.tagName === 'INPUT'); } }); syncMarketDayAddButton(root); }); } const day = days.find((candidate) => String(candidate.id) === String(dayId)); const inherited = Boolean(day?.inherit_from_first_day); const toggle = row.querySelector('[data-market-inherit-toggle]'); toggle.setAttribute('aria-checked', String(inherited)); toggle.classList.toggle('sg-form-active', inherited); row.querySelector('[name=\"inherit_from_first_day\"]').value = inherited ? '1' : '0'; form.querySelectorAll('input, select, textarea').forEach((control) => { if (control.name !== 'date' && control.name !== 'inherit_from_first_day' && control.name !== 'observations') { control.disabled = inherited; control.setAttribute('aria-disabled', String(inherited)); control.classList.toggle('sg-form-disabled', inherited); control.classList.toggle('sg-input-single-line--disabled', inherited && control.tagName === 'INPUT'); } }); }); };";
@@ -1197,8 +1199,8 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
echo " const marketDrawerTitle = (payload) => { const marketName = payload.market?.name || 'Marktdurchführung'; const dates = (Array.isArray(payload.occurrence?.days) ? payload.occurrence.days : []).map((day) => marketDate(day.date)).filter(Boolean); return dates.length ? marketName + ', Marktdurchführung ' + dates.join(', ') : marketName; };";
echo " const openMarketDrawer = async (trigger) => { const drawer = getMarketDrawer(); if (!drawer) { return; } const title = drawer.querySelector('[data-market-drawer-title]'); const content = drawer.querySelector('[data-market-drawer-content]'); setMarketDrawerState(true); if (title) { title.textContent = 'Marktdurchführung'; } if (content) { content.innerHTML = '<p class=\"sg-body\">Marktdetails werden geladen …</p>'; } try { const response = await fetch('/api/market-occurrence.php?id=' + encodeURIComponent(trigger.dataset.marketOccurrenceId || ''), { credentials: 'same-origin' }); const payload = await response.json(); if (!response.ok || !payload.ok) { throw new Error(payload.error || 'Marktdetails konnten nicht geladen werden.'); } drawer.dataset.marketPayload = JSON.stringify(payload); if (title) { title.textContent = marketDrawerTitle(payload); } if (content) { content.innerHTML = renderMarketDetailWithActions(payload); applyMarketTabs(content); syncMarketDayAddButton(content); } } catch (error) { if (content) { content.innerHTML = '<p class=\"sg-body\">' + marketEscape(error.message) + '</p>'; } } contentRoot.querySelectorAll('[data-market-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); };";
echo " const openNewMarketDrawer = async () => { const drawer = getMarketDrawer(); if (!drawer) { return; } const title = drawer.querySelector('[data-market-drawer-title]'); const content = drawer.querySelector('[data-market-drawer-content]'); setMarketDrawerState(true); if (title) { title.textContent = 'Neue Marktdurchführung'; } if (content) { content.innerHTML = '<p class=\"sg-body\">Pflichtangaben werden geladen …</p>'; } try { const response = await fetch('/api/market-occurrence.php?action=create_options', { credentials: 'same-origin' }); const payload = await response.json(); if (!response.ok || !payload.ok) { throw new Error(payload.error || 'Pflichtangaben konnten nicht geladen werden.'); } if (content) { content.innerHTML = marketCreateForm(payload.market_definitions || []); } } catch (error) { if (content) { content.innerHTML = '<p class=\"sg-body\">' + marketEscape(error.message) + '</p>'; } } };";
echo " const restoreMarketList = () => { if (marketMobileDetailHost?.parentNode) { const drawer = marketMobileDetailHost.querySelector('[data-market-mobile-drawer]'); if (drawer) { contentRoot.appendChild(drawer); drawer.removeAttribute('data-market-mobile-drawer'); drawer.dataset.open = 'false'; drawer.setAttribute('aria-hidden', 'true'); } marketMobileDetailHost.parentNode.removeChild(marketMobileDetailHost); } marketMobileDetailHost = null; marketDetailOpen = false; document.body.classList.remove('sg-market-mobile-detail-open'); bindTable(); };";
echo " const openMarketMobileDetail = (trigger) => { restoreMarketList(); const sourceDrawer = contentRoot.querySelector('[data-market-drawer]'); if (!sourceDrawer) { return; } const host = document.createElement('div'); host.className = 'sg-market-mobile-detail-host'; host.dataset.marketMobileDetailHost = 'true'; host.innerHTML = '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\"><div class=\"sg-card-segment sg-card-segment--body\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-market-detail-back>zurück</button></div></div></article></div></div>'; sourceDrawer.dataset.marketMobileDrawer = 'true'; sourceDrawer.dataset.open = 'true'; sourceDrawer.setAttribute('aria-hidden', 'false'); host.appendChild(sourceDrawer); document.body.appendChild(host); marketMobileDetailHost = host; document.body.classList.add('sg-market-mobile-detail-open'); marketOpeningMobileDetail = true; void openMarketDrawer(trigger).finally(() => { marketOpeningMobileDetail = false; }); marketDetailOpen = true; window.history.pushState({ marketView: 'detail' }, '', window.location.href); };";
echo " const restoreMarketList = () => { marketMobileDetail?.restore(); marketMobileDetailHost = null; marketDetailOpen = false; bindTable(); };";
echo " const openMarketMobileDetail = (trigger) => { restoreMarketList(); const sourceDrawer = contentRoot.querySelector('[data-market-drawer]'); if (!sourceDrawer) { return; } const host = document.createElement('div'); host.className = 'sg-market-mobile-detail-host'; host.dataset.marketMobileDetailHost = 'true'; host.innerHTML = '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\"><div class=\"sg-card-segment sg-card-segment--body\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-market-detail-back>zurück</button></div></div></article></div></div>'; sourceDrawer.dataset.marketMobileDrawer = 'true'; sourceDrawer.dataset.open = 'true'; sourceDrawer.setAttribute('aria-hidden', 'false'); host.appendChild(sourceDrawer); marketMobileDetailHost = host; marketDetailOpen = true; marketMobileDetail?.mount(sourceDrawer, trigger, host); if (window.SharedEditableActionCard) { window.SharedEditableActionCard.init(host); } marketOpeningMobileDetail = true; void openMarketDrawer(trigger).finally(() => { marketOpeningMobileDetail = false; }); };";
echo " const deleteMarketOccurrence = async (submit) => { if (submit.disabled) { return; } const card = submit.closest('[data-shared-editable-action-card]'); const confirmation = Array.from(card?.children || []).find((child) => child.matches('[data-shared-editable-card-delete-confirmation]')); const input = confirmation?.querySelector('[data-shared-editable-card-delete-input]'); const status = confirmation?.querySelector('[data-market-delete-status]'); const occurrenceId = card?.dataset.marketDeleteOccurrenceId || ''; if (!card || !input || input.value !== 'DELETE' || !occurrenceId) { return; } if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'delete', id: occurrenceId, confirmation: input.value }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } closeMarketDrawer(); if (marketDetailOpen) { restoreMarketList(); } await loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getCurrentLimit() }); } catch (error) { if (status) { status.textContent = marketEscape(error.message); } submit.disabled = false; submit.setAttribute('aria-disabled', 'false'); } };";
echo " const deleteMarketDay = async (submit) => { if (submit.disabled) { return; } const card = submit.closest('[data-shared-editable-action-card]'); const input = card?.querySelector('[data-shared-editable-card-delete-input]'); const status = card?.querySelector('[data-market-day-delete-status]'); const dayId = card?.dataset.marketDayDeleteId || ''; if (!card || !input || input.value !== 'DELETE' || !dayId) { return; } if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); try { const response = await fetch('/api/market-occurrence.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': contentRoot.querySelector('[data-market-csrf-token]')?.dataset.marketCsrfToken || '' }, body: JSON.stringify({ action: 'delete_day', id: dayId, confirmation: input.value }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } submit.disabled = false; submit.setAttribute('aria-disabled', 'false'); } };";
echo " const marketDayDeleteObserver = new MutationObserver(() => enhanceMarketDayDeleteControls(contentRoot)); marketDayDeleteObserver.observe(contentRoot, { childList: true, subtree: true });";
@@ -1241,6 +1243,8 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
echo " if (!contentRoot || !definitionContent || !drawer || !readCard || !card || !form || !editButton || definitionContent.dataset.marketDefinitionsBound === 'true') { return; }";
echo " definitionContent.dataset.marketDefinitionsBound = 'true';";
echo " let activeMarket = null;";
echo " let marketDefinitionMobileHost = null; let marketDefinitionMobileOpen = false;";
echo " const marketDefinitionMobileDetail = window.SharedMobileDetail?.create({ contentRoot, bodyClass: 'sg-market-definition-mobile-detail-open', hostClass: 'sg-market-definition-mobile-detail-host', hostDataAttribute: 'data-market-definition-mobile-detail-host', drawerDataAttribute: 'data-market-definition-mobile-drawer', backMarkup: '<div class=\"sg-navigation-card-layout sg-mobile-detail-back-card\"><div class=\"sg-navigation-card-block\"><article class=\"sg-card\" data-component=\"card\" data-pattern=\"navigation-card\"><div class=\"sg-card-segment sg-card-segment--body\"><div class=\"sg-navigation-card-center\"><button class=\"sg-interaction-element sg-hyperlink\" type=\"button\" data-market-definition-detail-back>zurück</button></div></div></article></div></div>', historyKey: 'marketDefinitionView' });";
echo <<<JS
const prepareMarketDefinitionDeleteCard = () => {
const existingCard = drawer.querySelector('[data-market-definition-card]');
@@ -1299,13 +1303,15 @@ JS;
echo " prepareMarketDefinitionDeleteCard();";
echo " const sharedCard = drawer.querySelector('[data-market-definition-card]');";
echo " if (window.SharedEditableActionCard) { window.SharedEditableActionCard.init(definitionContent); }";
echo " const setDrawerState = (open) => { drawer.dataset.open = String(open); drawer.setAttribute('aria-hidden', String(!open)); if (activeMarket?.trigger) { activeMarket.trigger.setAttribute('aria-expanded', String(open)); } const backdrop = definitionContent.querySelector('[data-market-definition-drawer-backdrop]'); if (backdrop) { backdrop.dataset.open = String(open); backdrop.setAttribute('aria-hidden', String(!open)); } document.body.classList.toggle('sg-card-list-page-drawer-open', open); };";
echo " const setDrawerState = (open) => { const drawerOpen = Boolean(open); if (!drawerOpen && marketDefinitionMobileOpen) { marketDefinitionMobileDetail?.restore(); marketDefinitionMobileHost = null; marketDefinitionMobileOpen = false; } const backdropOpen = drawerOpen && !marketDefinitionMobileOpen; drawer.dataset.open = String(drawerOpen); drawer.setAttribute('aria-hidden', String(!drawerOpen)); if (activeMarket?.trigger) { activeMarket.trigger.setAttribute('aria-expanded', String(drawerOpen)); } const backdrop = definitionContent.querySelector('[data-market-definition-drawer-backdrop]'); if (backdrop) { backdrop.dataset.open = String(backdropOpen); backdrop.setAttribute('aria-hidden', String(!backdropOpen)); } document.body.classList.toggle('sg-card-list-page-drawer-open', backdropOpen); };";
echo " const restoreMarketDefinitionMobileDetail = () => { marketDefinitionMobileDetail?.restore(); marketDefinitionMobileHost = null; marketDefinitionMobileOpen = false; };";
echo " const openMarketDefinitionMobileDetail = (trigger) => { restoreMarketDefinitionMobileDetail(); const sourceDrawer = drawer; const host = document.createElement('div'); host.className = 'sg-market-definition-mobile-detail-host'; host.dataset.marketDefinitionMobileDetailHost = 'true'; sourceDrawer.dataset.marketDefinitionMobileDrawer = 'true'; sourceDrawer.dataset.open = 'true'; sourceDrawer.setAttribute('aria-hidden', 'false'); host.appendChild(sourceDrawer); marketDefinitionMobileHost = host; marketDefinitionMobileOpen = true; marketDefinitionMobileDetail?.mount(sourceDrawer, trigger, host); if (window.SharedEditableActionCard) { window.SharedEditableActionCard.init(host); } showReadCard(trigger); };";
echo " const resetForm = () => { form.reset(); const status = form.querySelector('[data-market-definition-status]'); if (status) { status.textContent = ''; } syncNewOrganizerFields(); };";
echo " const syncNewOrganizerFields = () => { const select = form.querySelector('[data-market-definition-organizer]'); const fields = form.querySelector('[data-market-definition-new-organizer]'); if (!select || !fields) { return; } fields.hidden = select.value !== '__new__'; };";
echo " const closeDrawer = () => { resetForm(); if (sharedCard && window.SharedEditableActionCard) { window.SharedEditableActionCard.resetDeleteConfirmation(sharedCard); window.SharedEditableActionCard.setEditMode(sharedCard, false); } activeMarket = null; setDrawerState(false); };";
echo " const deleteMarketDefinition = async (submit) => { if (submit.disabled || !activeMarket?.id) { return; } const card = submit.closest('[data-shared-editable-action-card]'); const input = card?.querySelector('[data-shared-editable-card-delete-input]'); const status = card?.querySelector('[data-market-definition-delete-status]'); if (!card || !input || input.value !== 'DELETE') { return; } if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); try { const response = await fetch('/api/market-definition.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': definitionContent.dataset.marketDefinitionsCsrfToken || '' }, body: JSON.stringify({ action: 'delete', id: activeMarket.id, confirmation: input.value }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } const row = activeMarket.trigger.closest('[data-market-definition-row]'); row?.remove(); closeDrawer(); } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'; } submit.disabled = false; submit.setAttribute('aria-disabled', 'false'); } };";
echo " const showReadCard = (trigger) => { activeMarket = { id: trigger.dataset.marketDefinitionId || '', trigger }; drawer.querySelector('[data-market-definition-read-title]').textContent = trigger.dataset.marketDefinitionName || 'Markt'; readCard.querySelector('[data-market-definition-read-name]').textContent = trigger.dataset.marketDefinitionName || 'Nicht hinterlegt'; readCard.querySelector('[data-market-definition-read-category]').textContent = trigger.dataset.marketDefinitionCategoryName || 'Nicht hinterlegt'; readCard.querySelector('[data-market-definition-read-organizer]').textContent = trigger.dataset.marketDefinitionOrganizerName || 'Nicht hinterlegt'; readCard.querySelector('[data-market-definition-read-website]').textContent = trigger.dataset.marketDefinitionWebsite || 'Nicht hinterlegt'; const title = form.querySelector('[data-market-definition-form-title]'); if (title) { title.textContent = 'Markt bearbeiten'; } const name = form.querySelector('[data-market-definition-name]'); const category = form.querySelector('[data-market-definition-category]'); const organizer = form.querySelector('[data-market-definition-organizer]'); const website = form.querySelector('[data-market-definition-website]'); if (name) { name.value = trigger.dataset.marketDefinitionName || ''; } if (category) { category.value = trigger.dataset.marketDefinitionCategoryId || ''; } if (organizer) { organizer.value = trigger.dataset.marketDefinitionOrganizerId || ''; } if (website) { website.value = trigger.dataset.marketDefinitionWebsite === 'Nicht hinterlegt' ? '' : (trigger.dataset.marketDefinitionWebsite || ''); } syncNewOrganizerFields(); if (sharedCard && window.SharedEditableActionCard) { window.SharedEditableActionCard.setEditMode(sharedCard, false); } setDrawerState(true); };";
echo " definitionContent.addEventListener('click', (event) => { const trigger = event.target.closest('[data-market-definition-open]'); if (!trigger || !definitionContent.contains(trigger)) { return; } event.preventDefault(); event.stopPropagation(); showReadCard(trigger); }, true);";
echo " definitionContent.addEventListener('click', (event) => { const trigger = event.target.closest('[data-market-definition-open]'); if (!trigger || !definitionContent.contains(trigger)) { return; } event.preventDefault(); event.stopPropagation(); if (window.matchMedia('(max-width: 767px)').matches) { openMarketDefinitionMobileDetail(trigger); } else { showReadCard(trigger); } }, true);";
echo " definitionContent.addEventListener('click', (event) => { const deleteSubmit = event.target.closest('[data-market-definition-delete-submit]'); if (deleteSubmit) { void deleteMarketDefinition(deleteSubmit); return; } const trigger = event.target.closest('[data-market-definition-open]'); if (trigger) { const deleteButton = drawer.querySelector('[data-market-definition-delete]'); if (deleteButton) { deleteButton.hidden = false; } } if (event.target.closest('[data-market-definition-create]')) { const deleteButton = drawer.querySelector('[data-market-definition-delete]'); if (deleteButton) { deleteButton.hidden = true; } } });";
echo " const openEditCard = () => { if (!activeMarket) { return; } const title = form.querySelector('[data-market-definition-form-title]'); if (title) { title.textContent = 'Markt bearbeiten'; } const name = form.querySelector('[data-market-definition-name]'); const category = form.querySelector('[data-market-definition-category]'); const organizer = form.querySelector('[data-market-definition-organizer]'); const website = form.querySelector('[data-market-definition-website]'); if (name) { name.value = activeMarket.trigger.dataset.marketDefinitionName || ''; } if (category) { category.value = activeMarket.trigger.dataset.marketDefinitionCategoryId || ''; } if (organizer) { organizer.value = activeMarket.trigger.dataset.marketDefinitionOrganizerId || ''; } if (website) { website.value = activeMarket.trigger.dataset.marketDefinitionWebsite === 'Nicht hinterlegt' ? '' : (activeMarket.trigger.dataset.marketDefinitionWebsite || ''); } syncNewOrganizerFields(); };";
echo " editButton.addEventListener('click', (event) => { event.preventDefault(); openEditCard(); });";
@@ -1313,6 +1319,8 @@ JS;
echo " const appendMarketRow = (market) => { const table = contentRoot.querySelector('[data-market-definitions-table]'); if (!table) { return; } const row = document.createElement('div'); row.className = 'sg-card-segment sg-card-segment--body sg-large-table__row'; row.setAttribute('role', 'row'); row.dataset.componentPart = 'large-table-row'; row.dataset.marketDefinitionRow = ''; row.dataset.marketDefinitionId = String(market.id || ''); const category = form.querySelector('[data-market-definition-category]')?.selectedOptions[0]?.textContent || ''; const organizer = form.querySelector('[data-market-definition-organizer]')?.selectedOptions[0]?.textContent || ''; const website = market.website_url || 'Nicht hinterlegt'; const cells = document.createElement('div'); cells.className = 'sg-large-table__cell'; const trigger = document.createElement('a'); trigger.className = 'sg-hyperlink'; trigger.href = '#'; trigger.setAttribute('aria-haspopup', 'dialog'); trigger.setAttribute('aria-expanded', 'false'); trigger.dataset.marketDefinitionOpen = ''; trigger.dataset.marketDefinitionId = String(market.id || ''); trigger.dataset.marketDefinitionName = market.name || ''; trigger.dataset.marketDefinitionCategoryId = String(market.category_id || ''); trigger.dataset.marketDefinitionCategoryName = category; trigger.dataset.marketDefinitionOrganizerId = String(market.organizer_id || ''); trigger.dataset.marketDefinitionOrganizerName = organizer; trigger.dataset.marketDefinitionWebsite = website; trigger.textContent = market.name || ''; cells.appendChild(trigger); row.appendChild(cells); [category, organizer, website, '0', 'Aktiv'].forEach((value) => { const cell = document.createElement('div'); cell.className = 'sg-large-table__cell'; cell.setAttribute('role', 'cell'); cell.textContent = value; row.appendChild(cell); }); table.appendChild(row); };";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-market-definition-create]')) { event.preventDefault(); activeMarket = null; resetForm(); const title = form.querySelector('[data-market-definition-form-title]'); if (title) { title.textContent = 'Neuen Markt anlegen'; } if (sharedCard && window.SharedEditableActionCard) { window.SharedEditableActionCard.setEditMode(sharedCard, true); } setDrawerState(true); form.querySelector('[data-market-definition-name]')?.focus(); return; } if (event.target.closest('[data-market-definition-edit]')) { event.preventDefault(); openEditCard(); return; } if (event.target.closest('[data-market-definition-cancel]')) { event.preventDefault(); if (!activeMarket) { closeDrawer(); } return; } if (event.target.closest('[data-market-definition-drawer-backdrop]')) { event.preventDefault(); closeDrawer(); } });";
echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && drawer.dataset.open === 'true') { closeDrawer(); } });";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-market-definition-detail-back]')) { event.preventDefault(); if (window.history.state?.marketDefinitionView === 'detail') { window.history.back(); } else { restoreMarketDefinitionMobileDetail(); } } });";
echo " window.addEventListener('popstate', (event) => { if (marketDefinitionMobileOpen && (!event.state || event.state.marketDefinitionView !== 'detail')) { restoreMarketDefinitionMobileDetail(); } });";
echo " form.addEventListener('submit', async (event) => { event.preventDefault(); const status = form.querySelector('[data-market-definition-status]'); const submit = form.querySelector('[type=submit]'); const fields = Object.fromEntries(new FormData(form).entries()); const isUpdate = Boolean(activeMarket?.id); if (fields.organizer_id === '__new__' && !String(fields.organizer_name || '').trim()) { if (status) { status.textContent = 'Name des neuen Veranstalters ist erforderlich.'; } return; } if (status) { status.textContent = 'Speichern läuft …'; } if (submit) { submit.disabled = true; } try { const response = await fetch('/api/market-definition.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': definitionContent.dataset.marketDefinitionsCsrfToken || '' }, body: JSON.stringify({ action: isUpdate ? 'update' : 'create', id: isUpdate ? activeMarket.id : null, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } if (isUpdate) { const trigger = activeMarket.trigger; const category = form.querySelector('[data-market-definition-category]')?.selectedOptions[0]; const organizer = form.querySelector('[data-market-definition-organizer]')?.selectedOptions[0]; const website = result.market.website_url || 'Nicht hinterlegt'; trigger.textContent = result.market.name; trigger.dataset.marketDefinitionName = result.market.name; trigger.dataset.marketDefinitionCategoryId = String(result.market.category_id); trigger.dataset.marketDefinitionCategoryName = category?.textContent || ''; trigger.dataset.marketDefinitionOrganizerId = String(result.market.organizer_id); trigger.dataset.marketDefinitionOrganizerName = organizer?.textContent || ''; trigger.dataset.marketDefinitionWebsite = website; showReadCard(trigger); } else { appendMarketRow(result.market); closeDrawer(); } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'; } } finally { if (submit) { submit.disabled = false; } } });";
echo "}";
echo "function initBestellungenBindings() {";
+39 -10
View File
@@ -4,6 +4,19 @@ declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/market_management/mcp.php';
final class MarketManagementMcpJsonRpcException extends RuntimeException
{
public function __construct(private int $rpcCode, string $message, ?Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
}
public function rpcCode(): int
{
return $this->rpcCode;
}
}
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
@@ -18,6 +31,10 @@ try {
throw new InvalidArgumentException('Ungültige JSON-RPC-Anfrage.');
}
if (array_key_exists('params', $request) && !is_array($request['params'])) {
throw new MarketManagementMcpJsonRpcException(-32602, 'Parameter müssen als Objekt oder Array übergeben werden.');
}
$id = $request['id'] ?? null;
$method = (string) ($request['method'] ?? '');
$params = is_array($request['params'] ?? null) ? $request['params'] : [];
@@ -36,23 +53,35 @@ try {
} elseif ($method === 'tools/call') {
$tool = (string) ($params['name'] ?? '');
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
$result = [
'content' => [[
'type' => 'text',
'text' => json_encode(market_management_mcp_call_idempotent($pdo, $userSubject, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
]],
'isError' => false,
];
if (array_key_exists('arguments', $params) && !is_array($params['arguments'])) {
throw new MarketManagementMcpJsonRpcException(-32602, 'Tool-Argumente müssen als Objekt übergeben werden.');
}
try {
$result = [
'content' => [[
'type' => 'text',
'text' => json_encode(market_management_mcp_call_idempotent($pdo, $userSubject, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
]],
'isError' => false,
];
} catch (InvalidArgumentException $e) {
throw new MarketManagementMcpJsonRpcException(-32602, $e->getMessage(), $e);
}
} else {
throw new InvalidArgumentException('Unbekannte JSON-RPC-Methode: ' . $method);
throw new MarketManagementMcpJsonRpcException(-32601, 'Unbekannte JSON-RPC-Methode: ' . $method);
}
echo json_encode(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} catch (Throwable $e) {
http_response_code($e instanceof RuntimeException && str_contains($e->getMessage(), 'Token') ? 401 : 400);
$isAuthError = $e instanceof RuntimeException && str_contains($e->getMessage(), 'Token');
$isJsonRpcError = $e instanceof MarketManagementMcpJsonRpcException;
http_response_code($isJsonRpcError ? 200 : ($isAuthError ? 401 : 400));
echo json_encode([
'jsonrpc' => '2.0',
'id' => $request['id'] ?? null,
'error' => ['code' => -32600, 'message' => $e->getMessage()],
'error' => [
'code' => $isJsonRpcError ? $e->rpcCode() : -32600,
'message' => $e->getMessage(),
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+130 -15
View File
@@ -16,6 +16,7 @@
--color-process-inactive: #FFAE79;
--color-signal-green: #009101;
--color-signal-green-light: #65A665;
--color-signal-green-red: #C0624D;
--color-signal-yellow: #9C7A00;
--color-ampel-hellgelb: #D3B962;
--color-signal-red: #9B3B2F;
@@ -37,6 +38,7 @@
--surface-card-header-muted: var(--color-darkbrown);
--divider-card-segment: var(--color-white);
--text-card-header: var(--color-font-light);
--text-card-signal: var(--color-font-light);
--text-card-body: var(--color-font-dark);
--text-card-transparent: var(--color-font-light);
--layout-card-body-content-justify: flex-start;
@@ -144,7 +146,6 @@
--layout-preview-align-items: flex-start;
/* Semantic pattern tokens: Portal header */
--surface-portal-background: var(--color-background-purple);
--surface-portal-header: var(--color-darkblue);
--surface-portal-header-tab: var(--color-midblue);
--surface-portal-header-tab-active: var(--color-light-grey);
@@ -310,7 +311,6 @@
--dimension-input-label-width: 9rem;
--dimension-input-field-desktop-width: 400px;
--dimension-input-field-max-width: 600px;
--dimension-email-body-max-width: 600px;
--dimension-search-field-width: 15.3rem;
--dimension-public-content-width: 100%;
--dimension-layer-pulldown-panel: 40;
@@ -358,7 +358,7 @@ body {
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
color: var(--color-font-dark);
background: var(--surface-portal-background);
background: var(--color-background-purple);
margin: 0;
padding: var(--spacing-small);
}
@@ -1765,9 +1765,7 @@ section + section {
}
.sg-tooltip-wrap {
position: relative;
display: inline-flex;
vertical-align: middle;
display: contents;
}
.sg-tooltip-panel {
@@ -1853,6 +1851,47 @@ section + section {
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"] {
position: fixed;
top: 0;
left: 0;
z-index: var(--layer-pulldown-panel);
max-width: calc(100vw - (2 * var(--spacing-large)));
transform: none;
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="bottom"]::before {
top: calc(var(--spacing-small) * -1);
left: var(--tooltip-arrow-offset);
border-width: 0 var(--spacing-small) var(--spacing-small);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="top"]::before {
bottom: calc(var(--spacing-small) * -1);
left: var(--tooltip-arrow-offset);
border-width: var(--spacing-small) var(--spacing-small) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="left"]::before {
top: var(--tooltip-arrow-offset);
right: calc(var(--spacing-small) * -1);
border-width: var(--spacing-small) 0 var(--spacing-small) var(--spacing-small);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="right"]::before {
top: var(--tooltip-arrow-offset);
left: calc(var(--spacing-small) * -1);
border-width: var(--spacing-small) var(--spacing-small) var(--spacing-small) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-pulldown-panel {
position: absolute;
top: calc(100% + var(--spacing-small));
@@ -2341,6 +2380,52 @@ section + section {
}
}
/* ========================================================= */
/* Pattern: History Graph */
/* ========================================================= */
.sg-history-graph {
--layout-history-graph-connector-width: var(--score-marker-width);
--surface-history-graph-connector: var(--chart-marker-line);
width: 100%;
}
.sg-history-graph__events {
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.sg-history-graph__event {
position: relative;
width: 100%;
}
.sg-history-graph__event:not(:last-child)::after {
position: absolute;
top: 100%;
left: 50%;
width: var(--layout-history-graph-connector-width);
height: var(--spacing-large);
background: var(--surface-history-graph-connector);
content: "";
transform: translateX(-50%);
}
.sg-history-graph__card {
width: 100%;
max-width: var(--layout-object-card-min-width);
margin-inline: auto;
}
.sg-history-graph__navigation {
width: 100%;
}
/* ========================================================= */
/* Patterns: Portal Header */
/* ========================================================= */
@@ -2542,19 +2627,39 @@ section + section {
.sg-options-row--left-only .sg-options-row__left {
order: 0;
width: 100%;
padding: 0;
box-shadow: none;
}
.sg-options-row__left > .sg-pulldown-demo,
.sg-options-row__left > .sg-input-single-line-wrap,
.sg-options-row__left > .sg-search-field-row {
flex: 1 1 calc(50% - var(--layout-options-row-group-gap));
.sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
min-width: 0;
}
.sg-options-row__left:has(> .sg-pulldown-demo ~ .sg-pulldown-demo) > .sg-pulldown-demo {
flex-basis: calc(50% - (var(--layout-options-row-group-gap) / 2));
}
.sg-options-row--left-only .sg-options-row__left > .sg-pulldown-demo {
flex-basis: 100%;
}
.sg-options-row__left > .sg-search-field-row {
flex: 0 0 auto;
flex: 0 0 100%;
width: 100%;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row > .sg-search-field-input {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row .sg-input-single-line {
width: 100%;
min-width: 0;
}
.sg-options-row__left > .sg-input-single-line-wrap .sg-input-single-line,
@@ -4308,22 +4413,27 @@ main {
.sg-card-segment--signal-red {
background: var(--color-signal-red);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green {
background: var(--color-signal-green);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green-light {
background: var(--color-signal-green-light);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green-red {
background: var(--color-signal-green-red);
color: var(--text-card-signal);
}
.sg-card-segment--signal-yellow {
background: var(--color-signal-yellow);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--gray {
@@ -4343,6 +4453,11 @@ main {
--text-card-body: var(--color-font-light);
}
.sg-card.sg-card--notification-signal-green-red {
--text-card-header: var(--color-font-light);
--text-card-body: var(--color-font-light);
}
.sg-card--content-card {
--surface-card-header-primary: var(--surface-content-block-card-title);
--surface-card-body: var(--surface-content-block-card-content);
+3 -3
View File
@@ -1,7 +1,7 @@
{
"styleguideVersion": "2026.05.18.1",
"styleguideCommit": "944d791",
"syncedAtUtc": "2026-09-21T12:42:55Z",
"styleguideVersion": "2026.05.18.2",
"styleguideCommit": "519ed6b",
"syncedAtUtc": "2026-09-24T11:09:21Z",
"sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide",
"mirroredDocsPath": "docs/styleguide"
}
+130 -15
View File
@@ -16,6 +16,7 @@
--color-process-inactive: #FFAE79;
--color-signal-green: #009101;
--color-signal-green-light: #65A665;
--color-signal-green-red: #C0624D;
--color-signal-yellow: #9C7A00;
--color-ampel-hellgelb: #D3B962;
--color-signal-red: #9B3B2F;
@@ -37,6 +38,7 @@
--surface-card-header-muted: var(--color-darkbrown);
--divider-card-segment: var(--color-white);
--text-card-header: var(--color-font-light);
--text-card-signal: var(--color-font-light);
--text-card-body: var(--color-font-dark);
--text-card-transparent: var(--color-font-light);
--layout-card-body-content-justify: flex-start;
@@ -144,7 +146,6 @@
--layout-preview-align-items: flex-start;
/* Semantic pattern tokens: Portal header */
--surface-portal-background: var(--color-background-purple);
--surface-portal-header: var(--color-darkblue);
--surface-portal-header-tab: var(--color-midblue);
--surface-portal-header-tab-active: var(--color-light-grey);
@@ -310,7 +311,6 @@
--dimension-input-label-width: 9rem;
--dimension-input-field-desktop-width: 400px;
--dimension-input-field-max-width: 600px;
--dimension-email-body-max-width: 600px;
--dimension-search-field-width: 15.3rem;
--dimension-public-content-width: 100%;
--dimension-layer-pulldown-panel: 40;
@@ -358,7 +358,7 @@ body {
font-weight: var(--font-weight-regular);
line-height: var(--line-height-base);
color: var(--color-font-dark);
background: var(--surface-portal-background);
background: var(--color-background-purple);
margin: 0;
padding: var(--spacing-small);
}
@@ -1765,9 +1765,7 @@ section + section {
}
.sg-tooltip-wrap {
position: relative;
display: inline-flex;
vertical-align: middle;
display: contents;
}
.sg-tooltip-panel {
@@ -1853,6 +1851,47 @@ section + section {
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"] {
position: fixed;
top: 0;
left: 0;
z-index: var(--layer-pulldown-panel);
max-width: calc(100vw - (2 * var(--spacing-large)));
transform: none;
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="bottom"]::before {
top: calc(var(--spacing-small) * -1);
left: var(--tooltip-arrow-offset);
border-width: 0 var(--spacing-small) var(--spacing-small);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="top"]::before {
bottom: calc(var(--spacing-small) * -1);
left: var(--tooltip-arrow-offset);
border-width: var(--spacing-small) var(--spacing-small) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="left"]::before {
top: var(--tooltip-arrow-offset);
right: calc(var(--spacing-small) * -1);
border-width: var(--spacing-small) 0 var(--spacing-small) var(--spacing-small);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-panel[data-tooltip-portal="true"][data-tooltip-placement="right"]::before {
top: var(--tooltip-arrow-offset);
left: calc(var(--spacing-small) * -1);
border-width: var(--spacing-small) var(--spacing-small) var(--spacing-small) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-pulldown-panel {
position: absolute;
top: calc(100% + var(--spacing-small));
@@ -2341,6 +2380,52 @@ section + section {
}
}
/* ========================================================= */
/* Pattern: History Graph */
/* ========================================================= */
.sg-history-graph {
--layout-history-graph-connector-width: var(--score-marker-width);
--surface-history-graph-connector: var(--chart-marker-line);
width: 100%;
}
.sg-history-graph__events {
display: flex;
flex-direction: column;
gap: var(--spacing-large);
width: 100%;
margin: 0;
padding: 0;
list-style: none;
}
.sg-history-graph__event {
position: relative;
width: 100%;
}
.sg-history-graph__event:not(:last-child)::after {
position: absolute;
top: 100%;
left: 50%;
width: var(--layout-history-graph-connector-width);
height: var(--spacing-large);
background: var(--surface-history-graph-connector);
content: "";
transform: translateX(-50%);
}
.sg-history-graph__card {
width: 100%;
max-width: var(--layout-object-card-min-width);
margin-inline: auto;
}
.sg-history-graph__navigation {
width: 100%;
}
/* ========================================================= */
/* Patterns: Portal Header */
/* ========================================================= */
@@ -2542,19 +2627,39 @@ section + section {
.sg-options-row--left-only .sg-options-row__left {
order: 0;
width: 100%;
padding: 0;
box-shadow: none;
}
.sg-options-row__left > .sg-pulldown-demo,
.sg-options-row__left > .sg-input-single-line-wrap,
.sg-options-row__left > .sg-search-field-row {
flex: 1 1 calc(50% - var(--layout-options-row-group-gap));
.sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
min-width: 0;
}
.sg-options-row__left:has(> .sg-pulldown-demo ~ .sg-pulldown-demo) > .sg-pulldown-demo {
flex-basis: calc(50% - (var(--layout-options-row-group-gap) / 2));
}
.sg-options-row--left-only .sg-options-row__left > .sg-pulldown-demo {
flex-basis: 100%;
}
.sg-options-row__left > .sg-search-field-row {
flex: 0 0 auto;
flex: 0 0 100%;
width: 100%;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row > .sg-search-field-input {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.sg-options-row__left > .sg-search-field-row .sg-input-single-line {
width: 100%;
min-width: 0;
}
.sg-options-row__left > .sg-input-single-line-wrap .sg-input-single-line,
@@ -4308,22 +4413,27 @@ main {
.sg-card-segment--signal-red {
background: var(--color-signal-red);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green {
background: var(--color-signal-green);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green-light {
background: var(--color-signal-green-light);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--signal-green-red {
background: var(--color-signal-green-red);
color: var(--text-card-signal);
}
.sg-card-segment--signal-yellow {
background: var(--color-signal-yellow);
color: var(--text-card-header);
color: var(--text-card-signal);
}
.sg-card-segment--gray {
@@ -4343,6 +4453,11 @@ main {
--text-card-body: var(--color-font-light);
}
.sg-card.sg-card--notification-signal-green-red {
--text-card-header: var(--color-font-light);
--text-card-body: var(--color-font-light);
}
.sg-card--content-card {
--surface-card-header-primary: var(--surface-content-block-card-title);
--surface-card-body: var(--surface-content-block-card-content);
+27
View File
@@ -52,6 +52,19 @@ body.sg-card-list-page-drawer-open {
background: var(--color-background-purple);
}
.sg-market-definition-mobile-detail-host {
position: fixed;
inset: 0;
z-index: 1500;
display: flex;
flex-direction: column;
gap: var(--spacing-large);
padding: var(--spacing-small);
box-sizing: border-box;
overflow: auto;
background: var(--color-background-purple);
}
.sg-market-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
@@ -62,10 +75,24 @@ body.sg-card-list-page-drawer-open {
overflow: visible;
}
.sg-market-definition-mobile-detail-host .sg-card-list-page-drawer {
position: static;
display: flex;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
overflow: visible;
}
body.sg-market-mobile-detail-open > :not([data-market-mobile-detail-host]) {
display: none !important;
}
body.sg-market-definition-mobile-detail-open > :not([data-market-definition-mobile-detail-host]) {
display: none !important;
}
.market-detail-grid {
display: grid;
grid-template-columns: minmax(10rem, 0.8fr) minmax(0, 1.2fr);
+85
View File
@@ -0,0 +1,85 @@
(function () {
'use strict';
const create = ({ contentRoot, bodyClass, hostClass, hostDataAttribute, drawerDataAttribute, backdropSelector, backMarkup, historyKey }) => {
let host = null;
let drawer = null;
let trigger = null;
let open = false;
const restore = () => {
if (host && host.parentNode) {
const mobileDrawer = host.querySelector('[' + drawerDataAttribute + ']');
if (mobileDrawer) {
contentRoot.appendChild(mobileDrawer);
mobileDrawer.removeAttribute(drawerDataAttribute);
mobileDrawer.dataset.open = 'false';
mobileDrawer.setAttribute('aria-hidden', 'true');
}
host.parentNode.removeChild(host);
}
if (trigger) {
trigger.setAttribute('aria-expanded', 'false');
}
const backdrops = backdropSelector
? [contentRoot.querySelector(backdropSelector)].filter(Boolean)
: Array.from(contentRoot.querySelectorAll('.sg-card-list-page-drawer-backdrop'));
backdrops.forEach((backdrop) => {
backdrop.dataset.open = 'false';
backdrop.setAttribute('aria-hidden', 'true');
});
host = null;
drawer = null;
trigger = null;
open = false;
document.body.classList.remove(bodyClass);
document.body.classList.remove('sg-card-list-page-drawer-open');
};
const mount = (sourceDrawer, detailTrigger, existingHost = null) => {
restore();
if (!sourceDrawer) {
return null;
}
host = existingHost || document.createElement('div');
host.className = hostClass;
host.setAttribute(hostDataAttribute, 'true');
if (!existingHost) {
host.innerHTML = backMarkup;
}
drawer = sourceDrawer;
trigger = detailTrigger;
drawer.setAttribute(drawerDataAttribute, 'true');
drawer.dataset.open = 'true';
drawer.setAttribute('aria-hidden', 'false');
host.appendChild(drawer);
document.body.appendChild(host);
document.body.classList.add(bodyClass);
open = true;
if (historyKey) {
window.history.pushState({ ...(window.history.state || {}), [historyKey]: 'detail' }, '', window.location.href);
}
return { host, drawer, trigger: detailTrigger };
};
const close = () => {
if (!open) {
return;
}
if (window.history.state && window.history.state[historyKey] === 'detail') {
window.history.back();
return;
}
restore();
};
return { mount, restore, close, isOpen: () => open, getHost: () => host, getDrawer: () => drawer };
};
window.SharedMobileDetail = { create };
}());
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/market_management/mcp.php';
function fail_test(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
function assert_test(bool $condition, string $message): void
{
if (!$condition) {
fail_test($message);
}
}
function assert_schema_objects(array $schema, string $path = 'inputSchema'): void
{
assert_test(($schema['type'] ?? null) === 'object', $path . '.type must be object');
assert_test(isset($schema['properties']) && is_object($schema['properties']), $path . '.properties must be an object');
if (isset($schema['required'])) {
assert_test(is_array($schema['required']), $path . '.required must be an array');
foreach ($schema['required'] as $required) {
assert_test(is_string($required), $path . '.required entries must be strings');
}
}
foreach (get_object_vars($schema['properties']) as $propertyName => $propertySchema) {
assert_test(is_array($propertySchema), $path . '.properties.' . $propertyName . ' must be an object schema');
if (($propertySchema['type'] ?? null) === 'array' && isset($propertySchema['items'])) {
assert_test(is_array($propertySchema['items']), $path . '.properties.' . $propertyName . '.items must be an object schema');
if (($propertySchema['items']['type'] ?? null) === 'object') {
assert_schema_objects($propertySchema['items'], $path . '.properties.' . $propertyName . '.items');
}
}
}
}
$expected = [
'market_management.search_markets' => 'Märkte suchen',
'market_management.search_occurrences' => 'Marktdurchführungen suchen und auflisten',
'market_management.get_market' => 'Markt mit Marktdurchführungen lesen',
'market_management.create_market' => 'Markt mit verpflichtendem Veranstalter, Kategorie und optionaler Website-URL anlegen',
'market_management.update_market' => 'Markt inklusive Veranstalter und Website-URL ändern',
'market_management.archive_market' => 'Markt archivieren',
'market_management.reactivate_market' => 'Markt reaktivieren',
'market_management.list_categories' => 'Bestehende Kategorien lesen',
'market_management.list_market_organizers' => 'Aktive Veranstalter lesen',
'market_management.create_market_organizer' => 'Veranstalter mit optionalen Kontaktdaten anlegen',
'market_management.list_reference_data' => 'Kategorien, Kantone und Gemeinden lesen',
'market_management.create_municipality' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen',
'market_management.get_overview' => 'Marktübersicht mit serverseitigen Filtern lesen',
'market_management.create_occurrence' => 'Marktdurchführung mit mindestens einem Markttag anlegen',
'market_management.get_occurrence' => 'Marktdurchführung mit Markt, Markttagen und berechneten Verkaufsdaten lesen',
'market_management.update_occurrence' => 'Marktdurchführung ändern',
'market_management.archive_occurrence' => 'Marktdurchführung archivieren',
'market_management.reactivate_occurrence' => 'Marktdurchführung reaktivieren',
'market_management.create_day' => 'Markttag anlegen',
'market_management.update_day' => 'Markttag ändern',
'market_management.delete_day' => 'Markttag entfernen',
];
$tools = market_management_mcp_tools();
assert_test(count($tools) === 21, 'Expected 21 MCP tools, got ' . count($tools));
$actual = [];
foreach ($tools as $tool) {
assert_test(isset($tool['name'], $tool['description'], $tool['inputSchema']), 'Tool must contain name, description and inputSchema');
assert_schema_objects($tool['inputSchema'], $tool['name'] . '.inputSchema');
$actual[$tool['name']] = $tool['description'];
}
assert_test($actual === $expected, 'MCP tool names or descriptions changed unexpectedly');
fwrite(STDOUT, "market_management_mcp_tools_list_test: OK\n");