diff --git a/db/migrations/0034_market_occurrence_checklist.sql b/db/migrations/0034_market_occurrence_checklist.sql new file mode 100644 index 0000000..e9df1f5 --- /dev/null +++ b/db/migrations/0034_market_occurrence_checklist.sql @@ -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; diff --git a/docs/modules/market_management/konzepte/market_management_db_ownership.md b/docs/modules/market_management/konzepte/market_management_db_ownership.md index 7c97b85..9b677fe 100644 --- a/docs/modules/market_management/konzepte/market_management_db_ownership.md +++ b/docs/modules/market_management/konzepte/market_management_db_ownership.md @@ -13,6 +13,8 @@ Status: Verbindliches DB-Ownership-Konzept | `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 | 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 595a594..4cf58d6 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 @@ -177,6 +177,15 @@ Kategorien anlegen oder ändern. - erforderlich: `occurrence_id` - liefert zusätzlich den Markt, Markttage, Kosten und berechnete Tages- und Gesamterlöse +- `market_management.get_occurrence_checklist` + - erforderlich: `occurrence_id` + - liefert die 30 initialen beziehungsweise individuell gepflegten Punkte + und den Checklistenkommentar +- `market_management.update_occurrence_checklist` + - erforderlich: `occurrence_id`, `items` + - ersetzt die vollständige Checkliste atomar + - `items` enthält `label` und `is_completed`; `comment` ist optional und nullable + - 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 diff --git a/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md b/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md index ea4211e..c2e5caf 100644 --- a/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md +++ b/docs/modules/market_management/konzepte/market_management_hermes_mcp_konzept.md @@ -36,6 +36,7 @@ Vorgesehene Toolgruppen: - 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 @@ -67,6 +68,7 @@ Modulberechtigung. - 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 ``-Link im 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 c37502d..1a51a83 100644 --- a/docs/modules/market_management/konzepte/market_management_technisches_konzept.md +++ b/docs/modules/market_management/konzepte/market_management_technisches_konzept.md @@ -13,6 +13,8 @@ Tabellen: - `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` @@ -31,6 +33,7 @@ Prozessbereiche: - Umsatz- und Artikelmengen lesen und aggregieren - Marktübersicht lesen - Hermes-MCP-Zugriff ausführen +- Marktdurchführungs-Checkliste verwalten - Referenzdaten verwalten ## 2. Zweck und Scope diff --git a/modules/market_management/mcp.php b/modules/market_management/mcp.php index 1cac574..30cfce5 100644 --- a/modules/market_management/mcp.php +++ b/modules/market_management/mcp.php @@ -87,6 +87,8 @@ function market_management_mcp_tools(): array ['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']]]], + ['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'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'comment' => ['type' => ['string', 'null']], 'items' => ['type' => 'array', 'items' => ['type' => 'object', 'required' => ['label', 'is_completed'], 'properties' => ['label' => ['type' => 'string'], 'is_completed' => ['type' => 'boolean']]]]]]], ['name' => 'market_management.update_occurrence', 'description' => 'Marktdurchführung ändern', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer']] + $occurrenceProperties]], ['name' => 'market_management.archive_occurrence', 'description' => 'Marktdurchführung archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer']]]], ['name' => 'market_management.reactivate_occurrence', 'description' => 'Marktdurchführung reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer']]]], @@ -104,6 +106,7 @@ function market_management_mcp_is_write_tool(string $tool): bool '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); @@ -303,6 +306,16 @@ function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): a 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'), diff --git a/modules/market_management/occurrence_checklist.php b/modules/market_management/occurrence_checklist.php new file mode 100644 index 0000000..2bdb47c --- /dev/null +++ b/modules/market_management/occurrence_checklist.php @@ -0,0 +1,139 @@ +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; + } + + $item = $pdo->prepare( + 'INSERT INTO market_occurrence_checklist_item + (occurrence_id, position, label, is_completed) + VALUES (:occurrence_id, :position, :label, :is_completed)' + ); + foreach (market_management_checklist_default_items() as $position => $label) { + $item->bindValue(':occurrence_id', $occurrenceId, PDO::PARAM_INT); + $item->bindValue(':position', $position + 1, PDO::PARAM_INT); + $item->bindValue(':label', $label, PDO::PARAM_STR); + $item->bindValue(':is_completed', false, PDO::PARAM_BOOL); + $item->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, position, label, is_completed, created_at, updated_at + FROM market_occurrence_checklist_item + WHERE occurrence_id = :occurrence_id + ORDER BY position ASC, id ASC' + ); + $items->execute([':occurrence_id' => $occurrenceId]); + $checklist['items'] = $items->fetchAll(); + 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, position, label, is_completed) + VALUES (:occurrence_id, :position, :label, :is_completed)' + ); + foreach (array_values($items) as $position => $item) { + if (!is_array($item)) { + throw new InvalidArgumentException('Jeder Checklistenpunkt muss ein Objekt sein.'); + } + $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); + $insert->bindValue(':position', $position + 1, 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); +} diff --git a/modules/market_management/service.php b/modules/market_management/service.php index ddf14a9..5d7dd8f 100644 --- a/modules/market_management/service.php +++ b/modules/market_management/service.php @@ -2,6 +2,7 @@ declare(strict_types=1); require_once __DIR__ . '/../shared/db.php'; +require_once __DIR__ . '/occurrence_checklist.php'; const MARKET_MANAGEMENT_STATUSES = [ 'open', @@ -956,6 +957,7 @@ function market_management_create_occurrence(PDO $pdo, array $input): array 'tax_status' => $taxStatus, 'tax_rate' => $taxRate, ]); + market_management_checklist_initialize($pdo, (int) $occurrence['id']); if ($startedTransaction) { $pdo->commit(); } @@ -1132,6 +1134,7 @@ function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $inc ? null : round((float) $summary['gross_amount'] - (float) $occurrence['paid_amount'], 2), ]; + $occurrence['checklist'] = market_management_get_occurrence_checklist($pdo, $occurrenceId, $includeArchived); return $occurrence; } diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index 35a2bf1..a41dae7 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -1202,6 +1202,9 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM 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 = ''; 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 = '
' + title + '
' + (actions ? '
' + actions + '
' : '') + '
'; return '
' + header + '
' + body + '
' + (scope ? '' : '') + '
'; };"; + echo " const marketChecklistReadBody = (checklist) => { const items = Array.isArray(checklist.items) ? checklist.items : []; const rows = items.map((item, index) => '
' + marketEscape(item.label || '') + '
').join(''); return '
' + rows + '
Kommentar

' + marketDisplay(checklist.comment) + '

'; };"; + echo " const marketChecklistEditBody = (checklist) => { const items = Array.isArray(checklist.items) ? checklist.items : []; const rows = items.map((item) => '
').join(''); return '
' + rows + '
'; };"; + echo " const marketChecklistCard = (id, checklist) => { const actions = '' + marketEditButton('checklist', id); return '
Checkliste
' + actions + '
' + marketChecklistReadBody(checklist) + '
'; };"; 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 = '
Status: ' + marketEscape(marketStatusLabel(values.status)) + '
' + actions + '
'; return '
' + header + '
'; };"; echo " const marketEditButton = () => '';"; 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; };"; @@ -1232,9 +1235,15 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM 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 ? '
' + marketEscape(market.website_url) + '' : 'Nicht hinterlegt'; const contract = occurrence.contract_folder_url ? 'Vertragsordner öffnen' : 'Nicht hinterlegt'; const overview = marketRow('Markt', market.name) + marketRow('Kategorie', market.category_name || market.category_code) + '
Website
' + website + '
' + 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)) + '
Kostenpositionen
' + marketDisplay(occurrence.cost_items_text) + '
'; 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) + '
Vertragsordner
' + contract + '
'; 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 ''; }).join(''); const addDayTab = ''; const dayPanels = days.map((day, index) => { const label = day.__draft ? 'Neuer Tag' : marketDate(day.date); const selected = day.__draft || (!hasDraftDay && index === 0); const values = day.__draft ? Object.assign({}, day, { __occurrence_id: occurrence.id }) : day; return '
' + marketCard(label, '
' + marketRow('Datum', marketDate(day.date), true) + marketRow('Beobachtungen', day.observations, true) + marketRow('Standzeit von', day.stand_start_time, true) + marketRow('Standzeit bis', day.stand_end_time, true) + marketRow('Aufbau von', day.setup_start_time, true) + marketRow('Aufbau bis', day.setup_end_time, true) + marketRow('Anlieferung von', day.delivery_start_time, true) + marketRow('Anlieferung bis', day.delivery_end_time, true) + marketRow('Umsatz', marketMoney(day.gross_revenue), true) + marketRow('Verkaufte Artikel', day.sold_item_count, true) + '
', 'sg-card-segment--darkgreen', marketEditButton('day', day.id), 'day', day.id, values) + '
'; }).join(''); const salesCard = marketCard('Umsatz und Artikel', '
' + marketRow('Gesamterlös', marketMoney(salesSummary.gross_revenue)) + marketRow('Gesamtzahl verkaufter Artikel', salesSummary.sold_item_count) + marketRow('Durchschnittlicher Erlös pro Tag', marketMoney(salesSummary.average_revenue_per_day)) + marketRow('Durchschnittlicher Erlös pro verkauftem Artikel', marketMoney(salesSummary.average_revenue_per_item)) + '
', 'sg-card-segment--darkgreen'); return marketStatusCard(occurrence.id, occurrence) + marketCard('Markt und Marktdurchführung', '
' + overview + '
', 'sg-card-segment--darkgreen', marketEditButton('overview', occurrence.id), 'overview', occurrence.id, Object.assign({}, occurrence, { market_name: market.name, category_name: market.category_name || market.category_code, website_url: market.website_url, contact_email: contactPerson.email, contact_phone: contactPerson.phone })) + marketCard('Teilnahme und Zahlung', '
' + participation + '
', 'sg-card-segment--darkgreen', marketEditButton('participation', occurrence.id), 'participation', occurrence.id, occurrence) + marketCard('Infrastruktur', '
' + infrastructure + '
', 'sg-card-segment--darkgreen', marketEditButton('infrastructure', occurrence.id), 'infrastructure', occurrence.id, occurrence) + marketCard('Anreise und Logistik', '
' + travel + '
', 'sg-card-segment--darkgreen', marketEditButton('travel', occurrence.id), 'travel', occurrence.id, occurrence) + salesCard + marketCard('Weitere Angaben', '
' + notes + '
', 'sg-card-segment--darkgreen', marketEditButton('notes', occurrence.id), 'notes', occurrence.id, occurrence) + (days.length ? '
' + dayTabs + addDayTab + '
' + dayPanels + '
' : '

Keine Markttage vorhanden.

'); };"; + 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 ? '' + marketEscape(market.website_url) + '' : 'Nicht hinterlegt'; const contract = occurrence.contract_folder_url ? 'Vertragsordner öffnen' : 'Nicht hinterlegt'; const overview = marketRow('Markt', market.name) + marketRow('Kategorie', market.category_name || market.category_code) + '
Website
' + website + '
' + 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)) + '
Kostenpositionen
' + marketDisplay(occurrence.cost_items_text) + '
'; 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) + '
Vertragsordner
' + contract + '
'; 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 ''; }).join(''); const addDayTab = ''; const dayPanels = days.map((day, index) => { const label = day.__draft ? 'Neuer Tag' : marketDate(day.date); const selected = day.__draft || (!hasDraftDay && index === 0); const values = day.__draft ? Object.assign({}, day, { __occurrence_id: occurrence.id }) : day; return '
' + marketCard(label, '
' + marketRow('Datum', marketDate(day.date), true) + marketRow('Beobachtungen', day.observations, true) + marketRow('Standzeit von', day.stand_start_time, true) + marketRow('Standzeit bis', day.stand_end_time, true) + marketRow('Aufbau von', day.setup_start_time, true) + marketRow('Aufbau bis', day.setup_end_time, true) + marketRow('Anlieferung von', day.delivery_start_time, true) + marketRow('Anlieferung bis', day.delivery_end_time, true) + marketRow('Umsatz', marketMoney(day.gross_revenue), true) + marketRow('Verkaufte Artikel', day.sold_item_count, true) + '
', 'sg-card-segment--darkgreen', marketEditButton('day', day.id), 'day', day.id, values) + '
'; }).join(''); const salesCard = marketCard('Umsatz und Artikel', '
' + marketRow('Gesamterlös', marketMoney(salesSummary.gross_revenue)) + marketRow('Gesamtzahl verkaufter Artikel', salesSummary.sold_item_count) + marketRow('Durchschnittlicher Erlös pro Tag', marketMoney(salesSummary.average_revenue_per_day)) + marketRow('Durchschnittlicher Erlös pro verkauftem Artikel', marketMoney(salesSummary.average_revenue_per_item)) + '
', 'sg-card-segment--darkgreen'); return marketStatusCard(occurrence.id, occurrence) + marketCard('Markt und Marktdurchführung', '
' + overview + '
', 'sg-card-segment--darkgreen', marketEditButton('overview', occurrence.id), 'overview', occurrence.id, Object.assign({}, occurrence, { market_name: market.name, category_name: market.category_name || market.category_code, website_url: market.website_url, contact_email: contactPerson.email, contact_phone: contactPerson.phone })) + marketCard('Teilnahme und Zahlung', '
' + participation + '
', 'sg-card-segment--darkgreen', marketEditButton('participation', occurrence.id), 'participation', occurrence.id, occurrence) + marketCard('Infrastruktur', '
' + infrastructure + '
', 'sg-card-segment--darkgreen', marketEditButton('infrastructure', occurrence.id), 'infrastructure', occurrence.id, occurrence) + marketChecklistCard(occurrence.id, occurrence.checklist || {}) + marketCard('Anreise und Logistik', '
' + travel + '
', 'sg-card-segment--darkgreen', marketEditButton('travel', occurrence.id), 'travel', occurrence.id, occurrence) + salesCard + marketCard('Weitere Angaben', '
' + notes + '
', 'sg-card-segment--darkgreen', marketEditButton('notes', occurrence.id), 'notes', occurrence.id, occurrence) + (days.length ? '
' + dayTabs + addDayTab + '
' + dayPanels + '
' : '

Keine Markttage vorhanden.

'); };"; 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) => { const list = form?.querySelector('[data-checklist-edit-list]'); if (!list) { return; } const trailing = list.querySelector('[data-checklist-trailing-item]'); if (!trailing || (trailing.querySelector('[data-checklist-label]')?.value || '').trim() !== '') { const item = document.createElement('div'); item.className = 'market-checklist-edit-item market-checklist-edit-item--trailing'; item.dataset.checklistEditItem = ''; item.dataset.checklistTrailingItem = ''; item.innerHTML = ''; list.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 items = Array.isArray(payload.occurrence?.checklist?.items) ? payload.occurrence.checklist.items : []; if (!items[index]) { return; } const previous = Boolean(items[index].is_completed); const next = !previous; items[index].is_completed = next; setChecklistCheckbox(button, next); 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) => ({ 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) { items[index].is_completed = previous; setChecklistCheckbox(button, previous); if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'; } } finally { button.disabled = false; } };"; + echo " document.addEventListener('click', (event) => { const checklistOpen = event.target.closest('[data-checklist-open]'); if (checklistOpen) { checklistOpen.closest('[data-checklist-card]')?.querySelector('[data-checklist-read-checkbox]')?.focus(); return; } const checkbox = event.target.closest('[data-checklist-edit-checkbox]'); if (checkbox) { event.preventDefault(); setChecklistCheckbox(checkbox, 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) { deleteItem.closest('[data-checklist-edit-item]')?.remove(); ensureChecklistTrailingItem(deleteItem.closest('[data-checklist-form]') || deleteItem.closest('[data-market-checklist-form]')); } });"; + 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]'); if (item?.hasAttribute('data-checklist-trailing-item') && input.value.trim() !== '') { item.removeAttribute('data-checklist-trailing-item'); } ensureChecklistTrailingItem(form); });"; + 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 items = Array.from(form.querySelectorAll('[data-checklist-edit-item]')).filter((item) => !item.hasAttribute('data-checklist-trailing-item')).map((item) => ({ 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); } } 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 = '

Marktdetails werden geladen …

'; } 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 = '

' + marketEscape(error.message) + '

'; } } 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 = '

Pflichtangaben werden geladen …

'; } 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 = '

' + marketEscape(error.message) + '

'; } } };"; diff --git a/public/api/market-occurrence.php b/public/api/market-occurrence.php index dd7c0fc..dd05fa3 100644 --- a/public/api/market-occurrence.php +++ b/public/api/market-occurrence.php @@ -63,6 +63,13 @@ if ($requestMethod === 'POST') { $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)) { diff --git a/public/assets/styles.portal.css b/public/assets/styles.portal.css index 0264cf7..8ee9008 100644 --- a/public/assets/styles.portal.css +++ b/public/assets/styles.portal.css @@ -431,6 +431,36 @@ body.sg-lager-mobile-detail-open > :not([data-lager-mobile-detail-host]) { 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-edit-item [data-checklist-label] { + flex: 1 1 auto; + min-width: 0; +} + +.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; diff --git a/scripts/tests/market_management_mcp_tools_list_test.php b/scripts/tests/market_management_mcp_tools_list_test.php index 94e9c7b..6f653ef 100644 --- a/scripts/tests/market_management_mcp_tools_list_test.php +++ b/scripts/tests/market_management_mcp_tools_list_test.php @@ -58,6 +58,8 @@ $expected = [ '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', @@ -67,7 +69,7 @@ $expected = [ ]; $tools = market_management_mcp_tools(); -assert_test(count($tools) === 21, 'Expected 21 MCP tools, got ' . count($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');