diff --git a/docs/modules/market_management/data/market_management_data_architecture.md b/docs/modules/market_management/data/market_management_data_architecture.md
index 171ee73..29722f5 100644
--- a/docs/modules/market_management/data/market_management_data_architecture.md
+++ b/docs/modules/market_management/data/market_management_data_architecture.md
@@ -76,7 +76,7 @@ Logistik, Kosten, Zahlung, Vertragslink und Notizen.
- Wochentag aus dem Datum
- Saison aus dem Durchführungsdatum
-- Gesamtkosten aus den Kostenpositionen
+- Gesamtkosten aus dem erfassten Bruttobetrag
- offener Betrag aus Kosten und bezahltem Betrag
- Gesamterlös, Durchschnittswerte und Gesamtzahl verkaufter Artikel aus den
Durchführungstagen
diff --git a/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md b/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md
index e6b30e9..bbe8064 100644
--- a/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md
+++ b/docs/modules/market_management/konzepte/market_management_hermes_mcp_api.md
@@ -185,17 +185,12 @@ Weitere mögliche Tagesfelder sind `stand_start_time`, `stand_end_time`,
- 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.
-- `market_management.create_cost_item`
- - erforderlich: `occurrence_id`, `cost_type_id`, `gross_amount`, `tax_status`
- - `tax_status`: `tax_free` oder `taxable`
-- `market_management.update_cost_item`
- - erforderlich: `cost_item_id`
-- `market_management.delete_cost_item`
- - erforderlich: `cost_item_id`
-- Die erste Kostenposition jeder Durchführung ist `Standkosten` (`stand_fee`),
- nicht löschbar und darf den Betrag `0.00` haben. Weitere Positionen sind löschbar.
-- `market_management.list_cost_types` liest aktive Kostenarten;
- `market_management.create_cost_type` ergänzt das Register.
+- 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` sowie die
+ optionale mehrzeilige Freitext-Aufschlüsselung `cost_items_text`.
+- `gross_cost` darf `0.00` sein; einzelne Kostenpositionen und Kostenarten werden
+ nicht separat über MCP verwaltet.
## Beispiel: Markt anlegen
diff --git a/docs/modules/market_management/konzepte/market_management_technisches_konzept.md b/docs/modules/market_management/konzepte/market_management_technisches_konzept.md
index 50a5e8f..b0ca788 100644
--- a/docs/modules/market_management/konzepte/market_management_technisches_konzept.md
+++ b/docs/modules/market_management/konzepte/market_management_technisches_konzept.md
@@ -154,23 +154,21 @@ Minimale Pflichtfelder:
Alle weiteren Felder bleiben bei der Anlage optional und werden unabhängig vom
Status ergänzt.
-### 3.6 `market_occurrence_cost_item`
+### 3.6 Kosten einer Durchführung
-Einzelne Kostenposition einer Durchführung.
+Die Durchführung besitzt einen einfachen Gesamtkostenbetrag und eine optionale
+mehrzeilige Freitext-Aufschlüsselung der Kostenpositionen.
Normativ:
-- jede Kostenposition gehört genau einer Durchführung
-- jede Durchführung besitzt als erste Position zwingend die Kostenart
- `Standkosten` (`stand_fee`); deren Betrag darf `0.00` sein und der
- Standardsteuersatz beträgt 8.1 %
-- weitere Kostenarten werden im erweiterbaren Register `market_cost_type` geführt
+- 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
-- steuerfreie Positionen sind zulässig
- Netto- und Steuerbetrag werden aus Brutto und Steuerart/-satz berechnet
-- Gesamtkosten sind die Summe der Kostenpositionen und nicht manuell
- überschreibbar
+- die Freitext-Aufschlüsselung wird als Beschreibung gespeichert
### 3.7 `market_municipality_reference`
@@ -275,16 +273,16 @@ Schreibt:
Fachliche Aufgabe:
-- Kostenpositionen und manuelle Zahlungsinformationen pflegen
+- Gesamtkosten, Kostenaufschlüsselung und manuelle Zahlungsinformationen pflegen
Liest:
- Durchführung
-- bestehende Kostenpositionen
+- bestehende Kosteninformation
Schreibt:
-- `market_occurrence_cost_item`
+- technische Standkosten-Position in `market_occurrence_cost_item`
- Zahlungsfelder in `market_occurrence`
### 4.5 OTC-Verkauf einem Durchführungstag zuordnen
diff --git a/docs/modules/market_management/requirements/market_management_requirements.md b/docs/modules/market_management/requirements/market_management_requirements.md
index 88c4436..fb9ee09 100644
--- a/docs/modules/market_management/requirements/market_management_requirements.md
+++ b/docs/modules/market_management/requirements/market_management_requirements.md
@@ -159,7 +159,7 @@ Abgeleitete Werte werden nicht manuell eingegeben:
- Wochentag aus dem Datum
- Saison aus dem Durchführungsdatum
-- Gesamtkosten aus den Kostenpositionen
+- 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
diff --git a/modules/market_management/mcp.php b/modules/market_management/mcp.php
index e2e036b..6d939de 100644
--- a/modules/market_management/mcp.php
+++ b/modules/market_management/mcp.php
@@ -40,6 +40,7 @@ function market_management_mcp_tools(): array
'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], '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']],
@@ -82,8 +83,6 @@ function market_management_mcp_tools(): array
['name' => 'market_management.create_market_organizer', 'description' => 'Veranstalter mit optionalen Kontaktdaten anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string'], 'email' => ['type' => ['string', 'null'], 'format' => 'email'], 'phone' => ['type' => ['string', 'null']]]]],
['name' => 'market_management.list_reference_data', 'description' => 'Kategorien, Kantone und Gemeinden lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
['name' => 'market_management.create_municipality', 'description' => 'Lokale Gemeinde im Markt-Gemeinderegister anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string']]]],
- ['name' => 'market_management.list_cost_types', 'description' => 'Aktive Kostenarten lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]],
- ['name' => 'market_management.create_cost_type', 'description' => 'Kostenart anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name'], 'properties' => ['name' => ['type' => 'string'], 'code' => ['type' => 'string']]]],
['name' => 'market_management.get_overview', 'description' => 'Marktübersicht mit serverseitigen Filtern lesen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]],
['name' => 'market_management.create_occurrence', 'description' => 'Marktdurchführung mit mindestens einem Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['market_definition_id', 'days'], 'properties' => ['market_definition_id' => ['type' => 'integer'], 'status' => ['type' => 'string'], 'days' => ['type' => 'array', 'minItems' => 1, 'items' => ['type' => 'object', 'required' => ['date'], 'properties' => $dayProperties]] + $occurrenceProperties]]],
['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']]]],
@@ -93,9 +92,6 @@ function market_management_mcp_tools(): array
['name' => 'market_management.create_day', 'description' => 'Markttag anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'date'], 'properties' => ['occurrence_id' => ['type' => 'integer']] + $dayProperties]],
['name' => 'market_management.update_day', 'description' => 'Markttag ändern', 'inputSchema' => ['type' => 'object', 'required' => ['day_id'], 'properties' => ['day_id' => ['type' => 'integer']] + $dayProperties]],
['name' => 'market_management.delete_day', 'description' => 'Markttag entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['day_id'], 'properties' => ['day_id' => ['type' => 'integer']]]],
- ['name' => 'market_management.create_cost_item', 'description' => 'Kostenposition anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'cost_type_id', 'gross_amount', 'tax_status'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'cost_type_id' => ['type' => 'integer'], 'description' => ['type' => ['string', 'null']], 'gross_amount' => ['type' => 'number'], 'tax_status' => ['type' => 'string', 'enum' => ['tax_free', 'taxable']], 'tax_rate' => ['type' => 'number']]]],
- ['name' => 'market_management.update_cost_item', 'description' => 'Kostenposition ändern', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id'], 'properties' => ['cost_item_id' => ['type' => 'integer'], 'cost_type_id' => ['type' => 'integer'], 'description' => ['type' => ['string', 'null']], 'gross_amount' => ['type' => ['number', 'null']], 'tax_status' => ['type' => ['string', 'null'], 'enum' => ['tax_free', 'taxable']], 'tax_rate' => ['type' => ['number', 'null']]]]],
- ['name' => 'market_management.delete_cost_item', 'description' => 'Kostenposition entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['cost_item_id'], 'properties' => ['cost_item_id' => ['type' => 'integer']]]],
];
}
@@ -105,13 +101,10 @@ function market_management_mcp_is_write_tool(string $tool): bool
'market_management.create_market', 'market_management.update_market',
'market_management.create_market_organizer',
'market_management.create_municipality',
- 'market_management.create_cost_type',
'market_management.archive_market', 'market_management.reactivate_market',
'market_management.create_occurrence', 'market_management.update_occurrence',
'market_management.archive_occurrence', 'market_management.reactivate_occurrence',
'market_management.create_day', 'market_management.update_day', 'market_management.delete_day',
- 'market_management.create_cost_item', 'market_management.update_cost_item',
- 'market_management.delete_cost_item',
], true);
}
@@ -222,6 +215,16 @@ function market_management_mcp_get_market(PDO $pdo, int $marketId, bool $include
function market_management_mcp_update_occurrence(PDO $pdo, int $occurrenceId, array $input): array
{
+ $simpleCostFields = [];
+ foreach (['gross_cost', 'cost_items_text'] 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)) {
@@ -285,12 +288,6 @@ function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): a
$pdo,
(string) ($arguments['name'] ?? '')
),
- 'market_management.list_cost_types' => market_management_list_cost_types($pdo),
- 'market_management.create_cost_type' => market_management_create_cost_type(
- $pdo,
- (string) ($arguments['name'] ?? ''),
- isset($arguments['code']) ? (string) $arguments['code'] : null
- ),
'market_management.get_overview' => [
'markets' => market_management_search_markets($pdo, $arguments),
'occurrences' => market_management_search_occurrences($pdo, $arguments),
@@ -326,16 +323,6 @@ function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): a
$pdo,
market_management_require_positive_id($arguments['day_id'] ?? null, 'day_id')
),
- 'market_management.create_cost_item' => market_management_create_cost_item($pdo, $arguments),
- 'market_management.update_cost_item' => market_management_update_cost_item(
- $pdo,
- market_management_require_positive_id($arguments['cost_item_id'] ?? null, 'cost_item_id'),
- $arguments
- ),
- 'market_management.delete_cost_item' => market_management_delete_cost_item(
- $pdo,
- market_management_require_positive_id($arguments['cost_item_id'] ?? null, 'cost_item_id')
- ),
default => throw new InvalidArgumentException('Unbekanntes MCP-Tool: ' . $tool),
};
}
diff --git a/modules/market_management/service.php b/modules/market_management/service.php
index b9d5c0b..560e4b0 100644
--- a/modules/market_management/service.php
+++ b/modules/market_management/service.php
@@ -1355,3 +1355,37 @@ function market_management_update_cost_item(PDO $pdo, int $costItemId, array $in
$item['cost_type'] = market_management_get_cost_type($pdo, (int) $item['cost_type_id'])['name'];
return $item;
}
+
+function market_management_update_occurrence_simple_costs(PDO $pdo, int $occurrenceId, array $input): array
+{
+ $grossAmount = market_management_optional_number($input, 'gross_cost');
+ if ($grossAmount === null) {
+ throw new InvalidArgumentException('Gesamtkosten sind erforderlich.');
+ }
+ $itemStmt = $pdo->prepare(
+ 'SELECT id FROM market_occurrence_cost_item
+ WHERE occurrence_id = :occurrence_id
+ ORDER BY id ASC LIMIT 1'
+ );
+ $itemStmt->execute([':occurrence_id' => $occurrenceId]);
+ $itemId = $itemStmt->fetchColumn();
+ $description = array_key_exists('cost_items_text', $input)
+ ? market_management_optional_string($input, 'cost_items_text')
+ : null;
+ if ($itemId === false) {
+ $item = market_management_create_cost_item($pdo, [
+ 'occurrence_id' => $occurrenceId,
+ 'cost_type_id' => market_management_get_cost_type_id($pdo, 'stand_fee'),
+ 'description' => $description,
+ 'gross_amount' => $grossAmount,
+ 'tax_status' => 'taxable',
+ 'tax_rate' => 8.10,
+ ]);
+ } else {
+ $item = market_management_update_cost_item($pdo, (int) $itemId, [
+ 'description' => $description,
+ 'gross_amount' => $grossAmount,
+ ]);
+ }
+ return $item;
+}
diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php
index 45d0d9b..fd300ee 100644
--- a/modules/shared/auth/ui/home.php
+++ b/modules/shared/auth/ui/home.php
@@ -1184,30 +1184,22 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM
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 " const enhanceMarketCostItems = (root) => { const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; const items = Array.isArray(payload?.occurrence?.cost_items) ? payload.occurrence.cost_items : []; root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation]').forEach((form) => { if (form.dataset.marketCostItemsEnhanced === 'true') { return; } const field = form.querySelector('[name=cost_items_text]')?.closest('.sg-form-sections-card__field-group'); if (!field) { return; } const state = items.map((item) => Object.assign({}, item)); form._marketCostItems = state; field.innerHTML = '
'; const editor = field.querySelector('[data-market-cost-items-editor]'); const render = () => { editor.innerHTML = ''; form._marketCostItems.forEach((item, index) => { const row = document.createElement('div'); row.className = 'sg-form-sections-card__field-group'; row.dataset.marketCostItemRow = 'true'; row.dataset.marketCostItemId = item.id || ''; row.innerHTML = '' + (item.id ? '' : '') + '
'; editor.appendChild(row); }); }; form._marketCostItemRender = render; render(); form.dataset.marketCostItemsEnhanced = 'true'; }); };";
- echo " const marketCostItemObserver = new MutationObserver(() => enhanceMarketCostItems(contentRoot)); marketCostItemObserver.observe(contentRoot, { childList: true, subtree: true }); enhanceMarketCostItems(contentRoot);";
- echo " const normalizeMarketCostItems = (root) => { root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation]').forEach((form) => { const field = form.querySelector('[data-market-cost-items-editor]')?.parentElement; if (!field || field.dataset.marketCostItemsNormalized === 'true') { return; } const chapter = document.createElement('section'); chapter.className = 'sg-form-sections-card__chapter'; const title = document.createElement('h2'); title.className = 'sg-strong sg-form-sections-card__chapter-title'; title.textContent = 'Kostenpositionen'; chapter.appendChild(title); while (field.firstChild) { chapter.appendChild(field.firstChild); } field.replaceWith(chapter); form._marketCostItemsField = chapter; const render = form._marketCostItemRender; form._marketCostItemRender = () => { render(); form.querySelectorAll('[data-market-cost-item-save], [data-market-cost-item-delete], [data-market-cost-item-cancel]').forEach((button) => button.remove()); form.querySelectorAll('[data-market-cost-item-row] .sg-component-row').forEach((actions) => actions.remove()); }; form._marketCostItemRender(); chapter.dataset.marketCostItemsNormalized = 'true'; }); }; const collectMarketCostItems = (form) => Array.from(form.querySelectorAll('[data-market-cost-item-row]')).map((row) => { const item = Object.fromEntries(Array.from(row.querySelectorAll('[data-market-cost-field]')).map((input) => [input.dataset.marketCostField, input.value])); if (row.dataset.marketCostItemId) { item.id = Number(row.dataset.marketCostItemId); } return item; });";
- echo " const marketCostItemsNormalizationObserver = new MutationObserver(() => normalizeMarketCostItems(contentRoot)); marketCostItemsNormalizationObserver.observe(contentRoot, { childList: true, subtree: true }); normalizeMarketCostItems(contentRoot);";
- echo " const enhanceMarketCostTypeControls = (root) => { const drawer = getMarketDrawer(); const payload = drawer?.dataset.marketPayload ? JSON.parse(drawer.dataset.marketPayload) : null; const types = Array.isArray(payload?.occurrence?.reference_options?.cost_types) ? payload.occurrence.reference_options.cost_types : []; root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation] [data-market-cost-item-row]').forEach((row, index) => { const old = row.querySelector('[data-market-cost-field=cost_type]'); if (old && old.tagName === 'INPUT') { const select = document.createElement('select'); select.className = 'sg-interaction-element sg-input-single-line sg-pulldown'; select.dataset.marketCostField = 'cost_type_id'; select.innerHTML = types.map((type) => '').join('') + ''; const selected = types.find((type) => String(type.name) === String(old.value)); select.value = selected ? String(selected.id) : ''; old.replaceWith(select); } const select = row.querySelector('[data-market-cost-field=cost_type_id]'); if (!select || select.dataset.marketCostTypeBound === 'true') { return; } const syncNew = () => { row.querySelector('[data-market-new-cost-type]')?.remove(); if (select.value === '__new__') { row.insertAdjacentHTML('beforeend', ''); } }; select.addEventListener('change', syncNew); select.dataset.marketCostTypeBound = 'true'; syncNew(); if (index > 0 && !row.querySelector('[data-market-cost-item-remove]')) { row.insertAdjacentHTML('beforeend', ''); } }); }; const marketCostTypeObserver = new MutationObserver(() => { enhanceMarketCostTypeControls(contentRoot); enhanceMarketCostDeletePatterns(contentRoot); }); marketCostTypeObserver.observe(contentRoot, { childList: true, subtree: true }); enhanceMarketCostTypeControls(contentRoot);";
- echo " const marketCostDeleteConfirmation = () => 'Möchtest du diese Kostenposition wirklich löschen?
Du kannst das nicht rückgängig machen. Bestätige durch Eingabe von DELETE.
'";
- echo " const enhanceMarketCostDeletePatterns = (root) => { root.querySelectorAll('[data-market-edit-form][data-market-edit-scope=participation] [data-market-cost-item-row]').forEach((row, index) => { if (index === 0 || row.dataset.marketCostDeletePatternBound === 'true') { return; } const remove = row.querySelector('[data-market-cost-item-remove]'); if (!remove) { 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.marketCostItemDeleteCard = 'true'; row.parentNode.insertBefore(stage, row); stage.appendChild(target); target.appendChild(row); remove.dataset.sharedEditableCardDelete = ''; const confirmation = document.createElement('div'); confirmation.innerHTML = marketCostDeleteConfirmation(); const confirmationCard = confirmation.firstElementChild; confirmationCard.setAttribute('aria-labelledby', 'market-cost-item-delete-title-' + (row.dataset.marketCostItemId || index)); confirmationCard.querySelector('strong').parentElement.id = 'market-cost-item-delete-title-' + (row.dataset.marketCostItemId || index); target.appendChild(confirmationCard); row.dataset.marketCostDeletePatternBound = 'true'; }); };";
- echo " const removeMarketCostChapterLabel = (root) => { root.querySelectorAll('[data-market-cost-items-editor]').forEach((editor) => { const chapter = editor.parentElement; const label = Array.from(chapter?.children || []).find((child) => child.matches('label.sg-labeled-input-row')); label?.remove(); }); }; const marketCostChapterLabelObserver = new MutationObserver(() => removeMarketCostChapterLabel(contentRoot)); marketCostChapterLabelObserver.observe(contentRoot, { childList: true, subtree: true }); removeMarketCostChapterLabel(contentRoot);";
echo " if (!marketsBindingsInstalled) {";
echo " marketsBindingsInstalled = true;";
- echo " document.addEventListener('click', async (event) => { const costAdd = event.target.closest('[data-market-cost-item-add]'); if (costAdd) { const form = costAdd.closest('[data-market-edit-form]'); if (form?._marketCostItems) { form._marketCostItems.push({ cost_type: '', description: '', gross_amount: '', tax_status: 'tax_free', tax_rate: '0.00' }); form._marketCostItemRender(); } return; } const costCancel = event.target.closest('[data-market-cost-item-cancel]'); if (costCancel) { const form = costCancel.closest('[data-market-edit-form]'); const row = costCancel.closest('[data-market-cost-item-row]'); const index = Array.from(form.querySelectorAll('[data-market-cost-item-row]')).indexOf(row); if (index >= 0) { form._marketCostItems.splice(index, 1); form._marketCostItemRender(); } return; } const costSave = event.target.closest('[data-market-cost-item-save]'); if (costSave) { const form = costSave.closest('[data-market-edit-form]'); const row = costSave.closest('[data-market-cost-item-row]'); const fields = Object.fromEntries(Array.from(row.querySelectorAll('[data-market-cost-field]')).map((input) => [input.dataset.marketCostField, input.value])); const itemId = row.dataset.marketCostItemId; const status = form.querySelector('[data-market-cost-item-status]'); 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: itemId ? 'update_cost_item' : 'create_cost_item', id: itemId || form.dataset.marketEditId, 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); } } return; } const costDelete = event.target.closest('[data-market-cost-item-delete]'); if (costDelete) { const form = costDelete.closest('[data-market-edit-form]'); const row = costDelete.closest('[data-market-cost-item-row]'); const itemId = row?.dataset.marketCostItemId || ''; if (!itemId || !window.confirm('Diese Kostenposition löschen?')) { return; } const status = form.querySelector('[data-market-cost-item-status]'); if (status) { status.textContent = 'Löschen 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: 'delete_cost_item', id: itemId, confirmation: 'DELETE' }) }); 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); } } return; } 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 costAdd = event.target.closest('[data-market-cost-item-add]'); if (!costAdd) { return; } window.setTimeout(() => { const form = costAdd.closest('[data-market-edit-form]'); const item = form?._marketCostItems?.at(-1); if (item && !item.id && item.cost_type === '') { item.cost_type = 'Standkosten'; item.gross_amount = '0.00'; item.tax_status = 'taxable'; item.tax_rate = '8.10'; form._marketCostItemRender(); } }, 0); });";
- echo " document.addEventListener('click', (event) => { const submit = event.target.closest('[data-market-cost-item-delete-submit]'); if (!submit || submit.disabled) { return; } const card = submit.closest('[data-market-cost-item-delete-card]') || submit.closest('[data-shared-editable-action-card]'); const row = card?.querySelector('[data-market-cost-item-row]'); const form = row?.closest('[data-market-edit-form]'); const input = card?.querySelector('[data-shared-editable-card-delete-input]'); if (!row || !form || !input || input.value !== 'DELETE') { return; } const index = Array.from(form.querySelectorAll('[data-market-cost-item-row]')).indexOf(row); if (index <= 0) { return; } form._marketCostItems.splice(index, 1); form._marketCostItemRender(); });";
+ 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 = Object.fromEntries(new FormData(form).entries()); if (form.dataset.marketEditScope === 'participation') { fields.cost_items = JSON.stringify(collectMarketCostItems(form)); } 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: form.dataset.marketEditId, 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('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 = 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({ scope: form.dataset.marketEditScope, id: form.dataset.marketEditId, 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) => { 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 = ''; } if (contactField && contactSelect) { const value = contactSelect.selectedOptions[0]?.textContent || ''; contactField.innerHTML = ''; } 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) => { 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 = item?.description || ''; } if (totalLabel) { totalLabel.textContent = 'Gesamtkosten'; } if (textLabel) { textLabel.textContent = 'Kostenpositionen'; } }); };";
+ echo " const marketSimpleCostFieldsObserver = new MutationObserver(() => normalizeMarketSimpleCostFields(contentRoot)); marketSimpleCostFieldsObserver.observe(contentRoot, { childList: true, subtree: true }); normalizeMarketSimpleCostFields(contentRoot);";
echo " bindTable();";
echo "}";
echo "function initMarketDefinitionsBindings() {";
diff --git a/public/api/market-occurrence.php b/public/api/market-occurrence.php
index 61376c5..ca32278 100644
--- a/public/api/market-occurrence.php
+++ b/public/api/market-occurrence.php
@@ -43,10 +43,9 @@ if ($requestMethod === 'POST') {
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'] : [];
- $costTransactionStarted = false;
+ $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.');
@@ -64,22 +63,6 @@ if ($requestMethod === 'POST') {
$day = market_management_delete_day($pdo, $id);
echo json_encode(['ok' => true, 'day' => $day]);
exit;
- } elseif (($payload['action'] ?? '') === 'create_cost_item') {
- $fields['occurrence_id'] = $id;
- $costItem = market_management_create_cost_item($pdo, $fields);
- echo json_encode(['ok' => true, 'cost_item' => $costItem], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- exit;
- } elseif (($payload['action'] ?? '') === 'update_cost_item') {
- $costItem = market_management_update_cost_item($pdo, $id, $fields);
- echo json_encode(['ok' => true, 'cost_item' => $costItem], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- exit;
- } elseif (($payload['action'] ?? '') === 'delete_cost_item') {
- if (($payload['confirmation'] ?? '') !== 'DELETE') {
- throw new InvalidArgumentException('Bestätigung muss exakt DELETE lauten.');
- }
- $costItem = market_management_delete_cost_item($pdo, $id);
- echo json_encode(['ok' => true, 'cost_item' => $costItem], 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)) {
@@ -99,52 +82,18 @@ if ($requestMethod === 'POST') {
market_management_set_occurrence_contact($pdo, $contactFields);
}
if ($fields !== []) {
- $costItems = null;
- if (array_key_exists('cost_items', $fields)) {
- $costItems = is_string($fields['cost_items']) ? json_decode($fields['cost_items'], true) : $fields['cost_items'];
- if (!is_array($costItems)) {
- throw new InvalidArgumentException('Kostenpositionen sind ungültig.');
- }
- if (!$pdo->inTransaction()) {
- $pdo->beginTransaction();
- $costTransactionStarted = true;
- }
- unset($fields['cost_items']);
- $keptCostItemIds = array_values(array_filter(array_map(
- static fn (array $costItem): ?int => isset($costItem['id']) ? (int) $costItem['id'] : null,
- array_filter($costItems, 'is_array')
- )));
- $existingCostItems = $pdo->prepare('SELECT id FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC');
- $existingCostItems->execute([':occurrence_id' => $id]);
- foreach ($existingCostItems->fetchAll(PDO::FETCH_COLUMN) as $existingCostItemId) {
- if (!in_array((int) $existingCostItemId, $keptCostItemIds, true)) {
- market_management_delete_cost_item($pdo, (int) $existingCostItemId);
- }
- }
- foreach ($costItems as $costItem) {
- if (!is_array($costItem)) {
- throw new InvalidArgumentException('Kostenposition ist ungültig.');
- }
- if (($costItem['cost_type_id'] ?? null) === '__new__') {
- $newCostTypeName = trim((string) ($costItem['cost_type_name'] ?? ''));
- $newCostType = market_management_create_cost_type($pdo, $newCostTypeName);
- $costItem['cost_type_id'] = (int) $newCostType['id'];
- }
- unset($costItem['cost_type_name']);
- if (isset($costItem['id'])) {
- $costItemId = market_management_require_positive_id($costItem['id'], 'cost_item_id');
- unset($costItem['id']);
- market_management_update_cost_item($pdo, $costItemId, $costItem);
- } else {
- $costItem['occurrence_id'] = $id;
- market_management_create_cost_item($pdo, $costItem);
- }
+ $simpleCostFields = [];
+ foreach (['gross_cost', 'cost_items_text'] as $costField) {
+ if (array_key_exists($costField, $fields)) {
+ $simpleCostFields[$costField] = $fields[$costField];
+ unset($fields[$costField]);
}
}
- market_management_update_occurrence($pdo, $id, $fields);
- if ($costTransactionStarted) {
- $pdo->commit();
- $costTransactionStarted = false;
+ if ($simpleCostFields !== []) {
+ market_management_update_occurrence_simple_costs($pdo, $id, $simpleCostFields);
+ }
+ if ($fields !== []) {
+ market_management_update_occurrence($pdo, $id, $fields);
}
}
} else {
@@ -153,9 +102,6 @@ if ($requestMethod === 'POST') {
echo json_encode(['ok' => true]);
exit;
} catch (Throwable $error) {
- if ($costTransactionStarted && $pdo->inTransaction()) {
- $pdo->rollBack();
- }
http_response_code(422);
echo json_encode(['ok' => false, 'error' => $error->getMessage()], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
diff --git a/public/assets/styles.portal.css b/public/assets/styles.portal.css
index 3d39d48..0e93315 100644
--- a/public/assets/styles.portal.css
+++ b/public/assets/styles.portal.css
@@ -422,20 +422,3 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) {
[data-bestellungen-mobile-detail] .sg-navigation-card-layout {
margin-top: 0;
}
-
-.sg-card-list-page-drawer .market-cost-item-delete-actions {
- display: flex;
- justify-content: flex-end;
- margin-top: 0;
-}
-
-.sg-card-list-page-drawer [data-market-cost-item-add] {
- display: block;
- margin-left: auto;
-}
-
-.sg-card-list-page-drawer [data-market-cost-item-row] + [data-market-cost-item-row],
-.sg-card-list-page-drawer [data-market-cost-item-row] + .sg-delete-confirmation-pattern__stage,
-.sg-card-list-page-drawer .sg-delete-confirmation-pattern__stage + .sg-delete-confirmation-pattern__stage {
- margin-top: calc(var(--spacing-large) * 2);
-}