Compare commits

...
Author SHA1 Message Date
gitea_admin 8fa9714793 Initialisiere Checkliste vor Markttagen 2026-10-18 09:52:33 +02:00
gitea_admin d884793cb0 Expose MCP idempotency keys 2026-10-18 09:41:53 +02:00
gitea_admin 937d772c08 Fix market travel boolean serialization 2026-10-17 07:45:39 +02:00
gitea_admin a82515673d Sync styleguide 2026.05.18.2 2026-10-14 13:46:02 +02:00
gitea_admin c7ab2f2817 Sync styleguide 2026.05.18.2 2026-10-14 13:33:42 +02:00
gitea_admin b8ba2db7a5 Fix occurrence MCP schema and expose product sales 2026-10-12 21:47:12 +02:00
gitea_admin d871b94c52 OTC-Betragsfeld leer initialisieren 2026-10-01 08:39:26 +02:00
gitea_admin 4a819f7295 Marktbezug im Bestellungsdrawer anzeigen 2026-09-30 20:11:15 +02:00
gitea_admin 2ae368d2d6 OTC-Verkauf aktualisiert Bestellungen-Tabelle sofort 2026-09-30 20:07:00 +02:00
gitea_admin 9f35366449 Prevent checklist checkbox shrinking 2026-09-30 12:15:54 +02:00
gitea_admin dea0cd9e39 Centralize editable card visibility toggle 2026-09-30 12:13:01 +02:00
gitea_admin cc534b0ce4 Add fixed chapters to market occurrence checklists 2026-09-30 11:59:35 +02:00
gitea_admin 84a4e6ac63 Fix ERP lager MCP schema properties 2026-09-30 11:07:44 +02:00
gitea_admin 1b7e0c8a13 Add separate read-only ERP lager MCP 2026-09-30 10:51:03 +02:00
gitea_admin b0bf8753ed Disable checklist toggle while editing 2026-09-30 10:43:19 +02:00
gitea_admin 739ef662b9 Keep checklist checks beside inputs on mobile 2026-09-30 10:41:05 +02:00
gitea_admin d0aa29107d Keep checklist visible on mobile save 2026-09-30 10:40:17 +02:00
gitea_admin e25899b3ee Delay scroll restoration after card refresh 2026-09-30 10:37:38 +02:00
gitea_admin 01fa2bc2b6 Preserve card edit scroll position 2026-09-30 10:36:09 +02:00
gitea_admin ec6364b32b Sync checklist edit checks 2026-09-30 10:32:25 +02:00
gitea_admin cd3bd1e1b1 Fix checklist signal refresh 2026-09-30 10:31:03 +02:00
gitea_admin 332a385ccf Color checklist status header 2026-09-30 10:28:38 +02:00
gitea_admin 5935144a5a Toggle checklist visibility 2026-09-30 10:20:55 +02:00
gitea_admin 1de403f39d Align checklist edit fields 2026-09-30 10:15:57 +02:00
gitea_admin f9b5383ae9 Add occurrence checklist submodule 2026-09-30 10:08:48 +02:00
gitea_admin 5dbbac35c3 Sync styleguide 2026.05.18.2 2026-09-28 11:06:53 +02:00
gitea_admin 414781db74 Sync styleguide 2026.05.18.2 2026-09-28 10:50:35 +02:00
gitea_admin 46e31c5cbd Märkte-Tabelle vollständig sortierbar machen 2026-09-28 10:46:19 +02:00
gitea_admin 12a376c8fa Cachebusting für zentrale Tabellen-CSS ergänzen 2026-09-28 10:40:40 +02:00
gitea_admin 46a5eb53f1 Sync styleguide 2026.05.18.2 2026-09-28 10:36:07 +02:00
gitea_admin 07a48bbc9c Sync styleguide 2026.05.18.2 2026-09-28 10:34:47 +02:00
gitea_admin 3aaa188d38 Sync styleguide 2026.05.18.2 2026-09-28 10:33:12 +02:00
gitea_admin 1286333ead Sync styleguide 2026.05.18.2 2026-09-28 10:31:34 +02:00
gitea_admin 6a77b56d34 Sync styleguide 2026.05.18.2 2026-09-28 09:04:15 +02:00
gitea_admin a6cfcd4ccd Stelle horizontalen Tabellenscroll wieder her 2026-09-28 08:24:38 +02:00
gitea_admin 80f81bd217 Korrigiere Mehr-laden-Card in Tabellen 2026-09-28 08:21:58 +02:00
gitea_admin ccbcde70a6 Entferne Standardbreiten aus Tabellen 2026-09-28 07:58:15 +02:00
gitea_admin f346fc68e0 Sync styleguide 2026.05.18.2 2026-09-28 07:56:50 +02:00
gitea_admin 7306ded7dd Sync styleguide 2026.05.18.2 2026-09-28 07:43:52 +02:00
gitea_admin ae741aae61 Website-URLs ohne Protokoll akzeptieren 2026-09-27 13:48:29 +02:00
gitea_admin b963b798df Status-Card unter Marktdurchfuehrung verschieben 2026-09-27 09:20:24 +02:00
gitea_admin de2c6d0755 Anmeldehinweis sanitizen 2026-09-27 09:17:03 +02:00
gitea_admin 7f61df4052 Anmeldehinweis fuer Marktdurchfuehrungen ergaenzen 2026-09-27 09:14:33 +02:00
gitea_admin 367af9a1a7 Dokumentationspfad fuer MCP festhalten 2026-09-27 09:09:57 +02:00
gitea_admin 23e1c6f9d1 Fix MCP schema test 2026-09-27 08:50:20 +02:00
gitea_admin 345b015137 Remove market travel duration 2026-09-27 08:43:12 +02:00
gitea_admin 3c12bace28 Prevent large table resize observer loop 2026-09-27 08:33:12 +02:00
gitea_admin 817d3c10d1 Enable resizable columns for all ERP large tables 2026-09-27 08:29:26 +02:00
gitea_admin 900e7dee1c Sync styleguide 2026.05.18.2 2026-09-27 08:25:30 +02:00
gitea_admin ca6015024b Sync styleguide 2026.05.18.2 2026-09-27 08:20:05 +02:00
gitea_admin 8733ce6148 Fix MCP occurrence apostrophe escaping 2026-09-27 07:49:37 +02:00
gitea_admin 85834a0498 Sync styleguide 2026.05.18.2 2026-09-25 13:10:50 +02:00
gitea_admin 732068ad9c Unify mobile detail navigation 2026-09-25 08:19:56 +02:00
gitea_admin ea434033f0 Align MCP JSON-RPC error responses 2026-09-24 14:56:31 +02:00
gitea_admin ebb71b4769 Fix MCP tools/list schema serialization 2026-09-24 14:51:52 +02:00
gitea_admin 8c5668bf3d Sync styleguide 2026.05.18.2 2026-09-24 13:09:21 +02:00
gitea_admin 53baf0dfbf Sync styleguide 2026.05.18.2 2026-09-24 13:00:00 +02:00
gitea_admin 0c04caeb67 Sync styleguide 2026.05.18.2 2026-09-22 11:50:21 +02:00
gitea_admin 8b163c5734 Sync styleguide 2026.05.18.1 2026-09-22 09:55:02 +02:00
gitea_admin a6007b5853 Sync styleguide 2026.05.18.1 2026-09-21 17:27:45 +02:00
gitea_admin 386b539f88 Sync styleguide 2026.05.18.1 2026-09-21 16:24:42 +02:00
gitea_admin 58c5912c30 Sync styleguide 2026.05.18.1 2026-09-21 16:06:04 +02:00
gitea_admin 17e07d525b Require raw HTML from Hermes MCP 2026-09-21 15:50:19 +02:00
gitea_admin dd019afff6 Normalize escaped Hermes rich text 2026-09-21 15:45:53 +02:00
gitea_admin 276a496ae8 Fix market occurrence drawer rendering 2026-09-21 15:43:21 +02:00
gitea_admin 9f10344fa7 Allow external web links in market rich text 2026-09-21 15:33:38 +02:00
gitea_admin c25d3f1b3c Allow safe rich text in market multiline fields 2026-09-21 15:30:13 +02:00
gitea_admin 73e2144571 Merge market document links 2026-09-21 15:03:29 +02:00
gitea_admin 3d7f6b1820 Add read-only Hermes document links 2026-09-21 15:00:04 +02:00
gitea_admin d0079aa5f7 Sync styleguide 2026.05.18.1 2026-09-21 14:42:55 +02:00
gitea_admin e2e6a40d9a Sync styleguide 2026.05.18.1 2026-09-21 14:28:47 +02:00
gitea_admin 99eae4cada Kostenpositionen auf ein Textfeld vereinheitlichen 2026-09-21 12:55:41 +02:00
gitea_admin de10e37a0c GUI Kostenpositionen nach dem Speichern anzeigen 2026-09-21 12:09:57 +02:00
gitea_admin 4758aecec2 MCP-Doku um Kostenpositionen-Rückgabe ergänzen 2026-09-21 11:51:18 +02:00
gitea_admin c4e1c291d8 MCP Kostenpositionen als Text ausgeben 2026-09-21 11:49:09 +02:00
gitea_admin 42127b0916 Verkaufszahl um Umsatz erweitern 2026-09-21 08:21:02 +02:00
gitea_admin d03f653848 Verkaufszahl als eigene Ganzzahlspalte darstellen 2026-09-21 08:16:26 +02:00
gitea_admin b1fd16cf06 Verkaufte Produkte in Durchführungen anzeigen 2026-09-20 21:47:19 +02:00
gitea_admin b2cb88e4ff Archivierte Märkte aus aktiver Liste ausblenden 2026-09-20 21:43:23 +02:00
gitea_admin ee9bcc85ce Archivierten Markt aus aktiver Liste entfernen 2026-09-20 21:39:18 +02:00
gitea_admin 4f0094a821 Marktformular-Fehler verständlich und rot anzeigen 2026-09-20 21:07:52 +02:00
gitea_admin c2913c1026 MCP-Booleans typisiert an PostgreSQL binden 2026-09-20 21:00:26 +02:00
gitea_admin 13420b0e01 Marktdurchführung-Löschung korrekt ausführen 2026-09-20 20:53:11 +02:00
gitea_admin 5567b53581 Delete-Handler mit Asset-Version ausliefern 2026-09-20 20:48:23 +02:00
gitea_admin ff0fffccad Verschachtelte Delete-Dialoge korrekt zuordnen 2026-09-20 20:46:53 +02:00
gitea_admin 38886e39bd CSRF-Token bei Markt-Fragmenten erhalten 2026-09-20 20:35:05 +02:00
gitea_admin 25ef8ef7b0 MCP Occurrence-Felder beim Schreiben übernehmen 2026-09-20 19:45:55 +02:00
gitea_admin 6750d4778f Gespeicherte Portalnavigation beim Reload wiederherstellen 2026-09-20 13:18:26 +02:00
gitea_admin 7fc75eaee6 Anmeldefrist in Marktdurchführungstabelle anzeigen 2026-09-20 13:14:53 +02:00
gitea_admin f1b090ee85 Portalnavigation nach Reload beibehalten 2026-09-20 13:13:39 +02:00
gitea_admin e6431dbe66 Tabelle nach Durchführungsänderung aktualisieren 2026-09-20 13:12:10 +02:00
gitea_admin 23c0cf69d7 Mehrzeilige Marktformularfelder auf Einzeilenbreite setzen 2026-09-20 13:07:52 +02:00
gitea_admin e4be458433 Bezahlten Betrag unter Steuern anordnen 2026-09-20 13:05:51 +02:00
gitea_admin 0c8cb05237 Bezahlten Betrag im Durchführungsformular editierbar machen 2026-09-20 13:01:13 +02:00
gitea_admin 5d9c058e10 Kostenwerte bei Steuerstatuswechsel sofort aktualisieren 2026-09-20 12:59:08 +02:00
gitea_admin af235987ea Steuerstatus und Steuersatz in Kostenkarte wiederherstellen 2026-09-20 12:57:09 +02:00
gitea_admin bae293ad94 Drawer durch Kostenfeld-Observer nicht blockieren 2026-09-20 12:52:29 +02:00
gitea_admin 1ad35cdcfd Kostenpositionen durch einfache Gesamtkosten ersetzen 2026-09-20 12:47:37 +02:00
gitea_admin c0c3c2600f Kostenpositions-Overlay ohne zusaetzlichen Observer binden 2026-09-20 12:40:06 +02:00
gitea_admin 5413bae3ea Kostenpositionsloeschung mit Overlay-Card bestaetigen 2026-09-20 12:35:37 +02:00
gitea_admin e51f3ab507 Kostenpositions-Hinzufuegen rechtsbuendig ausrichten 2026-09-20 12:31:41 +02:00
gitea_admin e1f482ef5c Zusatzabstand vor Kostenpositionsloeschbutton entfernen 2026-09-20 12:30:32 +02:00
gitea_admin f14023bd92 Abstand des Kostenpositions-Löschbuttons reduzieren 2026-09-20 12:28:19 +02:00
gitea_admin d68654e94f Kostenpositionsueberschrift im Drawer bereinigen 2026-09-20 12:25:56 +02:00
gitea_admin 724e3413e4 Kostenpositions-Löschbutton im Formular ausrichten 2026-09-20 12:24:08 +02:00
gitea_admin f77ce71bd6 Kostenpositionsspeicherung atomar ausführen 2026-09-20 12:20:57 +02:00
gitea_admin dc52003c01 Standkosten mit Standardsteuersatz versehen 2026-09-20 12:19:13 +02:00
gitea_admin 3a0cb81a97 Kostenarten und geschützte Standkosten umsetzen 2026-09-20 12:16:31 +02:00
gitea_admin 313475afec Kostenpositionen als Formular-Kapitel speichern 2026-09-20 12:06:20 +02:00
gitea_admin 6226f2a037 Kostenpositionen im Durchführungsdrawer bearbeiten 2026-09-20 11:58:25 +02:00
gitea_admin 2a09401600 Gemeindenamen im Durchführungsdrawer anzeigen 2026-09-20 11:50:50 +02:00
gitea_admin 2d3c9ce4ab Gemeinderegister für Marktdurchführungen ergänzen 2026-09-20 11:48:00 +02:00
gitea_admin 273fb05833 Align historical lots and market drawer 2026-09-20 11:39:25 +02:00
gitea_admin dfe9748ff9 Render drawer details in two columns 2026-09-20 11:35:31 +02:00
gitea_admin 1b66cd3add Neue Veranstalterfelder standardmässig ausblenden 2026-09-20 11:13:54 +02:00
gitea_admin a6f9236abf Geerbten Veranstalter im Editmodus anzeigen 2026-09-20 11:05:39 +02:00
gitea_admin 36fb17ac32 Veranstalter strikt von Lieferanten trennen 2026-09-20 11:04:13 +02:00
gitea_admin 0e71aca89b Veranstalter am Markt verankern 2026-09-20 10:57:40 +02:00
gitea_admin ba36485180 Align market drawer edit action 2026-09-20 10:38:30 +02:00
gitea_admin 0edd9e7b3c Use compatible market drawer script output 2026-09-20 10:35:57 +02:00
gitea_admin 1fcd6aa245 Fix market drawer script syntax 2026-09-20 10:35:37 +02:00
gitea_admin 1d0e71c5db Keep market drawer actions in card header 2026-09-20 10:35:16 +02:00
gitea_admin 5825d37e83 Fix market drawer edit action layout 2026-09-20 10:30:58 +02:00
gitea_admin 5b83d10d92 Fix market status text contrast 2026-09-20 10:21:46 +02:00
gitea_admin 95c42b342b Make occurrence market fields read-only 2026-09-20 10:19:42 +02:00
gitea_admin 78cc6390f7 Add Markt-Drawer delete card 2026-09-20 10:13:23 +02:00
gitea_admin 9b49db98d4 Restore normal Markt-Link weight 2026-09-20 10:06:24 +02:00
gitea_admin e6bf7be2f9 Fix Markt-Link Cursor 2026-09-20 10:05:00 +02:00
gitea_admin 212476c3f6 Fix Markt-Detaildrawer 2026-09-20 10:01:27 +02:00
gitea_admin 42684f3864 Marktname als Drawer-Aktion rendern 2026-09-20 09:50:35 +02:00
gitea_admin e2e46a601b Marktlink im dynamischen Tabelleninhalt robust öffnen 2026-09-20 09:46:15 +02:00
gitea_admin a1ac5f34ae Marktlinks direkt an Detaildrawer binden 2026-09-20 09:42:06 +02:00
gitea_admin 1bf5b51679 Markt-Navigation wieder sichtbar schalten 2026-09-20 09:39:50 +02:00
gitea_admin b68546f824 Markt-Detailkarte an zentrales Editierpattern anbinden 2026-09-20 09:34:16 +02:00
gitea_admin 841e559a70 Markt-Bearbeiten robust gegen fehlenden Formulartitel machen 2026-09-20 09:30:06 +02:00
gitea_admin f0af2efe8a Markt-Bearbeiten direkt an Detailkarte binden 2026-09-20 09:17:25 +02:00
gitea_admin c9d386badd Marktname im Tabellen-Link normal gewichten 2026-09-20 09:14:57 +02:00
gitea_admin f4834e0e7a Markt-Drawer zeigt keine Neuanlagekarte in Details 2026-09-20 09:13:35 +02:00
gitea_admin 1921464e27 Marktname öffnet Detailkarte mit Lese- und Editieransicht 2026-09-20 09:07:19 +02:00
gitea_admin f720782736 Markt-Neuanlage im Märkte-Drawer implementieren 2026-09-20 08:57:54 +02:00
gitea_admin 43598bf65a MCP-Begriffe für Markt-Hierarchie und LLM-Doku vereinheitlichen 2026-09-20 08:49:05 +02:00
gitea_admin 2bfe91695e Marktbegriffe in Durchführungen und OTC vereinheitlichen 2026-09-20 08:46:17 +02:00
gitea_admin 22880b512a Marktdefinitionen als eigene Märkte-Konfiguration anzeigen 2026-09-20 08:34:08 +02:00
gitea_admin debee672f8 Märkte-Navigation in Markttage und Märkte umbenennen 2026-09-20 08:30:10 +02:00
gitea_admin 3d60998145 MCP-Toolschemas vollständig beschreiben 2026-09-19 21:14:34 +02:00
gitea_admin 481da014bc MCP-API an GUI-Felder und Durchführungsdaten angleichen 2026-09-19 21:13:43 +02:00
gitea_admin d22395b96c Bestätigungscard im zentralen Pattern hellgrau rendern 2026-09-19 21:05:28 +02:00
gitea_admin aaa9fa67e9 Bestätigungskarten zentral hellgrau darstellen 2026-09-19 21:04:10 +02:00
gitea_admin ca2facdbc6 Tages-Löschdialog auf die Durchführungskarte begrenzen 2026-09-19 21:03:23 +02:00
gitea_admin 3d638ab2a1 Durchführungstag im bestehenden Löschpattern entfernen 2026-09-19 20:59:53 +02:00
gitea_admin 30c11c5ca7 Durchführungsstatus im Tagescron automatisch abschliessen 2026-09-19 20:50:00 +02:00
gitea_admin 2a1144f0aa Tagesspezifisch aus Durchführungskarten entfernen 2026-09-19 20:37:12 +02:00
gitea_admin 21e0f48fd5 OTC-Response vor Throttling-Warnung schützen 2026-09-19 20:30:15 +02:00
gitea_admin 9fc85141e0 OTC-Veranstaltungen eindeutig verknüpfen und Fehler sichtbar halten 2026-09-19 20:21:52 +02:00
gitea_admin 184f50d034 Spacing zwischen Neuer-Durchführung-Karte und Tabelle korrigieren 2026-09-19 19:59:18 +02:00
gitea_admin 6fae2de15f Neue Durchführungen im Märkte-Drawer anlegen 2026-09-19 19:49:30 +02:00
gitea_admin e188f932f4 Remove market edit form headings 2026-09-19 18:33:11 +02:00
gitea_admin 71f329170f Align editable cards with read-only fields 2026-09-19 18:29:32 +02:00
gitea_admin 3ba33f85a0 Aggregate OTC sales by market day 2026-09-19 17:42:42 +02:00
gitea_admin 1b553a9095 Separate OTC market link segment 2026-09-19 17:37:10 +02:00
gitea_admin e9d157c87e Use disabled pulldown style for market link 2026-09-19 17:33:56 +02:00
gitea_admin 47c09a3e4c Link OTC sales to market days 2026-09-19 17:32:23 +02:00
gitea_admin 759e337976 Place observations before inheritance checkbox 2026-09-19 17:17:26 +02:00
gitea_admin b15eda6ff1 Place observations below day date 2026-09-19 17:15:09 +02:00
gitea_admin 71b27a387d Keep day observations independent 2026-09-19 17:10:25 +02:00
gitea_admin 2c734bd415 Fix Tag-1-Vererbung Checkbox 2026-09-19 16:59:32 +02:00
gitea_admin 20f130f614 Add first-day inheritance for occurrence days 2026-09-19 16:50:53 +02:00
gitea_admin 4eef9d9785 Validate chronological market occurrence days 2026-09-19 16:38:32 +02:00
gitea_admin 361635332c Apply highlighted market day tab style 2026-09-19 16:32:56 +02:00
gitea_admin a42d7a0718 Fix new market day tab selection 2026-09-19 16:27:00 +02:00
gitea_admin 4b112f3a57 Add market occurrence day tab creation 2026-09-19 16:24:45 +02:00
gitea_admin 0de04a8dfa Mark required market drawer fields 2026-09-19 16:09:39 +02:00
gitea_admin bace7b4a05 Tune occurrence days heading spacing 2026-09-19 16:01:24 +02:00
gitea_admin 18e89595e0 Add heading for market occurrence days 2026-09-19 16:00:08 +02:00
gitea_admin d1129175bc Fix delete confirmation backdrop state 2026-09-19 15:57:25 +02:00
gitea_admin c1cb272702 Move market delete action into titled card 2026-09-19 15:55:03 +02:00
gitea_admin b7ff51adc5 Show occurrence dates in market drawer title 2026-09-19 15:50:42 +02:00
gitea_admin fdc0f02b91 Restore market drawer card spacing 2026-09-19 15:48:44 +02:00
gitea_admin c4b93ff217 Align market delete confirmation overlay 2026-09-19 15:42:17 +02:00
gitea_admin ca42ded4e2 Add market occurrence delete confirmation 2026-09-19 15:39:05 +02:00
gitea_admin 30e44755fe Allow empty market municipality 2026-09-19 14:28:52 +02:00
gitea_admin 2862d7a6e9 Allow saving without contact details 2026-09-19 14:28:02 +02:00
gitea_admin 28784ed6ac Fix market card save validation 2026-09-19 14:26:50 +02:00
gitea_admin 6a12a43114 Complete market overview card editing 2026-09-19 14:22:07 +02:00
gitea_admin 94d336f090 Complete market overview edit fields 2026-09-19 14:12:00 +02:00
gitea_admin af0206d9cd Keep market status header visible 2026-09-19 14:09:15 +02:00
gitea_admin 65d2b1405f Remove cache workaround 2026-09-19 14:05:52 +02:00
gitea_admin 57d8da9577 Refresh shared editable card script 2026-09-19 14:05:17 +02:00
gitea_admin 2a754973cd Disable edit action while card is open 2026-09-19 14:04:16 +02:00
gitea_admin e503759197 Restore market drawer loader 2026-09-19 13:53:46 +02:00
gitea_admin 47baeaa5f7 Remove unused market edit legacy 2026-09-19 13:51:50 +02:00
gitea_admin 0fba652770 Remove legacy market edit path 2026-09-19 13:51:35 +02:00
gitea_admin 9064d4eb42 Fix market status card edit mode 2026-09-19 13:49:41 +02:00
gitea_admin f66c22027e Restore portal navigation JavaScript 2026-09-19 13:42:07 +02:00
gitea_admin 614a934dc1 Fix portal JavaScript syntax 2026-09-19 13:37:56 +02:00
gitea_admin 906c76a959 Align market cards with shared edit pattern 2026-09-19 13:36:01 +02:00
gitea_admin f2467f4411 Use shared editable card pattern for markets 2026-09-19 13:26:03 +02:00
gitea_admin aa68b41971 Add market drawer edit patterns 2026-09-19 13:17:22 +02:00
gitea_admin fdabe38180 Improve neutral market status contrast 2026-09-19 13:13:49 +02:00
gitea_admin ecdd973c3f Simplify market status card 2026-09-19 13:08:40 +02:00
gitea_admin fe641e9e7c Remove market drawer edit actions 2026-09-19 13:06:56 +02:00
gitea_admin 0c3a87868c Refine market drawer detail presentation 2026-09-19 13:02:43 +02:00
gitea_admin 278e415e08 Complete market occurrence drawer details 2026-09-19 12:59:14 +02:00
gitea_admin 16882207bb Merge shared editable action card feature 2026-09-19 12:40:02 +02:00
gitea_admin a672fc76e4 Add shared editable action card pattern 2026-09-19 12:39:05 +02:00
gitea_admin 1eef7bd70b Remove market drawer close button 2026-09-19 12:26:00 +02:00
gitea_admin 49fd392f79 Implement market occurrence drawer 2026-09-19 11:40:44 +02:00
gitea_admin 7178c4af53 Faerbe Marktstatus als Textfarbe 2026-09-19 11:16:59 +02:00
gitea_admin 551f90a617 Korrigiere Statusfarben in Markttabelle 2026-09-19 11:16:04 +02:00
gitea_admin b22e3ce391 Faerbe Marktstatus mit Styleguide-Ampelfarben 2026-09-19 11:15:08 +02:00
gitea_admin 662579cc6e Ermoegliche Statussuche ueber GUI-Bezeichnungen 2026-09-19 11:12:38 +02:00
gitea_admin bbec87abfa Ergaenze Markt-Durchfuehrungstabelle im Portal 2026-09-19 11:10:26 +02:00
gitea_admin c147624d71 Ergaenze Navigation fuer Marktmodul 2026-09-19 11:04:20 +02:00
gitea_admin 889530d8e6 Ergaenze Hauptmenuepunkt Maerkte 2026-09-19 11:03:31 +02:00
gitea_admin 5f3c71a96c Praezisiere technische GUI-MCP-Paritaet 2026-09-19 11:01:26 +02:00
gitea_admin 985ada2f39 Trenne allgemeine und projektspezifische Architektur 2026-09-19 10:41:07 +02:00
gitea_admin 18d73dd4d5 Verankere GUI-MCP-Paritaet als Architekturvorgabe 2026-09-19 10:38:46 +02:00
gitea_admin f9ffa5a6b6 Ergaenze Website-URL am Marktstamm 2026-09-19 10:08:49 +02:00
gitea_admin d259c35000 Behebe Boolean-Fehler bei Durchfuehrungsabruf 2026-09-19 09:49:16 +02:00
gitea_admin 667278563d Aktualisiere Hermes-MCP-Dokumentation 2026-09-19 09:28:29 +02:00
gitea_admin de61a3e515 Korrigiere Marktfilter und Detailabfrage 2026-09-19 09:26:32 +02:00
gitea_admin d0240ddfab Korrigiere Durchfuehrungslistenabfrage 2026-09-19 09:25:34 +02:00
gitea_admin bb2ddb388e Korrigiere Durchfuehrungsfilter 2026-09-19 09:25:10 +02:00
gitea_admin 9bde337506 Vervollstaendige Marktmanagement-MCP-Abdeckung 2026-09-19 09:24:14 +02:00
gitea_admin 3267a121de Erweitere Marktmanagement-MCP fuer Durchfuehrungen 2026-09-19 08:51:17 +02:00
gitea_admin df5a533983 Implementiere Marktmanagement-MCP-API 2026-09-19 08:45:47 +02:00
gitea_admin 2607f448ad Implementiere Marktmanagement-Datenmodell 2026-09-19 08:30:01 +02:00
gitea_admin 8492db2e39 Strukturiere Marktmanagement-Konzept 2026-09-19 08:20:56 +02:00
gitea_admin 7c2d1cedb6 Erstelle technisches Marktmanagement-Konzept 2026-09-19 08:11:02 +02:00
gitea_admin 2d49840aba Korrigiere deterministische Marktartenliste 2026-09-19 07:25:10 +02:00
gitea_admin 8d5cb3b289 Definiere zentrale Marktstammdaten 2026-09-19 07:24:56 +02:00
gitea_admin 5f6806ab33 Erweitere Marktmanagement-Datenhierarchie 2026-09-18 19:33:28 +02:00
gitea_admin 9e877ac464 Dokumentiere MCP-Architektur für Hermes 2026-09-18 19:31:08 +02:00
gitea_admin e960cca6ea Entferne Offline-Modus aus Marktanforderungen 2026-09-18 18:40:58 +02:00
gitea_admin 4d4f354eef Dokumentiere Marktmanagement-Anforderungen 2026-09-15 09:53:13 +02:00
gitea_admin 0e5d3fda91 Vereinheitliche Chargenformular auf Mobile und Desktop 2026-09-09 08:26:33 +02:00
gitea_admin d69faae5f4 Set product column to thirty percent 2026-09-08 19:54:33 +02:00
gitea_admin 48f8cc6890 Make current inventory quantity non-clickable 2026-09-08 19:54:11 +02:00
gitea_admin 4e477d9034 Normalize drawer spacing between lot cards 2026-09-08 19:53:14 +02:00
gitea_admin 91ac3a6828 Start forecast demand at first sale 2026-09-08 19:34:21 +02:00
gitea_admin 185dfcdb38 Merge charge save error fix 2026-09-08 19:32:05 +02:00
84 changed files with 9026 additions and 695 deletions

No files matched your search

+7
View File
@@ -53,3 +53,10 @@ Diese Datei enthaelt operative Arbeitsregeln fuer Agenten in diesem Repository.
- Wenn Prozesse umgesetzt werden: verwende immer den Skill prozess-umsetzung
- Wenn UI umgesetzt werden soll: verwende immer den Skill styleguide-anwendung
- Zwingende Vorgabe für alle Arbeiten ist /docs/technical_architecture.md
## 9. MCP-Dokumentation
- Bei jeder Änderung an der MCP-Schnittstelle des Marktmanagements ist neben der
Repository-Dokumentation auch die MCP-Dokumentation unter
`/Volumes/Hermes/Hermes Naurua/Marktmanagement/MCP` zu aktualisieren.
- Die MCP-Dokumentation ist damit ein verbindlicher zusätzlicher Dokumentationsstand
für alle zukünftigen MCP-Änderungen in diesem Projekt.
+2
View File
@@ -20,6 +20,8 @@
16. `db/migrations/0020_include_stock_adjustments_in_balance.sql`
17. `db/migrations/0021_stock_lot_activation_selection.sql`
18. `db/migrations/0022_backfill_stock_lot_activation_selection.sql`
19. `db/migrations/0023_market_management.sql` bis `db/migrations/0034_market_occurrence_checklist.sql`
20. `db/migrations/0035_market_occurrence_checklist_chapters.sql`
## Beispielausfuehrung
+215
View File
@@ -0,0 +1,215 @@
BEGIN;
-- Market Management base schema.
-- Fachliche Ownership: market_management.
CREATE TABLE market_category (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL,
name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_market_category_code ON market_category(code);
CREATE UNIQUE INDEX uq_market_category_name ON market_category(name);
INSERT INTO market_category (code, name)
VALUES
('herbstmarkt', 'Herbstmärkte'),
('weihnachtsmarkt', 'Weihnachtsmärkte'),
('fruehlingsmarkt', 'Frühlingsmärkte'),
('ostermarkt', 'Ostermärkte'),
('kunsthandwerksmarkt', 'Kunsthandwerksmärkte'),
('regionalmarkt', 'Regionalmärkte'),
('weitere_geeignete_maerkte', 'Weitere geeignete Märkte');
CREATE TABLE market_municipality_reference (
id BIGSERIAL PRIMARY KEY,
bfs_municipality_number INTEGER NOT NULL,
name TEXT NOT NULL,
canton_code TEXT NOT NULL,
data_version DATE NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_market_municipality_reference_bfs_number_version
ON market_municipality_reference(bfs_municipality_number, data_version);
CREATE INDEX idx_market_municipality_reference_canton
ON market_municipality_reference(canton_code, name);
CREATE TABLE market_definition (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
category_id BIGINT NOT NULL REFERENCES market_category(id) ON DELETE RESTRICT,
archived_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_market_definition_name ON market_definition(name);
CREATE INDEX idx_market_definition_category ON market_definition(category_id);
CREATE TABLE market_occurrence (
id BIGSERIAL PRIMARY KEY,
market_definition_id BIGINT NOT NULL REFERENCES market_definition(id) ON DELETE RESTRICT,
status TEXT NOT NULL DEFAULT 'open',
place TEXT,
address TEXT,
municipality_reference_id BIGINT REFERENCES market_municipality_reference(id) ON DELETE RESTRICT,
notes TEXT,
registration_deadline DATE,
request_or_registration_date DATE,
confirmation_date DATE,
payment_deadline DATE,
stand_size TEXT,
table_available BOOLEAN,
table_rentable BOOLEAN,
own_table_required BOOLEAN,
shelter_available BOOLEAN,
power_available BOOLEAN,
water_available BOOLEAN,
lighting_available BOOLEAN,
other_infrastructure TEXT,
own_stand_requirements TEXT,
travel_address TEXT,
travel_distance_km NUMERIC(10, 2),
travel_duration_minutes INTEGER,
parking_options TEXT,
delivery_access TEXT,
direct_unload_possible BOOLEAN,
storage_options TEXT,
travel_notes TEXT,
payment_status TEXT NOT NULL DEFAULT 'unpaid',
payment_date DATE,
paid_amount NUMERIC(14, 2),
payment_method_code TEXT,
cancellation_terms TEXT,
revenue_share_terms TEXT,
other_participation_conditions TEXT,
contract_folder_url TEXT,
archived_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_status CHECK (
status IN ('open', 'waitlist', 'registered', 'confirmation_pending', 'confirmed', 'cancelled', 'completed')
),
CONSTRAINT chk_market_occurrence_payment_status CHECK (
payment_status IN ('paid', 'unpaid')
),
CONSTRAINT chk_market_occurrence_travel_distance CHECK (
travel_distance_km IS NULL OR travel_distance_km >= 0
),
CONSTRAINT chk_market_occurrence_travel_duration CHECK (
travel_duration_minutes IS NULL OR travel_duration_minutes >= 0
),
CONSTRAINT chk_market_occurrence_paid_amount CHECK (
paid_amount IS NULL OR paid_amount >= 0
)
);
CREATE INDEX idx_market_occurrence_market ON market_occurrence(market_definition_id);
CREATE INDEX idx_market_occurrence_status ON market_occurrence(status);
CREATE INDEX idx_market_occurrence_municipality
ON market_occurrence(municipality_reference_id);
CREATE INDEX idx_market_occurrence_archived_at ON market_occurrence(archived_at);
CREATE TABLE market_occurrence_organizer (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL UNIQUE REFERENCES market_occurrence(id) ON DELETE RESTRICT,
organization_contact_id BIGINT NOT NULL,
contact_person_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_market_occurrence_organizer_organization
ON market_occurrence_organizer(organization_contact_id);
CREATE INDEX idx_market_occurrence_organizer_contact_person
ON market_occurrence_organizer(contact_person_id);
CREATE TABLE market_occurrence_day (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
date DATE NOT NULL,
stand_start_time TIME,
stand_end_time TIME,
setup_start_time TIME,
setup_end_time TIME,
delivery_start_time TIME,
delivery_end_time TIME,
observations TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_day_stand_time CHECK (
stand_start_time IS NULL OR stand_end_time IS NULL OR stand_end_time >= stand_start_time
),
CONSTRAINT chk_market_occurrence_day_setup_time CHECK (
setup_start_time IS NULL OR setup_end_time IS NULL OR setup_end_time >= setup_start_time
),
CONSTRAINT chk_market_occurrence_day_delivery_time CHECK (
delivery_start_time IS NULL OR delivery_end_time IS NULL OR delivery_end_time >= delivery_start_time
)
);
CREATE UNIQUE INDEX uq_market_occurrence_day_date ON market_occurrence_day(occurrence_id, date);
CREATE INDEX idx_market_occurrence_day_date ON market_occurrence_day(date);
CREATE TABLE market_occurrence_cost_item (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
cost_type TEXT NOT NULL,
description TEXT,
gross_amount NUMERIC(14, 2) NOT NULL,
tax_status TEXT NOT NULL,
tax_rate NUMERIC(5, 2) NOT NULL DEFAULT 0,
tax_amount NUMERIC(14, 2) GENERATED ALWAYS AS (
CASE
WHEN tax_status = 'tax_free' THEN 0
ELSE ROUND(gross_amount - gross_amount / (1 + tax_rate / 100), 2)
END
) STORED,
net_amount NUMERIC(14, 2) GENERATED ALWAYS AS (
gross_amount - CASE
WHEN tax_status = 'tax_free' THEN 0
ELSE ROUND(gross_amount - gross_amount / (1 + tax_rate / 100), 2)
END
) STORED,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_cost_gross_nonnegative CHECK (gross_amount >= 0),
CONSTRAINT chk_market_occurrence_cost_tax_status CHECK (tax_status IN ('tax_free', 'taxable')),
CONSTRAINT chk_market_occurrence_cost_tax_rate CHECK (
(tax_status = 'tax_free' AND tax_rate = 0)
OR (tax_status = 'taxable' AND tax_rate > 0)
)
);
CREATE INDEX idx_market_occurrence_cost_item_occurrence
ON market_occurrence_cost_item(occurrence_id);
CREATE TABLE market_mcp_idempotency (
id BIGSERIAL PRIMARY KEY,
user_subject TEXT NOT NULL,
client_id TEXT NOT NULL,
idempotency_key TEXT NOT NULL,
tool_name TEXT NOT NULL,
request_hash TEXT NOT NULL,
result_status TEXT NOT NULL,
result_entity_type TEXT,
result_entity_id TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
expires_at TIMESTAMP NOT NULL,
CONSTRAINT chk_market_mcp_idempotency_result_status CHECK (
result_status IN ('processing', 'succeeded', 'failed')
)
);
CREATE UNIQUE INDEX uq_market_mcp_idempotency_client_key
ON market_mcp_idempotency(client_id, idempotency_key);
CREATE INDEX idx_market_mcp_idempotency_expiry ON market_mcp_idempotency(expires_at);
COMMIT;
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE market_mcp_idempotency
ADD COLUMN result_payload JSONB;
COMMIT;
@@ -0,0 +1,10 @@
BEGIN;
ALTER TABLE market_definition
ADD COLUMN website_url TEXT;
ALTER TABLE market_definition
ADD CONSTRAINT chk_market_definition_website_url
CHECK (website_url IS NULL OR website_url ~* '^https?://');
COMMIT;
@@ -0,0 +1,6 @@
BEGIN;
ALTER TABLE market_occurrence_day
ADD COLUMN inherit_from_first_day BOOLEAN NOT NULL DEFAULT FALSE;
COMMIT;
+28
View File
@@ -0,0 +1,28 @@
BEGIN;
ALTER TABLE sales_order
ADD COLUMN market_occurrence_id BIGINT,
ADD COLUMN market_occurrence_day_id BIGINT;
ALTER TABLE market_occurrence_day
ADD CONSTRAINT uq_market_occurrence_day_occurrence_id UNIQUE (occurrence_id, id);
ALTER TABLE sales_order
ADD CONSTRAINT fk_sales_order_market_occurrence
FOREIGN KEY (market_occurrence_id)
REFERENCES market_occurrence (id)
ON DELETE RESTRICT,
ADD CONSTRAINT fk_sales_order_market_occurrence_day
FOREIGN KEY (market_occurrence_id, market_occurrence_day_id)
REFERENCES market_occurrence_day (occurrence_id, id)
ON DELETE RESTRICT,
ADD CONSTRAINT chk_sales_order_market_link_complete
CHECK ((market_occurrence_id IS NULL) = (market_occurrence_day_id IS NULL));
CREATE INDEX idx_sales_order_market_occurrence
ON sales_order (market_occurrence_id);
CREATE INDEX idx_sales_order_market_occurrence_day
ON sales_order (market_occurrence_day_id);
COMMIT;
@@ -0,0 +1,55 @@
BEGIN;
CREATE TABLE market_organizer (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_organizer_status CHECK (status IN ('active', 'inactive'))
);
CREATE UNIQUE INDEX uq_market_organizer_name ON market_organizer (LOWER(TRIM(name)));
INSERT INTO market_organizer (name)
VALUES ('Beispielveranstalter 1');
ALTER TABLE market_definition
ADD COLUMN organizer_id BIGINT;
UPDATE market_definition
SET organizer_id = (SELECT id FROM market_organizer WHERE name = 'Beispielveranstalter 1');
ALTER TABLE market_definition
ALTER COLUMN organizer_id SET NOT NULL,
ADD CONSTRAINT fk_market_definition_organizer
FOREIGN KEY (organizer_id) REFERENCES market_organizer(id) ON DELETE RESTRICT;
CREATE INDEX idx_market_definition_organizer ON market_definition (organizer_id);
ALTER TABLE market_occurrence_organizer
ADD COLUMN contact_person_name TEXT,
ADD COLUMN contact_email TEXT,
ADD COLUMN contact_phone TEXT;
UPDATE market_occurrence_organizer oo
SET contact_person_name = NULLIF(TRIM(CONCAT_WS(' ', c.first_name, c.last_name)), ''),
contact_email = c.email,
contact_phone = c.phone
FROM contact c
WHERE c.id = oo.contact_person_id;
ALTER TABLE market_occurrence_organizer
DROP COLUMN organization_contact_id,
DROP COLUMN contact_person_id;
INSERT INTO market_occurrence_organizer (occurrence_id, contact_person_name, created_at, updated_at)
SELECT o.id, NULL, NOW(), NOW()
FROM market_occurrence o
WHERE NOT EXISTS (
SELECT 1 FROM market_occurrence_organizer oo WHERE oo.occurrence_id = o.id
);
COMMIT;
@@ -0,0 +1,40 @@
BEGIN;
CREATE TABLE market_municipality (
id BIGSERIAL PRIMARY KEY,
municipality_reference_id BIGINT UNIQUE REFERENCES market_municipality_reference(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
canton_code TEXT,
bfs_municipality_number INTEGER,
data_version DATE,
source TEXT NOT NULL DEFAULT 'local',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_municipality_source CHECK (source IN ('reference', 'local'))
);
CREATE UNIQUE INDEX uq_market_municipality_name
ON market_municipality (LOWER(TRIM(name)));
CREATE INDEX idx_market_municipality_name
ON market_municipality (name);
INSERT INTO market_municipality (
municipality_reference_id, name, canton_code, bfs_municipality_number, data_version, source, is_active
)
SELECT id, name, canton_code, bfs_municipality_number, data_version, 'reference', is_active
FROM market_municipality_reference;
ALTER TABLE market_occurrence
DROP CONSTRAINT market_occurrence_municipality_reference_id_fkey;
UPDATE market_occurrence o
SET municipality_reference_id = m.id
FROM market_municipality m
WHERE m.municipality_reference_id = o.municipality_reference_id;
ALTER TABLE market_occurrence
ADD CONSTRAINT fk_market_occurrence_municipality
FOREIGN KEY (municipality_reference_id) REFERENCES market_municipality(id) ON DELETE RESTRICT;
COMMIT;
+63
View File
@@ -0,0 +1,63 @@
BEGIN;
CREATE TABLE market_cost_type (
id BIGSERIAL PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
is_system BOOLEAN NOT NULL DEFAULT FALSE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
INSERT INTO market_cost_type (code, name, is_system)
VALUES ('stand_fee', 'Standkosten', TRUE);
ALTER TABLE market_occurrence_cost_item
ADD COLUMN cost_type_id BIGINT REFERENCES market_cost_type(id) ON DELETE RESTRICT;
INSERT INTO market_cost_type (code, name)
SELECT DISTINCT
'legacy_' || md5(trim(cost_type)),
trim(cost_type)
FROM market_occurrence_cost_item
WHERE trim(cost_type) <> ''
AND lower(trim(cost_type)) <> 'standkosten'
ON CONFLICT (name) DO NOTHING;
UPDATE market_occurrence_cost_item item
SET cost_type_id = type.id
FROM market_cost_type type
WHERE type.name = CASE
WHEN lower(trim(item.cost_type)) = 'standkosten' THEN 'Standkosten'
ELSE trim(item.cost_type)
END;
UPDATE market_occurrence_cost_item item
SET cost_type_id = type.id
FROM market_cost_type type
WHERE type.code = 'stand_fee'
AND item.id = (
SELECT MIN(first_item.id)
FROM market_occurrence_cost_item first_item
WHERE first_item.occurrence_id = item.occurrence_id
);
INSERT INTO market_occurrence_cost_item (occurrence_id, cost_type_id, description, gross_amount, tax_status, tax_rate)
SELECT occurrence.id, type.id, NULL, 0.00, 'taxable', 8.10
FROM market_occurrence occurrence
JOIN market_cost_type type ON type.code = 'stand_fee'
WHERE NOT EXISTS (
SELECT 1 FROM market_occurrence_cost_item item WHERE item.occurrence_id = occurrence.id
);
ALTER TABLE market_occurrence_cost_item
ALTER COLUMN cost_type_id SET NOT NULL;
ALTER TABLE market_occurrence_cost_item
DROP COLUMN cost_type;
CREATE INDEX idx_market_occurrence_cost_item_type
ON market_occurrence_cost_item(cost_type_id);
COMMIT;
@@ -0,0 +1,11 @@
BEGIN;
UPDATE market_occurrence_cost_item item
SET tax_status = 'taxable', tax_rate = 8.10, updated_at = NOW()
WHERE item.id = (
SELECT MIN(first_item.id)
FROM market_occurrence_cost_item first_item
WHERE first_item.occurrence_id = item.occurrence_id
);
COMMIT;
@@ -0,0 +1,8 @@
BEGIN;
-- Reisedauer wird fachlich nicht mehr verwendet und vollständig entfernt.
ALTER TABLE market_occurrence
DROP CONSTRAINT IF EXISTS chk_market_occurrence_travel_duration,
DROP COLUMN IF EXISTS travel_duration_minutes;
COMMIT;
@@ -0,0 +1,2 @@
ALTER TABLE market_occurrence
ADD COLUMN registration_note TEXT;
@@ -0,0 +1,33 @@
BEGIN;
CREATE TABLE market_occurrence_checklist (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL UNIQUE REFERENCES market_occurrence(id) ON DELETE RESTRICT,
comment TEXT,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE TABLE market_occurrence_checklist_item (
id BIGSERIAL PRIMARY KEY,
occurrence_id BIGINT NOT NULL REFERENCES market_occurrence(id) ON DELETE RESTRICT,
position INTEGER NOT NULL,
label TEXT NOT NULL,
is_completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT chk_market_occurrence_checklist_item_position CHECK (position > 0)
);
CREATE INDEX idx_market_occurrence_checklist_item_occurrence
ON market_occurrence_checklist_item(occurrence_id, position, id);
INSERT INTO market_occurrence_checklist (occurrence_id)
SELECT id FROM market_occurrence;
INSERT INTO market_occurrence_checklist_item (occurrence_id, position, label)
SELECT occurrence_id, position, 'Position ' || position
FROM market_occurrence_checklist
CROSS JOIN generate_series(1, 30) AS positions(position);
COMMIT;
@@ -0,0 +1,53 @@
BEGIN;
ALTER TABLE market_occurrence_checklist_item
ADD COLUMN chapter_key TEXT;
DELETE FROM market_occurrence_checklist_item;
UPDATE market_occurrence_checklist
SET comment = NULL,
updated_at = NOW();
ALTER TABLE market_occurrence_checklist_item
ALTER COLUMN chapter_key SET NOT NULL,
ADD CONSTRAINT chk_market_occurrence_checklist_item_chapter
CHECK (chapter_key IN ('products_sales', 'decoration', 'stand_equipment', 'technology'));
INSERT INTO market_occurrence_checklist_item
(occurrence_id, chapter_key, position, label, is_completed)
SELECT checklist.occurrence_id,
defaults.chapter_key,
defaults.position,
defaults.label,
FALSE
FROM market_occurrence_checklist checklist
CROSS JOIN (
VALUES
('products_sales', 1, 'Fläschchen'),
('products_sales', 2, 'Säckchen'),
('products_sales', 3, 'Dunkle Kleber'),
('products_sales', 4, 'Naurua-Kleber'),
('products_sales', 5, 'Anwendungsbeilage'),
('products_sales', 6, 'Flyer Naurua'),
('products_sales', 7, 'Flyer Vitalpilze'),
('products_sales', 8, 'Vitalpilzbuch'),
('products_sales', 9, 'SumUp-Terminal'),
('products_sales', 10, 'Bargeld'),
('decoration', 1, 'Dekoration'),
('decoration', 2, 'Pilze'),
('decoration', 3, 'Blumenvase'),
('decoration', 4, 'Blume'),
('stand_equipment', 1, 'Tisch'),
('stand_equipment', 2, 'Tischtücher'),
('stand_equipment', 3, 'Beleuchtung'),
('stand_equipment', 4, 'Holzklötze'),
('stand_equipment', 5, 'Wagen'),
('stand_equipment', 6, 'Sonnenschirm'),
('stand_equipment', 7, 'Parkkarte/-Marktinformationen'),
('technology', 1, 'Batterie-/Powerbank fürs Handy')
) AS defaults(chapter_key, position, label);
CREATE INDEX idx_market_occurrence_checklist_item_chapter
ON market_occurrence_checklist_item(occurrence_id, chapter_key, position, id);
COMMIT;
+3
View File
@@ -42,6 +42,9 @@ Bestand, Charge, MHD und Bewegungen.
## Technisch umgesetzt
- `modules/erp/lager/service.php`
- Read-only MCP für Hermes: `modules/erp/lager/mcp.php`
- MCP-Endpoint: `public/api/erp-lager-mcp.php`
- [ERP-Lager-Hermes-MCP-API](konzepte/erp_lager_hermes_mcp_api.md)
Hersteller-Stammdaten gehören zum Modul `kontakte`. Eine reale Lagercharge kann dort einen Hersteller und dessen Hersteller-Chargennummer referenzieren; die interne Naurua-Chargennummer bleibt Eigentum des Moduls `lager`. Virtuelle Fehlbestandschargen haben keinen Herstellerbezug.
@@ -0,0 +1,31 @@
# Hermes: ERP-Lager-MCP-API
## Zweck
Das ERP-Lager stellt ein eigenes, read-only MCP-Interface bereit. Hermes kann dadurch ausschließlich die Lager-Tools laden, ohne Marktmanagement-Tools in seinen Kontext aufzunehmen.
Endpoint: `https://erpnaurua.imhochrain.ch/api/erp-lager-mcp.php`
Authentifizierung: `Authorization: Bearer <MARKET_MCP_TOKEN>`
Der Token ist identisch mit dem Marktmanagement-MCP, weil derselbe Hermes-Bot auf beide fachlich getrennten Interfaces zugreift. Die Toolsets und Endpoints bleiben trotzdem getrennt.
## Verfügbare Tools
### `erp_lager.list_products`
Liefert alle aktiven Produkte mit ihren stabilen IDs.
### `erp_lager.get_product`
Liest alle Lagerdaten eines aktiven Produkts. Eingabe: `{ "product_id": 12 }`.
Die Antwort enthält die GUI-parallele Produktübersicht mit Produktstammdaten, aktueller und verfügbarer Lagermenge, Menge im Wareneingang, aktueller Charge, Ausverkaufsdatum und Forecast-Status, nächster aktiver offener Charge, weiteren offenen zukünftigen Chargen, historischen geschlossenen Chargen sowie virtueller Fehlbestandsmenge und virtueller Fehlbestandscharge.
Das Interface ist ausschließlich lesend. Es gibt keine Lager-MCP-Tools zum Anlegen, Ändern, Buchen, Verschieben oder Löschen.
## JSON-RPC
Das Interface unterstützt `initialize`, `notifications/initialized`, `tools/list` und `tools/call` gemäß dem bestehenden Marktmanagement-MCP-Transport.
Bei `tools/list` ist `inputSchema.properties` für jedes Werkzeug immer ein JSON-Objekt; beim parameterlosen `erp_lager.list_products` ist es `{}`.
@@ -22,7 +22,7 @@ Direkt ausgeführter Lager-Prozess mit zwei Triggern: synchron nach jeder erfolg
3. Verkaufsallokationen der letzten 365 Tage aus `sales_order_line_lot_allocation`, `sales_order_line` und `sales_order` laden.
4. `allocated`- und `reserved`-Mengen nach Bestell- beziehungsweise OTC-Verkaufsdatum je Produkt und Kalendertag aggregieren.
5. Stornierte Auftrags- und Positionsmengen aus der Nachfrage herausrechnen.
6. Den gewichteten Tagesabsatz mit einer Halbwertszeit von 60 Tagen berechnen.
6. Den gewichteten Tagesabsatz ab dem ersten gültigen Verkaufstag innerhalb des geladenen Zeitraums mit einer Halbwertszeit von 60 Tagen berechnen; Tage vor dem ersten Verkauf werden nicht als Nullabsatz berücksichtigt.
7. Das Leer-Datum der aktuellen Charge aus ihrem physischen Bestand und der gewichteten Nachfrage berechnen. Das Leer-Datum der nächsten Charge wird mit derselben Nachfrage ab dem Leer-Datum der aktuellen Charge weitergerechnet; bei bereits leerer aktueller Charge beginnt es am aktuellen Datum.
8. Jede erfolgreiche Berechnung je Charge in `stock_lot_forecast_history` speichern und den jeweiligen Snapshot in `stock_lot` aktualisieren.
9. Im Cron Fehler einzelner Produkte sammeln und mit den übrigen Produkten fortfahren.
+67
View File
@@ -0,0 +1,67 @@
# Market Management
Stand: 2026-09-15
Status: Fachliches Zielbild / Anforderungen in Ausarbeitung
## Zweck
Das Modul verwaltet Märkte von der Recherche und Prüfung über Anfrage, Anmeldung
und Durchführung bis zur Kosten-, Zahlungs- und Erlösauswertung.
## Fachliche Ownership
Das Modul soll die fachliche Ownership für Marktstammdaten, Markttermine,
Teilnahme- und Durchführungsstatus, marktbezogene Kosten und Zahlungen sowie
Erlös- und Tagesauswertungen tragen.
## Zugriffsmodell
Die Nutzung des Moduls ist an eine Benutzeridentität gebunden. Das Modul soll
separat von der übrigen Anwendung nutzbar und an weitere Personen verkaufbar
sein.
Die Ownership der Lizenz-/Entitlement-Daten ist noch nicht entschieden und
bleibt offen.
## MCP-Schnittstelle zu Hermes
Die App stellt die MCP-Server-Schnittstelle bereit. Hermes Agent ist MCP-Client
und ruft ausschließlich freigegebene Funktionen des Marktmanagement-Moduls
auf.
Die App behält die Kontrolle über:
- Authentifizierung und Berechtigungsprüfung
- Validierung und fachliche Interpretation der Eingaben
- erlaubte Schreiboperationen
- Persistierung in der eigenen Datenbank
- Audit- und Quelleninformationen
Hermes erhält keinen direkten Zugriff auf die Datenbank. Das Zielbild und die
Schnittstellenentscheidungen sind im
[market_management_hermes_mcp_konzept.md](konzepte/market_management_hermes_mcp_konzept.md)
dokumentiert.
## Abgrenzung
- Zunächst keine eigene Benutzerverwaltung.
- Keine eigene technische Runtime- oder Scheduler-Logik.
- Fachlich neutrale UI- und technische Querschnittsbausteine gehören nach
`shared`.
## Dokumente
- [Anforderungsprofil](requirements/market_management_requirements.md)
- [Datenarchitektur](data/market_management_data_architecture.md)
- [Technisches Tabellen- und Prozesskonzept](konzepte/market_management_technisches_konzept.md)
- [Hermes-MCP-Konzept](konzepte/market_management_hermes_mcp_konzept.md)
- [DB-Ownership-Konzept](konzepte/market_management_db_ownership.md)
- Prozessdokumente werden unter `processes/main/` und
`processes/sub_processes/` angelegt, sobald die fachlichen Prozesse explizit
definiert sind.
## Offene Architekturentscheidung
Die Ownership und konkrete Ablage der Lizenz-/Entitlement-Daten ist offen.
Diese Entscheidung darf bei der weiteren Umsetzung nicht stillschweigend
vorweggenommen werden.
@@ -0,0 +1,106 @@
# market_management_data_architecture
Stand: 2026-09-18
Status: Bestätigtes Datenarchitektur-Zielbild
## 1. Ziel
Das Marktmanagement-Modul muss wiederkehrende Märkte mit mehreren konkreten
Durchführungen und mehreren Tagen je Durchführung ohne redundante Vermischung
der Daten abbilden können.
## 2. Bestätigte Hierarchie
```text
Kategorie 1 ─── n Marktdefinition 1 ─── n Durchführung 1 ─── n Durchführungstag
```
Eine Kategorie enthält beliebig viele Marktdefinitionen. Jede Marktdefinition
gehört genau einer Kategorie.
### Kategorie
Eine Kategorie ordnet Märkte fachlich. Fabienne darf die deterministische
Kategorie-/Marktartliste erweitern. Hermes darf bestehende Kategorien
verwenden, aber keine Kategorien anlegen.
### Marktdefinition
Eine Marktdefinition ist die übergeordnete fachliche Identität, unter der
mehrere konkrete Durchführungen geführt werden können. Der zentrale Marktstamm
enthält nur den eindeutigen Marktnamen und die Marktart/Kategorie.
### Durchführung
Eine Durchführung ist eine konkrete Ausgabe bzw. Teilnahme an einem Markt. Die
Durchführung kann einen oder mehrere Durchführungstage umfassen.
### Durchführungstag
Ein Durchführungstag ist ein einzelner Tag innerhalb einer konkreten
Durchführung. Tagesbezogene Zeit-, Erlös- und Verkaufsdaten gehören auf diese
Ebene.
## 3. Feld- und Referenzwert-Zuordnung
### Zentraler Marktstamm
- Marktname
- Marktart/Kategorie aus einer deterministischen Liste
- optionale Website-URL der Veranstaltung
### Durchführung
Alle übrigen veränderlichen Marktangaben werden pro Durchführung erfasst,
insbesondere Kontakt, Ort, Termine, Anmeldung, Status, Stand, Infrastruktur,
Logistik, Kosten, Zahlung, Vertragslink und Notizen.
### Durchführungstag
- Datum des Durchführungstags
- Standzeit und tagesbezogene Durchführungszeiten
- Bruttoerlös aus zugeordneten OTC-Verkäufen
- Anzahl verkaufter Artikel aus zugeordneten OTC-Positionen
- Ereignisse und Beobachtungen
### Deterministische Referenzwerte
- Marktart/Kategorie
- Marktstatus
- Zahlungsstatus
- Zahlungsart
- Kanton
- standardisierte Ja/Nein-Merkmale für Infrastrukturangaben
### Abgeleitete Werte
- Wochentag aus dem Datum
- Saison aus dem Durchführungsdatum
- Gesamtkosten aus dem erfassten Bruttobetrag
- offener Betrag aus Kosten und bezahltem Betrag
- Gesamterlös, Durchschnittswerte und Gesamtzahl verkaufter Artikel aus den
Durchführungstagen
## 4. Ownership- und Normalisierungsregeln
- Jede Ebene erhält eine eigene stabile Identität.
- Mehrere Durchführungstage werden nicht als wiederholte Spalten in der
Durchführung gespeichert.
- Mehrere Durchführungen werden nicht als wiederholte Spalten im Markt
gespeichert.
- Tageserlöse und Tagesverkaufszahlen werden nur für den Durchführungstag aus
zugeordneten OTC-Verkäufen abgeleitet.
- Aggregierte Werte für Durchführung oder Markt werden aus den untergeordneten
Durchführungstagen abgeleitet oder als klar definierte Auswertung bereitgestellt.
- MCP-Schreiboperationen müssen eindeutig an die betroffene Ebene gebunden sein.
- Fremde Modul- oder DB-Ownership wird durch diese Datenarchitektur nicht
eingeführt.
## 5. Bestätigte Ergänzungen
- Eine Durchführung ist eine konkrete Teilnahme bzw. Veranstaltungsausgabe.
- Jeder Durchführungstag ist eine eigene Subdurchführung mit eigenen OTC-
Verkäufen und abgeleiteten Tageswerten.
- Markt und Durchführung werden unabhängig voneinander archiviert und
reaktiviert.
- Der Zahlungsstatus ist manuell und besitzt nur `paid` oder `unpaid`.
@@ -0,0 +1,65 @@
# Market Management: DB-Ownership-Konzept
Stand: 2026-09-19
Status: Verbindliches DB-Ownership-Konzept
## 1. Owned Tabellen des Moduls
| Tabelle | Ownership | Schreibverantwortung |
|---|---|---|
| `market_category` | `market_management` | Marktmanagement-Service |
| `market_definition` | `market_management` | Marktmanagement-Service |
| `market_occurrence` | `market_management` | Marktmanagement-Service |
| `market_occurrence_organizer` | `market_management` | Marktmanagement-Service über Kontakt-Schnittstelle |
| `market_occurrence_day` | `market_management` | Marktmanagement-Service |
| `market_occurrence_cost_item` | `market_management` | Marktmanagement-Service |
| `market_occurrence_checklist` | `market_management` | Submodul `occurrence_checklist` |
| `market_occurrence_checklist_item` | `market_management` | Submodul `occurrence_checklist` |
| `market_municipality_reference` | `market_management` | Referenzdatenprozess |
| `market_municipality` | `market_management` | Marktmanagement-Service |
| `market_mcp_idempotency` | `market_management` | MCP-Submodul |
## 2. Fremde Datenreferenzen
### Kontaktmodul
Das Marktmanagement-Modul speichert nur die stabilen IDs von Organisation und
Kontaktperson. Es schreibt keine Kontakt-, Organisations- oder Adressdaten in
fremde Tabellen.
### OTC-/Direktverkaufsmodul
Das OTC-Modul bleibt Owner seiner Verkaufstabellen. Jeder neue OTC-Verkauf
referenziert einen gültigen `market_occurrence_day_id`. Marktmanagement liest
die Verkaufsdaten ausschließlich über die definierte Schnittstelle für die
Umsatz- und Artikelmengenaggregation.
## 3. Schreibgrenzen
- Direkte Cross-Modul-Writes sind verboten.
- Marktmanagement schreibt nicht in OTC-, Kontakt- oder Organisations-Tabellen.
- Das OTC-Modul schreibt nicht in Marktmanagement-Tabellen.
- MCP schreibt nie direkt in Tabellen.
- Alle Marktmanagement-Writes laufen über den owning Modul-Service.
## 4. Referenzdaten
Der Gemeindereferenzbestand verwendet die BFS-Gemeindenummer als stabile
fachliche Referenz und speichert den Datenstand zur Nachvollziehbarkeit.
Das Marktmanagement besitzt zusätzlich das eigene Gemeinderegister
`market_municipality`. Dieses Register enthält die offiziellen Gemeinden sowie
lokal neu angelegte Gemeinden und ist die einzige Auswahl- und Schreibbasis für
Gemeindezuordnungen von Marktdurchführungen.
Die Kategorie-/Marktartliste gehört zum Marktmanagement und wird durch Fabienne
gepflegt. Hermes darf sie nur lesen.
## 5. Technische Änderungsregeln
- Tabellen- und Constraint-Änderungen erfolgen ausschließlich über versionierte
Migrationen.
- Fachliche Constraints werden im DB- und Prozessvertrag gespiegelt.
- Aggregierte Umsatzwerte werden nicht als unabhängige Fachwahrheit gespeichert.
- Archivierte Datensätze bleiben erhalten.
- Markt und Durchführung besitzen getrennte Archivstatus.
@@ -0,0 +1,341 @@
# Hermes: Market-Management-MCP-API
Stand: 2026-09-27
Status: Aktive technische Schnittstellendokumentation
Die Marktdurchführungs-Schnittstelle enthält kein Feld `travel_duration_minutes`.
Die Reisedauer wird nicht gespeichert, gelesen, geändert oder ausgegeben.
## Verbindung
Hermes verwendet den MCP-Endpunkt der DEV-App:
```text
https://erpnaurua.imhochrain.ch/api/market-management-mcp.php
```
Jeder Request ist ein JSON-RPC-2.0-Request über HTTPS. Der Request benötigt:
```http
Authorization: Bearer <MARKET_MCP_TOKEN>
Content-Type: application/json
```
Der Token wird nicht in dieser Dokumentation oder im Repository gespeichert.
## Verbindungsablauf
## Dokumentlinks in Marktdurchführungen
Wenn Hermes Informationen aus einer Datei verarbeitet und diese in einer
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.
Der Link verwendet dieses Format:
```text
<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
den lokalen Mount-Prefix und nicht den Ordnernamen `Hermes Naurua` selbst.
Beispiel:
```text
Quelldatei: Hermes Naurua/Marktmanagement/2026 Bellevue Noël/Standmietvertrag_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 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
die auf DEV verifizierte Ablage der Synology-Freigabe.
## Fachliche Begrifflichkeit für Hermes
Die fachliche Hierarchie lautet:
```text
Markt (market_definition)
└── Marktdurchführung (market_occurrence)
└── Markttag / Markttage (market_occurrence_day)
```
Ein Markt ist die zentrale Marktdefinition. Eine Marktdurchführung ist eine
konkrete Ausgabe bzw. Teilnahme an diesem Markt. Eine Marktdurchführung besitzt
mindestens einen Markttag. OTC-Verkäufe werden immer einem konkreten Markttag
zugeordnet; die zugehörige Marktdurchführung und der Markt ergeben sich aus
dieser Referenz.
### 1. Initialisieren
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-03-26"
}
}
```
### 2. Werkzeuge laden
```json
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}
```
### 3. Werkzeug aufrufen
```json
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "market_management.list_categories",
"arguments": {}
}
}
```
Das Ergebnis steht bei `tools/call` als JSON im Textinhalt des ersten
`content`-Elements.
## Verfügbare Werkzeuge
### Marktdefinitionen
- `market_management.search_markets`
- optional: `search`, `category_id`, `status`, `place`,
`municipality_reference_id`, `date_from`, `date_to`, `include_archived`,
`sort`, `sort_direction`, `limit`, `offset`
- `market_management.search_occurrences`
- sucht Marktdurchführungen
- optional: `search`, `market_definition_id`, `status`, `place`, `address`,
`municipality_reference_id`, `date_from`, `date_to`, `include_archived`,
`sort`, `sort_direction`, `limit`, `offset`
- `market_management.get_market`
- erforderlich: `market_id`
- optional: `include_archived`
- liefert Marktdurchführungen inklusive Markttagen, Kosten und berechneten Verkaufswerten
- `market_management.create_market`
- erforderlich: `name`, `category_id`, `organizer_id`
- optional: `website_url` als HTTP(S)-URL des Marktes; Eingaben ohne Schema werden als `https://` normalisiert
- `market_management.list_market_organizers`
- liefert aktive Veranstalterorganisationen
- `market_management.create_market_organizer`
- erforderlich: `name`
- optional: `email`, `phone`
- `market_management.update_market`
- erforderlich: `market_id`
- änderbar: `name`, `category_id`, `website_url`
- `market_management.archive_market`
- erforderlich: `market_id`
- `market_management.reactivate_market`
- erforderlich: `market_id`
### Kategorien
- `market_management.list_categories`
Hermes darf Kategorien lesen und bei einem Markt referenzieren, aber keine
Kategorien anlegen oder ändern.
- `market_management.list_reference_data`
- liefert Kategorien, Kantone und aktive Gemeinden aus dem Markt-Gemeinderegister
- `market_management.create_municipality`
- erforderlich: `name`
- legt eine lokale Gemeinde im Markt-Gemeinderegister an
- `market_management.get_overview`
- liefert serverseitig gefilterte Markt- und Marktdurchführungslisten
### Marktdurchführungen
- `market_management.create_occurrence`
- erforderlich: `market_definition_id`, `days` mit mindestens einem Markttag
- initialer Status: `open`
- weitere Marktdurchführungsfelder sind optional und werden typisiert validiert
- legt automatisch eine Standard-Checkliste mit den vier festen Kapiteln an
- `market_management.get_occurrence`
- erforderlich: `occurrence_id`
- liefert zusätzlich den Markt, Markttage, Kosten und
berechnete Tages- und Gesamterlöse sowie `sales_items` je Markttag
- `market_management.get_occurrence_checklist`
- erforderlich: `occurrence_id`
- liefert die vier festen Kapitel, die Punkte mit `chapter_key` und den
Checklistenkommentar
- `market_management.update_occurrence_checklist`
- erforderlich: `occurrence_id`, `items`
- ersetzt die vollständige Checkliste atomar
- `items` enthält `chapter_key`, `label` und `is_completed`; `comment` ist optional und nullable
- feste Kapitel: `products_sales` (Produkte & Verkauf), `decoration` (Dekoration),
`stand_equipment` (Stand & Ausstattung), `technology` (Technik)
- Kapitel sind nicht editierbar; Hermes darf Punkte ergänzen, entfernen,
umbenennen, abhaken und zurücksetzen
- `market_management.update_occurrence`
- erforderlich: `occurrence_id`
- änderbar: sämtliche editierbaren GUI-Marktdurchführungsfelder einschließlich
Gemeinde, Kontaktperson, Kontakt-E-Mail, Kontakt-Telefon und `registration_note`
- der vom Markt geerbte Veranstalter ist nicht editierbar
Das optionale Feld `registration_note` speichert den Freitext-Hinweis zur
Anmeldung einer Marktdurchführung. Es wird bei `create_occurrence`,
`get_occurrence` und `update_occurrence` als nullable String verarbeitet.
- `market_management.archive_occurrence`
- erforderlich: `occurrence_id`
- `market_management.reactivate_occurrence`
- erforderlich: `occurrence_id`
Unterstützte Statuswerte:
```text
open
waitlist
registered
confirmation_pending
confirmed
cancelled
completed
```
Unterstützte Zahlungsstatuswerte:
```text
unpaid
paid
```
### Markttage
- `market_management.create_day`
- legt einen Markttag an
- erforderlich: `occurrence_id`, `date`
- `market_management.update_day`
- erforderlich: `day_id`
- `market_management.delete_day`
- erforderlich: `day_id`
Weitere mögliche Tagesfelder sind `stand_start_time`, `stand_end_time`,
`setup_start_time`, `setup_end_time`, `delivery_start_time`,
`delivery_end_time` und `observations`.
### Veranstalter und Kosten
- Veranstalter werden am Markt über `organizer_id` gesetzt und von den
Durchführungen geerbt. Marktdurchführungen können nur `contact_person_name`,
`contact_email` und `contact_phone` ändern.
- Kosten werden bei `market_management.create_occurrence` automatisch mit einer
`Standkosten`-Position (`stand_fee`) und dem Standardsteuersatz 8.1 % angelegt.
- `market_management.update_occurrence` ändert Kosten über `gross_cost`,
`tax_status`, `tax_rate` sowie die optionale mehrzeilige Freitext-Aufschlüsselung
`cost_items_text`.
- `cost_items_text` wird beim Schreiben als Beschreibung der automatisch
verwalteten `stand_fee`-Kostenposition gespeichert. In den MCP-Leseantworten
wird derselbe Wert zusätzlich als `cost_items_text` auf der Durchführung
ausgegeben; damit entspricht er direkt dem GUI-Feld „Kostenpositionen“.
- `tax_status` ist `taxable` oder `tax_free`; bei `tax_free` ist der Steuersatz
`0.00`.
- `gross_cost` darf `0.00` sein; einzelne Kostenpositionen und Kostenarten werden
nicht separat über MCP verwaltet.
## Beispiel: Markt anlegen
```json
{
"jsonrpc": "2.0",
"id": 10,
"method": "tools/call",
"params": {
"name": "market_management.create_market",
"arguments": {
"name": "Beispielmarkt Hermes Test",
"category_id": 1,
"idempotency_key": "hermes-create-market-001"
}
}
}
```
## Beispiel: Marktdurchführung mit Markttag anlegen
```json
{
"jsonrpc": "2.0",
"id": 11,
"method": "tools/call",
"params": {
"name": "market_management.create_occurrence",
"arguments": {
"market_definition_id": 1,
"status": "open",
"place": "Beispielort",
"registration_deadline": "2026-10-01",
"idempotency_key": "hermes-create-occurrence-001",
"days": [
{
"date": "2026-11-21",
"stand_start_time": "09:00",
"stand_end_time": "18:00"
}
]
}
}
}
```
```json
{
"jsonrpc": "2.0",
"id": 12,
"method": "tools/call",
"params": {
"name": "market_management.create_day",
"arguments": {
"occurrence_id": 1,
"date": "2026-11-21",
"stand_start_time": "09:00",
"stand_end_time": "18:00"
}
}
}
```
## Fachliche Grenzen
- Hermes erhält keinen direkten Datenbankzugriff.
- Hermes darf keine Kategorien anlegen.
- Umsatz und Artikelmengen werden nicht über MCP geschrieben; sie werden aus
den OTC-Verkäufen abgeleitet.
- `get_occurrence` liefert `sales_items` mit `day_id`, `date`, `product_id`,
`sku`, `product_name`, `quantity`, `gross_revenue` und `unit_price`.
- Markt und Marktdurchführung werden unabhängig archiviert.
- Alle Schreibvorgänge werden serverseitig validiert.
- Jeder schreibende MCP-Aufruf benötigt `idempotency_key`; das Feld ist in den
Schemas aller Schreibwerkzeuge als Pflichtfeld deklariert. Optional kann
`client_id` mitgegeben werden. Wiederholungen liefern dasselbe Ergebnis.
- Die gelieferten Verkaufspositionen stammen ausschließlich aus OTC-Verkäufen,
die einem konkreten Markttag zugeordnet sind.
## Fehler
Bei fehlender oder ungültiger Authentifizierung antwortet der Endpunkt mit
HTTP 401. Ungültige JSON-RPC-Requests oder fachlich ungültige Eingaben werden
als JSON-RPC-Fehlerantwort zurückgegeben.
@@ -0,0 +1,102 @@
# Market Management: Hermes-MCP-Konzept
Stand: 2026-09-19
Status: Verbindliches Schnittstellenkonzept
## 1. Zweck
Die App stellt einen geschützten MCP-Server bereit. Hermes Agent ist MCP-Client
und arbeitet im Benutzerkontext. Hermes kann Marktmanagement-Daten suchen,
anlegen, ändern, archivieren und reaktivieren.
## 2. Verantwortungen
Die App kontrolliert:
- Authentifizierung
- Benutzer- und Modulberechtigung
- Eingabevalidierung
- fachliche Feldzuordnung
- Persistierung
- Audit- und Herkunftsdaten
- Idempotenz
Hermes besitzt keinen direkten Zugriff auf Datenbank, interne Services oder
fremde Module.
## 3. Schnittstellenmodell
Der MCP-Server veröffentlicht fachliche Tools, keine generischen Datenbank-
oder Tabellenoperationen.
Vorgesehene Toolgruppen:
- Märkte suchen und lesen
- Märkte anlegen, ändern, archivieren und reaktivieren
- Marktdurchführungen suchen und lesen
- Marktdurchführungen anlegen, ändern, archivieren und reaktivieren
- Markttage anlegen, ändern und entfernen
- Checklisten einer Marktdurchführung lesen und vollständig ändern
- Kategorien, Kantone und Gemeinden lesen
- Kosten und Zahlungsstatus ändern
- Übersichtsdaten lesen
Hermes darf bestehende Kategorien verwenden, aber keine Kategorien anlegen.
Umsatz- und Artikelmengen werden nicht über Hermes geschrieben, sondern aus den
OTC-Verkäufen abgeleitet.
## 4. Authentifizierung und Autorisierung
- MCP-Endpunkt über geschütztes HTTPS
- Bearer-Token-Prüfung an der Transportgrenze
- der konfigurierte Token identifiziert den technischen Hermes-Benutzerkontext
- zusätzliche Prüfung im jeweiligen Tool-Handler
- kein direkter Datenbankzugriff für Hermes
Für die erste Implementierungsstufe wird bewusst keine neue OAuth-/OIDC- oder
Entitlement-Infrastruktur eingeführt. Der Token wird ausschließlich über die
geschützte App-Konfiguration bereitgestellt und mit konstanter Zeit verglichen.
Die Implementierung verwendet dafür den Konfigurationswert
`MARKET_MCP_TOKEN`.
Die fachliche Ownership der Lizenz-/Entitlement-Daten bleibt offen. Die
Schnittstelle prüft nur die über den bestehenden Zugriffskontext gelieferte
Modulberechtigung.
## 5. Schreibregeln
- Jeder Tool-Aufruf ist atomar.
- Fehlgeschlagene Aufrufe erzeugen keine Teilpersistierung.
- Alle Schreibvorgänge laufen über die interne Modul-API.
- Checklisten-Schreibvorgänge ersetzen die vollständige Checkliste atomar. Hermes darf Punkte ergänzen, entfernen, umbenennen, abhaken und zurücksetzen sowie den Checklistenkommentar ändern.
- 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 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.
## 6. Wiederholungsschutz
Schreibende Aufrufe können eine Idempotenzreferenz übergeben. Die App speichert
Client, Benutzer, Tool, Schlüssel, Request-Hash und Verarbeitungsergebnis in
`market_mcp_idempotency`.
Ein wiederholter Aufruf mit gleichem Schlüssel und gleichem Request liefert das
bereits bekannte Ergebnis. Ein gleicher Schlüssel mit verändertem Request wird
abgewiesen.
## 7. Daten- und Modulgrenzen
- Marktmanagement schreibt nur in eigene Tabellen.
- Das OTC-Modul schreibt die Verkaufstabelle und referenziert den
`market_occurrence_day_id`.
- Kontakt- und Organisationsdaten werden über das Kontaktmodul referenziert.
- Gemeindedaten werden über den eigenen Referenzbestand gelesen.
- MCP-Komposition enthält keine Marktfachlogik.
@@ -0,0 +1,414 @@
# Market Management: Normatives Tabellen- und Prozesskonzept
Stand: 2026-09-19
Status: Verbindliches Konzept für das Modul `market_management`
## 1. Übersicht
Tabellen:
- `market_category`
- `market_definition`
- `market_occurrence`
- `market_occurrence_organizer`
- `market_occurrence_day`
- `market_occurrence_cost_item`
- `market_occurrence_checklist`
- `market_occurrence_checklist_item`
- `market_municipality_reference`
- `market_municipality`
- `market_mcp_idempotency`
Bestehende Integrationsreferenz:
- `sales_order.market_occurrence_day_id` im owning OTC-/Direktverkaufsmodul
Prozessbereiche:
- zentrale Marktdefinition verwalten
- Durchführung verwalten
- Durchführungstage verwalten
- Kosten und Zahlungsstatus verwalten
- OTC-Verkäufe einem Durchführungstag zuordnen
- Umsatz- und Artikelmengen lesen und aggregieren
- Marktübersicht lesen
- Hermes-MCP-Zugriff ausführen
- Marktdurchführungs-Checkliste verwalten
- Referenzdaten verwalten
Die Checkliste gehört zur Marktdurchführung, nicht zum einzelnen Markttag.
Sie besitzt die vier festen Kapitel `Produkte & Verkauf`, `Dekoration`,
`Stand & Ausstattung` und `Technik`. Kapitel und Kapitelreihenfolge sind
fest; Punkte innerhalb eines Kapitels bleiben für Benutzer und Hermes
editierbar. Der Kommentar gehört zur gesamten Checkliste.
## 2. Zweck und Scope
Dieses Dokument beschreibt das normative technische Zielbild für die Umsetzung
bis einschließlich Punkt 10 des Anforderungsprofils.
Enthalten sind:
- zentrale Marktdefinition mit eindeutigem Namen und genau einer Kategorie
- beliebig viele Durchführungen je Markt
- mindestens ein Durchführungstag je Durchführung
- veranstaltungs-, tages-, kosten- und zahlungsbezogene Daten
- OTC-basierte Umsatz- und Artikelmengenermittlung
- Archivierung von Markt und Durchführung auf getrennten Ebenen
- serverseitige Übersichtsdaten
- Benutzer- und Modulberechtigung
- Hermes als MCP-Client der App
Nicht enthalten sind Punkt 11 und folgende, insbesondere intelligente
Auswertungen, Korrelationen und wirtschaftliche Gemeindedaten.
GUI-Details, konkrete HTTP-Komposition und technische Hermes-Implementierung
werden in den zugehörigen Detaildokumenten beschrieben.
## 3. Normatives Datenmodell
```text
Kategorie / Marktart
└── Marktdefinition
└── Durchführung
└── Durchführungstag / Subdurchführung
└── OTC-Verkäufe
```
### 3.1 `market_category`
Deterministische Kategorie-/Marktartliste.
Normativ:
- Kategorienamen sind eindeutig.
- Fabienne darf die Liste erweitern.
- Hermes darf bestehende Kategorien auswählen, aber keine Kategorien anlegen.
- Deaktivierte Kategorien bleiben für historische Zuordnungen erhalten.
### 3.2 `market_definition`
Zentrale fachliche Marktdefinition.
Normativ:
- jeder Markt besitzt genau einen eindeutigen Namen
- jeder Markt besitzt genau eine Kategorie
- jeder Markt besitzt genau einen verpflichtenden Veranstalter aus dem eigenen Markt-Veranstalterregister
- jeder Markt kann eine optionale Website-URL der Veranstaltung besitzen
- der Markt enthält keine Durchführungs-, Kontakt-, Kosten-, Zahlungs- oder
Erlösdaten
- Markt und Durchführungen werden getrennt archiviert und reaktiviert
### 3.3 `market_occurrence`
Konkrete Teilnahme bzw. Veranstaltungsausgabe eines Marktes.
Normativ:
- jede Durchführung gehört genau einer Marktdefinition
- jede Durchführung besitzt mindestens einen Durchführungstag
- variable Veranstaltungs-, Kontakt-, Orts-, Logistik-, Kosten- und
Zahlungsdaten gehören zur Durchführung
- der Status gehört zur Durchführung
- Zahlungsstatus und Marktstatus sind unabhängig
- der optionale `registration_note` gehört als Freitext zur Durchführung
- der Veranstalter wird von jeder Durchführung des Marktes geerbt und ist dort nicht editierbar
- der Zahlungsstatus ist manuell `paid` oder `unpaid`
Minimale Pflichtfelder über alle Statuswerte hinweg:
- `market_definition_id`
- initialer Status `open`
- mindestens ein Durchführungstag
### 3.4 `market_occurrence_organizer`
Veranstalterbeziehung einer Durchführung.
Normativ:
- die Tabelle enthält die durchführungsbezogene Kontaktperson als Freitext sowie optionale E-Mail und Telefonnummer
- jede Durchführung besitzt genau einen lokalen Kontaktdatensatz
- der Veranstalter selbst wird ausschließlich über `market_definition.organizer_id` vom Markt geerbt
- Veranstalter werden ausschließlich im Markt-Veranstalterregister verwaltet und nicht aus Lieferanten oder Kunden abgeleitet
### 3.5 `market_occurrence_day`
Einzelner Durchführungstag bzw. Subdurchführung.
Normativ:
- jeder Tag gehört genau einer Durchführung
- das Datum ist je Durchführung eindeutig
- Stand-, Aufbau-, Abbau- und tagesbezogene Anlieferungszeiten gehören hierher
- Ereignisse und Beobachtungen gehören hierher
- OTC-Verkäufe werden eindeutig diesem Datensatz zugeordnet
- Bruttoerlös und Artikelmenge werden aus den zugeordneten OTC-Verkäufen
abgeleitet und nicht manuell gespeichert
Minimale Pflichtfelder:
- `occurrence_id`
- `date`
### Mindestpflichtfelder
Über alle Statuswerte hinweg gelten nur diese Pflichtfelder:
- Marktdefinition: `name`, `category_id`; `website_url` ist optional
- Durchführung: `market_definition_id`, initialer Status `open`, mindestens ein
Durchführungstag
- Durchführungstag: `occurrence_id`, `date`
Alle weiteren Felder bleiben bei der Anlage optional und werden unabhängig vom
Status ergänzt.
### 3.6 Kosten einer Durchführung
Die Durchführung besitzt einen einfachen Gesamtkostenbetrag und eine optionale
mehrzeilige Freitext-Aufschlüsselung der Kostenpositionen.
Normativ:
- jede Durchführung besitzt genau eine technische `Standkosten`-Position
(`stand_fee`); deren Betrag darf `0.00` sein und der Standardsteuersatz beträgt
8.1 %
- weitere Einzelpositionen und Kostenarten werden nicht separat gepflegt
- CHF ist die einzige Währung
- Bruttobetrag wird erfasst
- Netto- und Steuerbetrag werden aus Brutto und Steuerart/-satz berechnet
- die Freitext-Aufschlüsselung wird als Beschreibung gespeichert
### 3.7 `market_municipality_reference`
Technischer Referenzbestand des offiziellen Schweizer Gemeindeverzeichnisses.
Normativ:
- Gemeinden werden über BFS-Gemeindenummern referenziert
- Gemeindename und Kantonszuordnung stammen aus dem Referenzbestand
- nur gültige Gemeinden stehen für neue Eingaben zur Auswahl
### 3.8 `market_municipality`
Eigenes Gemeinderegister des Marktmanagements. Es enthält sowohl aktive
Gemeinden aus dem offiziellen Referenzbestand als auch lokal neu angelegte
Gemeinden.
Normativ:
- jede aktive Gemeinde besitzt einen eindeutigen Namen
- Referenzgemeinden behalten ihre BFS-Gemeindenummer und den Datenstand
- lokal angelegte Gemeinden besitzen keine BFS-Referenz
- neue Gemeinden werden über das Marktmanagement angelegt und danach in der
Auswahl für weitere Marktdurchführungen angeboten
- eine Marktdurchführung referenziert genau einen Datensatz dieses Registers
- der Datenstand wird versioniert gespeichert
### 3.9 `market_mcp_idempotency`
Technischer Idempotenzbestand für wiederholte Hermes-Schreibaufrufe.
Normativ:
- derselbe Client und Idempotenzschlüssel erzeugen keine doppelte Wirkung
- ein abweichender Request mit demselben Schlüssel wird abgewiesen
- der gespeicherte Verarbeitungsausgang bleibt nachvollziehbar
### 3.9 OTC-Integration
Das OTC-Modul bleibt Owner seiner Verkaufstabellen. Jeder neue OTC-Verkauf
referenziert eindeutig einen `market_occurrence_day_id`. Markt, Durchführung,
Datum, Umsatz und Artikelmengen werden daraus ermittelt.
Historische OTC-Verkäufe ohne diese Zuordnung werden nicht migriert.
## 4. Normatives Prozessmodell
Alle fachlichen Prozesse gehören zum owning Modul `market_management`, außer der
OTC-Verkaufsprozess selbst. Die OTC-Integration erfolgt ausschließlich über die
öffentliche Marktmanagement-Schnittstelle.
### 4.1 Zentrale Marktdefinition verwalten
Fachliche Aufgabe:
- Marktdefinition mit eindeutigem Namen und Kategorie anlegen, ändern,
archivieren oder reaktivieren
Liest:
- Kategorien
- bestehende Marktdefinitionen
Schreibt:
- `market_category`, sofern Fabienne eine Kategorie erweitert
- `market_definition`
### 4.2 Durchführung verwalten
Fachliche Aufgabe:
- eine konkrete Durchführung eines Marktes anlegen, bearbeiten, archivieren
oder reaktivieren
Liest:
- Marktdefinition
- Kontaktmodul
- Gemeindereferenz
Schreibt:
- `market_occurrence`
- `market_occurrence_organizer`
### 4.3 Durchführungstage verwalten
Fachliche Aufgabe:
- Durchführungstage anlegen, bearbeiten und entfernen
Liest:
- Durchführung
Schreibt:
- `market_occurrence_day`
### 4.4 Kosten und Zahlungsstatus verwalten
Fachliche Aufgabe:
- Gesamtkosten, Kostenaufschlüsselung und manuelle Zahlungsinformationen pflegen
Liest:
- Durchführung
- bestehende Kosteninformation
Schreibt:
- technische Standkosten-Position in `market_occurrence_cost_item`
- Zahlungsfelder in `market_occurrence`
### 4.5 OTC-Verkauf einem Durchführungstag zuordnen
Fachliche Aufgabe:
- beim OTC-Verkauf einen gültigen Durchführungstag auswählen und referenzieren
Liest:
- verfügbare Märkte, Durchführungen und Durchführungstage über die
Marktmanagement-Schnittstelle
Schreibt:
- ausschließlich das OTC-Modul in seine eigene Verkaufstabelle
### 4.6 Umsatz- und Artikelmengen lesen
Fachliche Aufgabe:
- Umsatz und Artikelmengen für Tag, Durchführung und Markt aus den OTC-Daten
aggregieren
Liest:
- zugeordnete OTC-Verkäufe und Verkaufspositionen
- Markt-, Durchführungs- und Tagesdaten
Schreibt:
- keine fachlichen Umsatzsummen; die Werte sind abgeleitet
### 4.7 Marktübersicht lesen
Fachliche Aufgabe:
- Märkte, Durchführungen und Tage serverseitig suchen, filtern, sortieren und
aggregiert ausgeben
Liest:
- alle owned Marktmanagement-Daten
- Kontakt- und Gemeindereferenzen
- OTC-Aggregate
Schreibt:
- nichts
### 4.8 Hermes-MCP-Zugriff
Fachliche Aufgabe:
- Hermes über die kontrollierte MCP-Schnittstelle suchen, anlegen, ändern,
archivieren und reaktivieren lassen
Liest:
- Marktmanagement-Daten und erlaubte Referenzdaten
Schreibt:
- ausschließlich über die internen Marktmanagement-Services
- `market_mcp_idempotency` für Wiederholungsschutz
Die konkrete MCP-Schnittstelle ist in
[market_management_hermes_mcp_konzept.md](market_management_hermes_mcp_konzept.md)
dokumentiert.
## 5. Normative Betriebsregeln
- Marktname und Kategorie sind zentrale Stammdaten.
- Alle variablen Veranstaltungsdaten werden je Durchführung gepflegt.
- Tageswerte werden je Durchführungstag gepflegt.
- Wochentag, Saison, Datumsbereich, Gesamtkosten, offener Betrag, Umsatz und
Artikelmengen werden abgeleitet.
- Umsatz und Artikelmengen stammen ausschließlich aus OTC-Verkäufen.
- Stornos und Rückerstattungen werden in den Aggregaten abgezogen.
- Markt- und Durchführungarchivierung sind unabhängig.
- Der erste Status ist `offen`.
- `durchgefuehrt` wird nach dem letzten Durchführungstag automatisch gesetzt,
sofern die Durchführung nicht `abgesagt` ist.
- Die GUI enthält keine fachliche Primärlogik.
- Hermes erhält keinen direkten DB-Zugriff.
- Punkt 11 und folgende sind nicht Teil dieses Zielbilds.
## 6. Ausgabevertrag
Das Modul liefert:
- eindeutige Markt-, Durchführungs- und Tagesidentitäten
- validierte Stammdaten und Referenzen
- unabhängigen Markt- und Zahlungsstatus
- berechnete Kosten-, Umsatz- und Artikelmengenwerte
- serverseitig filter- und sortierbare Übersichtsdaten
- kontrollierte MCP-Ergebnisse für Hermes
## 7. Erfolgskriterien
- Marktdefinitionen sind eindeutig und genau einer Kategorie zugeordnet.
- Durchführungen und Durchführungstage sind unabhängig und vollständig
verwaltbar.
- Jeder neue OTC-Verkauf ist genau einem Durchführungstag zugeordnet.
- Umsatz und Artikelmengen werden nicht manuell doppelt erfasst.
- Kontakt- und Gemeindedaten werden über definierte Referenzen verwendet.
- Archivierung funktioniert getrennt für Markt und Durchführung.
- MCP-Schreibvorgänge sind authentifiziert, berechtigt und idempotent.
- Die fachliche Logik bleibt im owning Modul.
## 8. Noch nicht normative Detailverträge
Die folgenden Dokumente werden erst nach expliziter Prozessdefinition als
normative Verträge erstellt:
- Hauptprozesse unter `processes/main/`
- Sub-Prozesse unter `processes/sub_processes/`
- konkrete API-Schemas der Marktmanagement-Schnittstelle
- GUI- und View-Verträge
@@ -0,0 +1 @@
@@ -0,0 +1,111 @@
# market_management.occurrence_status_completion
Stand: 2026-09-19
Status: Verbindliche Prozess-Spezifikation
## 1. Zweck
Setzt aktive Durchführungen am täglichen Cron-Lauf automatisch auf
`completed`, sobald der Kalendertag nach ihrem letzten Durchführungstag erreicht
ist. Abgesagte Durchführungen werden nicht verändert.
## 2. Prozess-Einbettung
Täglicher synchroner Cron-Teilprozess des zentralen System-Cron-Entrypoints.
Die fachliche Statusänderung gehört zum owning Modul `market_management`.
## 3. Input
- aktuelles Datum in der Zeitzone `Europe/Zurich`
- keine vorbereiteten Fachdaten; der Prozess liest seine Daten selbst aus der
Datenbank
## 4. Exakter Prozessablauf
1. Einen technischen Lauf in `public.process_runs` mit dem aktuellen Datum
starten.
2. Aus `market_occurrence` und `market_occurrence_day` aktive Durchführungen
mit mindestens einem Tag bestimmen.
3. Durchführungen ausschliessen, deren Status `cancelled` oder bereits
`completed` ist.
4. Für jede verbleibende Durchführung den letzten Durchführungstag bestimmen.
5. Wenn dieser Tag vor dem aktuellen Datum liegt, den Status atomar auf
`completed` setzen.
6. Den technischen Lauf mit Anzahl und IDs der geänderten Durchführungen
abschliessen.
Gelesen werden `market_occurrence` und `market_occurrence_day`. Geschrieben
werden `market_occurrence.status`, `market_occurrence.updated_at` und der
technische Lauf in `public.process_runs`.
## 5. Batch, Betriebsmodell und Einbettung
Ein täglicher synchroner Cron-Lauf; ein SQL-Update verarbeitet den vollständigen
aktuellen Scope atomar. Die bestehende Cron-Sperre verhindert parallele Läufe.
## 6. Output
Ein kompakter Laufstatus mit aktuellem Datum, Anzahl und IDs der auf
`completed` gesetzten Durchführungen sowie der technischen Lauf-ID.
## 7. Erfolgskriterien
- Eine Durchführung wird am Tag nach ihrem letzten Durchführungstag auf
`completed` gesetzt.
- Abgesagte und bereits abgeschlossene Durchführungen bleiben unverändert.
- Durchführungen ohne Durchführungstag werden nicht verändert.
- Wiederholte Cron-Läufe sind idempotent.
- `public.process_runs` enthält einen abgeschlossenen technischen Lauf.
## 8. Fehlerschranke
Ein technischer Fehler bricht den Lauf ab, rollt die fachliche Transaktion
zurück und markiert `process_runs` als `failed`.
## 9. Fachliche Betriebsregel
Die Prüfung erfolgt einmal täglich über den bestehenden Cron. Die Statusänderung
ist unabhängig von GUI-Aufrufen und OTC-Bestellungen.
## 10. Sub-Prozess-Referenzen
Keine zusätzlichen Sub-Prozesse.
## 11. End-to-End Sub-Prozess-Kette
System-Cron → Statusabschlussprozess → `market_occurrence` und
`public.process_runs`.
## 12. Sub-Prozess-Wiederverwendung
Der bestehende System-Cron-Entrypoint und die bestehende Cron-Sperre werden
wiederverwendet. Ein zusätzlicher Scheduler wird nicht eingeführt.
## 13. Step-Liste
- Lauf starten
- eligible Durchführungen bestimmen und abschliessen
- Laufstatus persistieren
## 14. Parallelisierung
Keine Parallelisierung; der deterministische SQL-Batch läuft synchron und
atomar.
## 15. Output-Payload
```json
{
"status": "done",
"as_of_date": "YYYY-MM-DD",
"completed_count": 0,
"completed_occurrence_ids": [],
"technical_run_id": 0
}
```
## 16. Done-Kriterien
- Der tägliche Cron ruft den Prozess genau einmal auf.
- Die Statusregel ist serverseitig implementiert.
- Der Prozess schreibt `process_runs` vollständig.
- Wiederholte Läufe erzeugen keine erneuten Statusänderungen.
@@ -0,0 +1,295 @@
# Anforderungsprofil Marktübersicht
Stand: 2026-09-15
Status: Fachliche Arbeitsanforderung
## 1. Ziel des Systems
Das System unterstützt die Suche, Prüfung, Anmeldung, Durchführung und
Auswertung von Herbstmärkten, Weihnachtsmärkten und weiteren geeigneten
Märkten.
## 2. Umfang der ersten Phase
- Es wird jeweils nur ein Markt gleichzeitig bearbeitet.
- Zunächst gibt es nur eine Benutzerin: Fabienne.
- Eine eigene Benutzerverwaltung ist zunächst nicht erforderlich.
- Die Anwendung soll übersichtlich und einfach bedienbar sein.
## 3. Datenhierarchie
Die Daten müssen mehrstufig abbildbar sein:
```text
Kategorie
└── Markt
└── Durchführung
└── Durchführungstag
```
- Es können beliebig viele Kategorien und Märkte existieren.
- Ein Markt gehört genau einer Kategorie.
- Fabienne darf die deterministische Kategorie-/Marktartliste erweitern.
- Hermes darf bestehende Kategorien auswählen, aber keine Kategorien anlegen.
- Ein Markt kann beliebig viele Durchführungen enthalten.
- Eine Durchführung kann beliebig viele Durchführungstage enthalten.
- Erlös- und Verkaufsdaten werden auf Ebene des Durchführungstags erfasst und
auf Ebene der Durchführung sowie des Marktes auswertbar gemacht.
Der zentrale Marktstamm enthält ausschließlich:
- Marktname
- Marktart/Kategorie aus einer deterministischen Liste
- optionale Website-URL der Veranstaltung
Alle übrigen veränderlichen Angaben werden pro Durchführung erfasst. Tages-
und Verkaufsdaten werden pro Durchführungstag erfasst.
## 4. Nutzungsberechtigung
- Die Rechte zur Nutzung des Moduls werden an die Benutzeridentität geknüpft.
- Das Modul soll ohne die restliche Anwendung an weitere Personen verkauft und
separat genutzt werden können.
- Die Ownership und Ablage der Lizenz-/Entitlement-Daten ist noch nicht
entschieden und bleibt offen.
## 5. Marktarten
Initial verfügbare Marktarten:
- Herbstmärkte
- Weihnachtsmärkte
- Frühlingsmärkte
- Ostermärkte
- Kunsthandwerksmärkte
- Regionalmärkte
- Weitere geeignete Märkte
Die Marktart/Kategorie wird zentral am Markt aus einer deterministischen Liste
ausgewählt. Eine Durchführung überschreibt diese Zuordnung nicht.
## 6. Markt- und Durchführungsdaten
### 6.1 Allgemeine Informationen
- Veranstalter
- Ort
- Gemeinde
- Kanton
- Adresse
- Kontaktperson
- E-Mail-Adresse
- Telefonnummer
- Notizen
Diese Angaben werden pro Durchführung erfasst. Marktname und Marktart/Kategorie
gehören ausschließlich zur zentralen Marktdefinition.
### 6.2 Termin und Durchführung
- Beginn und Ende des Marktes
- Datum jedes einzelnen Markttages
- Standzeiten pro Tag
- Auf- und Abbauzeiten
- Anmeldefrist
- Datum der Anfrage oder Anmeldung
- Datum der Bestätigung
- Datum der Durchführung
- Wochentag
- Saison
### 6.3 Stand und Infrastruktur
- Standgröße
- Tisch vorhanden
- Tisch kann gemietet werden
- Eigener Tisch erforderlich
- Zelt oder Überdachung vorhanden
- Strom vorhanden
- Wasser vorhanden
- Beleuchtung vorhanden
- Weitere Infrastruktur
- Anforderungen an den eigenen Stand
### 6.4 Anfahrt und Logistik
- Anfahrtsadresse
- Entfernung oder Fahrzeit
- Parkmöglichkeiten
- Zufahrt für Anlieferung
- Zeitfenster für die Anlieferung
- Möglichkeit zum direkten Ausladen
- Lager- oder Aufbewahrungsmöglichkeiten
- Besondere Hinweise zur Anfahrt
### 6.5 Kosten und Konditionen
- Standgebühr
- Gebühren für Tisch, Strom oder weitere Infrastruktur
- Weitere Pflichtkosten
- Gesamtkosten
- Zahlungsfrist
- Zahlungsart
- Abmeldebedingungen
- Umsatzbeteiligung, falls vorhanden
- Weitere Teilnahmebedingungen
### 6.6 Vertrag und Unterlagen
Die zugehörigen Unterlagen sollen über einen Link mit einem eigenen Ordner
verbunden werden können.
- Link zum Vertragsordner
Der Link wird pro Durchführung erfasst.
### 6.7 Deterministische Referenzwerte und Berechnungen
Deterministisch vorgegebene Referenzwerte; die jeweilige Auswahl wird auf der
zuständigen Ebene gespeichert:
- Marktart/Kategorie
- Marktstatus
- Zahlungsstatus
- Zahlungsart
- Kanton
- standardisierte Ja/Nein-Merkmale für Infrastrukturangaben
Abgeleitete Werte werden nicht manuell eingegeben:
- Wochentag aus dem Datum
- Saison aus dem Durchführungsdatum
- Gesamtkosten als manuell erfassbarer Bruttobetrag mit optionaler Freitext-Aufschlüsselung
- offener Betrag aus Kosten und bezahltem Betrag
- Gesamterlös, Durchschnittswerte und Gesamtzahl verkaufter Artikel aus den
Durchführungstagen
## 7. Statusverwaltung
Folgende Marktstatus sollen verfügbar sein:
- Offen
- Warteliste
- Angemeldet
- Bestätigung ausstehend
- Bestätigt
- Abgesagt
- Durchgeführt
Der initiale Status ist `Offen`. Die Statuswerte werden grundsätzlich manuell
gesetzt. `Durchgeführt` wird nach dem letzten Durchführungstag automatisch
gesetzt, sofern die Durchführung nicht abgesagt ist. Manuelle Korrektur bleibt
möglich.
## 8. Bezahlung
Der Zahlungsstatus wird unabhängig vom allgemeinen Marktstatus gespeichert.
Zahlungsstatus:
- Nicht bezahlt
- Bezahlt
Zusätzliche Zahlungsinformationen:
- Zahlungsdatum
- Bezahlter Betrag
- Offener Betrag
## 9. Erlöserfassung
Der Erlös wird nicht manuell erfasst, sondern aus den OTC-Verkäufen berechnet.
Bei mehrtägigen Märkten wird der Erlös je Durchführungstag separat ermittelt.
Pro Durchführungstag:
- Datum
- Bruttoerlös aus OTC-Verkäufen
- Anzahl verkaufter Artikel aus OTC-Positionen
- Besondere Ereignisse oder Beobachtungen
Automatisch zu berechnen:
- Gesamterlös des Marktes
- Gesamtzahl verkaufter Artikel
- Durchschnittlicher Erlös pro Tag
- Durchschnittlicher Erlös pro verkauftem Artikel, sofern berechenbar
Die Tageswerte sollen einzeln und als Gesamtergebnis auswertbar sein.
## 10. Marktübersicht
Die zentrale Übersicht soll ermöglichen:
- alle Märkte anzuzeigen
- nach Marktart zu filtern
- nach Status zu filtern
- nach Gemeinde oder Ort zu filtern
- nach Datum zu sortieren
- nach Erlös zu sortieren
- den Bezahlstatus zu erkennen
- kommende Märkte hervorzuheben
- überfällige Anmeldungen oder Zahlungen zu erkennen
- Märkte zu öffnen und zu bearbeiten
Mindestens sichtbar:
- Marktname
- Marktart
- Ort oder Gemeinde
- Datum
- Wochentag
- Status
- Gesamtkosten
- Bezahlstatus
- Bruttoerlös
## 11. Intelligente Auswertungen
Das System soll später Zusammenhänge, Unterschiede und mögliche Korrelationen
zwischen Marktmerkmalen und Ergebnissen sichtbar machen.
### 10.1 Marktart und Erlös
- Welche Marktarten erzielen den höchsten Erlös?
- Sind Weihnachtsmärkte erfolgreicher als Herbstmärkte?
- Welche Marktarten haben das beste Verhältnis zwischen Erlös und Kosten?
- Welche Marktarten führen zu den meisten verkauften Artikeln?
### 10.2 Datum, Saison und Erlös
- An welchen Daten werden die höchsten Erlöse erzielt?
- Gibt es Unterschiede zwischen frühem und spätem Saisonzeitpunkt?
- Wie entwickelt sich der Erlös innerhalb einer Saison?
- Welche Monate und Jahreszeiten sind besonders erfolgreich?
- Gibt es einen Zusammenhang zwischen Marktdatum und Verkaufsleistung?
### 10.3 Zahlungstermin und Markterfolg
- Wie viele Tage liegen zwischen Zahlungstermin und Marktdatum?
- Laufen Märkte besser, wenn der Zahlungstermin früh oder spät vor dem Markt
liegt?
- Gibt es einen Zusammenhang zwischen dem Zeitpunkt der Zahlung und dem
späteren Erlös?
- Gibt es Unterschiede zwischen Märkten vor und nach dem eigenen Zahlungstag?
### 10.4 Gemeinde und wirtschaftliches Umfeld
Der Ort soll später über die Gemeinde mit zusätzlichen wirtschaftlichen Daten
vergleichbar sein.
Zu prüfen ist insbesondere die Hypothese, dass Märkte in Gemeinden mit höherem
durchschnittlichem Einkommen möglicherweise einen höheren Erlös erzielen als
Märkte in Gemeinden mit tieferem Einkommen.
Auswertungen:
- Zusammenhang zwischen Gemeinde und Erlös
- Zusammenhang zwischen Gemeinde-Einkommen und Erlös
- Zusammenhang zwischen Kaufkraft und Anzahl verkaufter Artikel
- Vergleich verschiedener Gemeinden
- Vergleich ähnlicher Märkte in Gemeinden mit unterschiedlichem
Einkommensniveau
Die Quelle, Aktualisierung und fachliche Ownership der zusätzlichen
wirtschaftlichen Gemeindedaten sind noch nicht spezifiziert.
@@ -0,0 +1,66 @@
# Editable Action Card
## Zweck
`Editable Action Card` ist ein fachlich neutrales UI-Pattern fuer Karten mit
Titelaktionen, Lesemodus und optionalem Bearbeitungsmodus.
Das Pattern kapselt nur die technische Interaktion:
- Umschalten zwischen Lese- und Bearbeitungsansicht
- Sichtbarer, im Bearbeitungsmodus deaktivierter Bearbeiten-Button
- Abbrechen einer Bearbeitung
- optionale Loeschbestaetigung mit konfigurierbarem Token
- Deaktivieren der Loeschaktion bis zur gueltigen Bestaetigung
- gleichzeitiges Bearbeiten mehrerer Karten
- optionales Ein- und Ausblenden des Karteninhalts
Fachliche Aktionen, API-Aufrufe, Validierung und die Aktualisierung fachlicher
Daten bleiben beim owning Modul.
## Markup-Vertrag
Eine Karte wird mit `data-shared-editable-action-card` markiert. Die
Bedienelemente verwenden folgende Attribute:
- `data-shared-editable-card-edit`
- `data-shared-editable-card-cancel`
- `data-shared-editable-card-read`
- `data-shared-editable-card-form`
- optional `data-shared-editable-card-collapsible`
- optional `data-shared-editable-card-collapsed`
- optional `data-shared-editable-card-toggle`
- optional `data-shared-editable-card-delete`
- optional `data-shared-editable-card-delete-confirmation`
- optional `data-shared-editable-card-delete-input`
- optional `data-shared-editable-card-delete-submit`
- optional `data-shared-editable-card-delete-cancel`
Der Bearbeiten-Button bleibt im Titelsegment sichtbar. Im Bearbeitungsmodus
wird er deaktiviert (`disabled`, `aria-disabled="true"` und
`sg-button--inactive`); die Aktionen zum Abbrechen und Speichern bleiben aktiv.
Das Loesch-Token wird je Karte mit
`data-shared-editable-delete-token` konfiguriert. Die Standardaktion fuer das
Loeschen bleibt im owning Modul und verwendet den bestehenden fachlichen
Handler.
## Optionales Ein- und Ausblenden
Eine Karte mit `data-shared-editable-card-collapsible` kann ihren Inhalt
ein- und ausblenden. Mit `data-shared-editable-card-collapsed` wird der initial
eingeklappte Zustand markiert. Der Toggle verwendet die Beschriftungen
„Anzeigen“ und „Verbergen“. Beim Wechsel in den Bearbeitungsmodus wird der
Inhalt eingeblendet und der Toggle deaktiviert. Nach dem Bearbeiten bleibt der
aktuelle Sichtbarkeitszustand erhalten.
Die zentrale Komponente stellt dafür `setVisibility(card, visible)`,
`isVisible(card)` und `setToggleEnabled(card, enabled)` bereit. Fachmodule
verwenden diese Methoden nur für fachliche Folgeaktionen wie das erneute
Rendern nach dem Speichern.
## Nutzung im Lager
Im Lager-Drawer verwenden die aktive Charge und offene Chargen das Pattern.
Rein lesende Karten, insbesondere historische und virtuelle Chargen, bleiben
beim bestehenden Content-Card-Pattern.
@@ -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
+20 -16
View File
@@ -77,8 +77,10 @@
<p class="sg-preview-label">Component: Large Table</p>
<p>Suche, Sortierung und „Mehr laden“ sind hier Demo-Markup. Produktions-Implementierungen müssen Treffer serverseitig neu rendern.</p>
<p>Optionale Spaltenbreiten-Anpassung: <code>data-column-resize="true"</code> aktiviert die Funktion. Die Tabelle benötigt dafür eine stabile, portalweit eindeutige <code>data-column-resize-key</code>. Die Header-Zellen können mit <code>data-default-width</code> (Prozent, Pixel oder relative <code>fr</code>-Werte) und die Tabelle mit <code>data-column-min-width</code> konfiguriert werden. Die Runtime wird nach dynamischem Nachladen über <code>window.sgInitLargeTableColumnResize({ root })</code> erneut aufgerufen. Gespeicherte Werte liegen als <code>{ version: 3, ratios: number[] }</code> unter <code>sg:large-table:column-widths:&lt;key&gt;</code> in <code>localStorage</code>; die Werte sind proportionale Anteile der verfügbaren Tabellenbreite.</p>
<article class="sg-card sg-large-table" data-component="large-table" role="table" aria-label="Beispiel Large Table">
<article id="styleguide-large-table" class="sg-card sg-large-table" data-component="large-table" data-column-resize="true" data-column-resize-key="styleguide-large-table" data-column-min-width="8rem" role="table" aria-label="Beispiel Large Table">
<div class="sg-large-table__viewport" data-component-part="large-table-viewport">
<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-large-table__title-segment" data-component-part="large-table-header">
<div class="sg-strong">Large Table</div>
<span class="sg-search-field-row" data-component="search-field">
@@ -96,31 +98,35 @@
</div>
<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--header" role="row" data-component-part="large-table-header-row">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="0">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="0" data-default-width="20%">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 1 sortieren">
<span class="sg-large-table__sort-label">Spalte 1</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
</button>
<span class="sg-large-table__resize-handle" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Breite von Spalte 1 anpassen" data-large-table-resize-handle="0"></span>
</div>
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="1">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="1" data-default-width="20%">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 2 sortieren">
<span class="sg-large-table__sort-label">Spalte 2</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
</button>
<span class="sg-large-table__resize-handle" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Breite von Spalte 2 anpassen" data-large-table-resize-handle="1"></span>
</div>
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="2">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="2" data-default-width="20%">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 3 sortieren">
<span class="sg-large-table__sort-label">Spalte 3</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
</button>
<span class="sg-large-table__resize-handle" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Breite von Spalte 3 anpassen" data-large-table-resize-handle="2"></span>
</div>
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="3">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="3" data-default-width="20%">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 4 sortieren">
<span class="sg-large-table__sort-label">Spalte 4</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
</button>
<span class="sg-large-table__resize-handle" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Breite von Spalte 4 anpassen" data-large-table-resize-handle="3"></span>
</div>
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="4">
<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="none" data-sort-key="4" data-default-width="20%">
<button class="sg-large-table__sort-button" type="button" aria-label="Spalte 5 sortieren">
<span class="sg-large-table__sort-label">Spalte 5</span>
<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="ascending"></span>
@@ -210,22 +216,20 @@
<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--load-more" role="row" data-component-part="large-table-row" data-large-table-load-more-row="true">
<div class="sg-large-table__cell sg-large-table__cell--load-more" role="cell">
<div class="sg-navigation-card-layout sg-large-table__load-more-layout">
<div class="sg-navigation-card-block">
<article class="sg-card" data-component="card" data-pattern="navigation-card" aria-label="Navigations-Card">
<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" data-large-table-load-more-trigger="true">Mehr laden</a>
</div>
</div>
</article>
<article class="sg-card sg-large-table__load-more-card" data-component="card" data-pattern="navigation-card" aria-label="Navigations-Card">
<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" data-large-table-load-more-trigger="true">Mehr laden</a>
</div>
</div>
</div>
</article>
</div>
</div>
</div>
</article>
</section>
<script src="../scripts/large-table-column-resize.js"></script>
</body>
</html>
@@ -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>
@@ -836,6 +836,21 @@
</span>
</section>
<!-- Component: Tooltip -->
<section id="component-tooltip">
<p class="sg-preview-label">Component: Tooltip</p>
<span class="sg-tooltip-wrap" data-component="tooltip">
<button class="sg-interaction-element sg-button sg-button--active" type="button" aria-describedby="component-tooltip-panel" aria-expanded="false" data-component-part="tooltip-trigger">
Tooltip anzeigen
</button>
<span class="sg-tooltip-panel sg-table-label" id="component-tooltip-panel" role="tooltip" hidden data-component-part="tooltip-panel">
Diese Sprechblase ist trigger-verankert und wechselt bei fehlendem Platz automatisch die Seite.
</span>
</span>
</section>
<!-- Component: Hyperlink -->
<section id="component-hyperlink">
@@ -847,6 +862,7 @@
</section>
<script src="../scripts/help-icon-overlays.js"></script>
<script src="../scripts/tooltip-overlays.js"></script>
<section id="component-file-input">
<p class="sg-preview-label">Component: File Input</p>
<div class="sg-form-preview-area">
+19 -1
View File
@@ -7,6 +7,7 @@ Der Styleguide bleibt in diesem Repository die Source of Truth. Der Sync verteil
- den deploy-relevanten CSS-Upstream versioniert als `public/assets/styleguide.upstream.css`
- die vollstaendige Styleguide-Dokumentation gespiegelt nach `docs/styleguide`
- die portal-spezifische Fertig-CSS-Datei als `public/assets/styles.css`
- die konsumierbare Large-Table-Runtime als `public/scripts/large-table-column-resize.js`
Zielrepos:
@@ -31,8 +32,10 @@ Beispiel:
- `public/assets/styleguide.upstream.css` aktualisiert
- `public/assets/styleguide.upstream.meta.json` aktualisiert (Version, Commit, Zeitstempel)
- `docs/styleguide` gespiegelt (mit `--delete`, ohne `.git`, `.codex`, `AGENTS.md`, `scripts/`)
- `docs/styleguide` gespiegelt (mit `--delete`, ohne `.git`, `.codex`, `AGENTS.md` und interne `scripts/`)
- `public/scripts/large-table-column-resize.js` aus der Styleguide-Source-of-Truth kopiert
- `public/assets/styles.css` aktualisiert
- `public/scripts/large-table-column-resize.js` aktualisiert; lokale Portal-Kopien sind nicht zulässig
- Optional: automatischer Commit + Push im Portalrepo
## Standardprozess je Release
@@ -42,3 +45,18 @@ Beispiel:
3. Styleguide commit + push
4. Sync-Skript ausfuehren
5. Beide Portalrepos Smoke-Testen
## Large-Table-Runtime
Die Runtime liegt ausschließlich im Styleguide unter `scripts/large-table-column-resize.js`.
Der Sync kopiert nur diese explizit freigegebene Runtime nach `public/scripts/`; andere
Styleguide-Scripts wie Prüf- und Sync-Shellscripts werden nicht in `docs/styleguide/scripts/`
gespiegelt. Dadurch bleibt der Styleguide die Source of Truth und ein erneuter Sync
überschreibt die Portaldatei kontrolliert.
Für den Smoke-Test in beiden Portalen ist zu prüfen:
- `public/scripts/large-table-column-resize.js` ist vorhanden und unter `/scripts/large-table-column-resize.js` ladbar.
- Die Large-Table-Seite lädt die Runtime ohne lokale Inline- oder Duplikatimplementierung.
- Nach einer Spaltenbreitenänderung wird der Eintrag unter `sg:large-table:column-widths:<key>` gespeichert.
- Ein erneuter Sync ersetzt die Datei aus der Styleguide-Quelle.
+19 -4
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>
@@ -176,8 +192,7 @@
<tr><td>notifications-card-min-width</td><td>445px</td><td>Mindestbreite der Notification Card im Pattern Notifications; 50px größer als die Object Card und Grundlage für Flex-Basis und Mindestbreite des Patterns.</td></tr>
<tr><td>search-field-width</td><td>15.3rem</td><td>Fixe Breite des Suchfeld-Inputs inklusive Clear-Button-Bereich.</td></tr>
<tr><td>layer-pulldown-panel</td><td>40</td><td>Layer-Stufe für geöffnete Pulldown-Ausklappfelder, damit sie über anderen Inhalten liegen.</td></tr>
<tr><td>multiselect-pulldown-panel-desktop-width</td><td>500px</td><td>Reservierte Foundation-Breite für Multiselektions-Pulldown-Panels; aktuell nicht aktiv genutzt, da das Panel inhaltsbasiert mit Viewport-Kappe skaliert.</td></tr>
<tr><td>multiselect-pulldown-panel-mobile-width</td><td>80vw</td><td>Reservierte mobile Foundation-Breite für Multiselektions-Pulldown-Panels; aktuell nicht aktiv genutzt, da das Panel inhaltsbasiert mit Viewport-Kappe skaliert.</td></tr>
<tr><td>multiselect-pulldown-panel-desktop-width</td><td>500px</td><td>Foundation-Breite des Multiselektions-Pulldown-Panels; wird als feste Pattern-Breite mit Viewport-Kappe wiederverwendet.</td></tr>
<tr><td>options-row-mode-toggle-width</td><td>7rem</td><td>Breite des Modus-Schiebers in der Options Row.</td></tr>
<tr><td>options-row-help-panel-width</td><td>16rem</td><td>Breite des Help-Panels in der Options Row.</td></tr>
<tr><td>card-list-drawer-width</td><td>40%</td><td>Relative Breite des ausziehbaren Card-Listenbereichs.</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>
@@ -224,14 +224,16 @@
</div>
<div class="sg-pulldown-panel__row sg-pulldown-panel__row--disabled" data-pulldown-filter-row data-active="false" data-component-part="pulldown-filter-row">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 1: aus">
<div class="sg-pulldown-panel__row-header">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 1: aus">
<span class="sg-mode-toggle__switch" aria-hidden="true">
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--left">aus</span>
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--right">an</span>
<span class="sg-mode-toggle__handle"></span>
</span>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 1</p>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 1</p>
</div>
<select class="sg-interaction-element sg-pulldown" aria-label="Pulldown 1 Auswahl" disabled>
<option selected>Option 1</option>
@@ -245,14 +247,16 @@
<h2 id="multiselect-block-4" class="sg-strong sg-form-sections-card__chapter-title">Block 4</h2>
<div class="sg-pulldown-panel__row sg-pulldown-panel__row--disabled" data-pulldown-filter-row data-active="false" data-component-part="pulldown-filter-row">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 2: aus">
<div class="sg-pulldown-panel__row-header">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 2: aus">
<span class="sg-mode-toggle__switch" aria-hidden="true">
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--left">aus</span>
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--right">an</span>
<span class="sg-mode-toggle__handle"></span>
</span>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 2</p>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 2</p>
</div>
<select class="sg-interaction-element sg-pulldown" aria-label="Pulldown 2 Auswahl" disabled>
<option selected>Option 1</option>
@@ -262,14 +266,16 @@
</div>
<div class="sg-pulldown-panel__row sg-pulldown-panel__row--disabled" data-pulldown-filter-row data-active="false" data-component-part="pulldown-filter-row">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 3: aus">
<div class="sg-pulldown-panel__row-header">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 3: aus">
<span class="sg-mode-toggle__switch" aria-hidden="true">
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--left">aus</span>
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--right">an</span>
<span class="sg-mode-toggle__handle"></span>
</span>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 3</p>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 3</p>
</div>
<select class="sg-interaction-element sg-pulldown" aria-label="Pulldown 3 Auswahl" disabled>
<option selected>Option 1</option>
@@ -279,14 +285,16 @@
</div>
<div class="sg-pulldown-panel__row sg-pulldown-panel__row--disabled" data-pulldown-filter-row data-active="false" data-component-part="pulldown-filter-row">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 4: aus">
<div class="sg-pulldown-panel__row-header">
<button class="sg-mode-toggle sg-mode-toggle--local sg-activation-mode-toggle" type="button" data-active="absolute" aria-label="Aktivierung Pulldown 4: aus">
<span class="sg-mode-toggle__switch" aria-hidden="true">
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--left">aus</span>
<span class="sg-activation-mode-toggle__switch-label sg-activation-mode-toggle__switch-label--right">an</span>
<span class="sg-mode-toggle__handle"></span>
</span>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 4</p>
</button>
<p class="sg-pulldown-panel__label sg-body">Pulldown 4</p>
</div>
<select class="sg-interaction-element sg-pulldown" aria-label="Pulldown 4 Auswahl" disabled>
<option selected>Option 1</option>
@@ -390,28 +398,6 @@
);
};
const updateMultiselectLabelAlignment = () => {
document.querySelectorAll('.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel').forEach((panel) => {
const labels = panel.querySelectorAll(
'.sg-slider-row[data-activatable="true"] > .sg-label, .sg-radio-activatable-group > .sg-label, [data-pulldown-filter-row] > .sg-pulldown-panel__label'
);
let maxWidth = 0;
labels.forEach((label) => {
const width = Math.ceil(label.scrollWidth || label.getBoundingClientRect().width);
if (width > maxWidth) {
maxWidth = width;
}
});
if (maxWidth > 0) {
panel.style.setProperty('--sg-multiselect-label-column-width', `${maxWidth}px`);
} else {
panel.style.removeProperty('--sg-multiselect-label-column-width');
}
});
};
document.querySelectorAll('.sg-slider-row').forEach((row) => {
const slider = row.querySelector('.sg-slider');
const valueOutput = row.querySelector('.sg-slider-value');
@@ -500,9 +486,6 @@
});
});
updateMultiselectLabelAlignment();
window.addEventListener('resize', updateMultiselectLabelAlignment);
document.querySelectorAll('.sg-pulldown-demo').forEach((demo) => {
const trigger = demo.querySelector('.sg-pulldown-demo__trigger');
@@ -527,38 +510,12 @@
}
});
demo.dataset.align = 'left';
demo.dataset.open = String(nextState);
trigger.setAttribute('aria-expanded', String(nextState));
if (!nextState) {
return;
}
updateMultiselectLabelAlignment();
const panel = demo.querySelector('.sg-pulldown-panel');
if (!panel) {
return;
}
demo.dataset.align = 'left';
panel.style.left = '0px';
panel.style.right = 'auto';
const initialRect = panel.getBoundingClientRect();
let offsetX = 0;
const maxRight = window.innerWidth;
if (initialRect.right > maxRight) {
offsetX -= initialRect.right - maxRight;
}
if (initialRect.left + offsetX < 0) {
offsetX += 0 - (initialRect.left + offsetX);
}
panel.style.left = `${offsetX}px`;
});
});
@@ -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; bei ungerader Anzahl steht der letzte Pulldown allein in einer 100-%-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>
@@ -0,0 +1,242 @@
(function initLargeTableColumnResizeModule() {
const STORAGE_PREFIX = 'sg:large-table:column-widths:';
const STORAGE_VERSION = 1;
const DEFAULT_MIN_WIDTH = 128;
const KEYBOARD_STEP = 8;
const KEYBOARD_LARGE_STEP = 40;
let activeResize = null;
const getTables = (root) => root.querySelectorAll('[data-component="large-table"][data-column-resize="true"]');
const getColumnHeaders = (table) => Array.from(
table.querySelectorAll('[data-component-part="large-table-header-row"] [role="columnheader"]'),
);
const getMinWidth = (table) => {
const configured = table.dataset.columnMinWidth;
const value = configured || getComputedStyle(table).getPropertyValue('--large-table-column-min-width').trim();
if (!value) {
return DEFAULT_MIN_WIDTH;
}
const probe = document.createElement('span');
probe.style.cssText = `position:absolute;width:${value};visibility:hidden;`;
table.appendChild(probe);
const width = probe.getBoundingClientRect().width;
probe.remove();
return width > 0 ? width : DEFAULT_MIN_WIDTH;
};
const parseDefaultWidth = (rawValue, tableWidth) => {
if (!rawValue) {
return null;
}
const value = Number.parseFloat(rawValue);
if (!Number.isFinite(value) || value <= 0) {
return null;
}
if (rawValue.trim().endsWith('%')) {
return tableWidth * value / 100;
}
if (rawValue.trim().endsWith('fr')) {
return value;
}
return value;
};
const normalizeWidths = (widths, tableWidth, minWidth) => {
const safeWidths = widths.map((width) => Math.max(minWidth, width));
const total = safeWidths.reduce((sum, width) => sum + width, 0);
if (total <= 0) {
return Array(widths.length).fill(Math.max(minWidth, tableWidth / widths.length));
}
if (widths.some((width) => width !== width)) {
return Array(widths.length).fill(Math.max(minWidth, tableWidth / widths.length));
}
return safeWidths.map((width) => width * tableWidth / total);
};
const getDefaultWidths = (table, headers, minWidth) => {
const tableWidth = Math.max(table.clientWidth, minWidth * headers.length);
const configured = headers.map((header) => parseDefaultWidth(header.dataset.defaultWidth, tableWidth));
if (configured.some((width) => width === null)) {
return Array(headers.length).fill(Math.max(minWidth, tableWidth / headers.length));
}
return normalizeWidths(configured, tableWidth, minWidth);
};
const getStorageKey = (table) => `${STORAGE_PREFIX}${table.dataset.columnResizeKey || ''}`;
const readStoredWidths = (table, count, minWidth) => {
const key = getStorageKey(table);
if (!table.dataset.columnResizeKey) {
return null;
}
try {
if (!window.localStorage) {
return null;
}
const stored = JSON.parse(window.localStorage.getItem(key));
if (stored?.version !== STORAGE_VERSION || !Array.isArray(stored.widths) || stored.widths.length !== count) {
return null;
}
if (stored.widths.some((width) => !Number.isFinite(width) || width < minWidth)) {
return null;
}
return stored.widths;
} catch (error) {
return null;
}
};
const writeStoredWidths = (table, widths) => {
if (!table.dataset.columnResizeKey) {
return;
}
try {
if (!window.localStorage) {
return;
}
window.localStorage.setItem(getStorageKey(table), JSON.stringify({
version: STORAGE_VERSION,
widths: widths.map((width) => Math.round(width)),
}));
} catch (error) {
// Storage can be unavailable in privacy-restricted browser contexts.
}
};
const updateHandleState = (table, widths, minWidth) => {
const handles = table.querySelectorAll('[data-large-table-resize-handle]');
handles.forEach((handle) => {
const index = Number.parseInt(handle.dataset.largeTableResizeHandle, 10);
const current = widths[index];
const maximum = Math.max(minWidth, widths[index] + widths[index + 1] - minWidth);
if (table.id) {
handle.setAttribute('aria-controls', table.id);
}
handle.setAttribute('aria-valuemin', String(Math.round(minWidth)));
handle.setAttribute('aria-valuemax', String(Math.round(maximum)));
handle.setAttribute('aria-valuenow', String(Math.round(current)));
handle.setAttribute('aria-valuetext', `${Math.round(current)} Pixel`);
});
};
const applyWidths = (table, widths, minWidth) => {
const safeWidths = widths.map((width) => Math.max(minWidth, width));
const total = safeWidths.reduce((sum, width) => sum + width, 0);
table.style.setProperty('--large-table-grid-template-columns', safeWidths.map((width) => `${width}px`).join(' '));
table.style.setProperty('--large-table-grid-min-width', `${Math.ceil(total)}px`);
table._largeTableColumnWidths = safeWidths;
updateHandleState(table, safeWidths, minWidth);
};
const resetWidths = (table) => {
const headers = getColumnHeaders(table);
const minWidth = getMinWidth(table);
const widths = getDefaultWidths(table, headers, minWidth);
try {
window.localStorage.removeItem(getStorageKey(table));
} catch (error) {
// Storage can be unavailable in privacy-restricted browser contexts.
}
applyWidths(table, widths, minWidth);
};
const resizeColumn = (table, index, delta, persist) => {
const widths = [...table._largeTableColumnWidths];
const minWidth = getMinWidth(table);
const nextLeft = Math.max(minWidth, Math.min(widths[index] + widths[index + 1] - minWidth, widths[index] + delta));
const appliedDelta = nextLeft - widths[index];
widths[index] = nextLeft;
widths[index + 1] -= appliedDelta;
applyWidths(table, widths, minWidth);
if (persist) {
writeStoredWidths(table, widths);
}
};
const initTable = (table) => {
const headers = getColumnHeaders(table);
if (headers.length < 2) {
return;
}
const minWidth = getMinWidth(table);
const stored = readStoredWidths(table, headers.length, minWidth);
applyWidths(table, stored || getDefaultWidths(table, headers, minWidth), minWidth);
if (table.dataset.largeTableResizeInitialized === 'true') {
return;
}
table.dataset.largeTableResizeInitialized = 'true';
table.addEventListener('pointerdown', (event) => {
const handle = event.target.closest('[data-large-table-resize-handle]');
if (!handle || !table.contains(handle)) {
return;
}
event.preventDefault();
activeResize = {
table,
handle,
index: Number.parseInt(handle.dataset.largeTableResizeHandle, 10),
startX: event.clientX,
startWidths: [...table._largeTableColumnWidths],
};
handle.dataset.largeTableResizeActive = 'true';
table.dataset.columnResizeState = 'resizing';
handle.setPointerCapture?.(event.pointerId);
});
table.addEventListener('pointermove', (event) => {
if (!activeResize || activeResize.table !== table) {
return;
}
applyWidths(table, activeResize.startWidths, getMinWidth(table));
resizeColumn(table, activeResize.index, event.clientX - activeResize.startX, false);
});
table.addEventListener('pointerup', () => {
if (!activeResize || activeResize.table !== table) {
return;
}
writeStoredWidths(table, table._largeTableColumnWidths);
activeResize.handle.dataset.largeTableResizeActive = 'false';
table.dataset.columnResizeState = 'idle';
activeResize = null;
});
table.addEventListener('keydown', (event) => {
const handle = event.target.closest('[data-large-table-resize-handle]');
if (!handle || !table.contains(handle)) {
return;
}
const index = Number.parseInt(handle.dataset.largeTableResizeHandle, 10);
const step = event.shiftKey ? KEYBOARD_LARGE_STEP : KEYBOARD_STEP;
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
resizeColumn(table, index, event.key === 'ArrowRight' ? step : -step, true);
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
const minWidth = getMinWidth(table);
const current = table._largeTableColumnWidths[index];
const target = event.key === 'Home' ? minWidth : current + table._largeTableColumnWidths[index + 1] - minWidth;
resizeColumn(table, index, target - current, true);
}
});
table.addEventListener('click', (event) => {
const reset = event.target.closest('[data-large-table-reset-widths]');
if (reset && table.contains(reset)) {
resetWidths(table);
}
});
};
window.sgInitLargeTableColumnResize = (options = {}) => {
const root = options.root || document;
getTables(root).forEach(initTable);
};
document.addEventListener('DOMContentLoaded', () => window.sgInitLargeTableColumnResize(), { once: true });
})();
+365
View File
@@ -0,0 +1,365 @@
(function initTooltipOverlayModule() {
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);
const rawValue = rootStyles.getPropertyValue(propertyName).trim();
const rootFontSize = parseFloat(rootStyles.fontSize) || 16;
const value = parseFloat(rawValue);
if (Number.isNaN(value)) {
return 0;
}
if (rawValue.endsWith('rem')) {
return value * rootFontSize;
}
return value;
};
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;
if (trigger.matches('button,[role="button"],[aria-expanded]')) {
trigger.setAttribute('aria-expanded', String(isOpen));
}
};
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 viewport = getViewport();
const safeInset = getPixelValue('--spacing-large');
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 (centeredRight > viewport.width - safeInset && hasLeftSpace) {
placement = 'left';
} else if (centeredLeft < safeInset && hasRightSpace) {
placement = 'right';
} else if (!hasBottomSpace && hasTopSpace) {
placement = 'top';
}
let panelLeft = centeredLeft;
let panelTop = triggerRect.bottom + gap;
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 togglePinned = (state) => {
if (state.pinned) {
state.pinned = false;
state.dismissed = true;
setOpenState(state, false);
restorePanel(state);
return;
}
state.pinned = true;
state.dismissed = false;
openTooltip(state);
};
const initTooltip = (wrap) => {
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';
states.set(wrap, state);
tooltipStates.add(state);
ensureAccessibility(state);
setOpenState(state, false);
wrap.addEventListener('pointerenter', (event) => {
if (event.pointerType !== 'mouse') {
return;
}
if (state.dismissed) {
return;
}
state.hoverTrigger = true;
openTooltip(state);
});
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 (!state.dismissed) {
state.focused = true;
openTooltip(state);
}
});
wrap.addEventListener('focusout', (event) => {
if (wrap.contains(event.relatedTarget) || state.panel.contains(event.relatedTarget)) {
return;
}
state.focused = false;
state.dismissed = false;
closeIfInactive(state);
});
state.trigger.addEventListener('click', (event) => {
togglePinned(state);
});
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);
}
});
};
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);
};
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();
}
})();
@@ -199,7 +199,6 @@
<tr><td>layout-pulldown-padding-inline</td><td>compact-interaction-padding-horizontal</td><td>Innenabstand links für Pulldown-Trigger und Select-Felder.</td></tr>
<tr><td>layout-pulldown-chevron-offset</td><td>compact-interaction-padding-horizontal</td><td>Rechter Innenabstand des Chevron-Icons im Pulldown.</td></tr>
<tr><td>layout-pulldown-chevron-reserved-space</td><td>spacing-large</td><td>Reservierte Breite zwischen Pulldown-Text und rechtem Chevron-Bereich.</td></tr>
<tr><td>layout-pulldown-panel-form-mobile-width</td><td>90%</td><td>Zielbreite für Pulldown-Panels mit Formularinhalt auf Mobile; wird durch die Viewport-Kappe begrenzt.</td></tr>
<tr><td>layer-pulldown-panel</td><td>dimension-layer-pulldown-panel</td><td>Layer-Stufe geöffneter Pulldown-Ausklappfelder über übrigen Seiteninhalten.</td></tr>
<tr><td>layout-multiselect-pulldown-panel-desktop-width</td><td>dimension-multiselect-pulldown-panel-desktop-width</td><td>Semantische Obergrenze für die Panelbreite geöffneter Pulldowns; das Panel darf über Triggerbreite wachsen, bleibt aber durch diese Breite und die Viewport-Kappe begrenzt.</td></tr>
<tr><td>surface-menu-panel-portal</td><td>color-light-grey</td><td>Fläche des Sandwich-Menü-Panels für große und kleine Variante.</td></tr>
@@ -224,6 +223,10 @@
<tr><td>text-help-icon</td><td>color-font-dark</td><td>Textfarbe im Help-Icon.</td></tr>
<tr><td>text-help-panel</td><td>color-font-dark</td><td>Textfarbe im Help-Panel.</td></tr>
<tr><td>layout-help-panel-width</td><td>calc(interaction-height * 8)</td><td>Standardbreite des Help-Panels.</td></tr>
<tr><td>surface-tooltip</td><td>color-white</td><td>Fläche der generischen Tooltip-Sprechblase.</td></tr>
<tr><td>text-tooltip</td><td>color-font-dark</td><td>Textfarbe der generischen Tooltip-Sprechblase.</td></tr>
<tr><td>layout-tooltip-width</td><td>calc(interaction-height * 8)</td><td>Standardbreite der generischen Tooltip-Sprechblase.</td></tr>
<tr><td>radius-tooltip</td><td>radius-interaction</td><td>Radius der generischen Tooltip-Sprechblase.</td></tr>
<tr><td>icon-sandwich-line-portal</td><td>color-font-dark</td><td>Linienfarbe des Sandwich-Icons für große und kleine Variante.</td></tr>
</tbody></table>
+10 -1
View File
@@ -129,7 +129,16 @@
<h3 class="sg-sub-heading sg-section-h3">Multiselektions-Pulldown</h3>
<table class="sg-foundation-table sg-table-label"><thead><tr><th>Semantischer Token</th><th>Verwendeter Foundation-Token</th><th>Beschreibung Verwendungszweck im Pattern</th></tr></thead><tbody>
<tr><td>layout-multiselect-pulldown-label-column-width-fallback</td><td>max-content</td><td>Fallback-Breite der gemeinsamen Labelspalte im Panel, bis die längste Labelbreite per Pattern-Logik ermittelt wurde.</td></tr>
<tr><td>layout-multiselect-panel-width</td><td>layout-multiselect-pulldown-panel-desktop-width</td><td>Feste Breite des Multiselektions-Pulldown-Panels auf allen Viewports innerhalb der vorhandenen Viewport-Kappe.</td></tr>
<tr><td>layout-multiselect-panel-available-width</td><td>Viewportbreite minus 2 × spacing-large</td><td>Viewport-Kappe für das Multiselektions-Pulldown-Panel mit gleichmäßigem Außenabstand.</td></tr>
<tr><td>layout-multiselect-chapter-gap</td><td>spacing-large</td><td>Einheitlicher vertikaler Abstand zwischen allen direkten Elementen eines Multiselect-Blocks.</td></tr>
<tr><td>layout-multiselect-column-gap</td><td>spacing-large</td><td>Horizontaler Abstand zwischen Aktivierungsschalter und Label in den Pattern-Zeilen.</td></tr>
<tr><td>layout-multiselect-control-gap</td><td>spacing-small</td><td>Standardabstand innerhalb der Slider-Zeilen.</td></tr>
<tr><td>layout-multiselect-visual-control-gap</td><td>2 × spacing-small</td><td>Visueller Abstand zwischen Kopfzeile und Interaktionselement bei Radio- und Pulldown-Zeilen.</td></tr>
<tr><td>layout-multiselect-control-row-height</td><td>interaction-height</td><td>Höhe der Kopfzeile in den filterbaren Pulldown-Zeilen.</td></tr>
<tr><td>layout-multiselect-value-min-width</td><td>1.25 × interaction-height</td><td>Mindestbreite der Slider-Wertanzeige.</td></tr>
<tr><td>typography-multiselect-label-line-height</td><td>line-height-base</td><td>Zeilenhöhe der Labels in den filterbaren Pulldown-Zeilen.</td></tr>
<tr><td>opacity-multiselect-disabled</td><td>disabled-opacity</td><td>Deckkraft deaktivierter Multiselect-Inhalte.</td></tr>
</tbody></table>
<h3 class="sg-sub-heading sg-section-h3">Overlay Card</h3>
+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";
+6 -4
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;
@@ -62,6 +64,7 @@
--surface-menu-panel-portal: var(--color-light-grey);
--surface-help-icon: var(--color-medium-grey);
--surface-help-panel: var(--color-light-grey);
--surface-tooltip: var(--color-white);
--surface-pulldown-panel: var(--color-light-grey);
--surface-activatable-remove: var(--surface-control-default);
--surface-slider-track: var(--color-medium-grey);
@@ -76,10 +79,8 @@
--layout-pulldown-chevron-offset: var(--compact-interaction-padding-horizontal);
--layout-tab-navigation-large-padding-inline: var(--interaction-padding-horizontal);
--layout-pulldown-chevron-reserved-space: var(--spacing-large);
--layout-pulldown-panel-form-mobile-width: 90%;
--layer-pulldown-panel: var(--dimension-layer-pulldown-panel);
--layout-multiselect-pulldown-panel-desktop-width: var(--dimension-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-pulldown-label-column-width-fallback: max-content;
--layout-input-label-width: var(--dimension-input-label-width);
--layout-input-field-desktop-width: var(--dimension-input-field-desktop-width);
--layout-input-field-max-width: var(--dimension-input-field-max-width);
@@ -89,6 +90,8 @@
--layout-mode-toggle-local-width-factor: 3;
--layout-mode-toggle-width: calc(var(--interaction-height) * 3.5);
--layout-help-panel-width: calc(var(--interaction-height) * 8);
--layout-tooltip-width: calc(var(--interaction-height) * 8);
--radius-tooltip: var(--radius-interaction);
--text-control-default: var(--color-font-dark);
--text-control-disabled: var(--color-dark-grey);
--text-button-process: var(--color-font-light);
@@ -98,6 +101,7 @@
--text-menu-link-portal: var(--color-font-dark);
--text-help-icon: var(--color-font-dark);
--text-help-panel: var(--color-font-dark);
--text-tooltip: var(--color-font-dark);
--text-activatable-remove: var(--text-control-default);
--text-hyperlink: var(--color-font-hyperlink);
--icon-pulldown-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M2 2l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='square' stroke-linejoin='miter'/%3E%3C/svg%3E");
@@ -140,7 +144,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);
@@ -306,7 +309,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);
}
@@ -1239,6 +1239,134 @@
right: 0;
}
.sg-tooltip-wrap {
display: contents;
}
.sg-tooltip-panel {
position: absolute;
z-index: var(--layer-pulldown-panel);
display: block;
width: var(--layout-tooltip-width);
max-width: calc(100vw - (2 * var(--spacing-large)));
box-sizing: border-box;
overflow-wrap: anywhere;
padding: var(--spacing-large);
border-radius: var(--radius-tooltip);
color: var(--text-tooltip);
background: var(--surface-tooltip);
box-shadow: var(--shadow-overlay);
}
.sg-tooltip-panel[hidden] {
display: none;
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel {
top: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel {
bottom: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel {
top: 50%;
right: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel {
top: 50%;
left: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-panel::before {
position: absolute;
width: 0;
height: 0;
border-style: solid;
content: "";
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel::before {
top: calc(var(--spacing-small) * -2);
left: 50%;
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel::before {
bottom: calc(var(--spacing-small) * -2);
left: 50%;
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel::before {
top: 50%;
right: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel::before {
top: 50%;
left: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
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) * -2);
left: var(--tooltip-arrow-offset);
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
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) * -2);
left: var(--tooltip-arrow-offset);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 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) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
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) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 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,44 @@
.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:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:nth-last-child(2):nth-child(odd)) > .sg-pulldown-demo:nth-last-child(2),
.sg-options-row:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:last-child:nth-child(odd)) > .sg-pulldown-demo:last-child {
flex-basis: 100%;
}
.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,
@@ -111,15 +136,6 @@
width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
width: min(
var(--layout-pulldown-panel-form-mobile-width),
calc(100vw - (2 * var(--spacing-large)))
);
min-width: 0;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
@@ -75,39 +75,63 @@
padding-left: var(--layout-form-sections-interaction-indent);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > :not(.sg-form-sections-card__chapter-title) + :not(.sg-form-sections-card__chapter-title) {
margin-top: var(--spacing-large);
.sg-options-row[data-pattern="multiselektions-pulldown"] {
--layout-multiselect-panel-width: var(--layout-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-panel-available-width: calc(100vw - (2 * var(--spacing-large)));
--layout-multiselect-chapter-gap: var(--spacing-large);
--layout-multiselect-column-gap: var(--spacing-large);
--layout-multiselect-control-gap: var(--spacing-small);
--layout-multiselect-visual-control-gap: calc(var(--spacing-small) * 2);
--layout-multiselect-control-row-height: var(--interaction-height);
--layout-multiselect-value-min-width: calc(var(--interaction-height) * 1.25);
--typography-multiselect-label-line-height: var(--line-height-base);
--opacity-multiselect-disabled: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-form-sections-card__chapter-title + * {
margin-top: var(--spacing-large);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > * + * {
margin-top: var(--layout-multiselect-chapter-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-slider-row + .sg-slider-row,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-checkbox-field-option + .sg-checkbox-field-option,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-radio-activatable-group + .sg-radio-activatable-group {
margin-top: var(--spacing-small);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-pulldown-panel__row + .sg-pulldown-panel__row {
margin-top: var(--spacing-large);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo .sg-pulldown {
width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
--sg-multiselect-label-column-width: var(--layout-multiselect-pulldown-label-column-width-fallback);
width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
min-width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
max-width: var(--layout-multiselect-panel-available-width);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
width: 100%;
max-width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: nowrap;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] .sg-pulldown-panel__label {
white-space: normal;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
display: grid;
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr) auto;
grid-template-columns: max-content minmax(0, 1fr) auto;
align-items: center;
column-gap: var(--spacing-large);
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-control-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable {
@@ -115,37 +139,43 @@
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider {
opacity: var(--disabled-opacity);
opacity: var(--opacity-multiselect-disabled);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider-value,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
opacity: var(--disabled-opacity);
opacity: var(--opacity-multiselect-disabled);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 3;
grid-column: 1 / 3;
grid-row: 2;
min-width: 0;
max-width: 100%;
width: 100%;
max-width: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 4;
grid-column: 3;
grid-row: 2;
margin: 0;
min-width: 0;
min-width: var(--layout-multiselect-value-min-width);
line-height: 1;
align-self: center;
white-space: nowrap;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
@@ -154,22 +184,26 @@
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
display: grid;
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
column-gap: var(--spacing-large);
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-visual-control-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
margin: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 3;
grid-column: 1 / -1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
@@ -178,119 +212,45 @@
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > :not(.sg-mode-toggle) {
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
pointer-events: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
column-gap: var(--spacing-large);
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--layout-multiselect-control-row-height) auto;
row-gap: var(--layout-multiselect-visual-control-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header {
display: flex;
grid-column: 1;
grid-row: 1;
align-items: center;
gap: var(--layout-multiselect-column-gap);
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label {
margin: 0;
min-width: 0;
line-height: var(--typography-multiselect-label-line-height);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 3;
grid-column: 1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled {
opacity: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-demo .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
@media (max-width: 48rem) {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
grid-template-columns: max-content minmax(0, 1fr) auto;
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: normal;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 1 / 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 1 / -1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 1 / -1;
grid-row: 2;
}
opacity: var(--opacity-multiselect-disabled);
}
.sg-form-sections-card__actions {
+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);
@@ -71,16 +71,32 @@
width: 100%;
}
.sg-large-table[data-column-resize="true"] {
overflow: hidden;
}
.sg-large-table__viewport {
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
width: auto;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
}
.sg-large-table__title-segment {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
box-sizing: border-box;
}
.sg-large-table__row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: var(--large-table-grid-template-columns, repeat(5, minmax(0, 1fr)));
align-items: stretch;
gap: 0;
width: 100%;
@@ -88,6 +104,11 @@
background: var(--color-white);
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row,
.sg-large-table[data-column-resize="true"] .sg-large-table__title-segment {
width: max(100%, var(--large-table-grid-min-width, 0px));
}
.sg-large-table__row--hidden {
display: none;
}
@@ -119,16 +140,59 @@
white-space: normal;
}
.sg-large-table__load-more-layout {
.sg-large-table__row--load-more,
.sg-large-table__cell--load-more,
.sg-large-table__load-more-card {
min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row--load-more {
grid-template-columns: minmax(0, 1fr);
}
.sg-large-table__cell--header {
position: relative;
padding: 0;
}
.sg-large-table__resize-handle {
position: absolute;
z-index: 1;
top: 0;
right: -0.25rem;
bottom: 0;
width: 0.5rem;
cursor: col-resize;
touch-action: none;
}
.sg-large-table__resize-handle::after {
content: "";
position: absolute;
top: 25%;
bottom: 25%;
left: 50%;
width: 0.1rem;
transform: translateX(-50%);
background: var(--color-darkblue);
box-shadow: -0.15rem 0 0 var(--color-darkblue), 0.15rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:hover::after,
.sg-large-table__resize-handle:focus-visible::after,
.sg-large-table[data-column-resize-state="resizing"] .sg-large-table__resize-handle[data-large-table-resize-active="true"]::after {
background: var(--color-darkblue);
box-shadow: -0.2rem 0 0 var(--color-darkblue), 0.2rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:focus-visible {
outline: 2px solid var(--color-darkblue);
outline-offset: -1px;
}
.sg-large-table__sort-button {
display: flex;
align-items: center;
@@ -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%;
}
+322
View File
@@ -0,0 +1,322 @@
# Technische Architektur
Stand: 2026-04-08
Status: Verbindlicher Architektur-Master
Dokumentklasse: normative
## 1. Zweck und Geltung
Dieses Dokument ist die verbindliche Architekturvorgabe fuer die Entwicklung hochmodularer Webportale in diesem Repository. Es steuert Analyse, Implementierung, Refactoring und Review durch Mensch und LLM.
Verbindliche Betriebsregel:
- Entwicklung und erste Integration erfolgen lokal auf `feature/<thema>` im lokalen Docker-Stack.
- `main` ist der gemeinsame, auf DEV betriebene Integrationsstand. Ein Feature wird erst nach lokaler Verifikation und expliziter Freigabe nach `main` gemergt.
- PROD wird ausschliesslich durch das bestehende Release-Skript mit einem expliziten Release-Tag eines in DEV getesteten `main`-Commits ausgerollt und nur nach expliziter User-Freigabe angefasst.
Es definiert verbindlich:
- Modul-, Prozess-, Frontend-, API- und Datenbankgrenzen
- Ownership und oeffentliche Schnittstellen
- Standard-Scope fuer Analyse und Implementierung
- Wiederverwendungs- und Redundanzregeln
- Regeln fuer neue Module, Submodule und zentrale Bausteine
Dieses Dokument ist kein Inventar konkreter Tabellen, Prozesse, Dateien oder Runtime-Artefakte. Solche Details liegen in den zustaendigen Modul-, Prozess- oder DB-Vertraegen.
## 2. Architekturprinzipien
### 2.1 Module
Ein Modul ist ein fachlich oder technisch gekapselter Contract, nicht nur eine Verzeichnisstruktur.
Verbindliche Regel:
- jedes Modul besitzt genau eine klar abgegrenzte Primaerverantwortung
- jedes Modul kapselt seine Domain-Logik, Datenzugriffe, externen Schnittstellen, internen Datenstrukturen und Speicherdetails
- jedes Modul definiert eine explizite oeffentliche Schnittstelle fuer andere Module
- jedes Modul besitzt definierte Owned Artefakte und Schreibrechte
- interne Implementierungen anderer Module duerfen nicht direkt genutzt werden
- jede Modulgrenze muss als potenzielle spaetere Service-Grenze behandelbar bleiben
### 2.2 Prozesse
Ein Prozess ist ein sequenzieller Hauptablauf innerhalb genau eines owning Hauptmoduls.
Verbindliche Regel:
- ein Hauptmodul besitzt beliebig viele Hauptprozesse; jeder Hauptprozess gehoert genau einem Hauptmodul
- ein Hauptprozess darf Submodule desselben Hauptmoduls nutzen
- fachliche Hauptprozesse ueber Hauptmodulgrenzen hinweg sind nicht zulaessig
- Batching, Parallelisierung, Retry, Resume, Fehlerklassifikation, Statusermittlung und Ergebnisvertrag gehoeren in den owning Prozessvertrag
- zusaetzliche harte Schreib-, Feld- oder Datenbankschranken sind nur zulaessig, wenn sie explizit im Prozess- oder DB-Vertrag festgelegt sind
Verbindliche Kettenarten:
- asynchrone Prozessketten: ein Startprozess stoesst Folgeprozesse an, diese laufen als eigene Runs weiter
- synchrone Prozessketten: Subprozesse haengen direkt aneinander; jeder Schritt stoesst nur den naechsten an
- der Kettenmodus muss pro Prozessvertrag explizit festgelegt sein
- die Wahl des Kettenmodus erfolgt jeweils nach fachlichem Sinn und Prozessvertrag
Aktive Prozesswahrheit:
`docs/modules/<modul>/<submodul>/processes/`
Verbindliche Regel:
- Prozess- und Sub-Prozess-Dokumente unter diesem Pfad sind die aktive fachliche Wahrheit
- Prozessnomenklatur folgt `component.subcomponent.process_name`
- jeder Hauptprozess und jeder Sub-Prozess besitzt genau einen owning Modulpfad
- `docs/system_components/` darf fuer aktive Prozessreferenzen nicht verwendet werden
### 2.3 Implementierung
Module, Prozesse und Sub-Prozesse sind technisch so scharf wie moeglich zu trennen.
Verbindliche Regel:
- jedes Modul besitzt eine interne API oder Zugriffsschicht
- Cross-Modul-Kommunikation erfolgt nur ueber definierte Modul-Schnittstellen
- direkte Zugriffe auf interne Implementierungen oder Daten anderer Module sind verboten
- neue APIs im geschuetzten Bereich sind standardmaessig `protected`; die Abo-Pruefung greift vor jeder Datenlieferung
- Module, Prozesse und Sub-Prozesse muessen getrennt entwickelbar, testbar, ausrollbar, ueberwachbar, debugbar und optimierbar sein
- technische Kopplung zwischen Prozessen und Sub-Prozessen ist auf das notwendige Minimum zu reduzieren
- Module duerfen keine impliziten Abhaengigkeiten besitzen, die eine spaetere Extraktion verhindern
### 2.4 Frontend
Das Frontend ist bewusst minimalistisch. Ziel ist eine kleine Zahl konsistenter, wiederverwendbarer UI-Bausteine statt vieler seiten- oder feature-spezifischer Sonderkomponenten.
Verbindliche Regel:
- Seiten und Views komponieren Daten, Layouts und Komponenten, erzeugen aber keine Fachlogik
- UI-Komponenten duerfen keine fachliche Primaerlogik, fachliche Dateninterpretation oder eigenstaendige Fachwahrheit besitzen
- fachliche UI-Komponenten, fachliche Darstellungsregeln und fachlich interpretierende UI-Logik gehoeren zum owning Fachmodul
- fachlich neutrale UI-Komponenten, Layout-Komponenten, Formatierungshelfer, Interaktionsmuster und Frontend-Utilities gehoeren nach `shared`
- neue oder geaenderte Frontend-Komponenten muessen zuerst gegen bestehende Komponenten, Patterns und `shared`-Bausteine geprueft werden
- Varianten bestehender Komponenten sind ueber Props, Konfiguration oder dokumentierte Erweiterungspunkte umzusetzen, nicht durch Copy-Paste
- Sortierung, Filterung und Suche werden serverseitig im owning Modul bzw. in der API umgesetzt; das Frontend rendert nur die Ergebnisse
- Karten, Tabellen, Filter, Tabs, Buttons, Statusanzeigen, Ladezustaende, Empty States, Fehlermeldungen und Detailansichten sind als wiederverwendbare Patterns zu behandeln
- seitenlokale Sonderkomponenten sind nur zulaessig, wenn sie nachweislich nicht sinnvoll wiederverwendbar sind
- API-Responses duerfen frontendnah komponiert sein, aber keine Fachlogik aus dem owning Modul in API oder Frontend verschieben
- Seiten mit externen Redirect- oder Callback-Flows muessen Browser-History und Ruecksprungverhalten bewusst behandeln; der verbindliche Detailvertrag liegt zentral unter `docs/modules/shared/ui_foundation/`.
### 2.4.1 Mehrsprachigkeit
Mehrsprachigkeit ist ein verbindlicher Teil der Frontend-Architektur und gilt identisch fuer statische Texte, dynamisch gerenderte Texte und interaktive UI-Elemente.
Verbindliche Regel:
- jeder sichtbare GUI-String muss ueber einen i18n-Key aufgeloest werden, unabhaengig davon, ob er serverseitig, clientseitig oder aus einer API-Komposition gerendert wird
- dazu gehoeren insbesondere Navigationslabels, Tab-Titel, Buttontexte, Menuepunkte, Tooltips, Hilfetexte, Platzhalter, Empty States, Ladezustände, Fehlermeldungen, Bestätigungsdialoge, ARIA-Labels und sonstige Screenreader-Texte
- source-code-Defaults sind nur Fallback- bzw. Seed-Werte; die Anzeige-Wahrheit wird pro Locale aus dem i18n-Store bezogen
- interaktive Elemente muessen sichtbare Beschriftung und funktionalen Wert strikt trennen: der angezeigte Text ist uebersetzbar, der Wert fuer State, Transport, Routing, Filterung und Backend-Verarbeitung bleibt technisch stabil und locale-unabhaengig
- Auswahlwerte, Keys, IDs, Data-Attribute, Option-Keys, Tab-Keys und sonstige Steuerungswerte duerfen nicht aus uebersetzten Labels abgeleitet werden
- Locale-Wechsel darf ausschliesslich die Anzeigeschicht wechseln; Funktion, Auswahlzustand, Navigation, Filterlogik und Datenuebergabe muessen identisch weiter funktionieren
- wenn Text im DOM sichtbar ist, gilt er als GUI-String und unterliegt dieser Regel auch dann, wenn er clientseitig zusammengesetzt wird
- zusammengesetzte UI-Texte duerfen keine fachliche Logik enthalten; sie kombinieren nur locale-gebundene Textbausteine mit stabilen technischen Werten
## 3. Modulmodell
### 3.1 Zulaessige Modulbereiche
Fachliche Hauptmodule:
- `company`
- `ratings`
- `groups`
- `lists`
Technische Modulbereiche:
- `system`
- `shared`
Verbindliche Regel:
- fachliche Hauptmodule besitzen Business-Logik und fachliche Ownership
- `system` enthaelt nur technische Runtime-, Start-, Trigger- und Laufzeitlogik
- `shared` enthaelt nur fachlich neutrale, wiederverwendbare technische Bausteine
- konkrete Submodule, Prozesse, Artefakte und Schnittstellen werden in Modulvertraegen dokumentiert, nicht in diesem Architektur-Master
### 3.2 `shared`
`shared` ist der zentrale technische Querschnittsbereich.
Verbindliche Regel:
- `shared` darf keine Business-Logik, Fachwahrheit oder primaere fachliche Ownership besitzen
- `shared` darf Services, Komponenten, Helper, technische Schnittstellen, UI-nahe und API-nahe Bausteine bereitstellen
- ein Baustein gehoert nur nach `shared`, wenn er fachlich neutral ist und keine Verantwortung eines Fachmoduls uebernimmt
- fachliche Regeln, Dateninterpretation, Statuslogik und Entscheidungen bleiben im owning Fachmodul
- technische Querschnittslogik ohne fachlichen Inhalt muss in `shared` oder einem dort dokumentierten zentralen Baustein liegen, sobald Wiederverwendung ueber mehr als eine Stelle absehbar ist
### 3.3 `admin`
`admin` ist kein Hauptmodul und besitzt keine fachliche Ownership.
Verbindliche Regel:
- Admin-Funktionen sind nur Bedienoberflaechen auf bestehende Hauptmodule
- Admin-spezifische Fachlogik liegt im owning Hauptmodul
- ein separates fachliches Modul `admin` ist nicht zulaessig
### 3.4 `public/api/`
`public/api/` ist die HTTP-Service- und Kompositionsschicht des modularen Monolithen.
Verbindliche Regel:
- `public/api/` besitzt keine fachliche Primaerverantwortung
- `public/api/` darf Modul- oder Submodul-Schnittstellen fuer konkrete Frontend-User-Stories zusammensetzen
- `public/api/` bleibt duenn und enthaelt nur HTTP-nahe und technische Kompositionslogik
- fachliche Ownership darf nicht aus Modulen in `public/api/` wandern
## 4. Datenbankgrenze
Die Plattform nutzt aktuell genau eine gemeinsame Datenbank. Diese gemeinsame Datenbank hebt Modul- und Ownership-Grenzen nicht auf.
Verbindliche Regel:
- jede Tabelle, View, materialisierte View und DB-Funktion besitzt genau eine primaere Ownership
- primaere Ownership folgt genau einem Hauptmodul oder einem technischen Shared-Bereich
- fachliche Beziehungen sowie Ownership- und Berechtigungsschluessel verwenden stabile interne Entitaets-IDs; veraenderliche Attribute wie E-Mail, Username oder Anzeigename duerfen dafuer nicht verwendet werden
- nur das owning Modul definiert Struktur, Write-Logik, Constraints und fachliche Semantik eines DB-Artefakts
- direkte Cross-Modul-Writes in fremde Owned Tabellen sind verboten
- andere Module duerfen fremde Artefakte nur ueber definierte Schnittstellen des owning Moduls nutzen
- physische DB-Splits sind kein aktueller Standard und beduerfen einer expliziten Architekturentscheidung
Operative DB-Referenz:
`docs/database/module_db_ownership.md`
Verbindliche Regel:
- konkrete DB-Artefaktlisten gehoeren in die DB-Ownership-Dokumentation, nicht in diesen Architektur-Master
- bei Analyse, Refactoring und Implementierung wird der DB-Scope zuerst ueber Modul plus Owned DB-Artefakte bestimmt
- fremde DB-Artefakte duerfen nur bei deklarierter Leseabhaengigkeit, FK-Beziehung oder Integrationspruefung in den Scope aufgenommen werden
## 5. Dokumentation und Lesescope
Aktive Dokumentation ist komponentennah.
Verbindliche Regel:
- aktive Modul- und Submodul-Dokumentation liegt unter `docs/modules/<modul>/...`
- aktive technische Shared-Dokumentation liegt unter `docs/modules/shared/...`
- technische Runtime-Dokumentation liegt unter `docs/modules/system/...`
- fachliche Hauptprozesse liegen innerhalb des owning Modulbaums
- historische oder abgeloeste Dokumentation liegt ausserhalb des aktiven Modulbaums
- `docs/technical_architecture.md` bleibt der einzige aktive Architektur-Master
Standard-Lesescope:
- Arbeit an einem Modul: `docs/technical_architecture.md` plus `docs/modules/<modul>/...`
- Arbeit an einem Submodul: `docs/technical_architecture.md` plus `docs/modules/<modul>/<submodul>/...`
- `docs/modules/shared/...`: nur bei betroffener Shared-Schnittstelle oder moeglicher Wiederverwendung
- `docs/modules/system/...`: nur bei Runtime-, Queue-, Scheduler-, Trigger- oder Supervisor-Themen
- andere Module: nur bei explizitem Integrationsbedarf
## 6. Wiederverwendung und Redundanzverbot
Vor jeder Aenderung an App, Frontend, UI-Komponenten, UI-Patterns, API, Prozesslogik, Datenzugriff, Infrastruktur oder Dokumentation ist Wiederverwendung vor Neuerstellung zu pruefen.
Ziel ist eine Architektur ohne redundante Fachlogik, technische Inselloesungen, doppelte UI-Komponenten oder mehrfach gepflegte Varianten desselben Musters.
Verbindliche Regel:
- zuerst bestehende Muster, Komponenten, Services, Modul-Schnittstellen und `shared`-Bausteine pruefen
- vorhandene passende Muster muessen wiederverwendet, erweitert oder als zentrale Schnittstelle bereitgestellt werden
- fachliche Inhalte gehoeren immer in das owning Fachmodul und werden von dort ueber definierte Schnittstellen bereitgestellt
- fachliche Logik darf nicht in `shared`, `public/api/`, `admin`, Skripte oder fachfremde Bereiche ausgelagert oder dupliziert werden
- fachlich neutrale technische Logik gehoert nach `shared`, sofern sie wiederverwendbar ist oder Wiederverwendung absehbar ist
- Frontend-Darstellung, Interaktionslogik, Layout, Formatierung und Zustandsdarstellung sind als bestehende oder neue zentrale UI-Komponenten beziehungsweise UI-Patterns in `shared` zu pruefen
- neue `shared`-Bausteine sind nur zulaessig, wenn sie keine Fachwahrheit enthalten, keine fachliche Primaerverantwortung uebernehmen und wiederverwendbar sind
- technische Querschnittsbausteine duerfen Module entkoppeln, aber keine fachlichen Entscheidungen aus owning Modulen herausziehen
- neue Loesungen sind nur zulaessig, wenn kein passendes bestehendes Muster, keine passende Modul-Schnittstelle und kein geeigneter `shared`-Baustein existiert
- Quickfixes, Workarounds, Copy-Paste-Varianten, seitenlokale UI-Duplikate und isolierte Sonderlogik fuer Einzelstellen sind verboten
- Redundanzvermeidung muss bei Analyse, Umsetzung, Review und Refactoring aktiv nachgewiesen werden koennen
## 7. Verbotene Strukturen
Nicht zulaessig sind:
- Module als reine Ordner ohne Contract
- direkte Cross-Modul-Zugriffe auf DB-Tabellen, interne Implementierungen oder Daten anderer Module
- versteckte oder implizite Modulabhaengigkeiten
- Vermischung mehrerer Primaerverantwortungen innerhalb eines Moduls
- parallele Fachlogik ausserhalb des owning Moduls
- redundante fachliche, technische oder UI-Loesungen, obwohl ein bestehendes Modul, eine Schnittstelle, ein Pattern oder ein `shared`-Baustein erweitert werden koennte
- neue generische Frameworks oder Orchestrator-Umbauten ohne explizite Architekturentscheidung
- implizite Schutz-, Schreib- oder Datenbankschranken ohne Prozess- oder DB-Vertrag
## 8. Erweiterungsregeln
Neue Hauptmodule, Submodule oder zentrale Bausteine duerfen nur eingefuehrt werden, wenn bestehende Strukturen die Verantwortung nicht sauber tragen koennen.
Prueffragen vor jeder Erweiterung:
1. Welches bestehende Hauptmodul ist primaer betroffen?
2. Reicht eine neue Datei, ein neues Submodul oder eine neue Schnittstelle im owning Hauptmodul aus?
3. Handelt es sich um fachlich neutrale Wiederverwendung, die nach `shared` gehoert?
4. Entsteht wirklich eine neue Primaerverantwortung mit eigenen Owned Artefakten und Schreibrechten?
5. Bleibt der Handshake zum Rest des Systems auf minimale Scope-Identifier begrenzt?
6. Reduziert der neue Schnitt Kopplung und Redundanz, statt sie zu erhoehen?
Verbindliche Regel:
- neue Hauptmodule sind nur zulaessig, wenn eine neue fachliche oder technische Primaerverantwortung entsteht, die nicht sauber in `company`, `ratings`, `groups`, `lists`, `system` oder `shared` aufgeht
- neue Submodule sind nur zulaessig, wenn innerhalb eines bestehenden Hauptmoduls eine klar isolierbare Verantwortung mit eigener Schnittstelle entsteht
- neue Prozess- und Sub-Prozess-Dokumente werden im owning Modulpfad angelegt und folgen `component.subcomponent.process_name`
- `admin`, `public/api/`, `scripts/` oder `lib/` begruenden kein neues fachliches Hauptmodul
- Bequemlichkeit, Dateigroesse oder kurzfristiger Umbauaufwand rechtfertigen kein neues Hauptmodul
- vor jeder normativen Architektur- oder Modulerweiterung muss dieses Dokument aktualisiert werden
## 9. Aenderungsregel
Jede strukturelle Aenderung an Hauptmodulen, Modulgrenzen, Prozesszuordnungen, Frontend-Prinzipien, API-Grenzen, DB-Ownership oder primaeren Ownership-Prinzipien muss zuerst in diesem Dokument normativ festgelegt und danach in Detaildokumenten nachgezogen werden.
Verbindlich ist:
- `docs/technical_architecture.md` ist der einzige aktive Architektur-Master des Repositories
- andere Dokumente duerfen diese Architektur erklaeren, anwenden oder historisieren, aber keine konkurrierende Master-Struktur definieren
- konkrete Inventare wie Tabellenlisten, Submodullisten, Prozesslisten oder Runtime-Artefakte werden in Detaildokumenten gepflegt und nicht hier dupliziert
## 10. Projektspezifische Architekturvorgaben
Dieses Kapitel enthaelt Architekturentscheidungen, die fuer dieses Projekt
gelten und nicht Teil der allgemeinen, projektuebergreifend wiederverwendbaren
Modularitaet sind.
### 10.1 Fachliches Hauptmodul `market_management`
Dieses Projekt besitzt zusaetzlich das fachliche Hauptmodul
`market_management`. Seine fachliche Ownership, Datenstruktur und
Schnittstellen werden in den Modulvertraegen unter
`docs/modules/market_management/` dokumentiert.
### 10.2 GUI-/MCP-Paritaet
Die MCP-Schnittstelle ist im Grundsatz funktional paritaetisch zur GUI. Ein
MCP-Nutzer ist hinsichtlich der fachlichen Nutzung einem GUI-Nutzer
gleichzustellen.
Verbindliche Regel:
- jede fachliche Funktion, die technisch ueber das GUI-Interface verfuegbar
ist, muss auch ueber die MCP-Schnittstelle ausfuehrbar sein
- die Paritaet bezieht sich auf die technischen Moeglichkeiten des Interfaces,
nicht nur auf aktuell im GUI dargestellte Funktionen; technisch vorhandene,
im GUI nicht angezeigte Funktionen muessen trotzdem ueber MCP verfuegbar sein
- alle fachlichen Lese-, Anlege-, Aenderungs-, Archivierungs- und
Reaktivierungsmoeglichkeiten des GUI-Interfaces muessen im MCP-Interface
abgebildet sein
- GUI und MCP verwenden dieselben fachlichen Validierungen, Berechtigungen,
Statusregeln und Ownership-Grenzen; fachliche Logik darf nicht doppelt in
GUI und MCP implementiert werden
- jede Erweiterung oder Aenderung eines GUI-Anwendungsfalls erfordert zwingend
die gleichzeitige Pruefung und, falls erforderlich, Anpassung des MCP-Interfaces
- ein MCP-Nutzer darf gegenueber einem gleich berechtigten GUI-Nutzer weder
fachlich eingeschraenkt noch durch abweichende Regeln bevorzugt werden
- die Paritaet betrifft die fachliche Funktion und den Datenzugriff, nicht die
visuelle Darstellung oder den Interaktionsablauf der GUI
+16 -2
View File
@@ -146,10 +146,12 @@ function create_direct_sales_order(PDO $pdo, array $fields): array
$stmt = $pdo->prepare(
'INSERT INTO sales_order (
id, external_ref, party_id, order_source, order_date, order_status, payment_status, payment_method_id,
amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency, imported_at, created_at, updated_at
amount_net, amount_shipping, amount_tax, amount_discount, total_amount, currency,
market_occurrence_id, market_occurrence_day_id, imported_at, created_at, updated_at
) VALUES (
:id, :external_ref, :party_id, \'direct\', :order_date, \'imported\', \'paid\', :payment_method_id,
:amount_net, 0, 0, 0, :total_amount, \'CHF\', NOW(), NOW(), NOW()
:amount_net, 0, 0, 0, :total_amount, \'CHF\',
:market_occurrence_id, :market_occurrence_day_id, NOW(), NOW(), NOW()
)
RETURNING id, external_ref'
);
@@ -161,6 +163,8 @@ function create_direct_sales_order(PDO $pdo, array $fields): array
':payment_method_id' => $fields[':payment_method_id'] ?? null,
':amount_net' => $fields[':amount_net'] ?? null,
':total_amount' => $fields[':total_amount'] ?? null,
':market_occurrence_id' => $fields[':market_occurrence_id'] ?? null,
':market_occurrence_day_id' => $fields[':market_occurrence_day_id'] ?? null,
]);
$row = $stmt->fetch();
@@ -390,6 +394,8 @@ function get_sales_order_detail(PDO $pdo, int $orderId): ?array
so.amount_discount,
so.total_amount,
so.currency,
md.name AS market_name,
mday.date AS market_occurrence_day_date,
COALESCE(ad.first_name, '') AS first_name,
COALESCE(ad.last_name, '') AS last_name,
COALESCE(ad.street, '') AS street,
@@ -409,6 +415,10 @@ function get_sales_order_detail(PDO $pdo, int $orderId): ?array
) ad ON TRUE
LEFT JOIN payment_method pm ON pm.id = so.payment_method_id
LEFT JOIN shipping_method sm ON sm.id = so.shipping_method_id
LEFT JOIN market_occurrence mo ON mo.id = so.market_occurrence_id
LEFT JOIN market_definition md ON md.id = mo.market_definition_id
LEFT JOIN market_occurrence_day mday
ON mday.id = so.market_occurrence_day_id AND mday.occurrence_id = mo.id
WHERE so.id = :order_id"
);
$orderStmt->execute([':order_id' => $orderId]);
@@ -495,6 +505,10 @@ function get_sales_order_detail(PDO $pdo, int $orderId): ?array
'payment_status' => (string) $order['payment_status'],
'payment_method' => (string) $order['payment_method'],
'shipping_method' => (string) $order['shipping_method'],
'market' => [
'name' => $order['market_name'] !== null ? (string) $order['market_name'] : null,
'occurrence_day_date' => $order['market_occurrence_day_date'] !== null ? (string) $order['market_occurrence_day_date'] : null,
],
'customer' => [
'first_name' => (string) $order['first_name'],
'last_name' => (string) $order['last_name'],
+47 -1
View File
@@ -41,6 +41,20 @@ $products = $data['products'];
$orderDate = trim((string) $data['orderDate']);
$payments = $data['payments'];
$billing = is_array($data['billing']) ? $data['billing'] : [];
$marketOccurrenceId = $data['marketOccurrenceId'] ?? null;
$marketOccurrenceDayId = $data['marketOccurrenceDayId'] ?? null;
if (($marketOccurrenceId === null) !== ($marketOccurrenceDayId === null)) {
json_response(422, ['ok' => false, 'error' => 'Markt und Markttag müssen gemeinsam angegeben werden.']);
}
if ($marketOccurrenceId !== null && (!ctype_digit((string) $marketOccurrenceId) || (int) $marketOccurrenceId <= 0)) {
json_response(422, ['ok' => false, 'error' => 'Ungültiger Markt.']);
}
if ($marketOccurrenceDayId !== null && (!ctype_digit((string) $marketOccurrenceDayId) || (int) $marketOccurrenceDayId <= 0)) {
json_response(422, ['ok' => false, 'error' => 'Ungültiger Markttag.']);
}
if (!is_array($products) || count($products) === 0) {
json_response(422, ['ok' => false, 'error' => 'No products specified']);
@@ -115,6 +129,27 @@ try {
$pdo = connect_database($env);
$pdo->beginTransaction();
if ($marketOccurrenceId !== null) {
$marketLinkStmt = $pdo->prepare(
'SELECT 1
FROM market_occurrence o
INNER JOIN market_definition m ON m.id = o.market_definition_id
INNER JOIN market_occurrence_day d ON d.occurrence_id = o.id
WHERE o.id = :occurrence_id
AND d.id = :day_id
AND m.archived_at IS NULL
AND o.archived_at IS NULL
LIMIT 1'
);
$marketLinkStmt->execute([
':occurrence_id' => (int) $marketOccurrenceId,
':day_id' => (int) $marketOccurrenceDayId,
]);
if ($marketLinkStmt->fetchColumn() === false) {
throw new InvalidArgumentException('Der Markt oder der Markttag ist nicht aktiv oder nicht miteinander verknüpft.');
}
}
$locations = get_default_location_ids($pdo);
$paymentMethodIds = [];
@@ -135,6 +170,8 @@ try {
':amount_net' => $totalPrice,
':total_amount' => $totalPrice,
':order_date' => $orderDateTime->format('Y-m-d H:i:s'),
':market_occurrence_id' => $marketOccurrenceId === null ? null : (int) $marketOccurrenceId,
':market_occurrence_day_id' => $marketOccurrenceDayId === null ? null : (int) $marketOccurrenceDayId,
]);
$orderId = (int) $order['id'];
@@ -221,7 +258,16 @@ try {
'excelTrigger' => $excelTrigger,
'message' => 'OTC order created successfully',
]);
} catch (Throwable $e) {
} catch (InvalidArgumentException $e) {
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
$pdo->rollBack();
}
json_response(422, [
'ok' => false,
'error' => $e->getMessage(),
]);
} catch (Throwable $e) {
if (isset($pdo) && $pdo instanceof PDO && $pdo->inTransaction()) {
$pdo->rollBack();
}
+95
View File
@@ -125,6 +125,101 @@ function get_manufacturer_options(PDO $pdo): array
);
}
function get_party_reference(PDO $pdo, int $partyId): ?array
{
$stmt = $pdo->prepare(
'SELECT id, name, email, phone
FROM party
WHERE id = :id'
);
$stmt->execute([':id' => $partyId]);
$party = $stmt->fetch();
return is_array($party) ? $party : null;
}
function get_contact_reference(PDO $pdo, int $contactId): ?array
{
$stmt = $pdo->prepare(
'SELECT id, party_id, first_name, last_name, email, phone, position_title
FROM contact
WHERE id = :id'
);
$stmt->execute([':id' => $contactId]);
$contact = $stmt->fetch();
return is_array($contact) ? $contact : null;
}
function get_party_options(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT id, name
FROM party
WHERE status = 'active'
ORDER BY name ASC, id ASC"
);
return array_map(
static fn (array $row): array => ['id' => (int) $row['id'], 'name' => (string) ($row['name'] ?? '')],
$stmt->fetchAll()
);
}
function get_contact_options(PDO $pdo): array
{
$stmt = $pdo->query(
"SELECT c.id, c.party_id, c.first_name, c.last_name, p.name AS party_name
FROM contact c
INNER JOIN party p ON p.id = c.party_id
WHERE p.status = 'active'
ORDER BY p.name ASC, c.last_name ASC, c.first_name ASC, c.id ASC"
);
return array_map(
static fn (array $row): array => [
'id' => (int) $row['id'],
'party_id' => (int) $row['party_id'],
'first_name' => (string) ($row['first_name'] ?? ''),
'last_name' => (string) ($row['last_name'] ?? ''),
'party_name' => (string) ($row['party_name'] ?? ''),
],
$stmt->fetchAll()
);
}
function update_contact_details(PDO $pdo, int $contactId, array $input): array
{
if ($contactId <= 0) {
throw new InvalidArgumentException('contact_person_id muss positiv sein.');
}
$sets = [];
$params = [':id' => $contactId];
foreach (['email', 'phone'] as $field) {
if (array_key_exists($field, $input)) {
$sets[] = $field . ' = :' . $field;
$value = trim((string) ($input[$field] ?? ''));
$params[':' . $field] = $value !== '' ? $value : null;
}
}
if ($sets === []) {
throw new InvalidArgumentException('Keine änderbaren Kontaktdaten übergeben.');
}
$sets[] = 'updated_at = NOW()';
$stmt = $pdo->prepare(
'UPDATE contact SET ' . implode(', ', $sets) . ' WHERE id = :id
RETURNING id, party_id, first_name, last_name, email, phone, position_title'
);
$stmt->execute($params);
$contact = $stmt->fetch();
if (!is_array($contact)) {
throw new RuntimeException('Kontaktperson nicht gefunden.');
}
return $contact;
}
function create_manufacturer(PDO $pdo, string $name, ?string $email = null, ?string $phone = null): array
{
$name = trim($name);
+4 -3
View File
@@ -131,13 +131,15 @@ function lager_forecast_calculate(
?DateTimeImmutable $forecastStartDate = null
): array
{
$lookbackDays = 365;
$firstSaleDate = empty($dailySales) ? null : min(array_keys($dailySales));
$lookbackDays = $firstSaleDate === null
? 0
: $asOfDate->diff(new DateTimeImmutable($firstSaleDate))->days + 1;
$halfLifeDays = 60;
$weightedUnits = 0.0;
$weightTotal = 0.0;
$salesUnits = 0.0;
$salesDays = 0;
$firstSaleDate = null;
$lastSaleDate = null;
for ($age = 0; $age < $lookbackDays; $age++) {
@@ -150,7 +152,6 @@ function lager_forecast_calculate(
if ($units > 0) {
$salesUnits += $units;
$salesDays++;
$firstSaleDate = $dateKey;
$lastSaleDate ??= $dateKey;
}
}
+91
View File
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/service.php';
function erp_lager_mcp_token(array $env): string
{
$token = env_value('MARKET_MCP_TOKEN', $env);
if ($token === '') {
throw new RuntimeException('MARKET_MCP_TOKEN ist nicht konfiguriert.');
}
return $token;
}
function erp_lager_mcp_authorize(array $env): string
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(.+)$/i', $header, $matches)) {
throw new RuntimeException('MCP Authorization Bearer Token fehlt.');
}
$token = trim($matches[1]);
if (!hash_equals(erp_lager_mcp_token($env), $token)) {
throw new RuntimeException('MCP Authorization Bearer Token ist ungültig.');
}
return 'hermes';
}
function erp_lager_mcp_tools(): array
{
return [
[
'name' => 'erp_lager.list_products',
'description' => 'Aktive ERP-Lagerprodukte zur Ermittlung der Produkt-IDs lesen',
'inputSchema' => ['type' => 'object', 'properties' => (object) []],
],
[
'name' => 'erp_lager.get_product',
'description' => 'Produkt, aktuelle Lagermenge, Chargen und Ausverkaufsprognose lesen',
'inputSchema' => [
'type' => 'object',
'required' => ['product_id'],
'properties' => [
'product_id' => ['type' => 'integer'],
],
],
],
];
}
function erp_lager_mcp_list_products(PDO $pdo): array
{
return array_map(
static fn (array $product): array => [
'id' => $product['id'],
'name' => $product['name'],
'sku' => $product['sku'],
'uom' => $product['uom'],
],
get_otc_order_form_products($pdo)
);
}
function erp_lager_mcp_get_product(PDO $pdo, int $productId): array
{
if ($productId < 1) {
throw new InvalidArgumentException('product_id muss eine positive Ganzzahl sein.');
}
foreach (get_lager_product_overview($pdo) as $product) {
if ((int) $product['id'] === $productId) {
return $product;
}
}
throw new InvalidArgumentException('Aktives Lagerprodukt nicht gefunden.');
}
function erp_lager_mcp_call(PDO $pdo, string $tool, array $arguments): array
{
return match ($tool) {
'erp_lager.list_products' => erp_lager_mcp_list_products($pdo),
'erp_lager.get_product' => erp_lager_mcp_get_product(
$pdo,
(int) ($arguments['product_id'] ?? 0)
),
default => throw new InvalidArgumentException('Unbekanntes Lager-MCP-Tool: ' . $tool),
};
}
+347
View File
@@ -0,0 +1,347 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/service.php';
function market_management_mcp_token(array $env): string
{
$token = env_value('MARKET_MCP_TOKEN', $env);
if ($token === '') {
throw new RuntimeException('MARKET_MCP_TOKEN ist nicht konfiguriert.');
}
return $token;
}
function market_management_mcp_authorize(array $env): string
{
$header = (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? '');
if (!preg_match('/^Bearer\s+(.+)$/i', trim($header), $matches)) {
throw new RuntimeException('Bearer-Token fehlt.');
}
$token = trim($matches[1]);
$expected = market_management_mcp_token($env);
if ($token === '' || !hash_equals($expected, $token)) {
throw new RuntimeException('Bearer-Token ist ungültig.');
}
return 'bearer:' . hash('sha256', $token);
}
function market_management_mcp_tools(): array
{
$idempotencyProperty = ['idempotency_key' => ['type' => 'string', 'minLength' => 1]];
$occurrenceProperties = [
'status' => ['type' => 'string'], 'place' => ['type' => ['string', 'null']], 'address' => ['type' => ['string', 'null']],
'municipality_reference_id' => ['type' => ['integer', 'null']], 'notes' => ['type' => ['string', 'null']],
'registration_deadline' => ['type' => ['string', 'null'], 'format' => 'date'],
'request_or_registration_date' => ['type' => ['string', 'null'], 'format' => 'date'],
'registration_note' => ['type' => ['string', 'null']],
'confirmation_date' => ['type' => ['string', 'null'], 'format' => 'date'],
'payment_deadline' => ['type' => ['string', 'null'], 'format' => 'date'],
'payment_status' => ['type' => ['string', 'null'], 'enum' => ['paid', 'unpaid']],
'payment_date' => ['type' => ['string', 'null'], 'format' => 'date'], 'paid_amount' => ['type' => ['number', 'null']],
'payment_method_code' => ['type' => ['string', 'null']], 'stand_size' => ['type' => ['string', 'null']],
'gross_cost' => ['type' => ['number', 'null'], 'minimum' => 0], 'tax_status' => ['type' => ['string', 'null'], 'enum' => ['tax_free', 'taxable']], 'tax_rate' => ['type' => ['number', 'null'], 'minimum' => 0], 'cost_items_text' => ['type' => ['string', 'null']],
'table_available' => ['type' => ['boolean', 'null']], 'table_rentable' => ['type' => ['boolean', 'null']],
'own_table_required' => ['type' => ['boolean', 'null']], 'shelter_available' => ['type' => ['boolean', 'null']],
'power_available' => ['type' => ['boolean', 'null']], 'water_available' => ['type' => ['boolean', 'null']],
'lighting_available' => ['type' => ['boolean', 'null']], 'other_infrastructure' => ['type' => ['string', 'null']],
'own_stand_requirements' => ['type' => ['string', 'null']], 'travel_address' => ['type' => ['string', 'null']],
'travel_distance_km' => ['type' => ['number', 'null']],
'parking_options' => ['type' => ['string', 'null']], 'delivery_access' => ['type' => ['string', 'null']],
'direct_unload_possible' => ['type' => ['boolean', 'null']], 'storage_options' => ['type' => ['string', 'null']],
'travel_notes' => ['type' => ['string', 'null']], 'cancellation_terms' => ['type' => ['string', 'null']],
'revenue_share_terms' => ['type' => ['string', 'null']], 'other_participation_conditions' => ['type' => ['string', 'null']],
'contract_folder_url' => ['type' => ['string', 'null'], 'format' => 'uri'],
'contact_person_name' => ['type' => ['string', 'null']],
'contact_email' => ['type' => ['string', 'null'], 'format' => 'email'], 'contact_phone' => ['type' => ['string', 'null']],
];
$dayProperties = [
'date' => ['type' => 'string', 'format' => 'date'], 'observations' => ['type' => ['string', 'null']],
'stand_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'stand_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
'setup_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'setup_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
'delivery_start_time' => ['type' => ['string', 'null'], 'format' => 'time'], 'delivery_end_time' => ['type' => ['string', 'null'], 'format' => 'time'],
'inherit_from_first_day' => ['type' => ['boolean', 'null']],
];
$searchProperties = [
'search' => ['type' => 'string'], 'market_definition_id' => ['type' => 'integer'], 'category_id' => ['type' => 'integer'],
'status' => ['type' => 'string'], 'place' => ['type' => 'string'], 'address' => ['type' => 'string'],
'municipality_reference_id' => ['type' => 'integer'], 'date_from' => ['type' => 'string', 'format' => 'date'],
'date_to' => ['type' => 'string', 'format' => 'date'], 'include_archived' => ['type' => 'boolean'],
'sort' => ['type' => 'string'], 'sort_direction' => ['type' => 'string', 'enum' => ['asc', 'desc']],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100], 'offset' => ['type' => 'integer', 'minimum' => 0],
];
return [
['name' => 'market_management.search_markets', 'description' => 'Märkte suchen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
['name' => 'market_management.search_occurrences', 'description' => 'Marktdurchführungen suchen und auflisten', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
['name' => 'market_management.get_market', 'description' => 'Markt mit Marktdurchführungen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
['name' => 'market_management.create_market', 'description' => 'Markt mit verpflichtendem Veranstalter, Kategorie und optionaler Website-URL anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'category_id', 'organizer_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'organizer_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null']]]]],
['name' => 'market_management.update_market', 'description' => 'Markt inklusive Veranstalter und Website-URL ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['market_id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'organizer_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null']]]]],
['name' => 'market_management.archive_market', 'description' => 'Markt archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['market_id' => ['type' => 'integer']]]],
['name' => 'market_management.reactivate_market', 'description' => 'Markt reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['market_id' => ['type' => 'integer']]]],
['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', 'idempotency_key'], 'properties' => $idempotencyProperty + ['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' => (object) []]],
['name' => 'market_management.create_municipality', 'description' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'idempotency_key'], 'properties' => $idempotencyProperty + ['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', 'idempotency_key'], 'properties' => $idempotencyProperty + ['market_definition_id' => ['type' => 'integer'], 'status' => ['type' => 'string']] + $occurrenceProperties + ['days' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => 'object', 'required' => ['date'], 'properties' => $dayProperties]]]]],
['name' => 'market_management.get_occurrence', 'description' => 'Marktdurchführung mit Markt, Markttagen und berechneten Verkaufsdaten lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
['name' => 'market_management.get_occurrence_checklist', 'description' => 'Checkliste einer Marktdurchführung lesen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]],
['name' => 'market_management.update_occurrence_checklist', 'description' => 'Checkliste einer Marktdurchführung vollständig ersetzen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'items', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer'], 'comment' => ['type' => ['string', 'null']], 'items' => ['type' => 'array', 'items' => ['type' => 'object', 'required' => ['chapter_key', 'label', 'is_completed'], 'properties' => ['chapter_key' => ['type' => 'string', 'enum' => ['products_sales', 'decoration', 'stand_equipment', 'technology']], 'label' => ['type' => 'string'], 'is_completed' => ['type' => 'boolean']]]]]]],
['name' => 'market_management.update_occurrence', 'description' => 'Marktdurchführung ändern', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer']] + $occurrenceProperties]],
['name' => 'market_management.archive_occurrence', 'description' => 'Marktdurchführung archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer']]]],
['name' => 'market_management.reactivate_occurrence', 'description' => 'Marktdurchführung reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer']]]],
['name' => 'market_management.create_day', 'description' => 'Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'date', 'idempotency_key'], 'properties' => $idempotencyProperty + ['occurrence_id' => ['type' => 'integer']] + $dayProperties]],
['name' => 'market_management.update_day', 'description' => 'Markttag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['day_id' => ['type' => 'integer']] + $dayProperties]],
['name' => 'market_management.delete_day', 'description' => 'Markttag entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['day_id', 'idempotency_key'], 'properties' => $idempotencyProperty + ['day_id' => ['type' => 'integer']]]],
];
}
function market_management_mcp_is_write_tool(string $tool): bool
{
return in_array($tool, [
'market_management.create_market', 'market_management.update_market',
'market_management.create_market_organizer',
'market_management.create_municipality',
'market_management.archive_market', 'market_management.reactivate_market',
'market_management.create_occurrence', 'market_management.update_occurrence',
'market_management.update_occurrence_checklist',
'market_management.archive_occurrence', 'market_management.reactivate_occurrence',
'market_management.create_day', 'market_management.update_day', 'market_management.delete_day',
], true);
}
function market_management_mcp_call_idempotent(PDO $pdo, string $userSubject, string $tool, array $arguments): array
{
if (!market_management_mcp_is_write_tool($tool)) {
return market_management_mcp_call($pdo, $tool, $arguments);
}
$idempotencyKey = trim((string) ($arguments['idempotency_key'] ?? ''));
if ($idempotencyKey === '') {
throw new InvalidArgumentException('Schreibende MCP-Aufrufe benötigen idempotency_key.');
}
$clientId = trim((string) ($arguments['client_id'] ?? 'hermes'));
if ($clientId === '') {
throw new InvalidArgumentException('client_id darf nicht leer sein.');
}
$businessArguments = $arguments;
unset($businessArguments['idempotency_key'], $businessArguments['client_id']);
$requestHash = hash('sha256', json_encode([
'tool' => $tool,
'arguments' => $businessArguments,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
$pdo->beginTransaction();
try {
$existingStmt = $pdo->prepare(
'SELECT user_subject, request_hash, result_status, result_payload
FROM market_mcp_idempotency
WHERE client_id = :client_id AND idempotency_key = :idempotency_key
FOR UPDATE'
);
$existingStmt->execute([':client_id' => $clientId, ':idempotency_key' => $idempotencyKey]);
$existing = $existingStmt->fetch();
if (is_array($existing)) {
if ((string) $existing['user_subject'] !== $userSubject || (string) $existing['request_hash'] !== $requestHash) {
throw new InvalidArgumentException('Idempotency-Schlüssel wurde bereits für eine andere Anfrage verwendet.');
}
if ((string) $existing['result_status'] === 'succeeded' && $existing['result_payload'] !== null) {
$pdo->commit();
return (array) json_decode((string) $existing['result_payload'], true, 512, JSON_THROW_ON_ERROR);
}
throw new RuntimeException('Idempotency-Anfrage wird bereits verarbeitet oder ist fehlgeschlagen.');
}
$insert = $pdo->prepare(
'INSERT INTO market_mcp_idempotency
(user_subject, client_id, idempotency_key, tool_name, request_hash, result_status, expires_at)
VALUES (:user_subject, :client_id, :idempotency_key, :tool_name, :request_hash, \'processing\', NOW() + INTERVAL \'24 hours\')'
);
$insert->execute([
':user_subject' => $userSubject,
':client_id' => $clientId,
':idempotency_key' => $idempotencyKey,
':tool_name' => $tool,
':request_hash' => $requestHash,
]);
$result = market_management_mcp_call($pdo, $tool, $businessArguments);
$update = $pdo->prepare(
'UPDATE market_mcp_idempotency
SET result_status = \'succeeded\', result_payload = CAST(:result_payload AS JSONB)
WHERE client_id = :client_id AND idempotency_key = :idempotency_key'
);
$update->execute([
':result_payload' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
':client_id' => $clientId,
':idempotency_key' => $idempotencyKey,
]);
$pdo->commit();
return $result;
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
function market_management_mcp_get_occurrence(PDO $pdo, int $occurrenceId, bool $includeArchived = false): array
{
$occurrence = market_management_get_occurrence($pdo, $occurrenceId, $includeArchived);
if ($occurrence === null) {
throw new RuntimeException('Marktdurchführung nicht gefunden.');
}
$market = market_management_get_market($pdo, (int) $occurrence['market_definition_id'], $includeArchived);
if ($market === null) {
throw new RuntimeException('Markt nicht gefunden.');
}
unset($market['occurrences']);
$occurrence['market'] = $market;
return $occurrence;
}
function market_management_mcp_get_market(PDO $pdo, int $marketId, bool $includeArchived = false): array
{
$market = market_management_get_market($pdo, $marketId, $includeArchived);
if ($market === null) {
throw new RuntimeException('Markt nicht gefunden.');
}
foreach ($market['occurrences'] as $index => $occurrence) {
$enriched = market_management_get_occurrence($pdo, (int) $occurrence['id'], $includeArchived);
if ($enriched !== null) {
$market['occurrences'][$index] = $enriched;
}
}
return $market;
}
function market_management_mcp_update_occurrence(PDO $pdo, int $occurrenceId, array $input): array
{
unset($input['occurrence_id']);
$simpleCostFields = [];
foreach (['gross_cost', 'cost_items_text', 'tax_status', 'tax_rate'] as $field) {
if (array_key_exists($field, $input)) {
$simpleCostFields[$field] = $input[$field];
unset($input[$field]);
}
}
if ($simpleCostFields !== []) {
market_management_update_occurrence_simple_costs($pdo, $occurrenceId, $simpleCostFields);
}
$contactFields = [];
foreach (['contact_person_name', 'contact_email', 'contact_phone'] as $field) {
if (array_key_exists($field, $input)) {
$contactFields[$field] = $input[$field];
unset($input[$field]);
}
}
$existing = null;
if ($contactFields !== []) {
$existing = market_management_get_occurrence($pdo, $occurrenceId, true);
if ($existing === null) {
throw new RuntimeException('Marktdurchführung nicht gefunden.');
}
}
if ($contactFields !== []) {
$contactFields['occurrence_id'] = $occurrenceId;
market_management_set_occurrence_contact($pdo, $contactFields);
}
if ($input !== []) {
market_management_update_occurrence($pdo, $occurrenceId, $input);
}
return market_management_mcp_get_occurrence($pdo, $occurrenceId, true);
}
function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): array
{
return match ($tool) {
'market_management.search_markets' => market_management_search_markets($pdo, $arguments),
'market_management.search_occurrences' => market_management_search_occurrences($pdo, $arguments),
'market_management.get_market' => market_management_mcp_get_market(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
(bool) ($arguments['include_archived'] ?? false)
),
'market_management.create_market' => market_management_create_market($pdo, $arguments),
'market_management.update_market' => market_management_update_market(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
$arguments
),
'market_management.archive_market' => market_management_set_market_archive(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
true
),
'market_management.reactivate_market' => market_management_set_market_archive(
$pdo,
market_management_require_positive_id($arguments['market_id'] ?? null, 'market_id'),
false
),
'market_management.list_categories' => market_management_list_categories($pdo),
'market_management.list_market_organizers' => market_management_list_organizers($pdo),
'market_management.create_market_organizer' => market_management_create_organizer(
$pdo,
(string) ($arguments['name'] ?? ''),
$arguments['email'] ?? null,
$arguments['phone'] ?? null
),
'market_management.list_reference_data' => market_management_list_reference_data($pdo, $arguments),
'market_management.create_municipality' => market_management_create_municipality(
$pdo,
(string) ($arguments['name'] ?? '')
),
'market_management.get_overview' => [
'markets' => market_management_search_markets($pdo, $arguments),
'occurrences' => market_management_search_occurrences($pdo, $arguments),
],
'market_management.create_occurrence' => (static function () use ($pdo, $arguments): array {
$occurrence = market_management_create_occurrence($pdo, $arguments);
return $occurrence;
})(),
'market_management.get_occurrence' => market_management_mcp_get_occurrence(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
(bool) ($arguments['include_archived'] ?? false)
),
'market_management.get_occurrence_checklist' => market_management_get_occurrence_checklist(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
(bool) ($arguments['include_archived'] ?? false)
),
'market_management.update_occurrence_checklist' => market_management_update_occurrence_checklist(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
$arguments
),
'market_management.update_occurrence' => market_management_mcp_update_occurrence(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
$arguments
),
'market_management.archive_occurrence' => market_management_set_occurrence_archive(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
true
),
'market_management.reactivate_occurrence' => market_management_set_occurrence_archive(
$pdo,
market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id'),
false
),
'market_management.create_day' => market_management_create_day($pdo, $arguments),
'market_management.update_day' => market_management_update_day(
$pdo,
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id'),
$arguments
),
'market_management.delete_day' => market_management_delete_day(
$pdo,
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id')
),
default => throw new InvalidArgumentException('Unbekanntes MCP-Tool: ' . $tool),
};
}
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
const MARKET_OCCURRENCE_CHECKLIST_CHAPTERS = [
[
'key' => 'products_sales',
'label' => 'Produkte & Verkauf',
'items' => ['Fläschchen', 'Säckchen', 'Dunkle Kleber', 'Naurua-Kleber', 'Anwendungsbeilage', 'Flyer Naurua', 'Flyer Vitalpilze', 'Vitalpilzbuch', 'SumUp-Terminal', 'Bargeld'],
],
[
'key' => 'decoration',
'label' => 'Dekoration',
'items' => ['Dekoration', 'Pilze', 'Blumenvase', 'Blume'],
],
[
'key' => 'stand_equipment',
'label' => 'Stand & Ausstattung',
'items' => ['Tisch', 'Tischtücher', 'Beleuchtung', 'Holzklötze', 'Wagen', 'Sonnenschirm', 'Parkkarte/-Marktinformationen'],
],
[
'key' => 'technology',
'label' => 'Technik',
'items' => ['Batterie-/Powerbank fürs Handy'],
],
];
function market_management_checklist_default_items(): array
{
$items = [];
foreach (MARKET_OCCURRENCE_CHECKLIST_CHAPTERS as $chapter) {
foreach ($chapter['items'] as $label) {
$items[] = [
'chapter_key' => $chapter['key'],
'label' => $label,
];
}
}
return $items;
}
function market_management_checklist_assert_occurrence(PDO $pdo, int $occurrenceId, bool $includeArchived = false): void
{
$predicate = $includeArchived ? 'TRUE' : 'archived_at IS NULL';
$stmt = $pdo->prepare('SELECT id FROM market_occurrence WHERE id = :id AND ' . $predicate);
$stmt->execute([':id' => $occurrenceId]);
if ($stmt->fetchColumn() === false) {
throw new InvalidArgumentException('occurrence_id verweist nicht auf eine Marktdurchführung.');
}
}
function market_management_checklist_initialize(PDO $pdo, int $occurrenceId): void
{
market_management_checklist_assert_occurrence($pdo, $occurrenceId, true);
$parent = $pdo->prepare(
'INSERT INTO market_occurrence_checklist (occurrence_id)
VALUES (:occurrence_id)
ON CONFLICT (occurrence_id) DO NOTHING'
);
$parent->execute([':occurrence_id' => $occurrenceId]);
$existing = $pdo->prepare('SELECT COUNT(*) FROM market_occurrence_checklist_item WHERE occurrence_id = :occurrence_id');
$existing->execute([':occurrence_id' => $occurrenceId]);
if ((int) $existing->fetchColumn() > 0) {
return;
}
$insert = $pdo->prepare(
'INSERT INTO market_occurrence_checklist_item
(occurrence_id, chapter_key, position, label, is_completed)
VALUES (:occurrence_id, :chapter_key, :position, :label, :is_completed)'
);
$chapterPositions = [];
foreach (market_management_checklist_default_items() as $item) {
$insert->bindValue(':occurrence_id', $occurrenceId, PDO::PARAM_INT);
$insert->bindValue(':chapter_key', $item['chapter_key'], PDO::PARAM_STR);
$chapterPositions[$item['chapter_key']] = ($chapterPositions[$item['chapter_key']] ?? 0) + 1;
$insert->bindValue(':position', $chapterPositions[$item['chapter_key']], PDO::PARAM_INT);
$insert->bindValue(':label', $item['label'], PDO::PARAM_STR);
$insert->bindValue(':is_completed', false, PDO::PARAM_BOOL);
$insert->execute();
}
}
function market_management_get_occurrence_checklist(PDO $pdo, int $occurrenceId, bool $includeArchived = false): array
{
market_management_checklist_assert_occurrence($pdo, $occurrenceId, $includeArchived);
$parent = $pdo->prepare(
'SELECT id, occurrence_id, comment, created_at, updated_at
FROM market_occurrence_checklist
WHERE occurrence_id = :occurrence_id'
);
$parent->execute([':occurrence_id' => $occurrenceId]);
$checklist = $parent->fetch();
if (!is_array($checklist)) {
throw new RuntimeException('Checkliste der Marktdurchführung fehlt.');
}
$items = $pdo->prepare(
'SELECT id, occurrence_id, chapter_key, position, label, is_completed, created_at, updated_at
FROM market_occurrence_checklist_item
WHERE occurrence_id = :occurrence_id
ORDER BY id ASC'
);
$items->execute([':occurrence_id' => $occurrenceId]);
$checklist['items'] = $items->fetchAll();
$checklist['chapters'] = array_map(
static fn (array $chapter): array => ['key' => $chapter['key'], 'label' => $chapter['label']],
MARKET_OCCURRENCE_CHECKLIST_CHAPTERS
);
return $checklist;
}
function market_management_update_occurrence_checklist(PDO $pdo, int $occurrenceId, array $input): array
{
market_management_checklist_assert_occurrence($pdo, $occurrenceId, true);
$items = $input['items'] ?? null;
if (!is_array($items)) {
throw new InvalidArgumentException('items muss eine Liste sein.');
}
$comment = $input['comment'] ?? null;
if ($comment !== null && !is_string($comment)) {
throw new InvalidArgumentException('comment muss ein String oder null sein.');
}
$startedTransaction = !$pdo->inTransaction();
if ($startedTransaction) {
$pdo->beginTransaction();
}
try {
$parent = $pdo->prepare(
'INSERT INTO market_occurrence_checklist (occurrence_id, comment)
VALUES (:occurrence_id, :comment)
ON CONFLICT (occurrence_id) DO UPDATE
SET comment = EXCLUDED.comment, updated_at = NOW()'
);
$parent->execute([':occurrence_id' => $occurrenceId, ':comment' => $comment === null ? null : trim($comment)]);
$delete = $pdo->prepare('DELETE FROM market_occurrence_checklist_item WHERE occurrence_id = :occurrence_id');
$delete->execute([':occurrence_id' => $occurrenceId]);
$insert = $pdo->prepare(
'INSERT INTO market_occurrence_checklist_item
(occurrence_id, chapter_key, position, label, is_completed)
VALUES (:occurrence_id, :chapter_key, :position, :label, :is_completed)'
);
$chapterKeys = array_fill_keys(array_column(MARKET_OCCURRENCE_CHECKLIST_CHAPTERS, 'key'), true);
$chapterPositions = [];
foreach (array_values($items) as $position => $item) {
if (!is_array($item)) {
throw new InvalidArgumentException('Jeder Checklistenpunkt muss ein Objekt sein.');
}
$chapterKey = $item['chapter_key'] ?? null;
if (!is_string($chapterKey) || !isset($chapterKeys[$chapterKey])) {
throw new InvalidArgumentException('chapter_key verweist nicht auf ein festes Checklisten-Kapitel.');
}
$label = $item['label'] ?? '';
if (!is_string($label)) {
throw new InvalidArgumentException('Der Text eines Checklistenpunkts muss ein String sein.');
}
$completed = $item['is_completed'] ?? false;
if (!is_bool($completed)) {
throw new InvalidArgumentException('is_completed muss boolean sein.');
}
$insert->bindValue(':occurrence_id', $occurrenceId, PDO::PARAM_INT);
$chapterPositions[$chapterKey] = ($chapterPositions[$chapterKey] ?? 0) + 1;
$insert->bindValue(':chapter_key', $chapterKey, PDO::PARAM_STR);
$insert->bindValue(':position', $chapterPositions[$chapterKey], PDO::PARAM_INT);
$insert->bindValue(':label', trim($label), PDO::PARAM_STR);
$insert->bindValue(':is_completed', $completed, PDO::PARAM_BOOL);
$insert->execute();
}
if ($startedTransaction) {
$pdo->commit();
}
} catch (Throwable $error) {
if ($startedTransaction && $pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
}
return market_management_get_occurrence_checklist($pdo, $occurrenceId, true);
}
File diff suppressed because it is too large. Load diff
+585 -83
View File
@@ -50,6 +50,216 @@ function auth_format_bestellung_total(?float $value): string
return 'CHF ' . number_format($value, 2, '.', '\'');
}
function auth_format_market_date(?string $earliest, ?string $latest): string
{
if ($earliest === null || $earliest === '') {
return '';
}
$start = (new DateTimeImmutable($earliest))->format('d.m.Y');
if ($latest === null || $latest === '' || $latest === $earliest) {
return $start;
}
return $start . '–' . (new DateTimeImmutable($latest))->format('d.m.Y');
}
function auth_market_status_label(string $status): string
{
return [
'open' => 'Offen',
'waitlist' => 'Warteliste',
'registered' => 'Angemeldet',
'confirmation_pending' => 'Bestätigung ausstehend',
'confirmed' => 'Bestätigt',
'cancelled' => 'Abgesagt',
'completed' => 'Abgeschlossen',
][$status] ?? $status;
}
function auth_market_status_signal_class(string $status): string
{
return [
'waitlist' => 'market-status--red',
'registered' => 'market-status--yellow',
'confirmation_pending' => 'market-status--yellow',
'confirmed' => 'market-status--green',
][$status] ?? '';
}
function auth_large_table_default_width(int $columnCount): string
{
return rtrim(rtrim(number_format(100 / max(1, $columnCount), 4, '.', ''), '0'), '.') . '%';
}
function auth_large_table_resize_handle(string $label, int $columnIndex, int $columnCount): string
{
if ($columnIndex >= $columnCount - 1) {
return '';
}
return '<span class="sg-large-table__resize-handle" role="separator" tabindex="0" aria-orientation="vertical" aria-label="Breite von ' . auth_escape_html($label) . ' anpassen" data-large-table-resize-handle="' . $columnIndex . '"></span>';
}
function auth_render_markets_large_table(array $marketTable): string
{
$rows = $marketTable['rows'] ?? $marketTable;
$search = (string) ($marketTable['search'] ?? '');
$sortColumn = (string) ($marketTable['sort_column'] ?? 'earliest_date');
$sortDirection = strtoupper((string) ($marketTable['sort_direction'] ?? 'ASC'));
$limit = (int) ($marketTable['limit'] ?? 20);
$pageSize = (int) ($marketTable['page_size'] ?? 20);
$hasMore = (bool) ($marketTable['has_more'] ?? (count($rows) >= $limit));
$sortColumns = [
'market_name' => 'Markt',
'earliest_date' => 'Markttag(e)',
'place' => 'Ort',
'registration_deadline' => 'Anmeldefrist',
'status' => 'Status',
];
$html = [];
$html[] = '<section class="sg-card-list-page" aria-label="Marktdurchführungen">';
$html[] = '<article class="sg-card" data-component="basic-card"><div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-component-part="card-body"><div class="sg-component-row sg-basic-card__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" aria-haspopup="dialog" aria-expanded="false" data-component="button" data-component-state="active" data-market-occurrence-create>Neue Marktdurchführung</button></div></div></article>';
$html[] = '<article class="sg-card sg-large-table sg-large-table--markets" data-component="large-table" data-column-resize="true" data-column-resize-key="market-occurrences" data-column-min-width="8rem" data-markets-large-table="true" data-markets-search="' . auth_escape_html($search) . '" data-markets-sort-column="' . auth_escape_html($sortColumn) . '" data-markets-sort-direction="' . auth_escape_html($sortDirection) . '" data-markets-limit="' . $limit . '" data-markets-page-size="' . $pageSize . '" role="table" aria-label="Marktdurchführungen">';
$html[] = '<div class="sg-large-table__viewport" data-component-part="large-table-viewport">';
$html[] = '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-large-table__title-segment" data-component-part="large-table-header">';
$html[] = '<div class="sg-strong">Marktdurchführungen</div>';
$html[] = '<span class="sg-search-field-row" data-component="search-field">';
$html[] = '<span class="sg-input-single-line-wrap" data-has-value="' . ($search !== '' ? 'true' : 'false') . '" data-component="single-line-input" data-component-state="' . ($search !== '' ? 'active' : 'inactive-selectable') . '">';
$html[] = '<input class="sg-interaction-element sg-input-single-line sg-search-field-input sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" placeholder="Suche" aria-label="Marktdurchführungen durchsuchen" value="' . auth_escape_html($search) . '" data-markets-search-input>';
$html[] = '<button class="sg-input-clear-button" type="button" aria-label="Eingabe löschen" data-markets-search-clear>×</button>';
$html[] = '</span></span></div>';
$html[] = '<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--header" role="row" data-component-part="large-table-header-row">';
$headerIndex = 0;
foreach ($sortColumns as $column => $label) {
$isActive = $column === $sortColumn;
$headerDirection = $isActive ? strtolower($sortDirection) : 'none';
$iconDirection = $isActive ? strtolower($sortDirection) : 'ascending';
$targetDirection = $isActive ? ($sortDirection === 'ASC' ? 'DESC' : 'ASC') : ($column === 'earliest_date' ? 'ASC' : 'ASC');
$ariaLabel = $isActive ? $label . ' ' . ($sortDirection === 'ASC' ? 'aufsteigend sortiert' : 'absteigend sortiert') : $label . ' sortieren';
$html[] = '<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="' . $headerDirection . '" data-sort-key="' . $headerIndex . '" data-default-width="' . auth_large_table_default_width(6) . '"><button class="sg-large-table__sort-button" type="button" aria-label="' . auth_escape_html($ariaLabel) . '" data-markets-sort-button="true" data-markets-sort-column="' . auth_escape_html($column) . '" data-markets-sort-direction="' . auth_escape_html($targetDirection) . '"><span class="sg-large-table__sort-label">' . auth_escape_html($label) . '</span><span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="' . auth_escape_html($iconDirection) . '"></span></button>' . auth_large_table_resize_handle($label, $headerIndex, 6) . '</div>';
$headerIndex++;
}
$html[] = '<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" data-default-width="' . auth_large_table_default_width(6) . '"><span class="sg-large-table__sort-label">Verkaufte Produkte</span></div>';
$html[] = '</div>';
foreach ($rows as $index => $row) {
$rowClass = 'sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row';
if ($index % 2 === 1) {
$rowClass .= ' sg-large-table__row--striped-light';
}
$html[] = '<div class="' . $rowClass . '" role="row" data-component-part="large-table-row">';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html((string) ($row['market_name'] ?? '')) . '</div>';
$html[] = '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-market-drawer-open="true" data-market-occurrence-id="' . (int) ($row['id'] ?? 0) . '">' . auth_escape_html(auth_format_market_date($row['earliest_date'] ?? null, $row['latest_date'] ?? null)) . '</a></div>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html((string) ($row['place'] ?? '')) . '</div>';
$registrationDeadline = (string) ($row['registration_deadline'] ?? '');
$registrationDeadlineLabel = '';
if ($registrationDeadline !== '') {
try {
$registrationDeadlineLabel = (new DateTimeImmutable($registrationDeadline))->format('d.m.Y');
} catch (Throwable) {
$registrationDeadlineLabel = $registrationDeadline;
}
}
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html($registrationDeadlineLabel) . '</div>';
$status = (string) ($row['status'] ?? '');
$statusSignalClass = trim(auth_market_status_signal_class($status));
$statusValue = '<span' . ($statusSignalClass !== '' ? ' class="' . auth_escape_html($statusSignalClass) . '"' : '') . '>' . auth_escape_html(auth_market_status_label($status)) . '</span>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . $statusValue . '</div>';
$soldItemCount = (int) round((float) ($row['sold_item_count'] ?? 0));
$soldGrossRevenue = number_format((float) ($row['sold_gross_revenue'] ?? 0), 0, '.', "'");
$html[] = '<div class="sg-large-table__cell" role="cell">' . $soldItemCount . '/' . $soldGrossRevenue . 'CHF</div>';
$html[] = '</div>';
}
if ($hasMore) {
$html[] = '<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--load-more" role="row" data-component-part="large-table-row"><div class="sg-large-table__cell sg-large-table__cell--load-more" role="cell"><article class="sg-card sg-large-table__load-more-card" data-component="card" data-pattern="navigation-card" aria-label="Mehr 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" data-markets-load-more-trigger="true">Mehr laden</a></div></div></article></div></div>';
}
$html[] = '</div></article></section>';
return implode('', $html);
}
function auth_render_markets_content(array $marketTable): string
{
return '<div data-markets-content="true" data-market-csrf-token="' . auth_escape_html(auth_csrf_token()) . '">'
. auth_render_markets_large_table($marketTable)
. '<div class="sg-card-list-page-drawer-backdrop" aria-hidden="true" data-market-drawer-backdrop="true" data-open="false"></div>'
. '<aside class="sg-card-list-page-drawer" aria-label="Marktdurchführung-Details" aria-hidden="true" data-market-drawer="true" data-open="false">'
. '<div class="sg-card-list-page-drawer__content" data-market-drawer-content><p class="sg-body">Marktdetails werden geladen …</p></div>'
. '</aside></div>';
}
function auth_render_market_definitions_content(array $marketTable): string
{
$rows = $marketTable['rows'] ?? $marketTable;
$categories = $marketTable['categories'] ?? [];
$organizers = $marketTable['organizers'] ?? [];
$sortColumn = (string) ($marketTable['sort_column'] ?? 'name');
$sortDirection = strtoupper((string) ($marketTable['sort_direction'] ?? 'ASC')) === 'DESC' ? 'DESC' : 'ASC';
$html = [];
$html[] = '<div data-market-definitions-content="true" data-market-definitions-csrf-token="' . auth_escape_html(auth_csrf_token()) . '"><section class="sg-card-list-page" aria-label="Märkte">';
$html[] = '<article class="sg-card" data-component="basic-card"><div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-component-part="card-body"><div class="sg-component-row sg-basic-card__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" aria-haspopup="dialog" aria-expanded="false" data-component="button" data-market-definition-create>Neuer Markt</button></div></div></article>';
$html[] = '<article class="sg-card sg-large-table sg-large-table--markets" data-component="large-table" data-column-resize="true" data-column-resize-key="market-definitions" data-column-min-width="8rem" data-market-definitions-table="true" role="table" aria-label="Märkte">';
$html[] = '<div class="sg-large-table__viewport" data-component-part="large-table-viewport">';
$html[] = '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-large-table__title-segment" data-component-part="large-table-header"><div class="sg-strong">Märkte</div></div>';
$html[] = '<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--header" role="row" data-component-part="large-table-header-row">';
$definitionColumns = [
'name' => 'Markt',
'category_name' => 'Kategorie',
'organizer_name' => 'Veranstalter',
'website_url' => 'Website',
'occurrence_count' => 'Durchführungen',
'status' => 'Status',
];
$definitionColumnIndex = 0;
foreach ($definitionColumns as $column => $label) {
$isActive = $column === $sortColumn;
$headerDirection = $isActive ? strtolower($sortDirection) : 'none';
$iconDirection = $isActive && $sortDirection === 'DESC' ? 'descending' : 'ascending';
$targetDirection = $isActive && $sortDirection === 'ASC' ? 'DESC' : 'ASC';
$ariaLabel = $isActive
? $label . ' ' . ($sortDirection === 'ASC' ? 'aufsteigend sortiert' : 'absteigend sortiert')
: $label . ' sortieren';
$sortButton = '<button class="sg-large-table__sort-button" type="button" aria-label="' . auth_escape_html($ariaLabel) . '" data-market-definition-sort data-market-definition-sort-column="' . auth_escape_html($column) . '" data-market-definition-sort-direction="' . auth_escape_html($targetDirection) . '"><span class="sg-large-table__sort-label">' . auth_escape_html($label) . '</span><span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="' . auth_escape_html($iconDirection) . '"></span></button>';
$html[] = '<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" aria-sort="' . auth_escape_html($headerDirection) . '" data-sort-key="' . $definitionColumnIndex . '" data-default-width="' . auth_large_table_default_width(count($definitionColumns)) . '">' . $sortButton . auth_large_table_resize_handle($label, $definitionColumnIndex, count($definitionColumns)) . '</div>';
$definitionColumnIndex++;
}
$html[] = '</div>';
foreach ($rows as $index => $row) {
$rowClass = 'sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row';
if ($index % 2 === 1) {
$rowClass .= ' sg-large-table__row--striped-light';
}
$website = (string) ($row['website_url'] ?? '');
$websiteValue = $website === '' ? 'Nicht hinterlegt' : $website;
$status = ($row['archived_at'] ?? null) === null ? 'Aktiv' : 'Archiviert';
$marketId = (int) ($row['id'] ?? 0);
$marketName = (string) ($row['name'] ?? '');
$organizerName = (string) ($row['organizer_name'] ?? '');
$html[] = '<div class="' . $rowClass . '" role="row" data-component-part="large-table-row" data-market-definition-row data-market-definition-id="' . $marketId . '">';
$html[] = '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-market-definition-open data-market-definition-id="' . $marketId . '" data-market-definition-name="' . auth_escape_html($marketName) . '" data-market-definition-category-id="' . (int) ($row['category_id'] ?? 0) . '" data-market-definition-category-name="' . auth_escape_html((string) ($row['category_name'] ?? $row['category_code'] ?? '')) . '" data-market-definition-organizer-id="' . (int) ($row['organizer_id'] ?? 0) . '" data-market-definition-organizer-name="' . auth_escape_html($organizerName) . '" data-market-definition-website="' . auth_escape_html($website) . '">' . auth_escape_html($marketName) . '</a></div>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html((string) ($row['category_name'] ?? $row['category_code'] ?? '')) . '</div>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html($organizerName) . '</div>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html($websiteValue) . '</div>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html((string) ($row['occurrence_count'] ?? 0)) . '</div>';
$html[] = '<div class="sg-large-table__cell" role="cell">' . auth_escape_html($status) . '</div>';
$html[] = '</div>';
}
$html[] = '</div></article></section>';
$html[] = '<div class="sg-card-list-page-drawer-backdrop" aria-hidden="true" data-market-definition-drawer-backdrop="true" data-open="false"></div>';
$html[] = '<aside class="sg-card-list-page-drawer" aria-label="Markt-Details" aria-hidden="true" data-market-definition-drawer="true" data-open="false"><div class="sg-card-list-page-drawer__content" data-market-definition-drawer-content><article class="sg-card sg-card--content-card" data-component="content-card" data-shared-editable-action-card data-market-definition-card><header class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header"><h2 class="sg-strong sg-text-on-dark" data-market-definition-read-title>Markt</h2><div class="sg-component-row"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-market-definition-edit data-shared-editable-card-edit>Bearbeiten</button></div></header><div class="sg-card-segment sg-card-segment--body sg-card-segment--white" data-market-definition-read-card data-shared-editable-card-read><dl class="market-detail-grid"><div class="market-detail-row"><dt>Marktname</dt><dd data-market-definition-read-name></dd></div><div class="market-detail-row"><dt>Kategorie/Marktart</dt><dd data-market-definition-read-category></dd></div><div class="market-detail-row"><dt>Veranstalter</dt><dd data-market-definition-read-organizer></dd></div><div class="market-detail-row"><dt>Website</dt><dd data-market-definition-read-website></dd></div></dl></div><div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-market-definition-create-card data-shared-editable-card-form hidden><h2 class="sg-strong" data-market-definition-form-title>Markt bearbeiten</h2><div class="sg-form-sections-card-wrapper" data-pattern="form-sections"><form class="sg-form-sections-card" data-market-definition-form><div class="sg-form-sections-card__body" data-pattern-part="form-body"><div class="sg-form-sections-card__section"><div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Marktname<span class="market-required-field-indicator" aria-hidden="true">*</span></span><input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" name="name" required data-market-definition-name></label></div><div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Kategorie/Marktart<span class="market-required-field-indicator" aria-hidden="true">*</span></span><select class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable sg-pulldown" name="category_id" required data-market-definition-category><option value="">Bitte auswählen</option>';
foreach ($categories as $category) {
$html[] = '<option value="' . (int) ($category['id'] ?? 0) . '">' . auth_escape_html((string) ($category['name'] ?? $category['code'] ?? '')) . '</option>';
}
$html[] = '</select></label></div><div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Veranstalter<span class="market-required-field-indicator" aria-hidden="true">*</span></span><select class="sg-interaction-element sg-input-single-line sg-pulldown" name="organizer_id" required data-market-definition-organizer><option value="">Bitte auswählen</option>';
foreach ($organizers as $organizer) {
$html[] = '<option value="' . (int) ($organizer['id'] ?? 0) . '">' . auth_escape_html((string) ($organizer['name'] ?? '')) . '</option>';
}
$html[] = '<option value="__new__">Neuen Veranstalter anlegen</option></select></label></div><div class="sg-form-sections-card__field-group" data-market-definition-new-organizer hidden><label class="sg-labeled-input-row"><span class="sg-label">Name des neuen Veranstalters<span class="market-required-field-indicator" aria-hidden="true">*</span></span><input class="sg-interaction-element sg-input-single-line" type="text" name="organizer_name" data-market-definition-organizer-name></label><label class="sg-labeled-input-row"><span class="sg-label">E-Mail</span><input class="sg-interaction-element sg-input-single-line" type="email" name="organizer_email" data-market-definition-organizer-email></label><label class="sg-labeled-input-row"><span class="sg-label">Telefon</span><input class="sg-interaction-element sg-input-single-line" type="tel" name="organizer_phone" data-market-definition-organizer-phone></label></div><div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Website</span><input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="url" name="website_url" placeholder="https://" data-market-definition-website></label></div></div></div><footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-market-definition-cancel data-shared-editable-card-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Speichern</button></div></footer><p class="sg-body" data-market-definition-status aria-live="polite"></p></form></div></div></div></article></div></aside></div>';
return implode('', $html);
}
function auth_render_bestellungen_large_table(array $bestellungenTable): string
{
$rows = $bestellungenTable['rows'] ?? [];
@@ -70,7 +280,8 @@ function auth_render_bestellungen_large_table(array $bestellungenTable): string
$html = [];
$html[] = '<section class="sg-card-list-page" aria-label="Bestellungen">';
$html[] = '<article class="sg-card sg-large-table sg-large-table--bestellungen" style="margin-top: var(--spacing-large);" data-component="large-table" data-bestellungen-large-table="true" data-bestellungen-search="' . auth_escape_html($search) . '" data-bestellungen-sort-column="' . auth_escape_html($sortColumn) . '" data-bestellungen-sort-direction="' . auth_escape_html($sortDirection) . '" data-bestellungen-limit="' . $limit . '" data-bestellungen-page-size="' . $pageSize . '" role="table" aria-label="Bestellungen">';
$html[] = '<article class="sg-card sg-large-table sg-large-table--bestellungen" style="margin-top: var(--spacing-large);" data-component="large-table" data-column-resize="true" data-column-resize-key="orders" data-column-min-width="8rem" data-bestellungen-large-table="true" data-bestellungen-search="' . auth_escape_html($search) . '" data-bestellungen-sort-column="' . auth_escape_html($sortColumn) . '" data-bestellungen-sort-direction="' . auth_escape_html($sortDirection) . '" data-bestellungen-limit="' . $limit . '" data-bestellungen-page-size="' . $pageSize . '" role="table" aria-label="Bestellungen">';
$html[] = '<div class="sg-large-table__viewport" data-component-part="large-table-viewport">';
$html[] = '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-large-table__title-segment" data-component-part="large-table-header">';
$html[] = '<div class="sg-strong">Bestellungen</div>';
$html[] = '<span class="sg-search-field-row" data-component="search-field">';
@@ -95,11 +306,11 @@ function auth_render_bestellungen_large_table(array $bestellungenTable): string
: ($column === 'order_date' ? 'DESC' : 'ASC');
$amountHeaderClass = $column === 'total_amount' ? ' sg-large-table__cell--amount' : '';
$html[] = '<div class="sg-large-table__cell sg-large-table__cell--header' . $amountHeaderClass . '" role="columnheader" aria-sort="' . $headerDirection . '" data-sort-key="' . $headerIndex . '">';
$html[] = '<div class="sg-large-table__cell sg-large-table__cell--header' . $amountHeaderClass . '" role="columnheader" aria-sort="' . $headerDirection . '" data-sort-key="' . $headerIndex . '" data-default-width="' . auth_large_table_default_width(4) . '">';
$html[] = '<button class="sg-large-table__sort-button" type="button" aria-label="' . auth_escape_html($ariaLabel) . '" data-bestellungen-sort-button="true" data-bestellungen-sort-column="' . auth_escape_html($column) . '" data-bestellungen-sort-direction="' . auth_escape_html($targetDirection) . '">';
$html[] = '<span class="sg-large-table__sort-label">' . auth_escape_html($label) . '</span>';
$html[] = '<span class="sg-large-table__sort-icon" aria-hidden="true" data-direction="' . auth_escape_html($iconDirection) . '"></span>';
$html[] = '</button>';
$html[] = '</button>' . auth_large_table_resize_handle($label, $headerIndex, 4);
$html[] = '</div>';
$headerIndex++;
}
@@ -147,9 +358,7 @@ function auth_render_bestellungen_large_table(array $bestellungenTable): string
$html[] = '<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--load-more" role="row" data-component-part="large-table-row" data-large-table-load-more-row="true">';
$html[] = '<div class="sg-large-table__cell sg-large-table__cell--load-more" role="cell">';
$html[] = '<div class="sg-navigation-card-layout sg-large-table__load-more-layout">';
$html[] = '<div class="sg-navigation-card-block">';
$html[] = '<article class="sg-card" data-component="card" data-pattern="navigation-card" aria-label="Mehr laden">';
$html[] = '<article class="sg-card sg-large-table__load-more-card" data-component="card" data-pattern="navigation-card" aria-label="Mehr laden">';
$html[] = '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-pattern-part="navigation-card-segment">';
$html[] = '<div class="sg-navigation-card-center">';
$html[] = '<a class="sg-hyperlink" href="' . auth_escape_html($loadMoreUrl) . '" data-component="hyperlink" data-large-table-load-more-trigger="true">Mehr laden</a>';
@@ -157,11 +366,9 @@ function auth_render_bestellungen_large_table(array $bestellungenTable): string
$html[] = '</div>';
$html[] = '</article>';
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
$html[] = '</div>';
}
$html[] = '</div>';
$html[] = '</article>';
$html[] = '</section>';
@@ -214,11 +421,12 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
return ['text' => $text, 'warning' => $warning, 'mhdWarning' => $mhdWarning];
};
$html = [];
$html[] = '<article class="sg-card sg-large-table sg-large-table--lager" data-component="large-table" data-lager-table="true" data-lager-table-key="' . auth_escape_html($tableKey) . '" role="table" aria-label="' . auth_escape_html($title) . '">';
$html[] = '<article class="sg-card sg-large-table sg-large-table--lager" data-component="large-table" data-column-resize="true" data-column-resize-key="lager-' . auth_escape_html($tableKey) . '" data-column-min-width="8rem" data-lager-table="true" data-lager-table-key="' . auth_escape_html($tableKey) . '" role="table" aria-label="' . auth_escape_html($title) . '">';
$html[] = '<div class="sg-large-table__viewport" data-component-part="large-table-viewport">';
$html[] = '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkblue sg-large-table__title-segment" data-component-part="large-table-header"><div class="sg-strong">' . auth_escape_html($title) . '</div></div>';
$html[] = '<div class="sg-card-segment sg-card-segment--body sg-card-segment--white sg-large-table__row sg-large-table__row--header" role="row" data-component-part="large-table-header-row">';
foreach ($columns as $label) {
$html[] = '<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader"><span class="sg-large-table__sort-label">' . auth_escape_html($label) . '</span></div>';
foreach ($columns as $index => $label) {
$html[] = '<div class="sg-large-table__cell sg-large-table__cell--header" role="columnheader" data-default-width="' . auth_large_table_default_width(count($columns)) . '"><span class="sg-large-table__sort-label">' . auth_escape_html($label) . '</span>' . auth_large_table_resize_handle($label, $index, count($columns)) . '</div>';
}
$html[] = '</div>';
@@ -282,7 +490,10 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
$html[] = '<div class="' . $rowClass . '" role="row" data-component-part="large-table-row" data-lager-row="true" data-lager-id="' . $id . '">';
$html[] = '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-lager-drawer-open="true"' . $nameData . $valueData . $typeData . $detailData . '>' . auth_escape_html($name) . '</a></div>';
$html[] = '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-lager-drawer-open="true"' . $nameData . $valueData . $typeData . $detailData . '>' . auth_escape_html($value) . '</a></div>';
$valueCell = $tableKey === 'products'
? '<div class="sg-large-table__cell" role="cell">' . auth_escape_html($value) . '</div>'
: '<div class="sg-large-table__cell" role="cell"><a class="sg-hyperlink" href="#" aria-haspopup="dialog" aria-expanded="false" data-lager-drawer-open="true"' . $nameData . $valueData . $typeData . $detailData . '>' . auth_escape_html($value) . '</a></div>';
$html[] = $valueCell;
if ($tableKey === 'products') {
$forecastClass = $currentForecast['warning']
? ' sg-strong lager-forecast-warning'
@@ -297,6 +508,7 @@ function auth_render_lager_table(string $title, array $rows, array $columns, str
$html[] = '</div>';
}
$html[] = '</div>';
$html[] = '</article>';
return implode('', $html);
}
@@ -328,17 +540,17 @@ function auth_render_lager_historical_lots(array $lagerProducts): string
$emptyDate = $qtyNet <= 0 ? auth_format_lager_date($lot['last_out_at'] ?? null) : 'Nicht eindeutig';
$html[] = '<article class="sg-card sg-card--content-card" data-component="content-card" aria-label="Vergangene Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Vergangene Charge</div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body">'
. '<p class="sg-body"><strong>Naurua-Chargennummer:</strong> ' . auth_escape_html((string) ($lot['lot_number'] ?? 'Nicht hinterlegt')) . '<br>'
. '<strong>Status:</strong> Abgeschlossen<br>'
. '<strong>Eingangsmenge:</strong> ' . auth_format_lager_quantity((float) ($lot['qty_in'] ?? 0)) . '<br>'
. '<strong>Verbrauchte Menge:</strong> ' . auth_format_lager_quantity((float) ($lot['qty_out'] ?? 0)) . '<br>'
. '<strong>Verbrauchszeitraum:</strong> ' . auth_format_lager_date($lot['first_in_at'] ?? null) . ' – ' . auth_format_lager_date($lot['last_out_at'] ?? null) . '<br>'
. '<strong>Aufgebraucht am:</strong> ' . $emptyDate . '<br>'
. '<strong>MHD:</strong> ' . auth_format_lager_date($lot['expiry_date'] ?? null) . '<br>'
. '<strong>Hersteller:</strong> ' . auth_escape_html((string) ($lot['manufacturer_name'] ?? 'Nicht hinterlegt')) . '<br>'
. '<strong>Hersteller-Chargennummer:</strong> ' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? 'Nicht hinterlegt')) . '</p>'
. '</div></article>';
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body"><dl class="market-detail-grid">'
. '<div class="market-detail-row"><dt>Naurua-Chargennummer</dt><dd>' . auth_escape_html((string) ($lot['lot_number'] ?? 'Nicht hinterlegt')) . '</dd></div>'
. '<div class="market-detail-row"><dt>Status</dt><dd>Abgeschlossen</dd></div>'
. '<div class="market-detail-row"><dt>Eingangsmenge</dt><dd>' . auth_format_lager_quantity((float) ($lot['qty_in'] ?? 0)) . '</dd></div>'
. '<div class="market-detail-row"><dt>Verbrauchte Menge</dt><dd>' . auth_format_lager_quantity((float) ($lot['qty_out'] ?? 0)) . '</dd></div>'
. '<div class="market-detail-row"><dt>Verbrauchszeitraum</dt><dd>' . auth_format_lager_date($lot['first_in_at'] ?? null) . ' – ' . auth_format_lager_date($lot['last_out_at'] ?? null) . '</dd></div>'
. '<div class="market-detail-row"><dt>Aufgebraucht am</dt><dd>' . $emptyDate . '</dd></div>'
. '<div class="market-detail-row"><dt>MHD</dt><dd>' . auth_format_lager_date($lot['expiry_date'] ?? null) . '</dd></div>'
. '<div class="market-detail-row"><dt>Hersteller</dt><dd>' . auth_escape_html((string) ($lot['manufacturer_name'] ?? 'Nicht hinterlegt')) . '</dd></div>'
. '<div class="market-detail-row"><dt>Hersteller-Chargennummer</dt><dd>' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? 'Nicht hinterlegt')) . '</dd></div>'
. '</dl></div></article>';
}
$html[] = '</div></div>';
}
@@ -364,18 +576,11 @@ function auth_render_lager_open_lots(array $lagerProducts, string $manufacturerO
&& trim((string) ($lot['purchase_date'] ?? '')) !== ''
&& (float) ($lot['qty_net'] ?? 0) > 0;
$selectButton = '<button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-select data-lager-open-selectable="' . ($isQualified ? 'true' : 'false') . '"' . ($isQualified && empty($lot['next_active']) ? '' : ' disabled aria-disabled="true"') . '>Als nächste aktive auswählen</button>';
$html[] = '<div class="sg-delete-confirmation-pattern__stage sg-delete-confirmation-pattern__host' . $selectedClass . '" data-pattern="overlay-card" data-dialog-open="false" data-lager-open-card-wrapper="' . $lotId . '"><article class="sg-card sg-card--content-card sg-delete-confirmation-pattern__target" data-component="content-card" data-pattern="overlay-card" aria-label="Offene Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Offene Charge</div><div class="sg-component-row">' . $selectButton . '<button class="sg-interaction-element sg-button sg-button--warning" type="button" data-component="button" data-component-variant="warning" data-lager-open-delete>Löschen</button><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-edit-open>Bearbeiten</button></div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-lager-open-card="' . (int) ($lot['lot_id'] ?? 0) . '"><p class="sg-body"><strong>Auswahl:</strong> <span data-lager-open-card-next-active>' . auth_escape_html($selectedText) . '</span><br><strong>Naurua-Chargennummer:</strong> <span data-lager-open-card-lot-number>' . auth_escape_html((string) ($lot['lot_number'] ?? 'Nicht hinterlegt')) . '</span><br>'
. '<strong>Status:</strong> Vorbereitet<br>'
. '<strong>Bestand:</strong> <span data-lager-open-card-qty-net>' . auth_format_lager_quantity((float) ($lot['qty_net'] ?? 0)) . '</span><br>'
. '<strong>Eingangsmenge:</strong> <span data-lager-open-card-qty-in>' . auth_format_lager_quantity((float) ($lot['qty_in'] ?? 0)) . '</span><br>'
. '<strong>Hersteller:</strong> <span data-lager-open-card-manufacturer>' . auth_escape_html((string) ($lot['manufacturer_name'] ?? 'Nicht hinterlegt')) . '</span><br>'
. '<strong>Hersteller-Chargennummer:</strong> <span data-lager-open-card-manufacturer-lot>' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? 'Nicht hinterlegt')) . '</span><br>'
. '<strong>Herstellungsdatum:</strong> <span data-lager-open-card-mfg-date>' . auth_format_lager_date($lot['mfg_date'] ?? null) . '</span><br>'
. '<strong>MHD:</strong> <span data-lager-open-card-expiry-date>' . auth_format_lager_date($lot['expiry_date'] ?? null) . '</span><br>'
. '<strong>Einkaufsdatum:</strong> <span data-lager-open-card-purchase-date>' . auth_format_lager_date($lot['purchase_date'] ?? null) . '</span></p></div>'
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-open-edit-form hidden><div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Offene Charge bearbeiten"><form class="sg-form-sections-card" data-lager-open-edit-form-element><div class="sg-form-sections-card__body" data-pattern-part="form-body"><h2 class="sg-strong sg-form-sections-card__title">Offene Charge bearbeiten</h2>'
$html[] = '<div class="sg-delete-confirmation-pattern__stage sg-delete-confirmation-pattern__host' . $selectedClass . '" data-pattern="overlay-card" data-dialog-open="false" data-lager-open-card-wrapper="' . $lotId . '"><article class="sg-card sg-card--content-card sg-delete-confirmation-pattern__target" data-component="content-card" data-pattern="overlay-card" data-shared-editable-action-card data-shared-editable-delete-token="DELETE" aria-label="Offene Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Offene Charge</div><div class="sg-component-row">' . $selectButton . '<button class="sg-interaction-element sg-button sg-button--warning" type="button" data-component="button" data-component-variant="warning" data-lager-open-delete data-shared-editable-card-delete>Löschen</button><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-edit-open data-shared-editable-card-edit>Bearbeiten</button></div></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-lager-open-card="' . (int) ($lot['lot_id'] ?? 0) . '" data-shared-editable-card-read><dl class="market-detail-grid"><div class="market-detail-row"><dt>Auswahl</dt><dd><span data-lager-open-card-next-active>' . auth_escape_html($selectedText) . '</span></dd></div><div class="market-detail-row"><dt>Naurua-Chargennummer</dt><dd><span data-lager-open-card-lot-number>' . auth_escape_html((string) ($lot['lot_number'] ?? 'Nicht hinterlegt')) . '</span></dd></div>'
. '<div class="market-detail-row"><dt>Status</dt><dd>Vorbereitet</dd></div><div class="market-detail-row"><dt>Bestand</dt><dd><span data-lager-open-card-qty-net>' . auth_format_lager_quantity((float) ($lot['qty_net'] ?? 0)) . '</span></dd></div><div class="market-detail-row"><dt>Eingangsmenge</dt><dd><span data-lager-open-card-qty-in>' . auth_format_lager_quantity((float) ($lot['qty_in'] ?? 0)) . '</span></dd></div><div class="market-detail-row"><dt>Hersteller</dt><dd><span data-lager-open-card-manufacturer>' . auth_escape_html((string) ($lot['manufacturer_name'] ?? 'Nicht hinterlegt')) . '</span></dd></div><div class="market-detail-row"><dt>Hersteller-Chargennummer</dt><dd><span data-lager-open-card-manufacturer-lot>' . auth_escape_html((string) ($lot['manufacturer_lot_number'] ?? 'Nicht hinterlegt')) . '</span></dd></div><div class="market-detail-row"><dt>Herstellungsdatum</dt><dd><span data-lager-open-card-mfg-date>' . auth_format_lager_date($lot['mfg_date'] ?? null) . '</span></dd></div><div class="market-detail-row"><dt>MHD</dt><dd><span data-lager-open-card-expiry-date>' . auth_format_lager_date($lot['expiry_date'] ?? null) . '</span></dd></div><div class="market-detail-row"><dt>Einkaufsdatum</dt><dd><span data-lager-open-card-purchase-date>' . auth_format_lager_date($lot['purchase_date'] ?? null) . '</span></dd></div></dl></div>'
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-open-edit-form data-shared-editable-card-form hidden><div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Offene Charge bearbeiten"><form class="sg-form-sections-card" data-lager-open-edit-form-element><div class="sg-form-sections-card__body" data-pattern-part="form-body"><h2 class="sg-strong sg-form-sections-card__title">Offene Charge bearbeiten</h2>'
. '<section class="sg-form-sections-card__chapter" aria-labelledby="lager-open-charge-data-' . $lotId . '"><h2 id="lager-open-charge-data-' . $lotId . '" class="sg-strong sg-form-sections-card__chapter-title">Chargendaten</h2>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Naurua-Chargennummer</span><input class="sg-interaction-element sg-input-single-line" type="text" data-lager-open-lot-number value="' . auth_escape_html((string) ($lot['lot_number'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Hersteller</span><select class="sg-interaction-element sg-input-single-line" data-lager-open-manufacturer>' . str_replace('value="' . (int) ($lot['manufacturer_party_id'] ?? 0) . '"', 'value="' . (int) ($lot['manufacturer_party_id'] ?? 0) . '" selected', $manufacturerOptions) . '</select></label></div>'
@@ -384,9 +589,9 @@ function auth_render_lager_open_lots(array $lagerProducts, string $manufacturerO
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">MHD</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-open-expiry-date value="' . auth_escape_html((string) ($lot['expiry_date'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Einkaufsdatum</span><input class="sg-interaction-element sg-input-single-line" type="date" data-lager-open-purchase-date value="' . auth_escape_html((string) ($lot['purchase_date'] ?? '')) . '"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Menge</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="1" inputmode="numeric" data-lager-open-target-qty value="' . auth_escape_html((string) ($lot['qty_net'] ?? 0)) . '"></label></div>'
. '</section><footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-open-edit-close>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Speichern</button></div></footer><p class="sg-body" data-lager-open-edit-status aria-live="polite"></p></div></form></div></div></article>'
. '</section><footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-open-edit-close data-shared-editable-card-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Speichern</button></div></footer><p class="sg-body" data-lager-open-edit-status aria-live="polite"></p></div></form></div></div></article>'
. '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Leere Felder bestätigen" role="dialog" aria-modal="true" aria-labelledby="lager-open-confirmation-title-' . $lotId . '" data-lager-open-confirmation-card hidden><div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body"><p class="sg-body sg-delete-confirmation-pattern__text" id="lager-open-confirmation-title-' . $lotId . '"><strong>Leere Felder speichern?</strong></p><p class="sg-body sg-delete-confirmation-pattern__text">Die folgenden Felder werden leer gespeichert:</p><div class="sg-delete-confirmation-pattern__list" data-lager-open-confirmation-empty-fields aria-label="Leer zu speichernde Felder"></div><div class="sg-delete-confirmation-pattern__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-confirmation-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process" type="button" data-lager-open-confirmation-submit>Speichern</button></div></div></article>'
. '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Löschbestätigung" role="dialog" aria-modal="true" aria-labelledby="lager-open-delete-title-' . $lotId . '" data-overlay-dialog="delete" data-lager-open-delete-confirmation hidden><div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body"><p class="sg-body sg-delete-confirmation-pattern__text" id="lager-open-delete-title-' . $lotId . '"><strong>Möchtest du diese offene Charge wirklich löschen?</strong></p><p class="sg-body sg-delete-confirmation-pattern__text">Du kannst das nicht rückgängig machen. Bestätige durch Eingabe von <span class="sg-delete-confirmation-pattern__code">DELETE</span>.</p><label class="sg-labeled-input-row sg-delete-confirmation-pattern__input-row"><span class="sg-label">Bestätigung</span><input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" placeholder="DELETE" aria-label="Löschbestätigung durch DELETE" data-lager-open-delete-confirmation-input></label><p class="sg-body" data-lager-open-delete-status aria-live="polite"></p><div class="sg-delete-confirmation-pattern__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-delete-confirmation-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive" type="button" disabled aria-disabled="true" data-lager-open-delete-confirmation-submit>Löschen</button></div></div></article></div>';
. '<article class="sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card" aria-label="Löschbestätigung" role="dialog" aria-modal="true" aria-labelledby="lager-open-delete-title-' . $lotId . '" data-overlay-dialog="delete" data-lager-open-delete-confirmation data-shared-editable-card-delete-confirmation hidden><div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body"><p class="sg-body sg-delete-confirmation-pattern__text" id="lager-open-delete-title-' . $lotId . '"><strong>Möchtest du diese offene Charge wirklich löschen?</strong></p><p class="sg-body sg-delete-confirmation-pattern__text">Du kannst das nicht rückgängig machen. Bestätige durch Eingabe von <span class="sg-delete-confirmation-pattern__code">DELETE</span>.</p><label class="sg-labeled-input-row sg-delete-confirmation-pattern__input-row"><span class="sg-label">Bestätigung</span><input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" placeholder="DELETE" aria-label="Löschbestätigung durch DELETE" data-lager-open-delete-confirmation-input data-shared-editable-card-delete-input></label><p class="sg-body" data-lager-open-delete-status aria-live="polite"></p><div class="sg-delete-confirmation-pattern__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-open-delete-confirmation-cancel data-shared-editable-card-delete-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive" type="button" disabled aria-disabled="true" data-lager-open-delete-confirmation-submit data-shared-editable-card-delete-submit>Löschen</button></div></div></article></div>';
}
$html[] = '</div></div>';
}
@@ -454,10 +659,10 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar
. '</article>'
. '<template data-lager-open-lot-template>' . $openLotCardTemplate . '</template>'
. '<div data-lager-drawer-topic="open-lots" aria-label="Offene Chargen">' . auth_render_lager_open_lots($lagerProducts, $manufacturerOptions) . '</div>'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="lot" aria-label="Aktive Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Aktive Charge</div><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-edit-open>Bearbeiten</button></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-lager-read-view><p class="sg-body"><strong>Naurua-Chargennummer:</strong> <span data-lager-drawer-lot-number></span><br><strong>Aktueller Bestand:</strong> <span data-lager-drawer-current-qty></span><br><strong>Eingangsmenge:</strong> <span data-lager-drawer-qty-in></span><br><strong>Hersteller:</strong> <span data-lager-drawer-manufacturer-name></span><br><strong>Hersteller-Chargennummer:</strong> <span data-lager-drawer-manufacturer-lot-number></span><br><strong>Herstellungsdatum:</strong> <span data-lager-drawer-mfg-date></span><br><strong>MHD:</strong> <span data-lager-drawer-expiry-date></span><br><strong>Einkaufsdatum:</strong> <span data-lager-drawer-purchase-date></span></p></div>'
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-edit-form hidden><div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Aktive Charge bearbeiten">'
. '<article class="sg-card sg-card--content-card" data-component="content-card" data-lager-drawer-topic="lot" data-shared-editable-action-card aria-label="Aktive Charge">'
. '<div class="sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header" data-component-part="card-header"><div class="sg-strong sg-text-on-dark">Aktive Charge</div><button class="sg-interaction-element sg-button sg-button--active" type="button" data-lager-edit-open data-shared-editable-card-edit>Bearbeiten</button></div>'
. '<div class="sg-card-segment sg-card-segment--body" data-component-part="card-body" data-lager-read-view data-shared-editable-card-read><dl class="market-detail-grid"><div class="market-detail-row"><dt>Naurua-Chargennummer</dt><dd><span data-lager-drawer-lot-number></span></dd></div><div class="market-detail-row"><dt>Aktueller Bestand</dt><dd><span data-lager-drawer-current-qty></span></dd></div><div class="market-detail-row"><dt>Eingangsmenge</dt><dd><span data-lager-drawer-qty-in></span></dd></div><div class="market-detail-row"><dt>Hersteller</dt><dd><span data-lager-drawer-manufacturer-name></span></dd></div><div class="market-detail-row"><dt>Hersteller-Chargennummer</dt><dd><span data-lager-drawer-manufacturer-lot-number></span></dd></div><div class="market-detail-row"><dt>Herstellungsdatum</dt><dd><span data-lager-drawer-mfg-date></span></dd></div><div class="market-detail-row"><dt>MHD</dt><dd><span data-lager-drawer-expiry-date></span></dd></div><div class="market-detail-row"><dt>Einkaufsdatum</dt><dd><span data-lager-drawer-purchase-date></span></dd></div></dl></div>'
. '<div class="sg-card-segment sg-card-segment--body sg-card-segment--gray" data-lager-edit-form data-shared-editable-card-form hidden><div class="sg-form-sections-card-wrapper" data-pattern="form-sections" aria-label="Aktive Charge bearbeiten">'
. '<form class="sg-form-sections-card" data-lager-edit-form-element><div class="sg-form-sections-card__body" data-pattern-part="form-body"><h2 class="sg-strong sg-form-sections-card__title">Aktive Charge bearbeiten</h2>'
. '<section class="sg-form-sections-card__chapter" aria-labelledby="lager-charge-traceability-title"><h2 id="lager-charge-traceability-title" class="sg-strong sg-form-sections-card__chapter-title">Rückverfolgbarkeit</h2>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Naurua-Chargennummer</span><input class="sg-interaction-element sg-input-single-line sg-form-inactive-selectable" type="text" data-lager-lot-number readonly></label></div>'
@@ -471,7 +676,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Zugang buchen</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="0.01" inputmode="decimal" data-lager-receipt-qty placeholder="Menge"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Bestandskorrektur (+ / −)</span><input class="sg-interaction-element sg-input-single-line" type="number" step="1" inputmode="numeric" data-lager-adjustment-qty placeholder="+ / − Menge"></label></div>'
. '<div class="sg-form-sections-card__field-group"><label class="sg-labeled-input-row"><span class="sg-label">Abgang buchen</span><input class="sg-interaction-element sg-input-single-line" type="number" min="0" step="0.01" inputmode="decimal" data-lager-out-qty placeholder="Menge"></label></div>'
. '</section><footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-edit-close>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Speichern</button></div></footer><p class="sg-body" data-lager-edit-status aria-live="polite"></p></div></form>'
. '</section><footer class="sg-form-sections-card__actions-segment" data-pattern-part="form-actions-segment"><div class="sg-form-sections-card__actions" data-pattern-part="form-actions"><button class="sg-interaction-element sg-button sg-button--active sg-form-sections-card__action" type="button" data-lager-edit-close data-shared-editable-card-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-form-sections-card__action" type="submit">Speichern</button></div></footer><p class="sg-body" data-lager-edit-status aria-live="polite"></p></div></form>'
. '</div></div>'
. '</article>'
. '<div data-lager-drawer-topic="historical-lots" aria-label="Vergangene Chargen">' . auth_render_lager_historical_lots($lagerProducts) . '</div>'
@@ -484,7 +689,7 @@ function auth_render_lager_content(array $lagerProducts, array $lagerArtikel, ar
. '</div>';
}
function render_auth_home_page(array $user, array $otcProducts = [], array $bestellungenTable = [], array $lagerProducts = [], array $lagerArtikel = [], array $lagerManufacturers = []): void
function render_auth_home_page(array $user, array $otcProducts = [], array $otcMarketOptions = [], array $bestellungenTable = [], array $marketOccurrencesTable = [], array $lagerProducts = [], array $lagerArtikel = [], array $lagerManufacturers = [], array $marketDefinitionsTable = []): void
{
$otcProductRows = '';
foreach ($otcProducts as $product) {
@@ -504,6 +709,26 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
$otcProductRows .= '</div>';
}
$otcMarketOccurrenceOptions = [];
$otcMarketDayOptions = '';
foreach ($otcMarketOptions as $option) {
$occurrenceId = (int) ($option['occurrence_id'] ?? 0);
$marketDefinitionId = (int) ($option['market_definition_id'] ?? 0);
$dayId = (int) ($option['day_id'] ?? 0);
if ($occurrenceId <= 0 || $marketDefinitionId <= 0 || $dayId <= 0) {
continue;
}
$marketName = (string) ($option['market_name'] ?? '');
$otcMarketOccurrenceOptions[$marketDefinitionId] = $marketName;
$day = (string) ($option['date'] ?? '');
$dayLabel = $day === '' ? '' : (new DateTimeImmutable($day))->format('d.m.Y');
$otcMarketDayOptions .= '<option value="' . $dayId . '" data-market-definition-id="' . $marketDefinitionId . '" data-market-occurrence-id="' . $occurrenceId . '">' . auth_escape_html($dayLabel) . '</option>';
}
$otcMarketOccurrenceOptionHtml = '<option value="">Markt auswählen</option>';
foreach ($otcMarketOccurrenceOptions as $marketDefinitionId => $marketName) {
$otcMarketOccurrenceOptionHtml .= '<option value="' . $marketDefinitionId . '">' . auth_escape_html($marketName) . '</option>';
}
if ($otcProductRows === '') {
$otcProductRows = '<p class="sg-body sg-form-sections-card__sentence">Keine aktiven OTC-Produkte erfasst.</p>';
}
@@ -512,16 +737,21 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
[
'Übersicht' => [],
'ERP' => ['Bestellungen', 'Lager', 'Produkte', 'Artikel', 'Kontakte'],
'Märkte' => ['Marktdurchführungen', 'Märkte'],
'Buchhaltung' => [],
'Kundenberatung' => [],
],
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
$bestellungenContent = auth_render_bestellungen_content($bestellungenTable);
$marketsContent = auth_render_markets_content($marketOccurrencesTable);
$marketDefinitionsContent = auth_render_market_definitions_content($marketDefinitionsTable);
$lagerContent = auth_render_lager_content($lagerProducts, $lagerArtikel, $lagerManufacturers, auth_csrf_token());
$moduleContentCards = json_encode(
[
'Bestellungen' => $bestellungenContent,
'Marktdurchführungen' => $marketsContent,
'Märkte' => $marketDefinitionsContent,
'Lager' => $lagerContent,
'Artikel' => auth_render_lager_content([], $lagerArtikel, $lagerManufacturers, auth_csrf_token(), 'Artikel'),
],
@@ -531,6 +761,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
[
'Übersicht' => 'Willkommen bei Naurua Übersicht',
'ERP' => 'Willkommen bei Naurua ERP',
'Märkte' => 'Willkommen bei Naurua Märkte',
'Buchhaltung' => 'Willkommen bei Naurua Buchhaltung',
'Kundenberatung' => 'Willkommen bei Naurua Kundenberatung',
],
@@ -543,7 +774,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '<meta charset="UTF-8">';
echo '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
echo '<title>Naurua ERP</title>';
echo '<link rel="stylesheet" href="/assets/styles.css">';
echo '<link rel="stylesheet" href="/assets/styles.css?v=' . filemtime(__DIR__ . '/../../../../public/assets/styles.css') . '">';
echo '<link rel="stylesheet" href="/assets/styles.portal.css?v=' . filemtime(__DIR__ . '/../../../../public/assets/styles.portal.css') . '">';
echo '</head>';
echo '<body>';
@@ -568,6 +799,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '<nav class="sg-portal-header__tabs sg-tab-button-group" aria-label="Hauptnavigation" data-component="tab-navigation" data-component-size="large" data-component-context="portal-header" data-pattern-part="portal-header-navigation">';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Übersicht</button>';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="true" data-component-part="tab-button" data-component-state="active">ERP</button>';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Märkte</button>';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Buchhaltung</button>';
echo '<button class="sg-interaction-element sg-button sg-tab-button" type="button" aria-selected="false" data-component-part="tab-button" data-component-state="inactive">Kundenberatung</button>';
echo '</nav>';
@@ -636,7 +868,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '</select>';
echo '</label>';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Betrag (CHF)</span>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="number" id="otc-payment-amount-1" min="0.01" step="0.01" value="0.00" data-otc-payment-amount>';
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="number" id="otc-payment-amount-1" min="0.01" step="0.01" placeholder="0.00" data-otc-payment-amount>';
echo '</label>';
echo '</div>';
echo '</div>';
@@ -676,6 +908,32 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '<input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" id="otc-city" value="Oberengstringen" data-otc-order-city>';
echo '</label>';
echo '</div>';
echo '</section>';
echo '<section class="sg-form-sections-card__chapter" aria-labelledby="otc-market-link-title">';
echo '<h2 id="otc-market-link-title" class="sg-strong sg-form-sections-card__chapter-title">Marktbezug</h2>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-checkbox-field-option sg-checkbox-field-option--inactive-selectable sg-body" data-component="checkbox-field" data-component-state="inactive-selectable">';
echo '<button class="sg-checkbox-field sg-checkbox-field--inactive-selectable" type="button" role="checkbox" aria-checked="false" aria-label="Verkauf ist auf einem Markttag" data-otc-market-link-toggle>';
echo '<span class="sg-checkbox-field__mark" aria-hidden="true">✓</span>';
echo '</button><span>Verkauf ist auf einem Markttag</span>';
echo '<input type="hidden" name="market_sale" value="0" data-otc-market-link-enabled>';
echo '</label>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Markt</span>';
echo '<select class="sg-interaction-element sg-input-single-line sg-input-single-line--disabled sg-form-disabled sg-pulldown sg-pulldown--disabled" disabled aria-disabled="true" data-otc-market-occurrence>';
echo $otcMarketOccurrenceOptionHtml;
echo '</select>';
echo '</label>';
echo '</div>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<label class="sg-labeled-input-row"><span class="sg-label">Markttag</span>';
echo '<select class="sg-interaction-element sg-input-single-line sg-input-single-line--disabled sg-form-disabled sg-pulldown sg-pulldown--disabled" disabled aria-disabled="true" data-otc-market-day>';
echo '<option value="">Zuerst Markt auswählen</option>' . $otcMarketDayOptions;
echo '</select>';
echo '</label>';
echo '</div>';
echo '</section>';
echo '<div class="sg-form-sections-card__field-group">';
echo '<div class="sg-body sg-otc-order-form__status hidden" data-otc-order-success>Bestellung erfolgreich erfasst! Die Bestellnummer wird automatisch generiert.</div>';
echo '<div class="sg-body sg-otc-order-form__status sg-otc-order-form__status--error hidden" data-otc-order-error></div>';
@@ -697,6 +955,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '<div class="sg-delete-confirmation-pattern__list" data-otc-confirmation-products aria-label="Produkte"></div>';
echo '<p class="sg-body sg-delete-confirmation-pattern__text"><strong>Gesamtzahl Flaschen: <span data-otc-confirmation-quantity>0</span></strong></p>';
echo '<p class="sg-body sg-delete-confirmation-pattern__text"><strong>Gesamtbetrag: CHF <span data-otc-confirmation-total>0.00</span></strong></p>';
echo '<p class="sg-body sg-otc-order-form__status sg-otc-order-form__status--error hidden" data-otc-confirmation-error aria-live="polite"></p>';
echo '<div class="sg-delete-confirmation-pattern__actions">';
echo '<button class="sg-interaction-element sg-button sg-button--active" type="button" data-otc-confirmation-cancel>Abbrechen</button>';
echo '<button class="sg-interaction-element sg-button sg-button--process" type="button" data-otc-confirmation-submit>Verkaufen</button>';
@@ -706,26 +965,36 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo '</div>';
echo '</section>';
echo '<script src="/scripts/help-icon-overlays.js"></script>';
echo '<script src="/scripts/shared-editable-action-card.js?v=20260930-2"></script>';
echo '<script src="/scripts/shared-mobile-detail.js?v=20260925-1"></script>';
echo '<script src="/scripts/large-table-column-resize.js?v=' . filemtime(__DIR__ . '/../../../../public/scripts/large-table-column-resize.js') . '"></script>';
echo '<script>';
echo 'const portalModuleNavigation = ' . $moduleNavigation . ';';
echo 'const portalModuleContentCards = ' . $moduleContentCards . ';';
echo 'const portalModuleHeadings = ' . $moduleHeadings . ';';
echo "const portalContentBody = document.querySelector('[data-left-navigation-content-body]');";
echo "if (portalContentBody && window.sgInitLargeTableColumnResize) { const portalLargeTableResizeObserver = new MutationObserver(() => window.sgInitLargeTableColumnResize({ root: portalContentBody })); portalLargeTableResizeObserver.observe(portalContentBody, { childList: true }); window.sgInitLargeTableColumnResize({ root: portalContentBody }); }";
echo "const renderMainHeading = (moduleName) => {";
echo " const heading = document.querySelector('[data-main-heading]');";
echo " if (heading && portalModuleHeadings[moduleName]) { heading.textContent = portalModuleHeadings[moduleName]; }";
echo "};";
echo "const renderLeftNavigation = (moduleName) => {";
echo "const updatePortalLocation = (moduleName, entryName = '') => { const url = new URL(window.location.href); url.searchParams.set('portal_module', moduleName); if (entryName) { url.searchParams.set('portal_section', entryName); } else { url.searchParams.delete('portal_section'); } window.history.replaceState({ ...(window.history.state || {}), portalModule: moduleName, portalSection: entryName }, '', url); };";
echo "const renderLeftNavigation = (moduleName, requestedEntry = '') => {";
echo " const title = document.querySelector('[data-left-navigation-title]');";
echo " const contentTitle = document.querySelector('[data-left-navigation-content-title]');";
echo " const contentSection = document.querySelector('.sg-left-navigation-pattern__group-card--content');";
echo " const contentBody = document.querySelector('[data-left-navigation-content-body]');";
echo " const menu = document.getElementById('left-navigation-menu');";
echo " if (title) { title.textContent = moduleName; }";
echo " if (!menu) { return; }";
echo " const entries = portalModuleNavigation[moduleName] || [];";
echo " const activeEntry = entries[0] || '';";
echo " const activeEntry = entries.includes(requestedEntry) ? requestedEntry : (entries[0] || '');";
echo " if (contentTitle) { contentTitle.textContent = activeEntry; }";
echo " if (contentSection) { contentSection.setAttribute('aria-hidden', 'false'); }";
echo " if (contentBody) { contentBody.innerHTML = portalModuleContentCards[activeEntry] || ''; }";
echo " if (activeEntry === 'Bestellungen') { initBestellungenBindings(); }";
echo " if (activeEntry === 'Marktdurchführungen') { initMarketsBindings(); }";
echo " if (activeEntry === 'Märkte') { initMarketDefinitionsBindings(); }";
echo " if (activeEntry === 'Lager' || activeEntry === 'Artikel') { initLagerBindings(); }";
echo " menu.innerHTML = '';";
echo " entries.forEach((entry, index) => {";
@@ -734,7 +1003,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " button.type = 'button';";
echo " button.setAttribute('role', 'tab');";
echo " button.setAttribute('data-component-part', 'tab-button');";
echo " button.setAttribute('aria-selected', String(index === 0));";
echo " button.setAttribute('aria-selected', String(entry === activeEntry));";
echo " button.textContent = entry;";
echo " menu.appendChild(button);";
echo " });";
@@ -743,11 +1012,15 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " menu.querySelectorAll('.sg-tab-button').forEach((otherButton) => {";
echo " otherButton.setAttribute('aria-selected', String(otherButton === button));";
echo " });";
echo " const entryName = button.textContent.trim();";
echo " if (contentTitle) { contentTitle.textContent = entryName; }";
echo " if (contentBody) { contentBody.innerHTML = portalModuleContentCards[entryName] || ''; }";
echo " if (entryName === 'Bestellungen') { initBestellungenBindings(); }";
echo " if (entryName === 'Lager' || entryName === 'Artikel') { initLagerBindings(); }";
echo " const entryName = button.textContent.trim();";
echo " updatePortalLocation(moduleName, entryName);";
echo " if (contentTitle) { contentTitle.textContent = entryName; }";
echo " if (contentSection) { contentSection.setAttribute('aria-hidden', 'false'); }";
echo " if (contentBody) { contentBody.innerHTML = portalModuleContentCards[entryName] || ''; }";
echo " if (entryName === 'Bestellungen') { initBestellungenBindings(); }";
echo " if (entryName === 'Marktdurchführungen') { initMarketsBindings(); }";
echo " if (entryName === 'Märkte') { initMarketDefinitionsBindings(); }";
echo " if (entryName === 'Lager' || entryName === 'Artikel') { initLagerBindings(); }";
echo " });";
echo " });";
echo "};";
@@ -759,8 +1032,10 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " otherButton.setAttribute('aria-selected', String(isActive));";
echo " otherButton.dataset.componentState = isActive ? 'active' : 'inactive';";
echo " });";
echo " renderMainHeading(button.textContent.trim());";
echo " renderLeftNavigation(button.textContent.trim());";
echo " const moduleName = button.textContent.trim();";
echo " updatePortalLocation(moduleName);";
echo " renderMainHeading(moduleName);";
echo " renderLeftNavigation(moduleName);";
echo " });";
echo " });";
echo "});";
@@ -831,18 +1106,19 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " if (lagerBindingsInstalled) { return; }";
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
echo " if (!contentRoot || !contentRoot.querySelector('[data-lager-content]')) { return; }";
echo " if (window.SharedEditableActionCard) { window.SharedEditableActionCard.init(contentRoot); }";
echo " lagerBindingsInstalled = true;";
echo " let lagerMobileDetailHost = null;";
echo " let lagerMobileDetailOpen = false;";
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; }); } 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 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; }";
@@ -850,6 +1126,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " restoreLagerMobileDetail();";
echo " };";
echo " const getDrawer = () => document.querySelector('[data-lager-mobile-drawer]') || contentRoot.querySelector('[data-lager-drawer]');";
echo " const populateLagerActiveLotForm = (drawer, trigger) => { if (!drawer || !trigger) { return; } const values = { '[data-lager-lot-number]': trigger.dataset.lagerDrawerLotNumber || '', '[data-lager-manufacturer]': trigger.dataset.lagerDrawerManufacturerPartyId || '', '[data-lager-manufacturer-lot]': trigger.dataset.lagerDrawerManufacturerLotNumber || '', '[data-lager-mfg-date]': trigger.dataset.lagerDrawerMfgDate || '', '[data-lager-expiry-date]': trigger.dataset.lagerDrawerExpiryDate || '', '[data-lager-purchase-date]': trigger.dataset.lagerDrawerPurchaseDate || '' }; Object.entries(values).forEach(([selector, value]) => { const field = drawer.querySelector(selector); if (field) { field.value = value; } }); drawer.dataset.lagerActiveLotId = trigger.dataset.lagerDrawerLotId || ''; };";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open][data-lager-drawer-type=\"Produkt\"]'); if (!trigger) { return; } const drawer = getDrawer(); const lotValue = drawer?.querySelector('[data-lager-drawer-virtual-lot-value]'); const qtyValue = drawer?.querySelector('[data-lager-drawer-virtual-lot-qty]'); if (lotValue) { lotValue.textContent = trigger.dataset.lagerDrawerVirtualLotNumber ? 'Virtuelle Charge: ' + trigger.dataset.lagerDrawerVirtualLotNumber : 'Keine virtuelle Fehlbestandscharge vorhanden.'; } if (qtyValue) { qtyValue.textContent = 'Bereits virtuell verkauft: ' + (trigger.dataset.lagerDrawerVirtualShortageQty || '0'); } });";
echo " const setDrawerState = (open) => {";
echo " const drawer = getDrawer(); const backdrop = contentRoot.querySelector('[data-lager-drawer-backdrop]');";
@@ -865,7 +1142,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " document.addEventListener('lager-save-complete', (event) => { const forecast = event.detail?.forecast; const productId = event.detail?.productId || ''; const trigger = contentRoot.querySelector('[data-lager-drawer-open][aria-expanded=\"true\"]') || contentRoot.querySelector('[data-lager-row][data-lager-id=\"' + productId + '\"] [data-lager-drawer-open]'); const drawer = getDrawer(); if (!forecast || !drawer) { return; } const date = forecast.date ? new Date(forecast.date + 'T00:00:00').toLocaleDateString('de-CH') : 'Keine Prognose'; const text = forecast.status === 'depleted' ? date + ' (BESTAND ENDE)' : date; const value = drawer.querySelector('[data-lager-drawer-forecast-value]'); if (value) { value.textContent = text; } if (trigger) { trigger.dataset.lagerDrawerForecast = text; const row = trigger.closest('[data-lager-row]'); const cells = row?.querySelectorAll('[role=\"cell\"]'); const forecastCell = cells?.[cells.length - 1]; if (forecastCell && cells.length === 3) { forecastCell.textContent = text; } } });";
echo " document.addEventListener('click', (event) => {";
echo " const trigger = event.target.closest('[data-lager-drawer-open]');";
echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; const manufacturer = drawer.querySelector('[data-lager-manufacturer]'); if (manufacturer) { manufacturer.value = trigger.dataset.lagerDrawerManufacturerPartyId || ''; } const manufacturerLot = drawer.querySelector('[data-lager-manufacturer-lot]'); if (manufacturerLot) { manufacturerLot.value = trigger.dataset.lagerDrawerManufacturerLotNumber || ''; } const mfgDate = drawer.querySelector('[data-lager-mfg-date]'); if (mfgDate) { mfgDate.value = trigger.dataset.lagerDrawerMfgDate || ''; } const expiryDate = drawer.querySelector('[data-lager-expiry-date]'); if (expiryDate) { expiryDate.value = trigger.dataset.lagerDrawerExpiryDate || ''; } const purchaseDate = drawer.querySelector('[data-lager-purchase-date]'); if (purchaseDate) { purchaseDate.value = trigger.dataset.lagerDrawerPurchaseDate || ''; } const editForm = drawer.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } drawer.dataset.lagerActiveLotId = trigger.dataset.lagerDrawerLotId || ''; } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches) { openLagerMobileDetail(trigger); return; } const drawer = getDrawer(); if (!drawer) { return; } const type = trigger.dataset.lagerDrawerType || 'Lager'; const name = trigger.dataset.lagerDrawerName || ''; const value = trigger.dataset.lagerDrawerValue || ''; drawer.querySelector('[data-lager-drawer-title]').textContent = type + ': ' + name; drawer.querySelector('[data-lager-drawer-name-value]').textContent = name; const valueRow = drawer.querySelector('[data-lager-drawer-value-row]'); if (valueRow) { valueRow.hidden = false; } drawer.querySelector('[data-lager-drawer-value-label]').textContent = type === 'Produkt' ? 'Anzahl im Lager:' : 'Artikelnummer:'; drawer.querySelector('[data-lager-drawer-value-value]').textContent = value; drawer.querySelectorAll('[data-lager-drawer-topic]').forEach((card) => { card.hidden = type !== 'Produkt'; }); if (type === 'Produkt') { const forecast = trigger.dataset.lagerDrawerForecast || 'Keine Prognose'; const warning = trigger.dataset.lagerDrawerForecastWarning === 'true' || trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; const warningValue = drawer.querySelector('[data-lager-drawer-warning-value]'); if (warningValue) { warningValue.textContent = warning ? forecast : 'Keine akute Warnung.'; warningValue.classList.toggle('sg-strong', warning); warningValue.classList.toggle('lager-forecast-warning', trigger.dataset.lagerDrawerForecastWarning === 'true'); warningValue.classList.toggle('lager-forecast-mhd-warning', trigger.dataset.lagerDrawerForecastMhdWarning === 'true'); } drawer.querySelector('[data-lager-drawer-lot-number]').textContent = trigger.dataset.lagerDrawerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-name]').textContent = trigger.dataset.lagerDrawerManufacturerName || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-manufacturer-lot-number]').textContent = trigger.dataset.lagerDrawerManufacturerLotNumber || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-mfg-date]').textContent = trigger.dataset.lagerDrawerMfgDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-expiry-date]').textContent = trigger.dataset.lagerDrawerExpiryDate || 'Nicht hinterlegt'; drawer.querySelector('[data-lager-drawer-purchase-date]').textContent = trigger.dataset.lagerDrawerPurchaseDate || 'Nicht hinterlegt'; const virtualLot = trigger.dataset.lagerDrawerVirtualLotNumber || ''; drawer.querySelector('[data-lager-drawer-virtual-lot-value]').textContent = virtualLot ? 'Virtuelle Charge: ' + virtualLot : 'Keine virtuelle Fehlbestandscharge vorhanden.'; populateLagerActiveLotForm(drawer, trigger); const editForm = drawer.querySelector('[data-lager-edit-form]'); if (editForm) { editForm.hidden = true; } } setDrawerState(true); contentRoot.querySelectorAll('[data-lager-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', String(button === trigger))); return; }";
echo " if (event.target.closest('[data-lager-drawer-close]') || event.target.closest('[data-lager-drawer-backdrop]')) { closeDrawer(); }";
echo " });";
echo " document.addEventListener('click', (event) => { const trigger = event.target.closest('[data-lager-drawer-open]'); if (!trigger || trigger.dataset.lagerDrawerType !== 'Produkt' || window.matchMedia('(max-width: 767px)').matches) { return; } const header = contentRoot.querySelector('[data-lager-drawer-warning-header]'); const title = contentRoot.querySelector('[data-lager-drawer-warning-title]'); if (!header || !title) { return; } const forecastWarning = trigger.dataset.lagerDrawerForecastWarning === 'true'; const mhdWarning = trigger.dataset.lagerDrawerForecastMhdWarning === 'true'; title.textContent = getLagerWarningTitle(trigger); header.classList.toggle('sg-card-segment--darkgreen', !forecastWarning && !mhdWarning); header.classList.toggle('sg-card-segment--signal-red', forecastWarning); header.classList.toggle('sg-card-segment--signal-yellow', mhdWarning); });";
@@ -900,6 +1177,207 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const lagerTotalObserver = new MutationObserver(syncLagerDisplayedTotal);";
echo " lagerTotalObserver.observe(contentRoot, { childList: true, subtree: true, characterData: true });";
echo "}";
echo "let marketsBindingsInstalled = false;";
echo "function initMarketsBindings() {";
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
echo " if (!contentRoot || !contentRoot.querySelector('[data-markets-large-table]')) { return; }";
echo " if (window.SharedEditableActionCard) { window.SharedEditableActionCard.init(contentRoot); }";
echo " const getTable = () => contentRoot.querySelector('[data-markets-large-table]');";
echo " let searchTimer = null;";
echo " const getSearchInput = () => getTable()?.querySelector('[data-markets-search-input]') || null;";
echo " const getSearchValue = () => (getSearchInput()?.value || '').trim();";
echo " const getSortColumn = () => getTable()?.dataset.marketsSortColumn || 'earliest_date';";
echo " const getSortDirection = () => (getTable()?.dataset.marketsSortDirection || 'ASC').toUpperCase();";
echo " const getCurrentLimit = () => parseInt(getTable()?.dataset.marketsLimit || '20', 10);";
echo " const getPageSize = () => parseInt(getTable()?.dataset.marketsPageSize || '20', 10);";
echo " const 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 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(' ')); };";
echo " const marketDate = (value) => { if (!value) { return 'Nicht hinterlegt'; } const parts = String(value).split('-'); return parts.length === 3 ? parts[2] + '.' + parts[1] + '.' + parts[0] : marketEscape(value); };";
echo " const marketStatusLabel = (value) => ({ open: 'Offen', waitlist: 'Warteliste', registered: 'Angemeldet', confirmation_pending: 'Bestätigung ausstehend', confirmed: 'Bestätigt', cancelled: 'Abgesagt', completed: 'Abgeschlossen' }[value] || value || 'Nicht hinterlegt');";
echo " const normalizeMarketBooleanFields = (fields) => { ['table_available', 'table_rentable', 'own_table_required', 'shelter_available', 'power_available', 'water_available', 'lighting_available', 'direct_unload_possible'].forEach((field) => { if (fields[field] === '') { fields[field] = null; } }); return fields; };";
echo " const marketStatusSegment = (value) => ({ waitlist: 'sg-card-segment--signal-red', registered: 'sg-card-segment--signal-yellow', confirmation_pending: 'sg-card-segment--signal-yellow', confirmed: 'sg-card-segment--signal-green' }[value] || 'sg-card-segment--gray');";
echo " const marketChecklistSignalSegment = (items) => { const list = Array.isArray(items) ? items : []; const completed = list.filter((item) => item && item.is_completed).length; return completed === 0 ? 'sg-card-segment--signal-red' : completed === list.length ? 'sg-card-segment--signal-green' : 'sg-card-segment--signal-yellow'; };";
echo " const marketRow = (label, value) => '<div class=\"market-detail-row\"><dt>' + marketEscape(label) + '</dt><dd>' + marketDisplay(value) + '</dd></div>';";
echo " const initializeMunicipalityFields = (scope) => { scope.querySelectorAll('select[name=\"municipality_reference_id\"]').forEach((select) => { if (!select.querySelector('option[value=\"__new__\"]')) { const option = document.createElement('option'); option.value = '__new__'; option.textContent = 'Neue Gemeinde'; select.append(option); } const form = select.closest('form'); if (!form) { return; } let fields = form.querySelector('[data-market-new-municipality]'); if (!fields) { fields = document.createElement('div'); fields.className = 'sg-form-sections-card__field-group'; fields.dataset.marketNewMunicipality = ''; fields.hidden = true; fields.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Gemeinde</span><input class=\"sg-interaction-element sg-input-single-line\" type=\"text\" name=\"municipality_name\" data-market-new-municipality-name></label>'; select.closest('.sg-form-sections-card__field-group')?.after(fields); } const sync = () => { const visible = select.value === '__new__'; fields.hidden = !visible; const input = fields.querySelector('[data-market-new-municipality-name]'); if (input) { input.disabled = !visible; input.setAttribute('aria-disabled', String(!visible)); } }; if (select.dataset.municipalityBound !== 'true') { select.addEventListener('change', sync); select.dataset.municipalityBound = 'true'; } sync(); }); };";
echo " const syncMunicipalityReadValue = (scope) => { const drawer = scope.querySelector('[data-market-drawer]'); if (!drawer?.dataset.marketPayload) { return; } let payload = {}; try { payload = JSON.parse(drawer.dataset.marketPayload); } catch (error) { return; } const value = payload.occurrence?.municipality_name || 'Nicht hinterlegt'; scope.querySelectorAll('.market-detail-row').forEach((row) => { if (row.querySelector('dt')?.textContent?.trim() !== 'Gemeinde') { return; } const output = row.querySelector('dd'); if (output && output.textContent !== value) { output.textContent = value; } }); };";
echo " const marketCard = (title, body, headerClass = 'sg-card-segment--darkgreen', actions = '', scope = '', id = '', values = null) => { const header = '<header class=\"sg-card-segment sg-card-segment--header ' + headerClass + ' sg-object-card__header\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">' + title + '</div>' + (actions ? '<div class=\"sg-component-row\">' + actions + '</div>' : '') + '</header>'; return '<article class=\"sg-card sg-card--content-card market-detail-card\" data-component=\"content-card\"' + (scope ? ' data-shared-editable-action-card' : '') + '>' + header + '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\"' + (scope ? ' data-shared-editable-card-read' : '') + '>' + body + '</div>' + (scope ? '<div class=\"sg-card-segment sg-card-segment--body sg-card-segment--gray\" data-shared-editable-card-form hidden>' + marketEditForm(scope, id, values) + '</div>' : '') + '</article>'; };";
echo " const marketChecklistChapters = (checklist) => Array.isArray(checklist.chapters) ? checklist.chapters : [];";
echo " const marketChecklistReadBody = (checklist) => { const items = Array.isArray(checklist.items) ? checklist.items : []; const chapterSections = marketChecklistChapters(checklist).map((chapter) => { const chapterItems = items.filter((item) => item.chapter_key === chapter.key); const rows = chapterItems.map((item) => { const index = items.indexOf(item); return '<div class=\"market-checklist-item\"><button class=\"sg-checkbox-field sg-checkbox-field--on-grey' + (item.is_completed ? ' sg-form-active' : '') + '\" type=\"button\" role=\"checkbox\" aria-checked=\"' + (item.is_completed ? 'true' : 'false') + '\" aria-label=\"Checklistenpunkt ' + (index + 1) + '\" data-checklist-read-checkbox=\"' + index + '\"><span class=\"sg-checkbox-field__mark\" aria-hidden=\"true\">✓</span></button><span class=\"sg-body\">' + marketEscape(item.label || '') + '</span></div>'; }).join(''); return '<section class=\"sg-form-sections-card__chapter market-checklist-chapter\" data-checklist-chapter=\"' + marketEscape(chapter.key) + '\"><h3 class=\"sg-form-sections-card__chapter-title\">' + marketEscape(chapter.label) + '</h3><div class=\"market-checklist-list\">' + rows + '</div></section>'; }).join(''); return '<div class=\"market-checklist-chapters\">' + chapterSections + '</div><div class=\"market-checklist-comment\"><div class=\"sg-strong\">Kommentar</div><p class=\"sg-body\">' + marketDisplay(checklist.comment) + '</p></div><p class=\"sg-body\" data-checklist-read-status aria-live=\"polite\"></p>'; };";
echo " const marketChecklistEditBody = (checklist) => { const items = Array.isArray(checklist.items) ? checklist.items : []; const sections = marketChecklistChapters(checklist).map((chapter) => { const chapterItems = items.filter((item) => item.chapter_key === chapter.key); const rows = chapterItems.map((item) => '<label class=\"sg-labeled-input-row market-checklist-edit-row\" data-checklist-edit-item data-checklist-chapter-key=\"' + marketEscape(chapter.key) + '\"><span class=\"sg-label\"><button class=\"sg-checkbox-field sg-checkbox-field--on-grey' + (item.is_completed ? ' sg-form-active' : '') + '\" type=\"button\" role=\"checkbox\" aria-checked=\"' + (item.is_completed ? 'true' : 'false') + '\" aria-label=\"Checklistenpunkt abhaken\" data-checklist-edit-checkbox><span class=\"sg-checkbox-field__mark\" aria-hidden=\"true\">✓</span></button></span><span class=\"market-checklist-edit-control\"><input class=\"sg-interaction-element sg-input-single-line\" type=\"text\" value=\"' + marketEscape(item.label || '') + '\" data-checklist-label><button class=\"sg-interaction-element sg-button sg-button--warning market-checklist-delete\" type=\"button\" aria-label=\"Checklistenpunkt löschen\" data-checklist-delete-item>−</button></span></label>').join(''); const trailing = '<label class=\"sg-labeled-input-row market-checklist-edit-row market-checklist-edit-row--trailing\" data-checklist-edit-item data-checklist-trailing-item data-checklist-chapter-key=\"' + marketEscape(chapter.key) + '\"><span class=\"sg-label\"><button class=\"sg-checkbox-field sg-checkbox-field--on-grey\" type=\"button\" role=\"checkbox\" aria-checked=\"false\" aria-label=\"Neuen Checklistenpunkt abhaken\" data-checklist-edit-checkbox><span class=\"sg-checkbox-field__mark\" aria-hidden=\"true\">✓</span></button></span><span class=\"market-checklist-edit-control\"><input class=\"sg-interaction-element sg-input-single-line\" type=\"text\" value=\"\" aria-label=\"Neuer Checklistenpunkt\" data-checklist-label></span></label>'; return '<section class=\"sg-form-sections-card__chapter market-checklist-chapter\" data-checklist-chapter=\"' + marketEscape(chapter.key) + '\"><h3 class=\"sg-form-sections-card__chapter-title\">' + marketEscape(chapter.label) + '</h3><div class=\"market-checklist-edit-list\" data-checklist-edit-list data-checklist-chapter-key=\"' + marketEscape(chapter.key) + '\">' + rows + trailing + '</div></section>'; }).join(''); return sections + '<label class=\"sg-labeled-input-row market-checklist-comment-field\"><span class=\"sg-label\">Kommentar</span><textarea class=\"sg-interaction-element sg-input-multi-line\" name=\"comment\" rows=\"4\" data-checklist-comment>' + marketEscape(checklist.comment || '') + '</textarea></label>'; };";
echo " const marketChecklistCard = (id, checklist) => { const headerClass = marketChecklistSignalSegment(checklist.items); const actions = '<button class=\"sg-interaction-element sg-button sg-button--active\" type=\"button\" data-shared-editable-card-toggle aria-expanded=\"false\">Anzeigen</button>' + marketEditButton('checklist', id); return '<article class=\"sg-card sg-card--content-card market-detail-card\" data-component=\"content-card\" data-shared-editable-action-card data-shared-editable-card-collapsible data-shared-editable-card-collapsed data-checklist-card><header class=\"sg-card-segment sg-card-segment--header ' + headerClass + ' sg-object-card__header\"><div class=\"sg-strong\">Checkliste</div><div class=\"sg-component-row\">' + actions + '</div></header><div class=\"sg-card-segment sg-card-segment--body sg-card-segment--gray\" data-shared-editable-card-read hidden>' + marketChecklistReadBody(checklist) + '</div><div class=\"sg-card-segment sg-card-segment--body sg-card-segment--gray\" data-shared-editable-card-form hidden><form class=\"sg-form-sections-card market-checklist-form\" data-market-checklist-form data-market-checklist-id=\"' + marketEscape(id) + '\"><div class=\"sg-form-sections-card__body\" data-pattern-part=\"form-body\">' + marketChecklistEditBody(checklist) + '</div><footer class=\"sg-form-sections-card__actions-segment\" data-pattern-part=\"form-actions-segment\"><div class=\"sg-form-sections-card__actions\" data-pattern-part=\"form-actions\"><button class=\"sg-interaction-element sg-button sg-button--active sg-form-sections-card__action\" type=\"button\" data-shared-editable-card-cancel>Abbrechen</button><button class=\"sg-interaction-element sg-button sg-button--process sg-form-sections-card__action\" type=\"submit\">Speichern</button></div></footer><p class=\"sg-body\" data-market-checklist-status aria-live=\"polite\"></p></form></div></article>'; };";
echo " const setChecklistVisibility = (card, visible, showRead = visible) => { if (!window.SharedEditableActionCard) { return; } window.SharedEditableActionCard.setVisibility(card, visible); if (!showRead) { const readView = card?.querySelector('[data-shared-editable-card-read]'); if (readView) { readView.hidden = true; } } };";
echo " const setChecklistSignal = (card, items) => { const header = card?.querySelector('header'); if (!header) { return; } header.classList.remove('sg-card-segment--signal-red', 'sg-card-segment--signal-yellow', 'sg-card-segment--signal-green'); header.classList.add(marketChecklistSignalSegment(items)); };";
echo " const marketStatusCard = (id, values) => { const headerClass = marketStatusSegment(values.status); const textClass = headerClass === 'sg-card-segment--gray' ? '' : 'sg-text-on-dark'; const actions = marketEditButton('status', id); const header = '<header class=\"sg-card-segment sg-card-segment--header ' + headerClass + ' sg-object-card__header\" data-component-part=\"card-header\"><div class=\"sg-strong ' + textClass + '\">Status: ' + marketEscape(marketStatusLabel(values.status)) + '</div><div class=\"sg-component-row\">' + actions + '</div></header>'; return '<article class=\"sg-card sg-card--content-card market-detail-card\" data-component=\"content-card\" data-shared-editable-action-card>' + header + '<div class=\"sg-card-segment sg-card-segment--body sg-card-segment--gray\" data-shared-editable-card-form hidden>' + marketEditForm('status', id, values) + '</div></article>'; };";
echo " const marketEditButton = () => '<button class=\"sg-interaction-element sg-button sg-button--active\" type=\"button\" data-shared-editable-card-edit>Bearbeiten</button>';";
echo " const marketHumanizeError = (error) => { const message = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'; const labels = { table_available: 'Tisch vorhanden', table_rentable: 'Tisch mietbar', own_table_required: 'Eigener Tisch erforderlich', shelter_available: 'Überdachung', power_available: 'Strom', water_available: 'Wasser', lighting_available: 'Beleuchtung', direct_unload_possible: 'Direktes Entladen' }; const match = message.match(/^([a-z_]+) muss boolean sein\\.$/); return match && labels[match[1]] ? labels[match[1]] + ' muss Ja oder Nein sein.' : message; };";
echo " document.addEventListener('submit', (event) => { const form = event.target.closest('[data-market-edit-form]'); if (!form) { return; } ['table_available', 'table_rentable', 'own_table_required', 'shelter_available', 'power_available', 'water_available', 'lighting_available', 'direct_unload_possible'].forEach((field) => { const control = form.elements.namedItem(field); if (!control) { return; } const label = control.selectedOptions?.[0]?.textContent?.trim(); control.value = label === 'Ja' ? '1' : label === 'Nein' ? '0' : control.value === '' || label === 'Nicht hinterlegt' ? '' : control.value; }); }, true);";
echo " const marketEditStatusObserver = new MutationObserver(() => { contentRoot.querySelectorAll('[data-market-edit-status], [data-market-create-status]').forEach((status) => { const message = status.textContent.trim(); if (!message || message === 'Speichern läuft …') { return; } const humanMessage = marketHumanizeError(new Error(message)); if (humanMessage !== message) { status.textContent = humanMessage; } status.classList.add('sg-form-validation-text'); status.setAttribute('role', 'alert'); }); }); marketEditStatusObserver.observe(contentRoot, { childList: true, characterData: true, subtree: true });";
echo " const marketEditField = (key, label, type, value, options = [], required = false, minDate = '') => { const current = value === null || value === undefined ? '' : String(value); const requiredAttributes = required ? ' required aria-required=\"true\"' : ''; const minAttribute = type === 'date' && minDate ? ' min=\"' + marketEscape(minDate) + '\"' : ''; const disabledAttributes = ' disabled aria-disabled=\"true\"'; const control = type === 'readonly' ? '<input class=\"sg-interaction-element sg-input-single-line sg-input-single-line--disabled sg-form-disabled\" type=\"text\" name=\"' + key + '\" value=\"' + marketEscape(current) + '\"' + disabledAttributes + '>' : type === 'readonly-textarea' ? '<textarea class=\"sg-input-multi-line sg-form-disabled\" name=\"' + key + '\" rows=\"4\"' + disabledAttributes + '>' + marketEscape(current) + '</textarea>' : type === 'textarea' ? '<textarea class=\"sg-interaction-element sg-input-multi-line\" name=\"' + key + '\" rows=\"4\"' + requiredAttributes + '>' + marketEscape(current) + '</textarea>' : type === 'select' ? '<select class=\"sg-interaction-element sg-input-single-line\" name=\"' + key + '\"' + requiredAttributes + '>' + options.map(([optionValue, optionLabel, contactEmail = '', contactPhone = '']) => '<option value=\"' + marketEscape(optionValue) + '\" data-contact-email=\"' + marketEscape(contactEmail) + '\" data-contact-phone=\"' + marketEscape(contactPhone) + '\"' + (String(optionValue) === current ? ' selected' : '') + '>' + marketEscape(optionLabel) + '</option>').join('') + '</select>' : '<input class=\"sg-interaction-element sg-input-single-line\" type=\"' + type + '\" name=\"' + key + '\" value=\"' + marketEscape(current) + '\"' + requiredAttributes + minAttribute + '>'; const requiredIndicator = required ? '<span class=\"market-required-field-indicator\" aria-hidden=\"true\">*</span>' : ''; return '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">' + marketEscape(label) + requiredIndicator + '</span>' + control + '</label>'; };";
echo " const marketDeletePattern = (id, body) => '<div class=\"sg-delete-confirmation-pattern__stage sg-delete-confirmation-pattern__host\" data-pattern=\"overlay-card\" data-dialog-open=\"false\"><div class=\"sg-delete-confirmation-pattern__target\" data-shared-editable-action-card data-shared-editable-delete-token=\"DELETE\" data-market-delete-occurrence-id=\"' + marketEscape(id) + '\"><div class=\"sg-component-row\"><button class=\"sg-interaction-element sg-button sg-button--warning\" type=\"button\" data-component=\"button\" data-component-variant=\"warning\" data-shared-editable-card-delete data-market-delete-button>Löschen</button></div>' + body + '<article class=\"sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card\" aria-label=\"Löschbestätigung\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"market-delete-title-' + marketEscape(id) + '\" data-overlay-dialog=\"delete\" data-shared-editable-card-delete-confirmation hidden><div class=\"sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body\"><p class=\"sg-body sg-delete-confirmation-pattern__text\" id=\"market-delete-title-' + marketEscape(id) + '\"><strong>Möchtest du diese Marktdurchführung wirklich löschen?</strong></p><p class=\"sg-body sg-delete-confirmation-pattern__text\">Du kannst das nicht rückgängig machen. Bestätige durch Eingabe von <span class=\"sg-delete-confirmation-pattern__code\">DELETE</span>.</p><label class=\"sg-labeled-input-row sg-delete-confirmation-pattern__input-row\"><span class=\"sg-label\">Bestätigung</span><input class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable\" type=\"text\" placeholder=\"DELETE\" aria-label=\"Löschbestätigung durch DELETE\" data-shared-editable-card-delete-input></label><p class=\"sg-body\" data-market-delete-status aria-live=\"polite\"></p><div class=\"sg-delete-confirmation-pattern__actions\"><button class=\"sg-interaction-element sg-button sg-button--active\" type=\"button\" data-shared-editable-card-delete-cancel>Abbrechen</button><button class=\"sg-interaction-element sg-button sg-button--process sg-button--process-inactive\" type=\"button\" disabled aria-disabled=\"true\" data-shared-editable-card-delete-submit data-market-delete-submit>Löschen</button></div></div></article></div></div>' ;";
echo " const marketDeletePatternWithTitle = (id, title, body) => { const template = document.createElement('template'); template.innerHTML = marketDeletePattern(id, body); const target = template.content.querySelector('[data-market-delete-occurrence-id]'); const row = target?.querySelector(':scope > .sg-component-row'); if (target && row) { const card = document.createElement('article'); card.className = 'sg-card sg-card--content-card market-delete-confirmation-card'; const header = document.createElement('header'); header.className = 'sg-card-segment sg-card-segment--header sg-card-segment--gray sg-object-card__header'; const heading = document.createElement('h2'); heading.className = 'sg-heading-h2 market-delete-confirmation-card__title'; heading.textContent = title; header.append(heading, row); card.append(header); target.prepend(card); } return template.innerHTML; };";
echo " const marketEditForm = (scope, id, values) => { const boolOptions = [['', 'Nicht hinterlegt'], ['1', 'Ja'], ['0', 'Nein']]; const statusOptions = [['open', 'Offen'], ['waitlist', 'Warteliste'], ['registered', 'Angemeldet'], ['confirmation_pending', 'Bestätigung ausstehend'], ['confirmed', 'Bestätigt'], ['cancelled', 'Abgesagt'], ['completed', 'Abgeschlossen']]; const referenceOptions = values.reference_options || {}; const categoryOptions = [['', 'Nicht hinterlegt'], ...(Array.isArray(referenceOptions.categories) ? referenceOptions.categories.map((option) => [option.id, option.name]) : [])]; const municipalityOptions = [['', 'Nicht hinterlegt'], ...(Array.isArray(referenceOptions.municipalities) ? referenceOptions.municipalities.map((option) => [option.id, option.name]) : [])]; const partyOptions = [['', 'Nicht hinterlegt'], ...(Array.isArray(referenceOptions.parties) ? referenceOptions.parties.map((option) => [option.id, option.name]) : [])]; const contactOptions = [['', 'Nicht hinterlegt'], ...(Array.isArray(referenceOptions.contacts) ? referenceOptions.contacts.map((option) => [option.id, [[option.first_name, option.last_name].filter(Boolean).join(' ') || 'Nicht hinterlegt', option.party_name].filter(Boolean).join(' – '), option.email || '', option.phone || '']) : [])]; const costSummary = values.cost_summary || {}; const formValues = Object.assign({}, values, { gross_cost: marketMoney(costSummary.gross_amount), net_cost: marketMoney(costSummary.net_amount), tax_cost: marketMoney(costSummary.tax_amount), open_cost: marketMoney(costSummary.open_amount), cost_items_text: values.cost_items_text, gross_revenue_display: marketMoney(values.gross_revenue), sold_item_count_display: marketDisplay(values.sold_item_count) }); const fieldSets = { status: [['status', 'Status', 'select', statusOptions]], overview: [['market_name', 'Markt', 'readonly'], ['category_name', 'Kategorie/Marktart', 'readonly'], ['website_url', 'Website', 'readonly'], ['place', 'Ort', 'text'], ['address', 'Adresse', 'textarea'], ['municipality_reference_id', 'Gemeinde', 'select', municipalityOptions], ['organization_contact_id', 'Veranstalter', 'select', partyOptions], ['contact_person_id', 'Kontaktperson', 'select', contactOptions], ['contact_email', 'Kontakt E-Mail', 'email'], ['contact_phone', 'Kontakt Telefon', 'tel']], participation: [['request_or_registration_date', 'Anmeldung / Anfrage', 'date'], ['registration_note', 'Hinweis Anmeldung', 'textarea'], ['registration_deadline', 'Anmeldefrist', 'date'], ['confirmation_date', 'Bestätigung', 'date'], ['payment_deadline', 'Zahlungsfrist', 'date'], ['payment_status', 'Zahlungsstatus', 'select', [['', 'Nicht hinterlegt'], ['paid', 'Bezahlt'], ['unpaid', 'Unbezahlt']]], ['payment_date', 'Zahlungsdatum', 'date'], ['paid_amount', 'Bezahlter Betrag', 'number'], ['payment_method_code', 'Zahlungsart', 'text'], ['gross_cost', 'Gesamtkosten brutto', 'readonly'], ['net_cost', 'Gesamtkosten netto', 'readonly'], ['tax_cost', 'Steuern', 'readonly'], ['open_cost', 'Offener Betrag', 'readonly'], ['cost_items_text', 'Kostenpositionen', 'readonly-textarea']], infrastructure: [['stand_size', 'Standgrösse', 'text'], ['table_available', 'Tisch vorhanden', 'select', boolOptions], ['table_rentable', 'Tisch mietbar', 'select', boolOptions], ['own_table_required', 'Eigener Tisch erforderlich', 'select', boolOptions], ['shelter_available', 'Überdachung', 'select', boolOptions], ['power_available', 'Strom', 'select', boolOptions], ['water_available', 'Wasser', 'select', boolOptions], ['lighting_available', 'Beleuchtung', 'select', boolOptions], ['other_infrastructure', 'Weitere Infrastruktur', 'textarea'], ['own_stand_requirements', 'Anforderungen an den eigenen Stand', 'textarea']], travel: [['travel_address', 'Reiseadresse', 'textarea'], ['travel_distance_km', 'Distanz (km)', 'number'], ['parking_options', 'Parkmöglichkeiten', 'textarea'], ['delivery_access', 'Anlieferung', 'textarea'], ['direct_unload_possible', 'Direktes Entladen', 'select', boolOptions], ['storage_options', 'Lagerung', 'textarea'], ['travel_notes', 'Besondere Anreisehinweise', 'textarea']], notes: [['cancellation_terms', 'Rücktrittsbedingungen', 'textarea'], ['revenue_share_terms', 'Umsatzbeteiligung', 'textarea'], ['other_participation_conditions', 'Teilnahmebedingungen', 'textarea'], ['notes', 'Notizen', 'textarea'], ['contract_folder_url', 'Vertragsordner', 'url']], day: [['date', 'Datum', 'date'], ['observations', 'Beobachtungen', 'textarea'], ['stand_start_time', 'Standzeit von', 'time'], ['stand_end_time', 'Standzeit bis', 'time'], ['setup_start_time', 'Aufbau von', 'time'], ['setup_end_time', 'Aufbau bis', 'time'], ['delivery_start_time', 'Anlieferung von', 'time'], ['delivery_end_time', 'Anlieferung bis', 'time'], ['gross_revenue_display', 'Umsatz', 'readonly'], ['sold_item_count_display', 'Verkaufte Artikel', 'readonly']] }; const requiredFields = { status: new Set(['status']), overview: new Set(), day: new Set(['d Line truncated
echo " const marketDeletePatternWithTitleAndDays = (id, title, body) => { const html = marketDeletePatternWithTitle(id, title, body); const template = document.createElement('template'); template.innerHTML = html; const target = template.content.querySelector('[data-market-delete-occurrence-id]'); const dayGroup = target?.querySelector('.market-days-group'); if (dayGroup) { const heading = document.createElement('h2'); heading.className = 'sg-heading-h2 market-days-group__title'; heading.textContent = 'Markttage'; dayGroup.before(heading); } return template.innerHTML; };";
echo " const marketCreateForm = (marketDefinitions) => { const options = [['', 'Bitte auswählen'], ...(Array.isArray(marketDefinitions) ? marketDefinitions.map((market) => [market.id, market.name + (market.category_name ? ' – ' + market.category_name : '')]) : [])]; const fields = '<div class=\"sg-form-sections-card__field-group\">' + marketEditField('market_definition_id', 'Markt', 'select', '', options, true) + '</div><div class=\"sg-form-sections-card__field-group\">' + marketEditField('date', 'Erster Markttag', 'date', '', [], true) + '</div>'; return '<article class=\"sg-card sg-card--content-card market-detail-card\" data-component=\"content-card\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen sg-object-card__header\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Pflichtangaben für neue Marktdurchführung</div></header><div class=\"sg-card-segment sg-card-segment--body sg-card-segment--gray\"><div class=\"sg-form-sections-card-wrapper\" data-pattern=\"form-sections\"><form class=\"sg-form-sections-card market-create-form\" data-market-create-form=\"true\"><div class=\"sg-form-sections-card__body\" data-pattern-part=\"form-body\"><div class=\"sg-form-sections-card__section\">' + fields + '</div></div><footer class=\"sg-form-sections-card__actions-segment\" data-pattern-part=\"form-actions-segment\"><div class=\"sg-form-sections-card__actions\" data-pattern-part=\"form-actions\"><button class=\"sg-interaction-element sg-button sg-button--active sg-form-sections-card__action\" type=\"button\" data-market-create-cancel>Abbrechen</button><button class=\"sg-interaction-element sg-button sg-button--process sg-form-sections-card__action\" type=\"submit\">Speichern</button></div></footer><p class=\"sg-body\" data-market-create-status aria-live=\"polite\"></p></form></div></div></article>'; };";
echo " const buildUrl = (params) => { const url = new URL(window.location.href); Object.entries(params).forEach(([key, value]) => { if (value === null || value === undefined || value === '') { url.searchParams.delete(key); } else { url.searchParams.set(key, String(value)); } }); return url; };";
echo " const replaceFragment = (html) => { const doc = new DOMParser().parseFromString(html, 'text/html'); const fragment = doc.querySelector('[data-markets-content]'); if (!fragment) { return false; } contentRoot.innerHTML = fragment.outerHTML; return Boolean(getTable()); };";
echo " const loadFragment = async (params) => { if (searchTimer) { window.clearTimeout(searchTimer); searchTimer = null; } const input = getSearchInput(); const wasFocused = input ? document.activeElement === input : false; const response = await fetch(buildUrl({ ...params, market_fragment: '1' }).toString(), { credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest' } }); if (!response.ok) { return; } const replaced = replaceFragment(await response.text()); if (!replaced) { return; } window.history.replaceState({}, '', buildUrl(params)); bindTable(); if (wasFocused) { const nextInput = getSearchInput(); nextInput?.focus({ preventScroll: true }); if (nextInput) { nextInput.setSelectionRange(nextInput.value.length, nextInput.value.length); } } };";
echo " const syncSearchState = () => { const input = getSearchInput(); const wrap = input?.closest('[data-component=\'single-line-input\']'); if (!input || !wrap) { return; } const hasValue = input.value.trim() !== ''; wrap.setAttribute('data-has-value', String(hasValue)); wrap.dataset.componentState = hasValue ? 'active' : 'inactive-selectable'; };";
echo " const bindTable = () => { const table = getTable(); if (!table) { return; } if (window.sgInitLargeTableColumnResize) { window.sgInitLargeTableColumnResize({ root: contentRoot }); } 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; } 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'); } }); }); };";
echo " const marketDayDeleteConfirmation = (id, label) => '<article class=\"sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card\" aria-label=\"Löschbestätigung für Markttag\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"market-day-delete-title-' + marketEscape(id) + '\" data-overlay-dialog=\"delete\" data-market-day-delete-confirmation data-shared-editable-card-delete-confirmation hidden><div class=\"sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body\"><p class=\"sg-body sg-delete-confirmation-pattern__text\" id=\"market-day-delete-title-' + marketEscape(id) + '\"><strong>Möchtest du den Markttag ' + marketEscape(label) + ' wirklich löschen?</strong></p><p class=\"sg-body sg-delete-confirmation-pattern__text\">Du kannst das nicht rückgängig machen. Bestätige durch Eingabe von <span class=\"sg-delete-confirmation-pattern__code\">DELETE</span>.</p><label class=\"sg-labeled-input-row sg-delete-confirmation-pattern__input-row\"><span class=\"sg-label\">Bestätigung</span><input class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable\" type=\"text\" placeholder=\"DELETE\" aria-label=\"Löschbestätigung durch DELETE\" data-shared-editable-card-delete-input></label><p class=\"sg-body\" data-market-day-delete-status aria-live=\"polite\"></p><div class=\"sg-delete-confirmation-pattern__actions\"><button class=\"sg-interaction-element sg-button sg-button--active\" type=\"button\" data-shared-editable-card-delete-cancel>Abbrechen</button><button class=\"sg-interaction-element sg-button sg-button--process sg-button--process-inactive\" type=\"button\" disabled aria-disabled=\"true\" data-shared-editable-card-delete-submit data-market-day-delete-submit>Löschen</button></div></div></article>';";
echo " const enhanceMarketDayDeleteControls = (root) => { const cards = Array.from(root.querySelectorAll('.market-days-group .sg-content-cards-group > [data-shared-editable-action-card]')); cards.forEach((card, index) => { if (index === 0 || card.querySelector('[data-market-edit-form][data-market-edit-draft=\"true\"]')) { return; } const form = card.querySelector('[data-market-edit-form][data-market-edit-scope=\"day\"]'); const actions = card.querySelector('.sg-object-card__header .sg-component-row'); if (!form || !actions || actions.querySelector('[data-market-day-delete-button]')) { return; } const dayId = form.dataset.marketEditId || ''; const label = card.querySelector('.sg-object-card__header .sg-strong')?.textContent?.trim() || 'diesen Markttag'; if (!dayId) { return; } const stage = document.createElement('div'); stage.className = 'sg-delete-confirmation-pattern__stage sg-delete-confirmation-pattern__host'; stage.dataset.pattern = 'overlay-card'; stage.dataset.dialogOpen = 'false'; card.classList.add('sg-delete-confirmation-pattern__target'); card.parentNode.replaceChild(stage, card); stage.appendChild(card); card.dataset.marketDayDeleteId = dayId; card.dataset.sharedEditableDeleteToken = 'DELETE'; actions.insertAdjacentHTML('afterbegin', '<button class=\"sg-interaction-element sg-button sg-button--warning\" type=\"button\" data-component=\"button\" data-component-variant=\"warning\" data-shared-editable-card-delete data-market-day-delete-button>Löschen</button>'); card.insertAdjacentHTML('beforeend', marketDayDeleteConfirmation(dayId, label)); }); };";
echo " const rerenderMarketDetail = (payload) => { const drawer = getMarketDrawer(); const content = drawer?.querySelector('[data-market-drawer-content]'); if (!drawer || !content) { return; } drawer.dataset.marketPayload = JSON.stringify(payload); content.innerHTML = renderMarketDetailWithActions(payload); applyMarketTabs(content); enhanceMarketDayDeleteControls(content); syncMarketDayAddButton(content); };";
echo " const addMarketDayDraft = () => { const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; if (!payload?.occurrence) { return; } const days = Array.isArray(payload.occurrence.days) ? payload.occurrence.days : []; if (days.some((day) => day.__draft)) { return; } days.push({ id: 'draft-' + Date.now(), date: '', __draft: true }); payload.occurrence.days = days; rerenderMarketDetail(payload); const draftEdit = contentRoot.querySelector('[data-market-edit-form][data-market-edit-draft=\"true\"]')?.closest('[data-shared-editable-action-card]')?.querySelector('[data-shared-editable-card-edit]'); draftEdit?.click(); window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); };";
echo " const discardMarketDayDraft = () => { const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; if (!payload?.occurrence || !Array.isArray(payload.occurrence.days)) { return; } const days = payload.occurrence.days.filter((day) => !day.__draft); if (days.length === payload.occurrence.days.length) { return; } payload.occurrence.days = days; rerenderMarketDetail(payload); };";
echo " const closeMarketDrawer = () => { setMarketDrawerState(false); contentRoot.querySelectorAll('[data-market-drawer-open]').forEach((button) => button.setAttribute('aria-expanded', 'false')); };";
echo " const renderMarketDetail = (payload) => { const market = payload.market || {}; const occurrence = payload.occurrence || {}; const days = Array.isArray(occurrence.days) ? occurrence.days : []; const hasDraftDay = days.some((day) => day.__draft); const organizer = occurrence.organizer || {}; const organization = organizer.organization || {}; const contactPerson = organizer.contact_person || {}; const website = market.website_url ? '<a class=\"sg-hyperlink\" href=\"' + marketEscape(market.website_url) + '\" target=\"_blank\" rel=\"noopener\">' + marketEscape(market.website_url) + '</a>' : 'Nicht hinterlegt'; const contract = occurrence.contract_folder_url ? '<a class=\"sg-hyperlink\" href=\"' + marketEscape(occurrence.contract_folder_url) + '\" target=\"_blank\" rel=\"noopener\">Vertragsordner öffnen</a>' : 'Nicht hinterlegt'; const overview = marketRow('Markt', market.name) + marketRow('Kategorie', market.category_name || market.category_code) + '<div class=\"market-detail-row\"><dt>Website</dt><dd>' + website + '</dd></div>' + marketRow('Ort', occurrence.place) + marketRow('Adresse', occurrence.address) + marketRow('Gemeinde', occurrence.municipality_reference_id) + marketRow('Veranstalter', organization.name) + marketRow('Kontaktperson', marketContactName(contactPerson)) + marketRow('Kontakt E-Mail', contactPerson.email) + marketRow('Kontakt Telefon', contactPerson.phone); const dates = days.map((day) => marketDate(day.date)).filter((date) => date !== 'Nicht hinterlegt').join(' – '); const costSummary = occurrence.cost_summary || {}; const participation = marketRow('Anmeldung / Anfrage', occurrence.request_or_registration_date) + marketRow('Hinweis Anmeldung', occurrence.registration_note) + marketRow('Anmeldefrist', occurrence.registration_deadline) + marketRow('Bestätigung', occurrence.confirmation_date) + marketRow('Zahlungsfrist', occurrence.payment_deadline) + marketRow('Zahlungsstatus', occurrence.payment_status) + marketRow('Zahlungsdatum', occurrence.payment_date) + marketRow('Bezahlter Betrag', marketMoney(occurrence.paid_amount)) + marketRow('Zahlungsart', occurrence.payment_method_code) + marketRow('Gesamtkosten brutto', marketMoney(costSummary.gross_amount)) + marketRow('Gesamtkosten netto', marketMoney(costSummary.net_amount)) + marketRow('Steuern', marketMoney(costSummary.tax_amount)) + marketRow('Offener Betrag', marketMoney(costSummary.open_amount)) + '<div class=\"market-detail-row\"><dt>Kostenpositionen</dt><dd>' + marketDisplay(occurrence.cost_items_text) + '</dd></div>'; const infrastructure = marketRow('Standgrösse', occurrence.stand_size) + marketRow('Tisch vorhanden', marketBoolean(occurrence.table_available)) + marketRow('Tisch mietbar', marketBoolean(occurrence.table_rentable)) + marketRow('Eigener Tisch erforderlich', marketBoolean(occurrence.own_table_required)) + marketRow('Überdachung', marketBoolean(occurrence.shelter_available)) + marketRow('Strom', marketBoolean(occurrence.power_available)) + marketRow('Wasser', marketBoolean(occurrence.water_available)) + marketRow('Beleuchtung', marketBoolean(occurrence.lighting_available)) + marketRow('Weitere Infrastruktur', occurrence.other_infrastructure) + marketRow('Anforderungen an den eigenen Stand', occurrence.own_stand_requirements); const travel = marketRow('Reiseadresse', occurrence.travel_address) + marketRow('Distanz (km)', occurrence.travel_distance_km) + marketRow('Parkmöglichkeiten', occurrence.parking_options) + marketRow('Anlieferung', occurrence.delivery_access) + marketRow('Direktes Entladen', marketBoolean(occurrence.direct_unload_possible)) + marketRow('Lagerung', occurrence.storage_options) + marketRow('Besondere Anreisehinweise', occurrence.travel_notes); const notes = marketRow('Teilnahmebedingungen', occurrence.other_participation_conditions) + marketRow('Rücktrittsbedingungen', occurrence.cancellation_terms) + marketRow('Umsatzbeteiligung', occurrence.revenue_share_terms) + marketRow('Notizen', occurrence.notes) + '<div class=\"market-detail-row\"><dt>Vertragsordner</dt><dd>' + contract + '</dd></div>'; const salesSummary = occurrence.sales_summary || {}; const dayTabs = days.map((day, index) => { const label = day.__draft ? 'Neuer Tag' : marketDate(day.date); const selected = day.__draft || (!hasDraftDay && index === 0); return '<button class=\"sg-interaction-element sg-button sg-button--active sg-tab-button\" type=\"button\" role=\"tab\" aria-selected=\"' + (selected ? 'true' : 'false') + '\" aria-controls=\"market-day-panel-' + day.id + '\" id=\"market-day-tab-' + day.id + '\" data-market-day-tab=\"true\">' + label + '</button>'; }).join(''); const addDayTab = '<button class=\"sg-interaction-element sg-button sg-button--active sg-tab-button market-day-add-button\" type=\"button\" data-market-day-add=\"true\" aria-label=\"Markttag hinzufügen\" title=\"Markttag hinzufügen\">+</button>'; const dayPanels = days.map((day, index) => { const label = day.__draft ? 'Neuer Tag' : marketDate(day.da Line truncated
echo " const reorderMarketStatusCard = (html) => { const template = document.createElement('template'); template.innerHTML = html; const target = template.content.querySelector('[data-market-delete-occurrence-id]'); const cards = target ? Array.from(target.children).filter((child) => child.matches('.market-detail-card')) : []; if (cards.length > 1) { target.insertBefore(cards[0], cards[1].nextSibling); } return template.innerHTML; };";
echo " const renderMarketDetailWithActions = (payload) => reorderMarketStatusCard(marketDeletePatternWithTitleAndDays(payload.occurrence?.id || '', marketDrawerTitle(payload), renderMarketDetail(payload)));";
echo " const ensureChecklistTrailingItem = (form, list) => { const targetList = list || form?.querySelector('[data-checklist-edit-list]'); if (!targetList) { return; } const trailing = targetList.querySelector('[data-checklist-trailing-item]'); if (!trailing || (trailing.querySelector('[data-checklist-label]')?.value || '').trim() !== '') { const item = document.createElement('label'); item.className = 'sg-labeled-input-row market-checklist-edit-row market-checklist-edit-row--trailing'; item.dataset.checklistEditItem = ''; item.dataset.checklistTrailingItem = ''; item.dataset.checklistChapterKey = targetList.dataset.checklistChapterKey || ''; item.innerHTML = '<span class=\"sg-label\"><button class=\"sg-checkbox-field sg-checkbox-field--on-grey\" type=\"button\" role=\"checkbox\" aria-checked=\"false\" aria-label=\"Neuen Checklistenpunkt abhaken\" data-checklist-edit-checkbox><span class=\"sg-checkbox-field__mark\" aria-hidden=\"true\">✓</span></button></span><span class=\"market-checklist-edit-control\"><input class=\"sg-interaction-element sg-input-single-line\" type=\"text\" value=\"\" aria-label=\"Neuer Checklistenpunkt\" data-checklist-label></span>'; targetList.append(item); } };";
echo " const setChecklistCheckbox = (button, checked) => { if (!button) { return; } button.setAttribute('aria-checked', String(checked)); button.classList.toggle('sg-form-active', checked); };";
echo " const persistChecklistReadToggle = async (button) => { const drawer = getMarketDrawer(); const card = button.closest('[data-checklist-card]'); const status = card?.querySelector('[data-checklist-read-status]'); const index = Number(button.dataset.checklistReadCheckbox); let payload = {}; try { payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : {}; } catch (error) { return; } const sourceItems = Array.isArray(payload.occurrence?.checklist?.items) ? payload.occurrence.checklist.items : []; if (!sourceItems[index]) { return; } const previous = button.getAttribute('aria-checked') === 'true'; const next = !previous; setChecklistCheckbox(button, next); setChecklistCheckbox(card.querySelectorAll('[data-checklist-edit-checkbox]')[index], next); const items = Array.from(card.querySelectorAll('[data-checklist-read-checkbox]')).map((checkbox, itemIndex) => ({ chapter_key: sourceItems[itemIndex]?.chapter_key || '', label: sourceItems[itemIndex]?.label || '', is_completed: checkbox.getAttribute('aria-checked') === 'true' })); setChecklistSignal(card, items); button.disabled = true; if (status) { status.textContent = 'Speichern läuft …'; } 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: 'update_checklist', id: payload.occurrence.id, items: items.map((item) => ({ chapter_key: item.chapter_key, label: item.label || '', is_completed: Boolean(item.is_completed) })), comment: payload.occurrence.checklist.comment || '' }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } payload.occurrence.checklist = result.checklist; if (drawer) { drawer.dataset.marketPayload = JSON.stringify(payload); } if (status) { status.textContent = ''; } } catch (error) { setChecklistCheckbox(button, previous); setChecklistCheckbox(card.querySelectorAll('[data-checklist-edit-checkbox]')[index], previous); const rollbackItems = Array.from(card.querySelectorAll('[data-checklist-read-checkbox]')).map((checkbox, itemIndex) => ({ chapter_key: sourceItems[itemIndex]?.chapter_key || '', label: sourceItems[itemIndex]?.label || '', is_completed: checkbox.getAttribute('aria-checked') === 'true' })); setChecklistSignal(card, rollbackItems); if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'; } } finally { button.disabled = false; } };";
echo " document.addEventListener('click', (event) => { const checkbox = event.target.closest('[data-checklist-edit-checkbox]'); if (checkbox) { event.preventDefault(); setChecklistCheckbox(checkbox, checkbox.getAttribute('aria-checked') !== 'true'); const editCard = checkbox.closest('[data-checklist-card]'); setChecklistSignal(editCard, Array.from(editCard?.querySelectorAll('[data-checklist-edit-item]') || []).filter((item) => !item.hasAttribute('data-checklist-trailing-item')).map((item) => ({ is_completed: item.querySelector('[data-checklist-edit-checkbox]')?.getAttribute('aria-checked') === 'true' }))); return; } const readCheckbox = event.target.closest('[data-checklist-read-checkbox]'); if (readCheckbox) { event.preventDefault(); void persistChecklistReadToggle(readCheckbox); return; } const deleteItem = event.target.closest('[data-checklist-delete-item]'); if (deleteItem) { const list = deleteItem.closest('[data-checklist-edit-list]'); deleteItem.closest('[data-checklist-edit-item]')?.remove(); ensureChecklistTrailingItem(deleteItem.closest('[data-market-checklist-form]'), list); const deleteCard = deleteItem.closest('[data-checklist-card]'); setChecklistSignal(deleteCard, Array.from(deleteCard?.querySelectorAll('[data-checklist-edit-item]') || []).filter((item) => !item.hasAttribute('data-checklist-trailing-item')).map((item) => ({ is_completed: item.querySelector('[data-checklist-edit-checkbox]')?.getAttribute('aria-checked') === 'true' }))); } });";
echo " document.addEventListener('input', (event) => { const input = event.target.closest('[data-market-checklist-form] [data-checklist-label]'); if (!input) { return; } const form = input.closest('[data-market-checklist-form]'); const item = input.closest('[data-checklist-edit-item]'); const list = input.closest('[data-checklist-edit-list]'); if (item?.hasAttribute('data-checklist-trailing-item') && input.value.trim() !== '') { item.removeAttribute('data-checklist-trailing-item'); } ensureChecklistTrailingItem(form, list); const card = input.closest('[data-checklist-card]'); setChecklistSignal(card, Array.from(card?.querySelectorAll('[data-checklist-edit-item]') || []).filter((item) => !item.hasAttribute('data-checklist-trailing-item')).map((item) => ({ is_completed: item.querySelector('[data-checklist-edit-checkbox]')?.getAttribute('aria-checked') === 'true' }))); });";
echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-checklist-form]'); if (!form) { return; } event.preventDefault(); event.stopImmediatePropagation(); const status = form.querySelector('[data-market-checklist-status]'); const checklistWasVisible = window.SharedEditableActionCard?.isVisible(form.closest('[data-checklist-card]')) === true; const items = Array.from(form.querySelectorAll('[data-checklist-edit-item]')).filter((item) => !item.hasAttribute('data-checklist-trailing-item')).map((item) => ({ chapter_key: item.dataset.checklistChapterKey || item.closest('[data-checklist-edit-list]')?.dataset.checklistChapterKey || '', label: item.querySelector('[data-checklist-label]')?.value || '', is_completed: item.querySelector('[data-checklist-edit-checkbox]')?.getAttribute('aria-checked') === 'true' })); if (status) { status.textContent = 'Speichern läuft …'; } 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: 'update_checklist', id: form.dataset.marketChecklistId, items, comment: form.querySelector('[data-checklist-comment]')?.value || '' }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const trigger = contentRoot.querySelector('[data-market-drawer-open][aria-expanded=\"true\"]'); if (trigger) { await openMarketDrawer(trigger); if (checklistWasVisible) { setChecklistVisibility(getMarketDrawer()?.querySelector('[data-checklist-card]'), true); } } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'; } } });";
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 = () => { 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 });";
echo " const municipalityFieldObserver = new MutationObserver(() => initializeMunicipalityFields(contentRoot)); municipalityFieldObserver.observe(contentRoot, { childList: true, subtree: true }); initializeMunicipalityFields(contentRoot);";
echo " const municipalityReadObserver = new MutationObserver(() => syncMunicipalityReadValue(contentRoot)); municipalityReadObserver.observe(contentRoot, { childList: true, subtree: true }); syncMunicipalityReadValue(contentRoot);";
echo " if (!marketsBindingsInstalled) {";
echo " marketsBindingsInstalled = true;";
echo " document.addEventListener('click', (event) => { const dayAdd = event.target.closest('[data-market-day-add]'); if (dayAdd) { event.preventDefault(); addMarketDayDraft(); return; } const editButton = event.target.closest('[data-shared-editable-card-edit]'); if (editButton) { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); return; } const cancelButton = event.target.closest('[data-shared-editable-card-cancel]'); if (cancelButton) { if (cancelButton.closest('[data-market-edit-form][data-market-edit-draft=\"true\"]')) { window.setTimeout(discardMarketDayDraft, 0); } else { window.setTimeout(() => syncMarketDayAddButton(contentRoot), 0); } } });";
echo " document.addEventListener('click', (event) => { const deleteDaySubmit = event.target.closest('[data-market-day-delete-submit]'); if (deleteDaySubmit) { void deleteMarketDay(deleteDaySubmit); return; } const createButton = event.target.closest('[data-market-occurrence-create]'); if (createButton) { event.preventDefault(); void openNewMarketDrawer(); return; } if (event.target.closest('[data-market-create-cancel]')) { closeMarketDrawer(); } });";
echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-create-form]'); if (!form) { return; } event.preventDefault(); event.stopImmediatePropagation(); const status = form.querySelector('[data-market-create-status]'); const fields = Object.fromEntries(new FormData(form).entries()); if (status) { status.textContent = 'Speichern läuft …'; } 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: 'create', fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } const detailResponse = await fetch('/api/market-occurrence.php?id=' + encodeURIComponent(result.occurrence_id), { credentials: 'same-origin' }); const payload = await detailResponse.json(); if (!detailResponse.ok || !payload.ok) { throw new Error(payload.error || 'Die neue Durchführung konnte nicht geladen werden.'); } const drawer = getMarketDrawer(); const title = drawer?.querySelector('[data-market-drawer-title]'); const content = drawer?.querySelector('[data-market-drawer-content]'); if (drawer) { 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 (status) { status.textContent = marketEscape(error.message); } } });";
echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-edit-form][data-market-edit-draft=\"true\"]'); if (!form) { return; } event.preventDefault(); event.stopImmediatePropagation(); const status = form.querySelector('[data-market-edit-status]'); const fields = Object.fromEntries(new FormData(form).entries()); if (status) { status.textContent = 'Speichern läuft …'; } 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: 'create_day', id: form.dataset.marketOccurrenceId, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern 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); } } });";
echo " document.addEventListener('click', (event) => { const deleteSubmit = event.target.closest('[data-market-delete-submit]'); if (deleteSubmit) { void deleteMarketOccurrence(deleteSubmit); return; } const sortButton = event.target.closest('[data-markets-sort-button]'); if (sortButton) { event.preventDefault(); void loadFragment({ market_search: getSearchValue(), market_sort: sortButton.dataset.marketsSortColumn || getSortColumn(), market_dir: sortButton.dataset.marketsSortDirection || getSortDirection(), market_limit: getPageSize() }); return; } const loadMore = event.target.closest('[data-markets-load-more-trigger]'); if (loadMore) { event.preventDefault(); void loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getCurrentLimit() + getPageSize() }); return; } const trigger = event.target.closest('[data-market-drawer-open]'); if (trigger) { event.preventDefault(); if (window.matchMedia('(max-width: 767px)').matches && !marketOpeningMobileDetail) { openMarketMobileDetail(trigger); } else { void openMarketDrawer(trigger); } return; } if (event.target.closest('[data-market-drawer-close]')) { closeMarketDrawer(); return; } if (event.target.closest('[data-market-detail-back]')) { if (window.history.state?.marketView === 'detail') { window.history.back(); } else { restoreMarketList(); } return; } });";
echo " document.addEventListener('submit', async (event) => { const form = event.target.closest('[data-market-edit-form]'); if (!form) { return; } event.preventDefault(); const status = form.querySelector('[data-market-edit-status]'); const fields = normalizeMarketBooleanFields(Object.fromEntries(new FormData(form).entries())); const occurrenceId = form.dataset.marketEditId || ''; if (status) { status.textContent = 'Speichern läuft …'; } 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({ scope: form.dataset.marketEditScope, id: occurrenceId, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } await loadFragment({ market_search: getSearchValue(), market_sort: getSortColumn(), market_dir: getSortDirection(), market_limit: getCurrentLimit() }); const refreshedTrigger = contentRoot.querySelector('[data-market-drawer-open][data-market-occurrence-id=\"' + occurrenceId + '\"]'); if (refreshedTrigger) { await openMarketDrawer(refreshedTrigger); } } catch (error) { if (status) { status.textContent = marketEscape(error.message); } } });";
echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-market-drawer-backdrop]')) { closeMarketDrawer(); } });";
echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { closeMarketDrawer(); restoreMarketList(); } });";
echo " window.addEventListener('popstate', () => { if (marketDetailOpen) { restoreMarketList(); } });";
echo " }";
echo " const normalizeOccurrenceOverviewForm = (root) => { root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=overview]').forEach((form) => { if (form.dataset.marketOccurrenceFieldsNormalized === 'true') { return; } const organizerSelect = form.querySelector('[name=organization_contact_id]'); const contactSelect = form.querySelector('[name=contact_person_id]'); const organizerField = organizerSelect?.closest('.sg-form-sections-card__field-group'); const contactField = contactSelect?.closest('.sg-form-sections-card__field-group'); if (organizerField && organizerSelect) { const value = organizerSelect.selectedOptions[0]?.textContent || 'Nicht hinterlegt'; organizerField.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Veranstalter</span><input class=\"sg-interaction-element sg-input-single-line sg-input-single-line--disabled sg-form-disabled\" type=\"text\" name=\"organizer_name\" value=\"' + marketEscape(value) + '\" disabled aria-disabled=\"true\"></label>'; } if (contactField && contactSelect) { const value = contactSelect.selectedOptions[0]?.textContent || ''; contactField.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Kontaktperson</span><input class=\"sg-interaction-element sg-input-single-line\" type=\"text\" name=\"contact_person_name\" value=\"' + marketEscape(value) + '\"></label>'; } form.dataset.marketOccurrenceFieldsNormalized = 'true'; }); };";
echo " const marketOccurrenceFieldObserver = new MutationObserver(() => normalizeOccurrenceOverviewForm(contentRoot)); marketOccurrenceFieldObserver.observe(contentRoot, { childList: true, subtree: true });";
echo " const normalizeMarketSimpleCostFields = (root) => { root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation]').forEach((form) => { if (form.dataset.marketSimpleCostsNormalized === 'true') { return; } const drawer = getMarketDrawer(); let payload = {}; try { payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : {}; } catch (error) {} const item = Array.isArray(payload.occurrence?.cost_items) ? payload.occurrence.cost_items[0] : null; const total = form.querySelector('[name=gross_cost]'); const text = form.querySelector('[name=cost_items_text]'); const totalLabel = total?.closest('.sg-labeled-input-row')?.querySelector('.sg-label'); const textLabel = text?.closest('.sg-labeled-input-row')?.querySelector('.sg-label'); if (total) { total.disabled = false; total.removeAttribute('disabled'); total.type = 'number'; total.step = '0.01'; total.min = '0'; total.required = true; total.classList.remove('sg-input-single-line--disabled', 'sg-form-disabled'); total.value = payload.occurrence?.cost_summary?.gross_amount ?? item?.gross_amount ?? ''; } if (text) { text.disabled = false; text.removeAttribute('disabled'); text.classList.remove('sg-form-disabled'); text.value = payload.occurrence?.cost_items_text ?? item?.description ?? ''; } if (totalLabel) { totalLabel.textContent = 'Gesamtkosten'; } if (textLabel) { textLabel.textContent = 'Kostenpositionen'; } form.dataset.marketSimpleCostsNormalized = 'true'; }); };";
echo " const marketSimpleCostFieldsObserver = new MutationObserver(() => normalizeMarketSimpleCostFields(contentRoot)); marketSimpleCostFieldsObserver.observe(contentRoot, { childList: true, subtree: true }); normalizeMarketSimpleCostFields(contentRoot);";
echo " const normalizeMarketTaxFields = (root) => { root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation]').forEach((form) => { if (form.dataset.marketTaxFieldsNormalized === 'true') { return; } const total = form.querySelector('[name=gross_cost]'); const totalGroup = total?.closest('.sg-form-sections-card__field-group'); if (!totalGroup) { return; } const drawer = getMarketDrawer(); let payload = {}; try { payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : {}; } catch (error) {} const item = Array.isArray(payload.occurrence?.cost_items) ? payload.occurrence.cost_items[0] : null; const taxStatusGroup = document.createElement('div'); taxStatusGroup.className = 'sg-form-sections-card__field-group'; taxStatusGroup.innerHTML = marketEditField('tax_status', 'Steuerstatus', 'select', item?.tax_status || 'taxable', [['taxable', 'Steuerpflichtig'], ['tax_free', 'Steuerfrei']], true); const taxRateGroup = document.createElement('div'); taxRateGroup.className = 'sg-form-sections-card__field-group'; taxRateGroup.innerHTML = marketEditField('tax_rate', 'Steuersatz', 'number', item?.tax_rate ?? '8.10', [], true); totalGroup.after(taxStatusGroup, taxRateGroup); const taxStatus = taxStatusGroup.querySelector('[name=tax_status]'); const taxRate = taxRateGroup.querySelector('[name=tax_rate]'); const sync = () => { const taxFree = taxStatus?.value === 'tax_free'; if (taxFree && taxRate) { taxRate.value = '0.00'; } if (taxRate) { taxRate.disabled = taxFree; taxRate.setAttribute('aria-disabled', String(taxFree)); } }; taxStatus?.addEventListener('change', sync); sync(); form.dataset.marketTaxFieldsNormalized = 'true'; }); };";
echo " const marketTaxFieldsObserver = new MutationObserver(() => normalizeMarketTaxFields(contentRoot)); marketTaxFieldsObserver.observe(contentRoot, { childList: true, subtree: true }); normalizeMarketTaxFields(contentRoot);";
echo " const marketTaxRateAttributesObserver = new MutationObserver(() => { contentRoot.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation] [name=tax_rate]').forEach((input) => { input.step = '0.01'; input.min = '0'; }); }); marketTaxRateAttributesObserver.observe(contentRoot, { childList: true, subtree: true });";
echo " const marketPaidAmountObserver = new MutationObserver(() => { contentRoot.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation] [name=paid_amount]').forEach((input) => { input.disabled = false; input.removeAttribute('disabled'); input.type = 'number'; input.step = '0.01'; input.min = '0'; input.classList.remove('sg-input-single-line--disabled', 'sg-form-disabled'); }); }); marketPaidAmountObserver.observe(contentRoot, { childList: true, subtree: true });";
echo " const marketPaidAmountPositionObserver = new MutationObserver(() => { contentRoot.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation]').forEach((form) => { const paidGroup = form.querySelector('[name=paid_amount]')?.closest('.sg-form-sections-card__field-group'); const taxGroup = form.querySelector('[name=tax_cost]')?.closest('.sg-form-sections-card__field-group'); if (paidGroup && taxGroup && taxGroup.nextElementSibling !== paidGroup) { taxGroup.after(paidGroup); } }); }); marketPaidAmountPositionObserver.observe(contentRoot, { childList: true, subtree: true });";
echo " const syncMarketSimpleCostTotals = (form) => { const gross = Number.parseFloat(form.querySelector('[name=gross_cost]')?.value || '0'); const rate = form.querySelector('[name=tax_status]')?.value === 'tax_free' ? 0 : Number.parseFloat(form.querySelector('[name=tax_rate]')?.value || '0'); const tax = Number.isFinite(gross) && Number.isFinite(rate) ? Math.round((gross - gross / (1 + rate / 100)) * 100) / 100 : 0; const net = Number.isFinite(gross) ? Math.round((gross - tax) * 100) / 100 : 0; const format = (value) => 'CHF ' + value.toFixed(2); const netField = form.querySelector('[name=net_cost]'); const taxField = form.querySelector('[name=tax_cost]'); if (netField) { netField.value = format(net); } if (taxField) { taxField.value = format(tax); } };";
echo " document.addEventListener('input', (event) => { const field = event.target.closest('[data-market-edit-form][data-market-edit-scope=participation] [name=gross_cost], [data-market-edit-form][data-market-edit-scope=participation] [name=tax_rate]'); if (field) { syncMarketSimpleCostTotals(field.closest('[data-market-edit-form]')); } });";
echo " document.addEventListener('change', (event) => { const field = event.target.closest('[data-market-edit-form][data-market-edit-scope=participation] [name=tax_status]'); if (field) { syncMarketSimpleCostTotals(field.closest('[data-market-edit-form]')); } });";
echo " bindTable();";
echo "}";
echo "function initMarketDefinitionsBindings() {";
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
echo " const definitionContent = contentRoot?.querySelector('[data-market-definitions-content]');";
echo " const drawer = definitionContent?.querySelector('[data-market-definition-drawer]');";
echo " const readCard = drawer?.querySelector('[data-market-definition-read-card]');";
echo " const card = drawer?.querySelector('[data-market-definition-create-card]');";
echo " const form = drawer?.querySelector('[data-market-definition-form]');";
echo " const editButton = drawer?.querySelector('[data-market-definition-edit]');";
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]');
if (!existingCard || drawer.querySelector('[data-market-definition-delete-id]')) {
return;
}
const stage = document.createElement('div');
stage.className = 'sg-delete-confirmation-pattern__stage sg-delete-confirmation-pattern__host';
stage.dataset.pattern = 'overlay-card';
stage.dataset.dialogOpen = 'false';
const target = document.createElement('div');
target.className = 'sg-delete-confirmation-pattern__target';
target.dataset.sharedEditableActionCard = '';
target.dataset.sharedEditableDeleteToken = 'DELETE';
target.dataset.marketDefinitionDeleteId = '';
target.dataset.marketDefinitionCard = '';
const titleCard = document.createElement('article');
titleCard.className = 'sg-card sg-card--content-card market-delete-confirmation-card';
const header = document.createElement('header');
header.className = 'sg-card-segment sg-card-segment--header sg-card-segment--gray sg-object-card__header';
const heading = document.createElement('h2');
heading.className = 'sg-heading-h2 market-delete-confirmation-card__title';
heading.dataset.marketDefinitionReadTitle = '';
heading.dataset.marketDefinitionDrawerTitle = '';
heading.textContent = 'Markt';
const actions = document.createElement('div');
actions.className = 'sg-component-row';
const deleteButton = document.createElement('button');
deleteButton.className = 'sg-interaction-element sg-button sg-button--warning';
deleteButton.type = 'button';
deleteButton.dataset.sharedEditableCardDelete = '';
deleteButton.dataset.marketDefinitionDelete = '';
deleteButton.textContent = 'Löschen';
actions.append(deleteButton);
header.append(heading, actions);
titleCard.append(header);
existingCard.querySelector('[data-market-definition-read-title]')?.remove();
existingCard.removeAttribute('data-shared-editable-action-card');
const confirmation = document.createElement('article');
confirmation.className = 'sg-card sg-card--overlay-host sg-delete-confirmation-pattern__floating-card';
confirmation.setAttribute('aria-label', 'Löschbestätigung');
confirmation.setAttribute('role', 'dialog');
confirmation.setAttribute('aria-modal', 'true');
confirmation.dataset.overlayDialog = 'delete';
confirmation.dataset.sharedEditableCardDeleteConfirmation = '';
confirmation.hidden = true;
confirmation.innerHTML = '<div class="sg-card-segment sg-card-segment--body sg-delete-confirmation-pattern__body"><p class="sg-body sg-delete-confirmation-pattern__text"><strong>Möchtest du diesen Markt wirklich löschen?</strong></p><p class="sg-body sg-delete-confirmation-pattern__text">Du kannst das nicht rückgängig machen. Bestätige durch Eingabe von <span class="sg-delete-confirmation-pattern__code">DELETE</span>.</p><label class="sg-labeled-input-row sg-delete-confirmation-pattern__input-row"><span class="sg-label">Bestätigung</span><input class="sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable" type="text" placeholder="DELETE" aria-label="Löschbestätigung durch DELETE" data-shared-editable-card-delete-input></label><p class="sg-body" data-market-definition-delete-status aria-live="polite"></p><div class="sg-delete-confirmation-pattern__actions"><button class="sg-interaction-element sg-button sg-button--active" type="button" data-shared-editable-card-delete-cancel>Abbrechen</button><button class="sg-interaction-element sg-button sg-button--process sg-button--process-inactive" type="button" disabled aria-disabled="true" data-shared-editable-card-delete-submit data-market-definition-delete-submit>Löschen</button></div></div>';
target.append(titleCard, existingCard, confirmation);
stage.append(target);
drawer.querySelector('[data-market-definition-drawer-content]')?.append(stage);
};
JS;
echo " prepareMarketDefinitionDeleteCard();";
echo " const sharedCard = drawer.querySelector('[data-market-definition-card]');";
echo " const websiteInput = form.querySelector('[data-market-definition-website]'); if (websiteInput) { websiteInput.type = 'text'; }";
echo " if (window.SharedEditableActionCard) { window.SharedEditableActionCard.init(definitionContent); }";
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(); if (window.matchMedia('(max-width: 767px)').matches) { openMarketDefinitionMobileDetail(trigger); } else { showReadCard(trigger); } }, true);";
echo " definitionContent.addEventListener('click', (event) => { const sortButton = event.target.closest('[data-market-definition-sort]'); if (!sortButton || !definitionContent.contains(sortButton)) { return; } event.preventDefault(); const url = new URL(window.location.href); url.searchParams.set('portal_module', 'Märkte'); url.searchParams.set('portal_section', 'Märkte'); url.searchParams.set('market_definition_sort', sortButton.dataset.marketDefinitionSortColumn || 'name'); url.searchParams.set('market_definition_dir', sortButton.dataset.marketDefinitionSortDirection || 'ASC'); window.location.assign(url.toString()); });";
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(); });";
echo " form.querySelector('[data-market-definition-organizer]')?.addEventListener('change', syncNewOrganizerFields);";
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() {";
echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');";
echo " if (!contentRoot) { return; }";
@@ -980,12 +1458,14 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const orderId = trigger.dataset.orderId || '';";
echo " const formatAmount = (value, currency) => value === null || value === undefined ? 'Nicht vorhanden' : currency + ' ' + Number(value).toFixed(2);";
echo " const formatDate = (value) => value ? new Date(value).toLocaleString('de-CH', { dateStyle: 'short', timeStyle: 'short' }) : 'Nicht vorhanden';";
echo " const value = (label, content) => '<p class=\"sg-body\"><strong>' + escapeHtml(label) + ':</strong> ' + escapeHtml(content || 'Nicht vorhanden') + '</p>';";
echo " const formatMarketDay = (value) => value ? new Date(value + 'T00:00:00').toLocaleDateString('de-CH') : '';";
echo " const value = (label, content) => '<div class=\"market-detail-row\"><dt>' + escapeHtml(label) + '</dt><dd>' + escapeHtml(content || 'Nicht vorhanden') + '</dd></div>';";
echo " const renderCards = (order) => {";
echo " const c = order.customer || {}; const a = order.amounts || {}; const address = [[c.street, c.house_number].filter(Boolean).join(' '), [c.zip, c.city].filter(Boolean).join(' '), c.country_name].filter(Boolean).join('<br>');";
echo " const lines = (order.lines || []).map((line) => '<div class=\"sg-card-segment sg-card-segment--body\"><p class=\"sg-body\"><strong>' + escapeHtml(line.item_name) + '</strong> · ' + escapeHtml(line.item_code) + ' · Menge: ' + escapeHtml(String(line.qty)) + '</p>' + value('Positionsstatus', line.line_status) + value('Einzelpreis', formatAmount(line.unit_price, a.currency || 'CHF')) + value('Positionssumme', formatAmount(line.line_total, a.currency || 'CHF')) + '</div>').join('');";
echo " const allocations = (order.lines || []).flatMap((line) => (line.product_allocations || []).map((item) => '<div class=\"sg-body\"><strong>' + escapeHtml(item.product_name) + ':</strong> ' + escapeHtml(item.lot_number || 'Nicht zugeordnet') + ' · ' + escapeHtml(item.allocated_qty === null ? 'Nicht zugeordnet' : String(item.allocated_qty)) + ' · ' + escapeHtml(item.allocation_status || 'Nicht vorhanden') + (item.lot_status === 'virtual_shortage' ? ' · Virtuelle Fehlbestandscharge' : '') + '</div>')).join('');";
echo " return '<article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Bestellübersicht\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Bestellübersicht</div></header><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\">' + value('Bestellnummer', order.external_ref) + value('Bestelldatum', formatDate(order.order_date)) + value('Verkaufskanal', order.order_source) + value('Bestellstatus', order.order_status) + value('Zahlungsstatus', order.payment_status) + value('Zahlungsart', order.payment_method) + value('Versandart', order.shipping_method) + '<p class=\"sg-body\"><strong>Kunde:</strong> ' + escapeHtml([c.first_name, c.last_name].filter(Boolean).join(' ') || 'Nicht vorhanden') + '</p><p class=\"sg-body\"><strong>Lieferadresse:</strong><br>' + address + '</p>' + value('Netto', formatAmount(a.net, a.currency || 'CHF')) + value('Versand', formatAmount(a.shipping, a.currency || 'CHF')) + value('Rabatt', formatAmount(a.discount, a.currency || 'CHF')) + value('Steuer', formatAmount(a.tax, a.currency || 'CHF')) + value('Gesamtsumme', formatAmount(a.total, a.currency || 'CHF')) + '</div></article><article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Bestellte Artikel\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Bestellte Artikel</div></header>' + (lines || '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\"><p class=\"sg-body\">Keine Artikel vorhanden.</p></div>') + '</article><article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Chargenzuordnung\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Chargenzuordnung</div></header><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\">' + (allocations || '<p class=\"sg-body\">Keine Chargenzuordnung vorhanden.</p>') + '</div></article>';";
echo " const c = order.customer || {}; const a = order.amounts || {}; const address = [[c.street, c.house_number].filter(Boolean).join(' '), [c.zip, c.city].filter(Boolean).join(' '), c.country_name].filter(Boolean).map(escapeHtml).join('<br>');";
echo " const lines = (order.lines || []).map((line) => '<div class=\"sg-card-segment sg-card-segment--body\"><dl class=\"market-detail-grid\">' + value(line.item_name, [line.item_code, 'Menge: ' + String(line.qty)].filter(Boolean).join(' · ')) + value('Positionsstatus', line.line_status) + value('Einzelpreis', formatAmount(line.unit_price, a.currency || 'CHF')) + value('Positionssumme', formatAmount(line.line_total, a.currency || 'CHF')) + '</dl></div>').join('');";
echo " const allocations = (order.lines || []).flatMap((line) => (line.product_allocations || []).map((item) => '<dl class=\"market-detail-grid\">' + value(item.product_name, [item.lot_number || 'Nicht zugeordnet', item.allocated_qty === null ? 'Nicht zugeordnet' : String(item.allocated_qty), item.allocation_status || 'Nicht vorhanden', item.lot_status === 'virtual_shortage' ? 'Virtuelle Fehlbestandscharge' : ''].filter(Boolean).join(' · ')) + '</dl>')).join('');";
echo " const market = order.market || {}; const marketReference = market.name && market.occurrence_day_date ? market.name + ', Marktdurchführung ' + formatMarketDay(market.occurrence_day_date) : 'kein Marktbezug';";
echo " return '<article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Bestellübersicht\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Bestellübersicht</div></header><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\"><dl class=\"market-detail-grid\">' + value('Bestellnummer', order.external_ref) + value('Bestelldatum', formatDate(order.order_date)) + value('Verkaufskanal', order.order_source) + value('Bestellstatus', order.order_status) + value('Zahlungsstatus', order.payment_status) + value('Zahlungsart', order.payment_method) + value('Versandart', order.shipping_method) + value('Kunde', [c.first_name, c.last_name].filter(Boolean).join(' ')) + '<div class=\"market-detail-row\"><dt>Lieferadresse</dt><dd>' + (address || 'Nicht vorhanden') + '</dd></div>' + value('Netto', formatAmount(a.net, a.currency || 'CHF')) + value('Versand', formatAmount(a.shipping, a.currency || 'CHF')) + value('Rabatt', formatAmount(a.discount, a.currency || 'CHF')) + value('Steuer', formatAmount(a.tax, a.currency || 'CHF')) + value('Gesamtsumme', formatAmount(a.total, a.currency || 'CHF')) + value('Markt:', marketReference) + '</dl></div></article><article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Bestellte Artikel\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Bestellte Artikel</div></header>' + (lines || '<div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\"><p class=\"sg-body\">Keine Artikel vorhanden.</p></div>') + '</article><article class=\"sg-card sg-card--content-card\" data-component=\"content-card\" aria-label=\"Chargenzuordnung\"><header class=\"sg-card-segment sg-card-segment--header sg-card-segment--darkgreen\" data-component-part=\"card-header\"><div class=\"sg-strong sg-text-on-dark\">Chargenzuordnung</div></header><div class=\"sg-card-segment sg-card-segment--body\" data-component-part=\"card-body\">' + (allocations || '<p class=\"sg-body\">Keine Chargenzuordnung vorhanden.</p>') + '</div></article>';";
echo " };";
echo " setDrawerState(true);";
echo " if (drawerTitle) { drawerTitle.textContent = trigger.dataset.orderNumber ? 'Bestellung ' + trigger.dataset.orderNumber : 'Bestellung'; }";
@@ -1212,6 +1692,9 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " closeDrawer();";
echo " }";
echo " });";
echo " document.addEventListener('otc-order-created', () => {";
echo " void reloadBestellungenSection();";
echo " });";
echo " window.addEventListener('popstate', (event) => {";
echo " if (bestellungenDetailOpen && (!event.state || event.state.bestellungenView !== 'detail')) {";
echo " restoreBestellungenList();";
@@ -1240,6 +1723,10 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const houseNumberInput = form.querySelector('[data-otc-order-house-number]');";
echo " const zipInput = form.querySelector('[data-otc-order-zip]');";
echo " const cityInput = form.querySelector('[data-otc-order-city]');";
echo " const marketLinkToggle = form.querySelector('[data-otc-market-link-toggle]');";
echo " const marketLinkEnabledInput = form.querySelector('[data-otc-market-link-enabled]');";
echo " const marketOccurrenceInput = form.querySelector('[data-otc-market-occurrence]');";
echo " const marketDayInput = form.querySelector('[data-otc-market-day]');";
echo " const submitBtn = overlay.querySelector('[data-otc-order-submit]');";
echo " const errorEl = form.querySelector('[data-otc-order-error]');";
echo " const successEl = form.querySelector('[data-otc-order-success]');";
@@ -1250,6 +1737,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const confirmationQuantity = overlay.querySelector('[data-otc-confirmation-quantity]');";
echo " const confirmationTotal = overlay.querySelector('[data-otc-confirmation-total]');";
echo " const confirmationSubmit = overlay.querySelector('[data-otc-confirmation-submit]');";
echo " const confirmationError = overlay.querySelector('[data-otc-confirmation-error]');";
echo " let pendingOrderData = null;";
echo " const openTriggers = () => document.querySelectorAll('[data-otc-order-open]');";
echo " const setTriggerState = (expanded) => {";
@@ -1257,6 +1745,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " };";
echo " const clearError = () => {";
echo " if (errorEl) { errorEl.textContent = ''; errorEl.classList.add('hidden'); }";
echo " if (confirmationError) { confirmationError.textContent = ''; confirmationError.classList.add('hidden'); }";
echo " };";
echo " const clearSuccess = () => {";
echo " if (successEl) { successEl.textContent = successDefaultText; successEl.classList.add('hidden'); }";
@@ -1293,6 +1782,10 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " };";
echo " const getInputValue = (input) => parseInt(input.value || '0', 10) || 0;";
echo " const getFieldValue = (input) => (input ? input.value.trim() : '');";
echo " const isMarketLinkEnabled = () => marketLinkToggle?.getAttribute('aria-checked') === 'true';";
echo " const setDisabledSelectState = (select, disabled) => { if (!select) { return; } select.disabled = disabled; select.setAttribute('aria-disabled', String(disabled)); select.classList.toggle('sg-form-disabled', disabled); select.classList.toggle('sg-input-single-line--disabled', disabled); select.classList.toggle('sg-pulldown--disabled', disabled); select.classList.toggle('sg-form-inactive-selectable', !disabled); select.classList.toggle('sg-input-single-line--inactive-selectable', !disabled); };";
echo " const syncMarketDayOptions = () => { if (!marketOccurrenceInput || !marketDayInput) { return; } const marketDefinitionId = marketOccurrenceInput.value; Array.from(marketDayInput.options).forEach((option, index) => { if (index === 0) { option.hidden = false; option.disabled = false; return; } const matches = marketDefinitionId !== '' && option.dataset.marketDefinitionId === marketDefinitionId; option.hidden = !matches; option.disabled = !matches; }); if (!marketDefinitionId || !Array.from(marketDayInput.options).some((option) => option.value === marketDayInput.value && !option.disabled)) { marketDayInput.value = ''; } setDisabledSelectState(marketDayInput, !isMarketLinkEnabled() || marketDefinitionId === ''); };";
echo " const syncMarketLinkState = () => { const enabled = isMarketLinkEnabled(); if (marketLinkEnabledInput) { marketLinkEnabledInput.value = enabled ? '1' : '0'; } setDisabledSelectState(marketOccurrenceInput, !enabled); if (!enabled && marketOccurrenceInput) { marketOccurrenceInput.value = ''; } syncMarketDayOptions(); };";
echo " const updatePaymentAddButton = () => { addPaymentButton.disabled = paymentRows.querySelectorAll('[data-otc-payment-row]').length >= paymentOptionValues.length; };";
echo " const updateFormState = () => {";
echo " const totalQty = productInputs.reduce((sum, input) => sum + getInputValue(input), 0);";
@@ -1304,7 +1797,8 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " && !!getFieldValue(streetInput)";
echo " && !!getFieldValue(houseNumberInput)";
echo " && !!getFieldValue(zipInput)";
echo " && !!getFieldValue(cityInput);";
echo " && !!getFieldValue(cityInput)";
echo " && (!isMarketLinkEnabled() || (!!getFieldValue(marketOccurrenceInput) && !!getFieldValue(marketDayInput)));";
echo " let errorMsg = '';";
echo " if (totalQty === 0) {";
echo " errorMsg = 'Mindestens ein Produkt mit Menge > 0 erforderlich.';";
@@ -1312,6 +1806,8 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " errorMsg = 'Bestelldatum auswählen.';";
echo " } else if (!getFieldValue(firstNameInput) || !getFieldValue(lastNameInput) || !getFieldValue(streetInput) || !getFieldValue(houseNumberInput) || !getFieldValue(zipInput) || !getFieldValue(cityInput)) {";
echo " errorMsg = 'Alle Rechnungsadress-Felder ausfüllen.';";
echo " } else if (isMarketLinkEnabled() && (!getFieldValue(marketOccurrenceInput) || !getFieldValue(marketDayInput))) {";
echo " errorMsg = 'Markt und Markttag auswählen.';";
echo " }";
echo " if (errorEl) {";
echo " errorEl.textContent = errorMsg;";
@@ -1328,6 +1824,8 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " updatePaymentAddButton();";
echo " clearError();";
echo " if (!preserveSuccess) { clearSuccess(); }";
echo " if (marketLinkToggle) { marketLinkToggle.setAttribute('aria-checked', 'false'); marketLinkToggle.classList.remove('sg-form-active'); marketLinkToggle.classList.add('sg-checkbox-field--inactive-selectable'); }";
echo " syncMarketLinkState();";
echo " if (!preserveSuccess) { updateFormState(); }";
echo " };";
echo " const closeConfirmation = () => {";
@@ -1384,7 +1882,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const row = document.createElement('div');";
echo " row.className = 'sg-form-sections-card__field-group';";
echo " row.dataset.otcPaymentRow = '';";
echo " row.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Bezahlart</span><select class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable sg-pulldown\" id=\"otc-payment-method-' + number + '\" data-otc-payment-method>' + paymentOptions + '</select></label><label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Betrag (CHF)</span><input class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable\" type=\"number\" id=\"otc-payment-amount-' + number + '\" min=\"0.01\" step=\"0.01\" value=\"0.00\" data-otc-payment-amount></label>' ;";
echo " row.innerHTML = '<label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Bezahlart</span><select class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable sg-pulldown\" id=\"otc-payment-method-' + number + '\" data-otc-payment-method>' + paymentOptions + '</select></label><label class=\"sg-labeled-input-row\"><span class=\"sg-label\">Betrag (CHF)</span><input class=\"sg-interaction-element sg-input-single-line sg-input-single-line--inactive-selectable sg-form-inactive-selectable\" type=\"number\" id=\"otc-payment-amount-' + number + '\" min=\"0.01\" step=\"0.01\" placeholder=\"0.00\" data-otc-payment-amount></label>' ;";
echo " row.querySelector('[data-otc-payment-method]').value = nextPaymentMethod;";
echo " paymentRows.appendChild(row);";
echo " bindPaymentRow(row);";
@@ -1394,6 +1892,9 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " addPaymentButton.addEventListener('click', addPaymentRow);";
echo " bindPaymentRow(paymentRows.querySelector('[data-otc-payment-row]'));";
echo " updatePaymentAddButton();";
echo " marketLinkToggle?.addEventListener('click', (event) => { event.stopImmediatePropagation(); const enabled = marketLinkToggle.getAttribute('aria-checked') !== 'true'; marketLinkToggle.setAttribute('aria-checked', String(enabled)); marketLinkToggle.classList.toggle('sg-form-active', enabled); marketLinkToggle.classList.toggle('sg-checkbox-field--inactive-selectable', !enabled); syncMarketLinkState(); updateFormState(); });";
echo " marketOccurrenceInput?.addEventListener('change', () => { syncMarketDayOptions(); updateFormState(); });";
echo " marketDayInput?.addEventListener('change', updateFormState);";
echo " const submitOrder = async (orderData) => {";
echo " let submissionSucceeded = false;";
echo " const originalBtnText = confirmationSubmit.innerHTML;";
@@ -1409,6 +1910,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " const result = await response.json();";
echo " if (response.ok && result.ok) {";
echo " submissionSucceeded = true;";
echo " document.dispatchEvent(new CustomEvent('otc-order-created', { detail: { orderId: result.orderId } }));";
echo " if (successEl) {";
echo " successEl.textContent = result.externalRef ? 'Bestellung erfolgreich erfasst! Bestellnummer: ' + result.externalRef : successDefaultText;";
echo " successEl.classList.remove('hidden');";
@@ -1416,17 +1918,15 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " closeConfirmation();";
echo " resetForm();";
echo " toggleOverlay(false);";
echo " } else if (errorEl) {";
echo " errorEl.textContent = result.error || 'Unbekannter Fehler';";
echo " errorEl.classList.remove('hidden');";
echo " closeConfirmation();";
echo " } else {";
echo " const message = result.error || 'Unbekannter Fehler';";
echo " if (errorEl) { errorEl.textContent = message; errorEl.classList.remove('hidden'); }";
echo " if (confirmationError) { confirmationError.textContent = message; confirmationError.classList.remove('hidden'); }";
echo " }";
echo " } catch (error) {";
echo " if (errorEl) {";
echo " errorEl.textContent = 'Netzwerkfehler: ' + error.message;";
echo " errorEl.classList.remove('hidden');";
echo " }";
echo " closeConfirmation();";
echo " const message = 'Netzwerkfehler: ' + error.message;";
echo " if (errorEl) { errorEl.textContent = message; errorEl.classList.remove('hidden'); }";
echo " if (confirmationError) { confirmationError.textContent = message; confirmationError.classList.remove('hidden'); }";
echo " } finally {";
echo " confirmationSubmit.innerHTML = originalBtnText;";
echo " confirmationSubmit.disabled = false;";
@@ -1443,14 +1943,16 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " products: products,";
echo " orderDate: orderDateInput ? orderDateInput.value : '',";
echo " payments: Array.from(paymentRows.querySelectorAll('[data-otc-payment-row]')).map((row) => ({ paymentMethod: row.querySelector('[data-otc-payment-method]').value, amount: parseFloat(row.querySelector('[data-otc-payment-amount]').value || '0') || 0 })),";
echo " billing: {";
echo " billing: {";
echo " firstName: getFieldValue(firstNameInput),";
echo " lastName: getFieldValue(lastNameInput),";
echo " street: getFieldValue(streetInput),";
echo " houseNumber: getFieldValue(houseNumberInput),";
echo " zip: getFieldValue(zipInput),";
echo " city: getFieldValue(cityInput)";
echo " }";
echo " city: getFieldValue(cityInput)";
echo " },";
echo " marketOccurrenceId: isMarketLinkEnabled() ? (marketDayInput?.selectedOptions[0]?.dataset.marketOccurrenceId || null) : null,";
echo " marketOccurrenceDayId: isMarketLinkEnabled() ? getFieldValue(marketDayInput) : null";
echo " };";
echo " openConfirmation(orderData);";
echo " });";
@@ -1458,6 +1960,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " if (event.target.closest('[data-otc-confirmation-cancel]')) { closeConfirmation(); return; }";
echo " if (event.target.closest('[data-otc-confirmation-submit]') && pendingOrderData) { submitOrder(pendingOrderData); }";
echo " });";
echo " syncMarketLinkState();";
echo " updateFormState();";
echo "})();";
echo "if (window.sgInitHelpIconOverlays) {";
@@ -1466,8 +1969,7 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $best
echo " outsideClickIgnoreSelectors: ['.sg-sandwich-menu-wrap'],";
echo " });";
echo "}";
echo "renderMainHeading('ERP');";
echo "renderLeftNavigation('ERP');";
echo "(() => { const url = new URL(window.location.href); const requestedModule = url.searchParams.get('portal_module') || 'ERP'; const moduleButton = Array.from(document.querySelectorAll('.sg-portal-header__tabs .sg-tab-button')).find((button) => button.textContent.trim() === requestedModule) || document.querySelector('.sg-portal-header__tabs .sg-tab-button'); if (!moduleButton) { return; } const moduleName = moduleButton.textContent.trim(); document.querySelectorAll('.sg-portal-header__tabs .sg-tab-button').forEach((button) => { const active = button === moduleButton; button.setAttribute('aria-selected', String(active)); button.dataset.componentState = active ? 'active' : 'inactive'; }); renderMainHeading(moduleName); renderLeftNavigation(moduleName, url.searchParams.get('portal_section') || ''); })();";
echo '</script>';
echo '</body>';
echo '</html>';
+1 -1
View File
@@ -10,7 +10,7 @@ function throttle_webhook_channel(string $channel, int $minIntervalSeconds): voi
$sanitizedChannel = preg_replace('/[^a-z0-9_-]+/i', '_', $channel) ?? 'default';
$lockPath = sys_get_temp_dir() . '/erp_naurua_webhook_throttle_' . $sanitizedChannel . '.lock';
$handle = fopen($lockPath, 'c+');
$handle = @fopen($lockPath, 'c+');
if ($handle === false) {
return;
}
+2
View File
@@ -5,6 +5,7 @@ require_once __DIR__ . '/../shared/db.php';
require_once __DIR__ . '/../erp/lager/forecast.php';
require_once __DIR__ . '/../erp/import-integration/shopify-webhook-worker.php';
require_once __DIR__ . '/../erp/import-integration/shopify-delta-run.php';
require_once __DIR__ . '/../market_management/service.php';
if (PHP_SAPI !== 'cli') {
http_response_code(404);
@@ -42,6 +43,7 @@ try {
}
},
'erp.lager.stock_lot_forecast_refresh' => static fn (): array => run_lager_stock_lot_forecast($pdo),
'market_management.occurrence_status_completion' => static fn (): array => market_management_auto_complete_occurrences($pdo),
];
$results = [];
foreach ($jobs as $name => $job) {
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/erp/lager/mcp.php';
final class ErpLagerMcpJsonRpcException 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');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
$request = [];
try {
erp_lager_mcp_authorize($env);
$pdo = connect_database($env);
$body = file_get_contents('php://input');
$request = json_decode($body === false ? '' : $body, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($request) || ($request['jsonrpc'] ?? '') !== '2.0') {
throw new InvalidArgumentException('Ungültige JSON-RPC-Anfrage.');
}
if (array_key_exists('params', $request) && !is_array($request['params'])) {
throw new ErpLagerMcpJsonRpcException(-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'] : [];
if ($method === 'initialize') {
$result = [
'protocolVersion' => (string) ($params['protocolVersion'] ?? '2025-03-26'),
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'naurua-erp-lager', 'version' => '1.0.0'],
];
} elseif ($method === 'notifications/initialized') {
http_response_code(202);
exit;
} elseif ($method === 'tools/list') {
$result = ['tools' => erp_lager_mcp_tools()];
} elseif ($method === 'tools/call') {
$tool = (string) ($params['name'] ?? '');
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
if (array_key_exists('arguments', $params) && !is_array($params['arguments'])) {
throw new ErpLagerMcpJsonRpcException(-32602, 'Tool-Argumente müssen als Objekt übergeben werden.');
}
try {
$result = [
'content' => [[
'type' => 'text',
'text' => json_encode(erp_lager_mcp_call($pdo, $tool, $arguments), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
]],
'isError' => false,
];
} catch (InvalidArgumentException $e) {
throw new ErpLagerMcpJsonRpcException(-32602, $e->getMessage(), $e);
}
} else {
throw new ErpLagerMcpJsonRpcException(-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) {
$isAuthError = $e instanceof RuntimeException && str_contains($e->getMessage(), 'Token');
$isJsonRpcError = $e instanceof ErpLagerMcpJsonRpcException;
http_response_code($isJsonRpcError ? 200 : ($isAuthError ? 401 : 400));
echo json_encode([
'jsonrpc' => '2.0',
'id' => $request['id'] ?? null,
'error' => [
'code' => $isJsonRpcError ? $e->rpcCode() : -32600,
'message' => $e->getMessage(),
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../modules/market_management/service.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
$pdo = connect_database($env);
auth_bootstrap_session();
if (auth_current_user($pdo) === null) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Nicht autorisiert.']);
exit;
}
if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) !== 'POST') {
http_response_code(405);
echo json_encode(['ok' => false, 'error' => 'Methode nicht erlaubt.']);
exit;
}
$csrf = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if (!auth_validate_csrf_token($csrf)) {
http_response_code(419);
echo json_encode(['ok' => false, 'error' => 'Ungültiges Sicherheits-Token.']);
exit;
}
$payload = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($payload)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Ungültige Anfrage.']);
exit;
}
try {
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
if (($fields['organizer_id'] ?? '') === '__new__') {
$organizer = market_management_create_organizer(
$pdo,
(string) ($fields['organizer_name'] ?? ''),
(string) ($fields['organizer_email'] ?? ''),
(string) ($fields['organizer_phone'] ?? '')
);
$fields['organizer_id'] = $organizer['id'];
}
if (($payload['action'] ?? 'create') === 'delete') {
$marketId = (int) ($payload['id'] ?? 0);
if ($marketId < 1) {
throw new InvalidArgumentException('id ist erforderlich.');
}
if (($payload['confirmation'] ?? '') !== 'DELETE') {
throw new InvalidArgumentException('Die Löschbestätigung muss exakt DELETE lauten.');
}
$market = market_management_set_market_archive($pdo, $marketId, true);
} elseif (($payload['action'] ?? 'create') === 'update') {
$marketId = (int) ($payload['id'] ?? 0);
if ($marketId < 1) {
throw new InvalidArgumentException('id ist erforderlich.');
}
$market = market_management_update_market($pdo, $marketId, [
'name' => $fields['name'] ?? null,
'category_id' => $fields['category_id'] ?? null,
'organizer_id' => $fields['organizer_id'] ?? null,
'website_url' => $fields['website_url'] ?? null,
]);
} else {
$market = market_management_create_market($pdo, [
'name' => $fields['name'] ?? null,
'category_id' => $fields['category_id'] ?? null,
'organizer_id' => $fields['organizer_id'] ?? null,
'website_url' => $fields['website_url'] ?? null,
]);
}
echo json_encode(['ok' => true, 'market' => $market], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
} catch (Throwable $error) {
http_response_code(422);
echo json_encode(['ok' => false, 'error' => $error->getMessage()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+87
View File
@@ -0,0 +1,87 @@
<?php
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');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
try {
$userSubject = market_management_mcp_authorize($env);
$pdo = connect_database($env);
$body = file_get_contents('php://input');
$request = json_decode($body === false ? '' : $body, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($request) || ($request['jsonrpc'] ?? '') !== '2.0') {
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'] : [];
if ($method === 'initialize') {
$result = [
'protocolVersion' => (string) ($params['protocolVersion'] ?? '2025-03-26'),
'capabilities' => ['tools' => ['listChanged' => false]],
'serverInfo' => ['name' => 'naurua-market-management', 'version' => '1.0.0'],
];
} elseif ($method === 'notifications/initialized') {
http_response_code(202);
exit;
} elseif ($method === 'tools/list') {
$result = ['tools' => market_management_mcp_tools()];
} elseif ($method === 'tools/call') {
$tool = (string) ($params['name'] ?? '');
$arguments = is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
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 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) {
$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' => $isJsonRpcError ? $e->rpcCode() : -32600,
'message' => $e->getMessage(),
],
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+147
View File
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/shared/db.php';
require_once __DIR__ . '/../../modules/shared/auth/service.php';
require_once __DIR__ . '/../../modules/erp/kontakte/service.php';
require_once __DIR__ . '/../../modules/market_management/service.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
$env = expand_env_values(parse_env_file(__DIR__ . '/../../.env'));
$pdo = connect_database($env);
auth_bootstrap_session();
if (auth_current_user($pdo) === null) {
http_response_code(401);
echo json_encode(['ok' => false, 'error' => 'Unauthorized']);
exit;
}
$requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if ($requestMethod === 'POST') {
$csrf = (string) ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');
if (!auth_validate_csrf_token($csrf)) {
http_response_code(419);
echo json_encode(['ok' => false, 'error' => 'Ungültiges Sicherheits-Token']);
exit;
}
$payload = json_decode((string) file_get_contents('php://input'), true);
if (!is_array($payload)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Ungültige Anfrage']);
exit;
}
try {
if (($payload['action'] ?? '') === 'create') {
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
$occurrence = market_management_create_occurrence($pdo, [
'market_definition_id' => $fields['market_definition_id'] ?? null,
'days' => [['date' => $fields['date'] ?? null]],
]);
echo json_encode(['ok' => true, 'occurrence_id' => (int) $occurrence['id']], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$scope = (string) ($payload['scope'] ?? '');
$id = market_management_require_positive_id($payload['id'] ?? null, 'id');
$fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : [];
if (($payload['action'] ?? '') === 'delete') {
if (($payload['confirmation'] ?? '') !== 'DELETE') {
throw new InvalidArgumentException('Die Löschbestätigung muss exakt DELETE lauten.');
}
market_management_set_occurrence_archive($pdo, $id, true);
} elseif (($payload['action'] ?? '') === 'create_day') {
$fields['occurrence_id'] = $id;
$day = market_management_create_day($pdo, $fields);
echo json_encode(['ok' => true, 'day' => $day]);
exit;
} elseif (($payload['action'] ?? '') === 'delete_day') {
if (($payload['confirmation'] ?? '') !== 'DELETE') {
throw new InvalidArgumentException('Bestätigung muss exakt DELETE lauten.');
}
$day = market_management_delete_day($pdo, $id);
echo json_encode(['ok' => true, 'day' => $day]);
exit;
} elseif (($payload['action'] ?? '') === 'update_checklist') {
$checklist = market_management_update_occurrence_checklist($pdo, $id, [
'items' => $payload['items'] ?? null,
'comment' => $payload['comment'] ?? null,
]);
echo json_encode(['ok' => true, 'checklist' => $checklist], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
} elseif ($scope === 'day') {
market_management_update_day($pdo, $id, $fields);
} elseif (in_array($scope, ['occurrence', 'status', 'overview', 'participation', 'infrastructure', 'travel', 'notes'], true)) {
$contactFields = [];
foreach (['contact_person_name', 'contact_email', 'contact_phone'] as $contactField) {
if (array_key_exists($contactField, $fields)) {
$contactFields[$contactField] = $fields[$contactField];
unset($fields[$contactField]);
}
}
$existingOccurrence = null;
if ($contactFields !== []) {
$existingOccurrence = market_management_get_occurrence($pdo, $id, true);
}
if ($contactFields !== []) {
$contactFields['occurrence_id'] = $id;
market_management_set_occurrence_contact($pdo, $contactFields);
}
if ($fields !== []) {
$simpleCostFields = [];
foreach (['gross_cost', 'cost_items_text', 'tax_status', 'tax_rate'] as $costField) {
if (array_key_exists($costField, $fields)) {
$simpleCostFields[$costField] = $fields[$costField];
unset($fields[$costField]);
}
}
if ($simpleCostFields !== []) {
market_management_update_occurrence_simple_costs($pdo, $id, $simpleCostFields);
}
if ($fields !== []) {
market_management_update_occurrence($pdo, $id, $fields);
}
}
} else {
throw new InvalidArgumentException('Ungültiger Bearbeitungsbereich.');
}
echo json_encode(['ok' => true]);
exit;
} catch (Throwable $error) {
http_response_code(422);
echo json_encode(['ok' => false, 'error' => $error->getMessage()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
}
if (($requestMethod === 'GET') && ($_GET['action'] ?? '') === 'create_options') {
echo json_encode([
'ok' => true,
'market_definitions' => market_management_list_active_market_definitions($pdo),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
$occurrenceId = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
if (!is_int($occurrenceId) || $occurrenceId <= 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid occurrence id']);
exit;
}
$occurrence = market_management_get_occurrence($pdo, $occurrenceId);
if ($occurrence === null) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Occurrence not found']);
exit;
}
$market = market_management_get_market($pdo, (int) $occurrence['market_definition_id']);
if ($market === null) {
http_response_code(404);
echo json_encode(['ok' => false, 'error' => 'Market not found']);
exit;
}
echo json_encode(['ok' => true, 'market' => $market, 'occurrence' => $occurrence], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
+369 -143
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;
@@ -62,6 +64,7 @@
--surface-menu-panel-portal: var(--color-light-grey);
--surface-help-icon: var(--color-medium-grey);
--surface-help-panel: var(--color-light-grey);
--surface-tooltip: var(--color-white);
--surface-pulldown-panel: var(--color-light-grey);
--surface-activatable-remove: var(--surface-control-default);
--surface-slider-track: var(--color-medium-grey);
@@ -76,10 +79,8 @@
--layout-pulldown-chevron-offset: var(--compact-interaction-padding-horizontal);
--layout-tab-navigation-large-padding-inline: var(--interaction-padding-horizontal);
--layout-pulldown-chevron-reserved-space: var(--spacing-large);
--layout-pulldown-panel-form-mobile-width: 90%;
--layer-pulldown-panel: var(--dimension-layer-pulldown-panel);
--layout-multiselect-pulldown-panel-desktop-width: var(--dimension-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-pulldown-label-column-width-fallback: max-content;
--layout-input-label-width: var(--dimension-input-label-width);
--layout-input-field-desktop-width: var(--dimension-input-field-desktop-width);
--layout-input-field-max-width: var(--dimension-input-field-max-width);
@@ -89,6 +90,8 @@
--layout-mode-toggle-local-width-factor: 3;
--layout-mode-toggle-width: calc(var(--interaction-height) * 3.5);
--layout-help-panel-width: calc(var(--interaction-height) * 8);
--layout-tooltip-width: calc(var(--interaction-height) * 8);
--radius-tooltip: var(--radius-interaction);
--text-control-default: var(--color-font-dark);
--text-control-disabled: var(--color-dark-grey);
--text-button-process: var(--color-font-light);
@@ -98,6 +101,7 @@
--text-menu-link-portal: var(--color-font-dark);
--text-help-icon: var(--color-font-dark);
--text-help-panel: var(--color-font-dark);
--text-tooltip: var(--color-font-dark);
--text-activatable-remove: var(--text-control-default);
--text-hyperlink: var(--color-font-hyperlink);
--icon-pulldown-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M2 2l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='square' stroke-linejoin='miter'/%3E%3C/svg%3E");
@@ -140,7 +144,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);
@@ -306,7 +309,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;
@@ -354,7 +356,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);
}
@@ -1760,6 +1762,134 @@ section + section {
right: 0;
}
.sg-tooltip-wrap {
display: contents;
}
.sg-tooltip-panel {
position: absolute;
z-index: var(--layer-pulldown-panel);
display: block;
width: var(--layout-tooltip-width);
max-width: calc(100vw - (2 * var(--spacing-large)));
box-sizing: border-box;
overflow-wrap: anywhere;
padding: var(--spacing-large);
border-radius: var(--radius-tooltip);
color: var(--text-tooltip);
background: var(--surface-tooltip);
box-shadow: var(--shadow-overlay);
}
.sg-tooltip-panel[hidden] {
display: none;
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel {
top: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel {
bottom: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel {
top: 50%;
right: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel {
top: 50%;
left: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-panel::before {
position: absolute;
width: 0;
height: 0;
border-style: solid;
content: "";
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel::before {
top: calc(var(--spacing-small) * -2);
left: 50%;
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel::before {
bottom: calc(var(--spacing-small) * -2);
left: 50%;
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel::before {
top: 50%;
right: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel::before {
top: 50%;
left: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
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) * -2);
left: var(--tooltip-arrow-offset);
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
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) * -2);
left: var(--tooltip-arrow-offset);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 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) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
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) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-pulldown-panel {
position: absolute;
top: calc(100% + var(--spacing-small));
@@ -2248,6 +2378,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 */
/* ========================================================= */
@@ -2449,19 +2625,44 @@ 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:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:nth-last-child(2):nth-child(odd)) > .sg-pulldown-demo:nth-last-child(2),
.sg-options-row:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:last-child:nth-child(odd)) > .sg-pulldown-demo:last-child {
flex-basis: 100%;
}
.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,
@@ -2469,15 +2670,6 @@ section + section {
width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
width: min(
var(--layout-pulldown-panel-form-mobile-width),
calc(100vw - (2 * var(--spacing-large)))
);
min-width: 0;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
@@ -2793,39 +2985,63 @@ section + section {
padding-left: var(--layout-form-sections-interaction-indent);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > :not(.sg-form-sections-card__chapter-title) + :not(.sg-form-sections-card__chapter-title) {
margin-top: var(--spacing-large);
.sg-options-row[data-pattern="multiselektions-pulldown"] {
--layout-multiselect-panel-width: var(--layout-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-panel-available-width: calc(100vw - (2 * var(--spacing-large)));
--layout-multiselect-chapter-gap: var(--spacing-large);
--layout-multiselect-column-gap: var(--spacing-large);
--layout-multiselect-control-gap: var(--spacing-small);
--layout-multiselect-visual-control-gap: calc(var(--spacing-small) * 2);
--layout-multiselect-control-row-height: var(--interaction-height);
--layout-multiselect-value-min-width: calc(var(--interaction-height) * 1.25);
--typography-multiselect-label-line-height: var(--line-height-base);
--opacity-multiselect-disabled: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-form-sections-card__chapter-title + * {
margin-top: var(--spacing-large);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > * + * {
margin-top: var(--layout-multiselect-chapter-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-slider-row + .sg-slider-row,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-checkbox-field-option + .sg-checkbox-field-option,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-radio-activatable-group + .sg-radio-activatable-group {
margin-top: var(--spacing-small);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-pulldown-panel__row + .sg-pulldown-panel__row {
margin-top: var(--spacing-large);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo .sg-pulldown {
width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
--sg-multiselect-label-column-width: var(--layout-multiselect-pulldown-label-column-width-fallback);
width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
min-width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
max-width: var(--layout-multiselect-panel-available-width);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
width: 100%;
max-width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: nowrap;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] .sg-pulldown-panel__label {
white-space: normal;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
display: grid;
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr) auto;
grid-template-columns: max-content minmax(0, 1fr) auto;
align-items: center;
column-gap: var(--spacing-large);
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-control-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable {
@@ -2833,37 +3049,43 @@ section + section {
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider {
opacity: var(--disabled-opacity);
opacity: var(--opacity-multiselect-disabled);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider-value,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
opacity: var(--disabled-opacity);
opacity: var(--opacity-multiselect-disabled);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 3;
grid-column: 1 / 3;
grid-row: 2;
min-width: 0;
max-width: 100%;
width: 100%;
max-width: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 4;
grid-column: 3;
grid-row: 2;
margin: 0;
min-width: 0;
min-width: var(--layout-multiselect-value-min-width);
line-height: 1;
align-self: center;
white-space: nowrap;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
@@ -2872,22 +3094,26 @@ section + section {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
display: grid;
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
column-gap: var(--spacing-large);
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-visual-control-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
margin: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 3;
grid-column: 1 / -1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
@@ -2896,119 +3122,45 @@ section + section {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > :not(.sg-mode-toggle) {
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
pointer-events: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
column-gap: var(--spacing-large);
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--layout-multiselect-control-row-height) auto;
row-gap: var(--layout-multiselect-visual-control-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header {
display: flex;
grid-column: 1;
grid-row: 1;
align-items: center;
gap: var(--layout-multiselect-column-gap);
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label {
margin: 0;
min-width: 0;
line-height: var(--typography-multiselect-label-line-height);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 3;
grid-column: 1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled {
opacity: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-demo .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
@media (max-width: 48rem) {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
grid-template-columns: max-content minmax(0, 1fr) auto;
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: normal;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 1 / 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 1 / -1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 1 / -1;
grid-row: 2;
}
opacity: var(--opacity-multiselect-disabled);
}
.sg-form-sections-card__actions {
@@ -4215,22 +4367,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 {
@@ -4250,6 +4407,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);
@@ -4738,16 +4900,32 @@ main {
width: 100%;
}
.sg-large-table[data-column-resize="true"] {
overflow: hidden;
}
.sg-large-table__viewport {
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
width: auto;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
}
.sg-large-table__title-segment {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
box-sizing: border-box;
}
.sg-large-table__row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: var(--large-table-grid-template-columns, repeat(5, minmax(0, 1fr)));
align-items: stretch;
gap: 0;
width: 100%;
@@ -4755,6 +4933,11 @@ main {
background: var(--color-white);
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row,
.sg-large-table[data-column-resize="true"] .sg-large-table__title-segment {
width: max(100%, var(--large-table-grid-min-width, 0px));
}
.sg-large-table__row--hidden {
display: none;
}
@@ -4786,16 +4969,59 @@ main {
white-space: normal;
}
.sg-large-table__load-more-layout {
.sg-large-table__row--load-more,
.sg-large-table__cell--load-more,
.sg-large-table__load-more-card {
min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row--load-more {
grid-template-columns: minmax(0, 1fr);
}
.sg-large-table__cell--header {
position: relative;
padding: 0;
}
.sg-large-table__resize-handle {
position: absolute;
z-index: 1;
top: 0;
right: -0.25rem;
bottom: 0;
width: 0.5rem;
cursor: col-resize;
touch-action: none;
}
.sg-large-table__resize-handle::after {
content: "";
position: absolute;
top: 25%;
bottom: 25%;
left: 50%;
width: 0.1rem;
transform: translateX(-50%);
background: var(--color-darkblue);
box-shadow: -0.15rem 0 0 var(--color-darkblue), 0.15rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:hover::after,
.sg-large-table__resize-handle:focus-visible::after,
.sg-large-table[data-column-resize-state="resizing"] .sg-large-table__resize-handle[data-large-table-resize-active="true"]::after {
background: var(--color-darkblue);
box-shadow: -0.2rem 0 0 var(--color-darkblue), 0.2rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:focus-visible {
outline: 2px solid var(--color-darkblue);
outline-offset: -1px;
}
.sg-large-table__sort-button {
display: flex;
align-items: center;
+3 -3
View File
@@ -1,7 +1,7 @@
{
"styleguideVersion": "2026.05.18.1",
"styleguideCommit": "71734d6",
"syncedAtUtc": "2026-09-08T09:49:27Z",
"styleguideVersion": "2026.05.18.2",
"styleguideCommit": "08fc673",
"syncedAtUtc": "2026-10-14T11:46:02Z",
"sourceRepo": "/Users/mathias/Documents/Dokumente Chouchou/Codebases/Styleguide",
"mirroredDocsPath": "docs/styleguide"
}
+369 -143
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;
@@ -62,6 +64,7 @@
--surface-menu-panel-portal: var(--color-light-grey);
--surface-help-icon: var(--color-medium-grey);
--surface-help-panel: var(--color-light-grey);
--surface-tooltip: var(--color-white);
--surface-pulldown-panel: var(--color-light-grey);
--surface-activatable-remove: var(--surface-control-default);
--surface-slider-track: var(--color-medium-grey);
@@ -76,10 +79,8 @@
--layout-pulldown-chevron-offset: var(--compact-interaction-padding-horizontal);
--layout-tab-navigation-large-padding-inline: var(--interaction-padding-horizontal);
--layout-pulldown-chevron-reserved-space: var(--spacing-large);
--layout-pulldown-panel-form-mobile-width: 90%;
--layer-pulldown-panel: var(--dimension-layer-pulldown-panel);
--layout-multiselect-pulldown-panel-desktop-width: var(--dimension-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-pulldown-label-column-width-fallback: max-content;
--layout-input-label-width: var(--dimension-input-label-width);
--layout-input-field-desktop-width: var(--dimension-input-field-desktop-width);
--layout-input-field-max-width: var(--dimension-input-field-max-width);
@@ -89,6 +90,8 @@
--layout-mode-toggle-local-width-factor: 3;
--layout-mode-toggle-width: calc(var(--interaction-height) * 3.5);
--layout-help-panel-width: calc(var(--interaction-height) * 8);
--layout-tooltip-width: calc(var(--interaction-height) * 8);
--radius-tooltip: var(--radius-interaction);
--text-control-default: var(--color-font-dark);
--text-control-disabled: var(--color-dark-grey);
--text-button-process: var(--color-font-light);
@@ -98,6 +101,7 @@
--text-menu-link-portal: var(--color-font-dark);
--text-help-icon: var(--color-font-dark);
--text-help-panel: var(--color-font-dark);
--text-tooltip: var(--color-font-dark);
--text-activatable-remove: var(--text-control-default);
--text-hyperlink: var(--color-font-hyperlink);
--icon-pulldown-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M2 2l4 4 4-4' fill='none' stroke='currentColor' stroke-width='1.5' stroke-linecap='square' stroke-linejoin='miter'/%3E%3C/svg%3E");
@@ -140,7 +144,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);
@@ -306,7 +309,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;
@@ -354,7 +356,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);
}
@@ -1760,6 +1762,134 @@ section + section {
right: 0;
}
.sg-tooltip-wrap {
display: contents;
}
.sg-tooltip-panel {
position: absolute;
z-index: var(--layer-pulldown-panel);
display: block;
width: var(--layout-tooltip-width);
max-width: calc(100vw - (2 * var(--spacing-large)));
box-sizing: border-box;
overflow-wrap: anywhere;
padding: var(--spacing-large);
border-radius: var(--radius-tooltip);
color: var(--text-tooltip);
background: var(--surface-tooltip);
box-shadow: var(--shadow-overlay);
}
.sg-tooltip-panel[hidden] {
display: none;
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel {
top: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel {
bottom: calc(100% + var(--spacing-small));
left: 50%;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel {
top: 50%;
right: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel {
top: 50%;
left: calc(100% + var(--spacing-small));
transform: translateY(-50%);
}
.sg-tooltip-panel::before {
position: absolute;
width: 0;
height: 0;
border-style: solid;
content: "";
}
.sg-tooltip-wrap[data-tooltip-placement="bottom"] .sg-tooltip-panel::before {
top: calc(var(--spacing-small) * -2);
left: 50%;
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent var(--surface-tooltip);
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="top"] .sg-tooltip-panel::before {
bottom: calc(var(--spacing-small) * -2);
left: 50%;
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: var(--surface-tooltip) transparent transparent;
transform: translateX(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="left"] .sg-tooltip-panel::before {
top: 50%;
right: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
border-color: transparent transparent transparent var(--surface-tooltip);
transform: translateY(-50%);
}
.sg-tooltip-wrap[data-tooltip-placement="right"] .sg-tooltip-panel::before {
top: 50%;
left: calc(var(--spacing-small) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
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) * -2);
left: var(--tooltip-arrow-offset);
border-width: 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
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) * -2);
left: var(--tooltip-arrow-offset);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 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) * -2);
border-width: calc(var(--spacing-small) * 2) 0 calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2);
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) * -2);
border-width: calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) calc(var(--spacing-small) * 2) 0;
border-color: transparent var(--surface-tooltip) transparent transparent;
transform: translateY(-50%);
}
.sg-pulldown-panel {
position: absolute;
top: calc(100% + var(--spacing-small));
@@ -2248,6 +2378,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 */
/* ========================================================= */
@@ -2449,19 +2625,44 @@ 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:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:nth-last-child(2):nth-child(odd)) > .sg-pulldown-demo:nth-last-child(2),
.sg-options-row:not(.sg-options-row--left-only) > .sg-options-row__left:has(> .sg-pulldown-demo:last-child:nth-child(odd)) > .sg-pulldown-demo:last-child {
flex-basis: 100%;
}
.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,
@@ -2469,15 +2670,6 @@ section + section {
width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
width: min(
var(--layout-pulldown-panel-form-mobile-width),
calc(100vw - (2 * var(--spacing-large)))
);
min-width: 0;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
@@ -2793,39 +2985,63 @@ section + section {
padding-left: var(--layout-form-sections-interaction-indent);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > :not(.sg-form-sections-card__chapter-title) + :not(.sg-form-sections-card__chapter-title) {
margin-top: var(--spacing-large);
.sg-options-row[data-pattern="multiselektions-pulldown"] {
--layout-multiselect-panel-width: var(--layout-multiselect-pulldown-panel-desktop-width);
--layout-multiselect-panel-available-width: calc(100vw - (2 * var(--spacing-large)));
--layout-multiselect-chapter-gap: var(--spacing-large);
--layout-multiselect-column-gap: var(--spacing-large);
--layout-multiselect-control-gap: var(--spacing-small);
--layout-multiselect-visual-control-gap: calc(var(--spacing-small) * 2);
--layout-multiselect-control-row-height: var(--interaction-height);
--layout-multiselect-value-min-width: calc(var(--interaction-height) * 1.25);
--typography-multiselect-label-line-height: var(--line-height-base);
--opacity-multiselect-disabled: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-form-sections-card__chapter-title + * {
margin-top: var(--spacing-large);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > * + * {
margin-top: var(--layout-multiselect-chapter-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-slider-row + .sg-slider-row,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-checkbox-field-option + .sg-checkbox-field-option,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-radio-activatable-group + .sg-radio-activatable-group {
margin-top: var(--spacing-small);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo {
flex: 1 1 100%;
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-form-sections-card__chapter > .sg-pulldown-panel__row + .sg-pulldown-panel__row {
margin-top: var(--spacing-large);
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-options-row__left > .sg-pulldown-demo .sg-pulldown {
width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
--sg-multiselect-label-column-width: var(--layout-multiselect-pulldown-label-column-width-fallback);
width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
min-width: min(
var(--layout-multiselect-panel-width),
var(--layout-multiselect-panel-available-width)
);
max-width: var(--layout-multiselect-panel-available-width);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card-wrapper,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel .sg-form-sections-card {
min-width: 0;
width: 100%;
max-width: 100%;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: nowrap;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] .sg-pulldown-panel__label {
white-space: normal;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
display: grid;
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr) auto;
grid-template-columns: max-content minmax(0, 1fr) auto;
align-items: center;
column-gap: var(--spacing-large);
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-control-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable {
@@ -2833,37 +3049,43 @@ section + section {
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider {
opacity: var(--disabled-opacity);
opacity: var(--opacity-multiselect-disabled);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable .sg-slider-value,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
opacity: var(--disabled-opacity);
opacity: var(--opacity-multiselect-disabled);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 3;
grid-column: 1 / 3;
grid-row: 2;
min-width: 0;
max-width: 100%;
width: 100%;
max-width: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 4;
grid-column: 3;
grid-row: 2;
margin: 0;
min-width: 0;
min-width: var(--layout-multiselect-value-min-width);
line-height: 1;
align-self: center;
white-space: nowrap;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
@@ -2872,22 +3094,26 @@ section + section {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
display: grid;
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
grid-template-columns: max-content minmax(0, 1fr);
align-items: center;
column-gap: var(--spacing-large);
column-gap: var(--layout-multiselect-column-gap);
row-gap: var(--layout-multiselect-visual-control-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
margin: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 3;
grid-column: 1 / -1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > .sg-radio-activatable-group__choices {
@@ -2896,119 +3122,45 @@ section + section {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"].sg-slider-row--inactive-selectable > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group[data-component-state="inactive-selectable"] > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > :not(.sg-mode-toggle) {
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > :not(.sg-mode-toggle),
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
pointer-events: none;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
grid-template-columns: max-content var(--sg-multiselect-label-column-width) minmax(0, 1fr);
column-gap: var(--spacing-large);
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: var(--layout-multiselect-control-row-height) auto;
row-gap: var(--layout-multiselect-visual-control-gap);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header {
display: flex;
grid-column: 1;
grid-row: 1;
align-items: center;
gap: var(--layout-multiselect-column-gap);
min-width: 0;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label {
margin: 0;
min-width: 0;
line-height: var(--typography-multiselect-label-line-height);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 3;
grid-column: 1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled {
opacity: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown-panel__row-header > .sg-pulldown-panel__label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row].sg-pulldown-panel__row--disabled > .sg-pulldown {
opacity: var(--disabled-opacity);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-demo .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
@media (max-width: 48rem) {
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-pulldown-panel {
min-width: 100%;
width: max-content;
max-width: calc(100vw - (2 * var(--spacing-large)));
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] {
grid-template-columns: max-content minmax(0, 1fr) auto;
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label,
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
white-space: normal;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider {
grid-column: 1 / 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-slider-row[data-activatable="true"] .sg-slider-value {
grid-column: 3;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] .sg-radio-activatable-group > .sg-radio-activatable-group__choices {
grid-column: 1 / -1;
grid-row: 2;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] {
grid-template-columns: max-content minmax(0, 1fr);
row-gap: var(--spacing-small);
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-mode-toggle {
grid-column: 1;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown-panel__label {
grid-column: 2;
grid-row: 1;
}
.sg-options-row[data-pattern="multiselektions-pulldown"] [data-pulldown-filter-row] > .sg-pulldown {
grid-column: 1 / -1;
grid-row: 2;
}
opacity: var(--opacity-multiselect-disabled);
}
.sg-form-sections-card__actions {
@@ -4215,22 +4367,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 {
@@ -4250,6 +4407,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);
@@ -4738,16 +4900,32 @@ main {
width: 100%;
}
.sg-large-table[data-column-resize="true"] {
overflow: hidden;
}
.sg-large-table__viewport {
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
width: auto;
max-width: 100%;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
}
.sg-large-table__title-segment {
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--spacing-large);
box-sizing: border-box;
}
.sg-large-table__row {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: var(--large-table-grid-template-columns, repeat(5, minmax(0, 1fr)));
align-items: stretch;
gap: 0;
width: 100%;
@@ -4755,6 +4933,11 @@ main {
background: var(--color-white);
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row,
.sg-large-table[data-column-resize="true"] .sg-large-table__title-segment {
width: max(100%, var(--large-table-grid-min-width, 0px));
}
.sg-large-table__row--hidden {
display: none;
}
@@ -4786,16 +4969,59 @@ main {
white-space: normal;
}
.sg-large-table__load-more-layout {
.sg-large-table__row--load-more,
.sg-large-table__cell--load-more,
.sg-large-table__load-more-card {
min-width: 0;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.sg-large-table[data-column-resize="true"] .sg-large-table__row--load-more {
grid-template-columns: minmax(0, 1fr);
}
.sg-large-table__cell--header {
position: relative;
padding: 0;
}
.sg-large-table__resize-handle {
position: absolute;
z-index: 1;
top: 0;
right: -0.25rem;
bottom: 0;
width: 0.5rem;
cursor: col-resize;
touch-action: none;
}
.sg-large-table__resize-handle::after {
content: "";
position: absolute;
top: 25%;
bottom: 25%;
left: 50%;
width: 0.1rem;
transform: translateX(-50%);
background: var(--color-darkblue);
box-shadow: -0.15rem 0 0 var(--color-darkblue), 0.15rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:hover::after,
.sg-large-table__resize-handle:focus-visible::after,
.sg-large-table[data-column-resize-state="resizing"] .sg-large-table__resize-handle[data-large-table-resize-active="true"]::after {
background: var(--color-darkblue);
box-shadow: -0.2rem 0 0 var(--color-darkblue), 0.2rem 0 0 var(--color-darkblue);
}
.sg-large-table__resize-handle:focus-visible {
outline: 2px solid var(--color-darkblue);
outline-offset: -1px;
}
.sg-large-table__sort-button {
display: flex;
align-items: center;
+251 -5
View File
@@ -39,6 +39,102 @@ body.sg-card-list-page-drawer-open {
overflow: hidden;
}
.sg-market-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-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;
width: 100%;
max-width: none;
transform: none;
box-shadow: none;
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);
margin: 0;
}
.market-detail-row {
display: contents;
}
.market-detail-row dt,
.market-detail-row dd {
margin: 0;
padding: var(--spacing-small) 0;
border-bottom: 1px solid var(--color-light-grey);
}
.market-detail-row dt {
color: var(--color-black);
font-weight: var(--font-weight-semibold);
}
.market-detail-row dd {
overflow-wrap: anywhere;
}
.market-edit-form .sg-component-row {
margin-top: var(--spacing-medium);
align-items: center;
}
.market-edit-form .sg-labeled-input-row .sg-input-multi-line {
width: 100%;
max-width: var(--layout-form-input-field-max-width);
}
@media (max-width: 767px) {
.market-detail-grid {
grid-template-columns: minmax(8rem, 0.9fr) minmax(0, 1.1fr);
}
}
.sg-card-list-page-drawer-backdrop {
position: fixed;
inset: 0;
@@ -207,7 +303,7 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
.sg-large-table--bestellungen .sg-large-table__row {
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1.35fr) fit-content(12.5ch);
grid-template-columns: var(--large-table-grid-template-columns, minmax(0, 1.15fr) minmax(0, 1.35fr) fit-content(12.5ch));
}
.sg-large-table--bestellungen .sg-large-table__row > :nth-child(2) {
@@ -220,7 +316,7 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
.sg-large-table--bestellungen .sg-large-table__row {
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.35fr) fit-content(12.5ch);
grid-template-columns: var(--large-table-grid-template-columns, minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.35fr) fit-content(12.5ch));
}
.sg-large-table--bestellungen .sg-large-table__cell--amount {
@@ -243,11 +339,15 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
}
.sg-large-table--lager .sg-large-table__row {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: var(--large-table-grid-template-columns, repeat(2, minmax(0, 1fr)));
}
.sg-large-table--markets .sg-large-table__row {
grid-template-columns: var(--large-table-grid-template-columns, repeat(6, minmax(0, 1fr)));
}
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__row {
grid-template-columns: repeat(5, minmax(0, 1fr));
grid-template-columns: var(--large-table-grid-template-columns, 30% repeat(4, minmax(0, 1fr)));
}
.sg-large-table--lager[data-lager-table-key="products"] .sg-large-table__cell:last-child {
@@ -270,15 +370,142 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
color: var(--color-signal-yellow);
}
.sg-large-table--markets .market-status--red {
color: var(--color-signal-red);
}
.sg-large-table--markets .market-status--yellow {
color: var(--color-signal-yellow);
}
.sg-large-table--markets .market-status--green {
color: var(--color-signal-green);
}
.sg-card-list-page-drawer [data-lager-edit-form][hidden],
.sg-card-list-page-drawer [data-lager-read-view][hidden],
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-form"][hidden],
.sg-card-list-page-drawer [data-lager-new-lot-notification-wrapper][hidden],
.sg-card-list-page-drawer [data-lager-open-edit-form][hidden],
.sg-card-list-page-drawer [data-lager-open-card][hidden] {
.sg-card-list-page-drawer [data-lager-open-card][hidden],
.sg-card-list-page-drawer [data-market-definition-read-card][hidden],
.sg-card-list-page-drawer [data-market-definition-create-card][hidden],
.sg-card-list-page-drawer [data-market-definition-new-organizer][hidden] {
display: none;
}
.sg-card-list-page-drawer [data-market-new-municipality][hidden] {
display: none;
}
[data-shared-editable-card-read][hidden],
[data-shared-editable-card-form][hidden] {
display: none;
}
.sg-card-list-page-drawer [data-market-definition-card] .sg-object-card__header > .sg-component-row {
margin-left: auto;
}
.sg-card-list-page-drawer [data-market-delete-occurrence-id],
.sg-card-list-page-drawer [data-market-definition-delete-id] {
display: flex;
flex-direction: column;
gap: var(--spacing-large);
--layout-delete-confirmation-overlay-offset-block-start: 100px;
}
.sg-card-list-page-drawer [data-market-delete-occurrence-id] > .sg-component-row,
.sg-card-list-page-drawer [data-market-definition-delete-id] > .sg-component-row,
.sg-card-list-page-drawer .market-delete-confirmation-card .sg-component-row {
justify-content: flex-end;
}
.market-delete-confirmation-card__title {
margin: 0;
color: var(--color-black);
}
.market-days-group__title {
margin: var(--spacing-small) 0 0;
color: var(--color-white);
}
.market-checklist-list,
.market-checklist-edit-list {
display: flex;
flex-direction: column;
gap: var(--spacing-small);
}
.market-checklist-item,
.market-checklist-edit-item {
display: flex;
align-items: center;
gap: var(--spacing-small);
}
.market-checklist-item .sg-checkbox-field,
.market-checklist-edit-row .sg-checkbox-field {
flex: 0 0 auto;
}
.market-checklist-edit-item [data-checklist-label] {
flex: 1 1 auto;
min-width: 0;
}
.market-checklist-edit-row .sg-label {
display: flex;
align-items: center;
}
.market-checklist-edit-control {
display: flex;
align-items: center;
gap: var(--spacing-small);
flex: 1 1 auto;
width: 100%;
min-width: 0;
max-width: var(--layout-form-input-field-max-width);
}
.market-checklist-edit-control [data-checklist-label] {
flex: 1 1 auto;
width: 100%;
min-width: 0;
max-width: none;
}
.market-checklist-comment-field .sg-input-multi-line {
width: 100%;
max-width: var(--layout-form-input-field-max-width);
}
.market-checklist-delete {
min-width: var(--small-interaction-element-size);
width: var(--small-interaction-element-size);
padding-inline: 0;
}
.market-checklist-comment,
.market-checklist-comment-field {
margin-top: var(--spacing-large);
}
.market-required-field-indicator {
position: relative;
top: -0.25em;
margin-left: 0.15em;
color: var(--color-signal-red);
font-size: 0.75em;
line-height: 0;
}
.sg-card-list-page-drawer [data-component="content-group-box"] {
margin-block: 0;
}
.sg-card-list-page-drawer [data-lager-drawer-topic="new-lot-action"] {
display: flex;
align-items: center;
@@ -293,3 +520,22 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
[data-bestellungen-mobile-detail] .sg-navigation-card-layout {
margin-top: 0;
}
@media (max-width: 767px) {
.market-checklist-edit-row {
flex-direction: row;
align-items: center;
gap: var(--spacing-small);
}
.market-checklist-edit-row .sg-label {
min-width: 0;
flex: 0 0 auto;
margin-right: 0;
margin-bottom: 0;
}
.market-checklist-edit-control {
max-width: none;
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../modules/shared/db.php';
require_once __DIR__ . '/../modules/shared/auth/service.php';
$requestMethod = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if ($requestMethod !== 'GET') {
http_response_code(405);
header('Allow: GET');
echo 'Methode nicht erlaubt.';
exit;
}
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
$pdo = connect_database($env);
auth_bootstrap_session();
auth_require_user($pdo);
$documentRoot = rtrim(env_value('HERMES_DOCUMENT_ROOT', $env), DIRECTORY_SEPARATOR);
if ($documentRoot === '' || !str_starts_with($documentRoot, DIRECTORY_SEPARATOR)) {
http_response_code(500);
echo 'Dokumentablage ist nicht konfiguriert.';
exit;
}
$resolvedRoot = realpath($documentRoot);
$relativePath = (string) ($_GET['path'] ?? '');
if ($resolvedRoot === false || !is_dir($resolvedRoot) || $relativePath === '' || str_contains($relativePath, "\0") || str_contains($relativePath, '\\') || str_starts_with($relativePath, '/')) {
http_response_code(404);
echo 'Dokument nicht gefunden.';
exit;
}
$segments = explode('/', $relativePath);
foreach ($segments as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
http_response_code(404);
echo 'Dokument nicht gefunden.';
exit;
}
}
$resolvedFile = realpath($resolvedRoot . DIRECTORY_SEPARATOR . $relativePath);
$rootPrefix = $resolvedRoot . DIRECTORY_SEPARATOR;
if ($resolvedFile === false || !is_file($resolvedFile) || !is_readable($resolvedFile) || !str_starts_with($resolvedFile, $rootPrefix)) {
http_response_code(404);
echo 'Dokument nicht gefunden.';
exit;
}
$fileSize = filesize($resolvedFile);
$mimeType = mime_content_type($resolvedFile);
if ($fileSize === false || $mimeType === false) {
http_response_code(500);
echo 'Dokument konnte nicht bereitgestellt werden.';
exit;
}
$downloadName = basename($resolvedFile);
header('Content-Type: ' . $mimeType);
header('Content-Length: ' . (string) $fileSize);
header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($downloadName));
header('Cache-Control: private, no-store');
header('X-Content-Type-Options: nosniff');
readfile($resolvedFile);
+41 -1
View File
@@ -8,6 +8,7 @@ require_once __DIR__ . '/../modules/erp/bestellungen/service.php';
require_once __DIR__ . '/../modules/erp/lager/service.php';
require_once __DIR__ . '/../modules/erp/kontakte/service.php';
require_once __DIR__ . '/../modules/erp/artikel-mapping/service.php';
require_once __DIR__ . '/../modules/market_management/service.php';
$env = expand_env_values(parse_env_file(__DIR__ . '/../.env'));
$pdo = connect_database($env);
@@ -48,6 +49,7 @@ if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$currentUser = auth_current_user($pdo);
if ($currentUser !== null) {
$otcProducts = get_otc_order_form_products($pdo);
$otcMarketOptions = market_management_list_active_occurrence_options($pdo);
$lagerProducts = get_lager_product_overview($pdo);
$lagerManufacturers = get_manufacturer_options($pdo);
$lagerArtikel = get_lager_artikel_overview($pdo);
@@ -58,11 +60,49 @@ if ($currentUser !== null) {
'limit' => (int) ($_GET['bestellungen_limit'] ?? 20),
'page_size' => 20,
]);
$marketSearch = (string) ($_GET['market_search'] ?? '');
$marketSort = (string) ($_GET['market_sort'] ?? 'earliest_date');
$marketDirection = strtoupper((string) ($_GET['market_dir'] ?? 'ASC'));
$marketLimit = min(100, max(1, (int) ($_GET['market_limit'] ?? 20)));
$marketOccurrencesTable = [
'rows' => market_management_search_occurrences($pdo, [
'search' => $marketSearch,
'sort' => $marketSort,
'sort_direction' => $marketDirection,
'limit' => $marketLimit,
'offset' => 0,
]),
'search' => $marketSearch,
'sort_column' => $marketSort,
'sort_direction' => $marketDirection,
'limit' => $marketLimit,
'page_size' => 20,
];
$marketOccurrencesTable['has_more'] = count($marketOccurrencesTable['rows']) >= $marketLimit;
$marketDefinitionSort = (string) ($_GET['market_definition_sort'] ?? 'name');
$marketDefinitionDirection = strtoupper((string) ($_GET['market_definition_dir'] ?? 'ASC'));
$marketDefinitionsTable = [
'rows' => market_management_search_markets($pdo, [
'include_archived' => false,
'sort' => $marketDefinitionSort,
'sort_direction' => $marketDefinitionDirection,
'limit' => 100,
'offset' => 0,
]),
'categories' => market_management_list_categories($pdo),
'organizers' => market_management_list_organizers($pdo),
'sort_column' => $marketDefinitionSort,
'sort_direction' => $marketDefinitionDirection,
];
if ((string) ($_GET['bestellungen_fragment'] ?? '') === '1') {
echo auth_render_bestellungen_content($bestellungenTable);
exit;
}
render_auth_home_page($currentUser, $otcProducts, $bestellungenTable, $lagerProducts, $lagerArtikel, $lagerManufacturers);
if ((string) ($_GET['market_fragment'] ?? '') === '1') {
echo auth_render_markets_content($marketOccurrencesTable);
exit;
}
render_auth_home_page($currentUser, $otcProducts, $otcMarketOptions, $bestellungenTable, $marketOccurrencesTable, $lagerProducts, $lagerArtikel, $lagerManufacturers, $marketDefinitionsTable);
exit;
}
+245
View File
@@ -0,0 +1,245 @@
(function initLargeTableColumnResizeModule() {
const STORAGE_PREFIX = 'sg:large-table:column-widths:';
const STORAGE_VERSION = 3;
const DEFAULT_MIN_WIDTH = 128;
const KEYBOARD_STEP = 8;
const KEYBOARD_LARGE_STEP = 40;
let activeResize = null;
const getTables = (root) => root.querySelectorAll('[data-component="large-table"][data-column-resize="true"]');
const getColumnHeaders = (table) => Array.from(
table.querySelectorAll('[data-component-part="large-table-header-row"] [role="columnheader"]'),
);
const getMinWidth = (table) => {
const configured = table.dataset.columnMinWidth;
const value = configured || getComputedStyle(table).getPropertyValue('--large-table-column-min-width').trim();
if (!value) {
return DEFAULT_MIN_WIDTH;
}
const probe = document.createElement('span');
probe.style.cssText = `position:absolute;width:${value};visibility:hidden;`;
table.appendChild(probe);
const width = probe.getBoundingClientRect().width;
probe.remove();
return width > 0 ? width : DEFAULT_MIN_WIDTH;
};
const parseDefaultWidth = (rawValue, tableWidth) => {
if (!rawValue) {
return null;
}
const value = Number.parseFloat(rawValue);
if (!Number.isFinite(value) || value <= 0) {
return null;
}
if (rawValue.trim().endsWith('%')) {
return tableWidth * value / 100;
}
if (rawValue.trim().endsWith('fr')) {
return value;
}
return value;
};
const normalizeRatios = (values) => {
const safeValues = values.map((value) => (Number.isFinite(value) && value > 0 ? value : 0));
const total = safeValues.reduce((sum, value) => sum + value, 0);
if (total <= 0) {
return Array(values.length).fill(1 / values.length);
}
return safeValues.map((value) => value / total);
};
const getDefaultRatios = (table, headers, minWidth) => {
const tableWidth = Math.max(table.querySelector('[data-component-part="large-table-viewport"]')?.clientWidth || 0, minWidth * headers.length);
const configured = headers.map((header) => parseDefaultWidth(header.dataset.defaultWidth, tableWidth));
if (configured.some((width) => width === null)) {
return Array(headers.length).fill(1 / headers.length);
}
return normalizeRatios(configured);
};
const getStorageKey = (table) => `${STORAGE_PREFIX}${table.dataset.columnResizeKey || ''}`;
const readStoredRatios = (table, count) => {
const key = getStorageKey(table);
if (!table.dataset.columnResizeKey) {
return null;
}
try {
if (!window.localStorage) {
return null;
}
const stored = JSON.parse(window.localStorage.getItem(key));
if (stored?.version !== STORAGE_VERSION || !Array.isArray(stored.ratios) || stored.ratios.length !== count) {
return null;
}
if (stored.ratios.some((ratio) => !Number.isFinite(ratio) || ratio <= 0)) {
return null;
}
return normalizeRatios(stored.ratios);
} catch (error) {
return null;
}
};
const writeStoredRatios = (table, ratios) => {
if (!table.dataset.columnResizeKey) {
return;
}
try {
if (!window.localStorage) {
return;
}
window.localStorage.setItem(getStorageKey(table), JSON.stringify({
version: STORAGE_VERSION,
ratios: normalizeRatios(ratios),
}));
} catch (error) {
// Storage can be unavailable in privacy-restricted browser contexts.
}
};
const updateHandleState = (table, widths, minWidth) => {
const handles = table.querySelectorAll('[data-large-table-resize-handle]');
handles.forEach((handle) => {
const index = Number.parseInt(handle.dataset.largeTableResizeHandle, 10);
const current = widths[index];
const maximum = Math.max(minWidth, widths[index] + widths[index + 1] - minWidth);
if (table.id) {
handle.setAttribute('aria-controls', table.id);
}
handle.setAttribute('aria-valuemin', String(Math.round(minWidth)));
handle.setAttribute('aria-valuemax', String(Math.round(maximum)));
handle.setAttribute('aria-valuenow', String(Math.round(current)));
handle.setAttribute('aria-valuetext', `${Math.round(current)} Pixel`);
});
};
const getRenderedWidths = (table, count, minWidth) => {
const headers = getColumnHeaders(table);
const widths = headers.map((header) => header.getBoundingClientRect().width);
if (widths.length === count && widths.every((width) => width > 0)) {
return widths;
}
return Array(count).fill(minWidth);
};
const applyRatios = (table, ratios, minWidth) => {
const safeRatios = normalizeRatios(ratios);
const columnTemplate = safeRatios
.map((ratio) => `minmax(${Math.ceil(minWidth)}px, ${ratio}fr)`)
.join(' ');
table.style.setProperty('--large-table-grid-template-columns', columnTemplate);
table.style.setProperty('--large-table-grid-min-width', `${Math.ceil(minWidth * safeRatios.length)}px`);
table._largeTableColumnRatios = safeRatios;
table._largeTableColumnWidths = getRenderedWidths(table, safeRatios.length, minWidth);
updateHandleState(table, table._largeTableColumnWidths, minWidth);
};
const resizeColumn = (table, index, delta, persist, baseWidths = table._largeTableColumnWidths) => {
const widths = [...baseWidths];
const minWidth = getMinWidth(table);
const nextLeft = Math.max(minWidth, Math.min(widths[index] + widths[index + 1] - minWidth, widths[index] + delta));
const appliedDelta = nextLeft - widths[index];
widths[index] = nextLeft;
widths[index + 1] -= appliedDelta;
applyRatios(table, widths.map((width) => width / widths.reduce((sum, value) => sum + value, 0)), minWidth);
if (persist) {
writeStoredRatios(table, table._largeTableColumnRatios);
}
};
const initTable = (table) => {
const headers = getColumnHeaders(table);
if (headers.length < 2) {
return;
}
const minWidth = getMinWidth(table);
const stored = readStoredRatios(table, headers.length);
applyRatios(table, stored || getDefaultRatios(table, headers, minWidth), minWidth);
if (table.dataset.largeTableResizeInitialized === 'true') {
return;
}
table.dataset.largeTableResizeInitialized = 'true';
table.addEventListener('pointerdown', (event) => {
const handle = event.target.closest('[data-large-table-resize-handle]');
if (!handle || !table.contains(handle)) {
return;
}
event.preventDefault();
activeResize = {
table,
handle,
index: Number.parseInt(handle.dataset.largeTableResizeHandle, 10),
startX: event.clientX,
startWidths: [...table._largeTableColumnWidths],
};
handle.dataset.largeTableResizeActive = 'true';
table.dataset.columnResizeState = 'resizing';
handle.setPointerCapture?.(event.pointerId);
});
table.addEventListener('pointermove', (event) => {
if (!activeResize || activeResize.table !== table) {
return;
}
resizeColumn(
table,
activeResize.index,
event.clientX - activeResize.startX,
false,
activeResize.startWidths,
);
});
table.addEventListener('pointerup', () => {
if (!activeResize || activeResize.table !== table) {
return;
}
writeStoredRatios(table, table._largeTableColumnRatios);
activeResize.handle.dataset.largeTableResizeActive = 'false';
table.dataset.columnResizeState = 'idle';
activeResize = null;
});
if (typeof ResizeObserver === 'function') {
table._largeTableResizeObserver = new ResizeObserver(() => {
applyRatios(table, table._largeTableColumnRatios, getMinWidth(table));
});
table._largeTableResizeObserver.observe(table.querySelector('[data-component-part="large-table-viewport"]'));
}
table.addEventListener('keydown', (event) => {
const handle = event.target.closest('[data-large-table-resize-handle]');
if (!handle || !table.contains(handle)) {
return;
}
const index = Number.parseInt(handle.dataset.largeTableResizeHandle, 10);
const step = event.shiftKey ? KEYBOARD_LARGE_STEP : KEYBOARD_STEP;
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
resizeColumn(table, index, event.key === 'ArrowRight' ? step : -step, true);
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
const minWidth = getMinWidth(table);
const current = table._largeTableColumnWidths[index];
const target = event.key === 'Home' ? minWidth : current + table._largeTableColumnWidths[index + 1] - minWidth;
resizeColumn(table, index, target - current, true);
}
});
};
window.sgInitLargeTableColumnResize = (options = {}) => {
const root = options.root || document;
getTables(root).forEach(initTable);
};
document.addEventListener('DOMContentLoaded', () => window.sgInitLargeTableColumnResize(), { once: true });
})();
@@ -0,0 +1,241 @@
(function () {
'use strict';
const cardSelector = '[data-shared-editable-action-card]';
const scrollRestoreStates = new WeakMap();
const getCard = (element) => element.closest(cardSelector);
const setToggleEnabled = (card, enabled) => {
const toggle = card?.querySelector('[data-shared-editable-card-toggle]');
if (!toggle) {
return;
}
toggle.disabled = !enabled;
toggle.setAttribute('aria-disabled', String(!enabled));
toggle.classList.toggle('sg-button--inactive', !enabled);
};
const setVisibility = (card, visible) => {
if (!card || !card.hasAttribute('data-shared-editable-card-collapsible')) {
return;
}
const readView = card.querySelector('[data-shared-editable-card-read]');
const toggle = card.querySelector('[data-shared-editable-card-toggle]');
card.dataset.sharedEditableCardVisible = String(visible);
card.classList.toggle('shared-editable-action-card--collapsed', !visible);
if (readView && card.querySelector('[data-shared-editable-card-form]')?.hidden !== false) {
readView.hidden = !visible;
}
if (toggle) {
toggle.textContent = visible ? 'Verbergen' : 'Anzeigen';
toggle.setAttribute('aria-expanded', String(visible));
}
};
const isVisible = (card) => card?.dataset.sharedEditableCardVisible === 'true';
const getScrollContainer = (element) => {
let candidate = element?.parentElement || null;
while (candidate && candidate !== document.body) {
const style = window.getComputedStyle(candidate);
const scrollable = /(auto|scroll|overlay)/.test(style.overflowY);
if (scrollable && candidate.scrollHeight > candidate.clientHeight) {
return candidate;
}
candidate = candidate.parentElement;
}
return document.scrollingElement || document.documentElement;
};
const captureScrollPosition = (root, card) => {
const container = getScrollContainer(card);
scrollRestoreStates.set(root, {
card,
container,
top: container.scrollTop,
left: container.scrollLeft,
timer: null,
});
};
const restoreScrollPosition = (root) => {
const state = scrollRestoreStates.get(root);
if (!state) {
return;
}
if (state.timer) {
window.clearTimeout(state.timer);
}
state.timer = window.setTimeout(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (state.container?.isConnected) {
state.container.scrollTo({ top: state.top, left: state.left, behavior: 'auto' });
}
scrollRestoreStates.delete(root);
});
});
}, 100);
};
const getDirectDeleteElement = (card, selector) => Array.from(card?.children || [])
.find((child) => child.matches(selector)) || null;
const setEditMode = (card, enabled) => {
if (!card) {
return;
}
const readView = card.querySelector('[data-shared-editable-card-read]');
const editView = card.querySelector('[data-shared-editable-card-form]');
const editButtons = card.querySelectorAll('[data-shared-editable-card-edit]');
if (card.hasAttribute('data-shared-editable-card-collapsible')) {
if (enabled) {
setVisibility(card, true);
} else if (!card.dataset.sharedEditableCardVisible) {
setVisibility(card, !card.hasAttribute('data-shared-editable-card-collapsed'));
}
setToggleEnabled(card, !enabled);
}
if (readView) {
readView.hidden = enabled;
}
if (editView) {
editView.hidden = !enabled;
}
editButtons.forEach((editButton) => {
editButton.disabled = enabled;
editButton.setAttribute('aria-disabled', String(enabled));
editButton.classList.toggle('sg-button--inactive', enabled);
});
if (card.hasAttribute('data-shared-editable-card-collapsible') && enabled && readView) {
readView.hidden = true;
}
};
const resetDeleteConfirmation = (card) => {
const stage = card?.closest('.sg-delete-confirmation-pattern__stage');
const confirmation = getDirectDeleteElement(card, '[data-shared-editable-card-delete-confirmation]');
const input = confirmation?.querySelector('[data-shared-editable-card-delete-input]');
const submit = confirmation?.querySelector('[data-shared-editable-card-delete-submit]');
if (input) {
input.value = '';
}
if (submit) {
submit.disabled = true;
submit.setAttribute('aria-disabled', 'true');
submit.classList.add('sg-button--process-inactive');
}
if (confirmation) {
confirmation.hidden = true;
}
if (stage) {
stage.dataset.dialogOpen = 'false';
}
};
const openDeleteConfirmation = (card) => {
const stage = card?.closest('.sg-delete-confirmation-pattern__stage');
const confirmation = getDirectDeleteElement(card, '[data-shared-editable-card-delete-confirmation]');
const input = confirmation?.querySelector('[data-shared-editable-card-delete-input]');
if (input) {
input.value = '';
input.focus();
}
if (confirmation) {
confirmation.hidden = false;
}
if (stage) {
stage.dataset.dialogOpen = 'true';
}
};
const updateDeleteButton = (input) => {
const card = getCard(input);
const confirmation = input?.closest('[data-shared-editable-card-delete-confirmation]');
const submit = confirmation?.querySelector('[data-shared-editable-card-delete-submit]');
const token = card?.dataset.sharedEditableDeleteToken || 'DELETE';
const valid = input.value === token;
if (submit) {
submit.disabled = !valid;
submit.setAttribute('aria-disabled', String(!valid));
submit.classList.toggle('sg-button--process-inactive', !valid);
}
};
const init = (root) => {
if (!root || root.dataset.sharedEditableActionCardsInitialized === 'true') {
return;
}
root.dataset.sharedEditableActionCardsInitialized = 'true';
root.querySelectorAll(cardSelector).forEach((card) => setEditMode(card, false));
root.addEventListener('click', (event) => {
const toggle = event.target.closest('[data-shared-editable-card-toggle]');
if (toggle && root.contains(toggle)) {
const card = getCard(toggle);
if (card?.querySelector('[data-shared-editable-card-form]')?.hidden === false) {
return;
}
setVisibility(card, !isVisible(card));
return;
}
const edit = event.target.closest('[data-shared-editable-card-edit]');
if (edit && root.contains(edit)) {
const card = getCard(edit);
captureScrollPosition(root, card);
setEditMode(card, true);
return;
}
const cancel = event.target.closest('[data-shared-editable-card-cancel]');
if (cancel && root.contains(cancel)) {
const card = getCard(cancel);
resetDeleteConfirmation(card);
setEditMode(card, false);
scrollRestoreStates.delete(root);
return;
}
const deleteButton = event.target.closest('[data-shared-editable-card-delete]');
if (deleteButton && root.contains(deleteButton)) {
openDeleteConfirmation(getCard(deleteButton));
return;
}
const deleteCancel = event.target.closest('[data-shared-editable-card-delete-cancel]');
if (deleteCancel && root.contains(deleteCancel)) {
resetDeleteConfirmation(getCard(deleteCancel));
}
});
const scrollObserver = new MutationObserver(() => {
const state = scrollRestoreStates.get(root);
if (state && !root.contains(state.card)) {
restoreScrollPosition(root);
}
});
scrollObserver.observe(root, { childList: true, subtree: true });
root.addEventListener('input', (event) => {
const input = event.target.closest('[data-shared-editable-card-delete-input]');
if (input && root.contains(input)) {
updateDeleteButton(input);
}
});
};
window.SharedEditableActionCard = { init, resetDeleteConfirmation, setEditMode, setToggleEnabled, setVisibility, isVisible };
}());
+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,24 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/erp/lager/mcp.php';
function assert_test(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, $message . PHP_EOL);
exit(1);
}
}
$tools = erp_lager_mcp_tools();
assert_test(count($tools) === 2, 'Expected 2 ERP lager MCP tools, got ' . count($tools));
$names = array_map(static fn (array $tool): string => $tool['name'], $tools);
assert_test($names === ['erp_lager.list_products', 'erp_lager.get_product'], 'Unexpected ERP lager MCP tool names');
$productTool = $tools[1];
assert_test($productTool['inputSchema']['required'] === ['product_id'], 'Product detail tool must require product_id');
assert_test($productTool['inputSchema']['properties']['product_id']['type'] === 'integer', 'Product ID must be an integer');
assert_test(is_object($tools[0]['inputSchema']['properties']), 'List products properties must serialize as a JSON object');
fwrite(STDOUT, "erp_lager_mcp_tools_list_test: OK\n");
@@ -0,0 +1,106 @@
<?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_array($schema['properties']) || is_object($schema['properties'])), $path . '.properties must be an object-compatible map');
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');
}
}
$properties = is_object($schema['properties'])
? get_object_vars($schema['properties'])
: $schema['properties'];
foreach ($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.get_occurrence_checklist' => 'Checkliste einer Marktdurchführung lesen',
'market_management.update_occurrence_checklist' => 'Checkliste einer Marktdurchführung vollständig ersetzen',
'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) === 23, 'Expected 23 MCP tools, got ' . count($tools));
$occurrenceTool = array_values(array_filter($tools, static fn (array $tool): bool => $tool['name'] === 'market_management.update_occurrence'))[0] ?? null;
assert_test(is_array($occurrenceTool), 'Update occurrence MCP tool must exist');
assert_test(isset($occurrenceTool['inputSchema']['properties']['registration_note']), 'MCP occurrence schema must expose registration_note');
$createOccurrenceTool = array_values(array_filter($tools, static fn (array $tool): bool => $tool['name'] === 'market_management.create_occurrence'))[0] ?? null;
assert_test(is_array($createOccurrenceTool), 'Create occurrence MCP tool must exist');
$createProperties = $createOccurrenceTool['inputSchema']['properties'];
assert_test(isset($createProperties['place'], $createProperties['days']['items']['properties']['date']), 'Create occurrence schema must expose both levels');
assert_test(!isset($createProperties['days']['items']['properties']['place']), 'Occurrence fields must not be nested under days');
$writeTools = [
'market_management.create_market', 'market_management.update_market',
'market_management.create_market_organizer', 'market_management.create_municipality',
'market_management.archive_market', 'market_management.reactivate_market',
'market_management.create_occurrence', 'market_management.update_occurrence',
'market_management.update_occurrence_checklist', 'market_management.archive_occurrence',
'market_management.reactivate_occurrence', 'market_management.create_day',
'market_management.update_day', 'market_management.delete_day',
];
foreach ($writeTools as $writeToolName) {
$writeTool = array_values(array_filter($tools, static fn (array $tool): bool => $tool['name'] === $writeToolName))[0] ?? null;
assert_test(is_array($writeTool), $writeToolName . ' must exist');
assert_test(in_array('idempotency_key', $writeTool['inputSchema']['required'] ?? [], true), $writeToolName . ' must require idempotency_key');
assert_test(isset($writeTool['inputSchema']['properties']['idempotency_key']), $writeToolName . ' must expose idempotency_key');
}
$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");
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/market_management/service.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);
}
}
$plainText = "Test 'A' & <B> \"C\"";
$sanitized = market_management_sanitize_multiline_html($plainText);
assert_test($sanitized === "Test 'A' &amp; &lt;B&gt; \"C\"", 'Plain text must preserve quotes and escape markup characters once');
$link = '<a href="https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2Fvertrag.pdf">Vertrag</a>';
$sanitizedLink = market_management_sanitize_multiline_html($link);
assert_test(str_contains($sanitizedLink, 'class="sg-hyperlink"'), 'Allowed links must remain links');
assert_test(str_contains($sanitizedLink, 'href="https://erpnaurua.imhochrain.ch/files.php?path=Marktmanagement%2Fvertrag.pdf"'), 'Link href must remain valid');
fwrite(STDOUT, "market_management_sanitizer_test: OK\n");
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../modules/market_management/service.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);
}
}
$normalized = market_management_optional_url(['website_url' => 'www.stadtaffoltern.ch'], 'website_url');
assert_test($normalized === 'https://www.stadtaffoltern.ch', 'Website without scheme must be normalized to HTTPS');
$existingScheme = market_management_optional_url(['website_url' => 'http://www.stadtaffoltern.ch'], 'website_url');
assert_test($existingScheme === 'http://www.stadtaffoltern.ch', 'Existing HTTP scheme must be preserved');
try {
market_management_optional_url(['website_url' => 'not a url'], 'website_url');
fail_test('Invalid website URL must be rejected');
} catch (InvalidArgumentException) {
}
fwrite(STDOUT, "market_management_url_test: OK\n");