From 0e71aca89b2a01d8ae3f00158350a01d1d483a46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mathias=20Gla=CC=88ser?= Date: Sun, 20 Sep 2026 10:57:40 +0200 Subject: [PATCH] Veranstalter am Markt verankern --- .../0028_market_organizer_inheritance.sql | 66 ++++++++++++++ .../market_management_hermes_mcp_api.md | 13 ++- .../market_management_technisches_konzept.md | 10 ++- modules/erp/kontakte/service.php | 70 +++++++++++++++ modules/market_management/mcp.php | 53 ++++------- modules/market_management/service.php | 89 +++++++++++++------ modules/shared/auth/ui/home.php | 29 ++++-- public/api/market-definition.php | 12 +++ public/api/market-occurrence.php | 34 ++----- public/index.php | 1 + 10 files changed, 271 insertions(+), 106 deletions(-) create mode 100644 db/migrations/0028_market_organizer_inheritance.sql diff --git a/db/migrations/0028_market_organizer_inheritance.sql b/db/migrations/0028_market_organizer_inheritance.sql new file mode 100644 index 0000000..57bb45d --- /dev/null +++ b/db/migrations/0028_market_organizer_inheritance.sql @@ -0,0 +1,66 @@ +BEGIN; + +ALTER TABLE party_role + DROP CONSTRAINT chk_party_role_code; + +ALTER TABLE party_role + ADD CONSTRAINT chk_party_role_code + CHECK (role_code IN ('manufacturer', 'market_organizer')); + +ALTER TABLE market_definition + ADD COLUMN organizer_party_id BIGINT; + +UPDATE market_definition m +SET organizer_party_id = source.organization_contact_id +FROM ( + SELECT o.market_definition_id, MIN(oo.organization_contact_id) AS organization_contact_id + FROM market_occurrence o + INNER JOIN market_occurrence_organizer oo ON oo.occurrence_id = o.id + GROUP BY o.market_definition_id +) source +WHERE source.market_definition_id = m.id; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM market_definition WHERE organizer_party_id IS NULL) THEN + RAISE EXCEPTION 'Alle bestehenden Märkte benötigen vor der Migration einen Veranstalter.'; + END IF; +END $$; + +ALTER TABLE market_definition + ALTER COLUMN organizer_party_id SET NOT NULL, + ADD CONSTRAINT fk_market_definition_organizer_party + FOREIGN KEY (organizer_party_id) REFERENCES party(id) ON DELETE RESTRICT; + +CREATE INDEX idx_market_definition_organizer_party + ON market_definition(organizer_party_id); + +INSERT INTO party_role (party_id, role_code) +SELECT DISTINCT organizer_party_id, 'market_organizer' +FROM market_definition +ON CONFLICT (party_id, role_code) DO NOTHING; + +ALTER TABLE market_occurrence_organizer + ADD COLUMN contact_person_name TEXT, + ADD COLUMN contact_email TEXT, + ADD COLUMN contact_phone TEXT; + +UPDATE market_occurrence_organizer oo +SET contact_person_name = NULLIF(TRIM(CONCAT_WS(' ', c.first_name, c.last_name)), ''), + contact_email = c.email, + contact_phone = c.phone +FROM contact c +WHERE c.id = oo.contact_person_id; + +INSERT INTO market_occurrence_organizer (occurrence_id, contact_person_name, created_at, updated_at) +SELECT o.id, NULL, NOW(), NOW() +FROM market_occurrence o +WHERE NOT EXISTS ( + SELECT 1 FROM market_occurrence_organizer oo WHERE oo.occurrence_id = o.id +); + +ALTER TABLE market_occurrence_organizer + DROP COLUMN organization_contact_id, + DROP COLUMN contact_person_id; + +COMMIT; 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 dfda3a4..9ede661 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 @@ -97,7 +97,12 @@ Das Ergebnis steht bei `tools/call` als JSON im Textinhalt des ersten - optional: `include_archived` - liefert Marktdurchführungen inklusive Markttagen, Kosten und berechneten Verkaufswerten - `market_management.create_market` - - erforderlich: `name`, `category_id` + - erforderlich: `name`, `category_id`, `organizer_party_id` +- `market_management.list_market_organizers` + - liefert aktive Veranstalterorganisationen +- `market_management.create_market_organizer` + - erforderlich: `name` + - optional: `email`, `phone` - optional: `website_url` als HTTP(S)-URL des Marktes - `market_management.update_market` - erforderlich: `market_id` @@ -173,9 +178,9 @@ Weitere mögliche Tagesfelder sind `stand_start_time`, `stand_end_time`, ### Veranstalter und Kosten -- `market_management.set_organizer` - - erforderlich: `occurrence_id`, `organization_contact_id`, - `contact_person_id` +- Veranstalter werden am Markt über `organizer_party_id` gesetzt und von den + Durchführungen geerbt. Marktdurchführungen können nur `contact_person_name`, + `contact_email` und `contact_phone` ändern. - `market_management.delete_organizer` - erforderlich: `occurrence_id` - `market_management.create_cost_item` 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 2864f73..adccbfe 100644 --- a/docs/modules/market_management/konzepte/market_management_technisches_konzept.md +++ b/docs/modules/market_management/konzepte/market_management_technisches_konzept.md @@ -84,6 +84,7 @@ Normativ: - jeder Markt besitzt genau einen eindeutigen Namen - jeder Markt besitzt genau eine Kategorie +- jeder Markt besitzt genau einen verpflichtenden Veranstalter aus dem Kontaktmodul - jeder Markt kann eine optionale Website-URL der Veranstaltung besitzen - der Markt enthält keine Durchführungs-, Kontakt-, Kosten-, Zahlungs- oder Erlösdaten @@ -101,6 +102,7 @@ Normativ: Zahlungsdaten gehören zur Durchführung - der Status gehört zur Durchführung - Zahlungsstatus und Marktstatus sind unabhängig +- der Veranstalter wird von jeder Durchführung des Marktes geerbt und ist dort nicht editierbar - der Zahlungsstatus ist manuell `paid` oder `unpaid` Minimale Pflichtfelder über alle Statuswerte hinweg: @@ -115,10 +117,10 @@ Veranstalterbeziehung einer Durchführung. Normativ: -- der Veranstalter ist eine Organisation aus dem Kontaktmodul -- jede Durchführung besitzt höchstens genau einen Veranstalterdatensatz -- der Veranstalterdatensatz referenziert genau eine Kontaktperson -- Kontakt- und Organisationsdaten werden nicht im Marktmanagement dupliziert +- die Tabelle enthält die durchführungsbezogene Kontaktperson als Freitext sowie optionale E-Mail und Telefonnummer +- jede Durchführung besitzt genau einen lokalen Kontaktdatensatz +- der Veranstalter selbst wird ausschließlich über `market_definition.organizer_party_id` vom Markt geerbt +- Veranstalterorganisationen werden im Kontaktmodul über die Rolle `market_organizer` verwaltet ### 3.5 `market_occurrence_day` diff --git a/modules/erp/kontakte/service.php b/modules/erp/kontakte/service.php index f046e07..8a3f224 100644 --- a/modules/erp/kontakte/service.php +++ b/modules/erp/kontakte/service.php @@ -166,6 +166,76 @@ function get_party_options(PDO $pdo): array ); } +function get_market_organizer_options(PDO $pdo): array +{ + $stmt = $pdo->query( + "SELECT p.id, p.name, p.email, p.phone + FROM party p + INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'market_organizer' + WHERE p.status = 'active' + ORDER BY p.name ASC, p.id ASC" + ); + + return $stmt->fetchAll() ?: []; +} + +function get_market_organizer_reference(PDO $pdo, int $partyId): ?array +{ + $stmt = $pdo->prepare( + "SELECT p.id, p.name, p.email, p.phone + FROM party p + INNER JOIN party_role pr ON pr.party_id = p.id AND pr.role_code = 'market_organizer' + WHERE p.id = :id AND p.status = 'active'" + ); + $stmt->execute([':id' => $partyId]); + $party = $stmt->fetch(); + + return is_array($party) ? $party : null; +} + +function create_market_organizer(PDO $pdo, string $name, ?string $email = null, ?string $phone = null): array +{ + $name = trim($name); + $email = $email !== null ? trim($email) : null; + $phone = $phone !== null ? trim($phone) : null; + if ($name === '') { + throw new InvalidArgumentException('Veranstaltername fehlt.'); + } + + $pdo->beginTransaction(); + try { + $stmt = $pdo->prepare( + "SELECT id, name, email, phone FROM party WHERE LOWER(TRIM(name)) = LOWER(:name) LIMIT 1 FOR UPDATE" + ); + $stmt->execute([':name' => $name]); + $party = $stmt->fetch(); + if (!is_array($party)) { + $stmt = $pdo->prepare( + "INSERT INTO party (type, name, email, phone, status, created_at, updated_at) + VALUES ('customer', :name, :email, :phone, 'active', NOW(), NOW()) + RETURNING id, name, email, phone" + ); + $stmt->execute([':name' => $name, ':email' => $email ?: null, ':phone' => $phone ?: null]); + $party = $stmt->fetch(); + } + if (!is_array($party)) { + throw new RuntimeException('Veranstalter konnte nicht angelegt werden.'); + } + $role = $pdo->prepare( + "INSERT INTO party_role (party_id, role_code) VALUES (:party_id, 'market_organizer') + ON CONFLICT (party_id, role_code) DO NOTHING" + ); + $role->execute([':party_id' => (int) $party['id']]); + $pdo->commit(); + return (array) $party; + } catch (Throwable $error) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + throw $error; + } +} + function get_contact_options(PDO $pdo): array { $stmt = $pdo->query( diff --git a/modules/market_management/mcp.php b/modules/market_management/mcp.php index 73e1f90..98d8e80 100644 --- a/modules/market_management/mcp.php +++ b/modules/market_management/mcp.php @@ -51,7 +51,7 @@ function market_management_mcp_tools(): array 'travel_notes' => ['type' => ['string', 'null']], 'cancellation_terms' => ['type' => ['string', 'null']], 'revenue_share_terms' => ['type' => ['string', 'null']], 'other_participation_conditions' => ['type' => ['string', 'null']], 'contract_folder_url' => ['type' => ['string', 'null'], 'format' => 'uri'], - 'organization_contact_id' => ['type' => ['integer', 'null']], 'contact_person_id' => ['type' => ['integer', 'null']], + 'contact_person_name' => ['type' => ['string', 'null']], 'contact_email' => ['type' => ['string', 'null'], 'format' => 'email'], 'contact_phone' => ['type' => ['string', 'null']], ]; $dayProperties = [ @@ -73,11 +73,13 @@ function market_management_mcp_tools(): array ['name' => 'market_management.search_markets', 'description' => 'Märkte suchen', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]], ['name' => 'market_management.search_occurrences', 'description' => 'Marktdurchführungen suchen und auflisten', 'inputSchema' => ['type' => 'object', 'properties' => $searchProperties]], ['name' => 'market_management.get_market', 'description' => 'Markt mit Marktdurchführungen lesen', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'include_archived' => ['type' => 'boolean']]]], - ['name' => 'market_management.create_market', 'description' => 'Markt mit bestehender Kategorie und optionaler Website-URL anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'category_id'], 'properties' => ['name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]], - ['name' => 'market_management.update_market', 'description' => 'Markt inklusive Website-URL ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]], + ['name' => 'market_management.create_market', 'description' => 'Markt mit verpflichtendem Veranstalter, Kategorie und optionaler Website-URL anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['name', 'category_id', 'organizer_party_id'], 'properties' => ['name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'organizer_party_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]], + ['name' => 'market_management.update_market', 'description' => 'Markt inklusive Veranstalter und Website-URL ändern', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer'], 'name' => ['type' => 'string'], 'category_id' => ['type' => 'integer'], 'organizer_party_id' => ['type' => 'integer'], 'website_url' => ['type' => ['string', 'null'], 'format' => 'uri']]]], ['name' => 'market_management.archive_market', 'description' => 'Markt archivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer']]]], ['name' => 'market_management.reactivate_market', 'description' => 'Markt reaktivieren', 'inputSchema' => ['type' => 'object', 'required' => ['market_id'], 'properties' => ['market_id' => ['type' => 'integer']]]], ['name' => 'market_management.list_categories', 'description' => 'Bestehende Kategorien lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]], + ['name' => 'market_management.list_market_organizers', 'description' => 'Aktive Veranstalter lesen', 'inputSchema' => ['type' => 'object', 'properties' => []]], + ['name' => 'market_management.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.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]]], @@ -88,8 +90,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.set_organizer', 'description' => 'Veranstalter einer Marktdurchführung setzen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'organization_contact_id', 'contact_person_id'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'organization_contact_id' => ['type' => 'integer'], 'contact_person_id' => ['type' => 'integer']]]], - ['name' => 'market_management.delete_organizer', 'description' => 'Veranstalter einer Marktdurchführung entfernen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id'], 'properties' => ['occurrence_id' => ['type' => 'integer']]]], ['name' => 'market_management.create_cost_item', 'description' => 'Kostenposition anlegen', 'inputSchema' => ['type' => 'object', 'required' => ['occurrence_id', 'cost_type', 'gross_amount', 'tax_status'], 'properties' => ['occurrence_id' => ['type' => 'integer'], 'cost_type' => ['type' => 'string'], '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' => ['type' => 'string'], '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']]]], @@ -100,11 +100,11 @@ function market_management_mcp_is_write_tool(string $tool): bool { return in_array($tool, [ 'market_management.create_market', 'market_management.update_market', + 'market_management.create_market_organizer', 'market_management.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.set_organizer', 'market_management.delete_organizer', 'market_management.create_cost_item', 'market_management.update_cost_item', 'market_management.delete_cost_item', ], true); @@ -218,41 +218,22 @@ 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 { $contactFields = []; - foreach (['contact_email', 'contact_phone'] as $field) { + foreach (['contact_person_name', 'contact_email', 'contact_phone'] as $field) { if (array_key_exists($field, $input)) { - $contactFields[substr($field, 8)] = $input[$field]; - unset($input[$field]); - } - } - $organizerFields = []; - foreach (['organization_contact_id', 'contact_person_id'] as $field) { - if (array_key_exists($field, $input)) { - $organizerFields[$field] = $input[$field]; + $contactFields[$field] = $input[$field]; unset($input[$field]); } } $existing = null; - if ($contactFields !== [] || $organizerFields !== []) { + if ($contactFields !== []) { $existing = market_management_get_occurrence($pdo, $occurrenceId, true); if ($existing === null) { throw new RuntimeException('Marktdurchführung nicht gefunden.'); } } - if ($organizerFields !== []) { - $organizer = $existing['organizer'] ?? []; - $organizerFields['occurrence_id'] = $occurrenceId; - $organizerFields['organization_contact_id'] ??= $organizer['organization_contact_id'] ?? null; - $organizerFields['contact_person_id'] ??= $organizer['contact_person_id'] ?? null; - market_management_set_organizer($pdo, $organizerFields); - } if ($contactFields !== []) { - $contactId = (int) ($organizerFields['contact_person_id'] ?? $existing['contact_person_id'] ?? 0); - $hasContactValue = array_reduce($contactFields, static fn (bool $carry, mixed $value): bool => $carry || trim((string) $value) !== '', false); - if ($contactId > 0 && ($hasContactValue || array_key_exists('contact_person_id', $organizerFields))) { - update_contact_details($pdo, $contactId, $contactFields); - } elseif ($hasContactValue) { - throw new InvalidArgumentException('Für E-Mail oder Telefon muss eine Kontaktperson ausgewählt sein.'); - } + $contactFields['occurrence_id'] = $occurrenceId; + market_management_set_occurrence_contact($pdo, $contactFields); } if ($input !== []) { market_management_update_occurrence($pdo, $occurrenceId, $input); @@ -287,6 +268,13 @@ function market_management_mcp_call(PDO $pdo, string $tool, array $arguments): a false ), 'market_management.list_categories' => market_management_list_categories($pdo), + 'market_management.list_market_organizers' => get_market_organizer_options($pdo), + 'market_management.create_market_organizer' => create_market_organizer( + $pdo, + (string) ($arguments['name'] ?? ''), + $arguments['email'] ?? null, + $arguments['phone'] ?? null + ), 'market_management.list_reference_data' => market_management_list_reference_data($pdo, $arguments), 'market_management.get_overview' => [ 'markets' => market_management_search_markets($pdo, $arguments), @@ -323,11 +311,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.set_organizer' => market_management_set_organizer($pdo, $arguments), - 'market_management.delete_organizer' => market_management_delete_organizer( - $pdo, - market_management_require_positive_id($arguments['occurrence_id'] ?? null, 'occurrence_id') - ), 'market_management.create_cost_item' => market_management_create_cost_item($pdo, $arguments), 'market_management.update_cost_item' => market_management_update_cost_item( $pdo, diff --git a/modules/market_management/service.php b/modules/market_management/service.php index eb14ca1..1516058 100644 --- a/modules/market_management/service.php +++ b/modules/market_management/service.php @@ -212,6 +212,7 @@ function market_management_create_market(PDO $pdo, array $input): array { $name = trim((string) ($input['name'] ?? '')); $categoryId = market_management_require_positive_id($input['category_id'] ?? null, 'category_id'); + $organizerPartyId = market_management_require_positive_id($input['organizer_party_id'] ?? null, 'organizer_party_id'); $websiteUrl = market_management_optional_url($input, 'website_url'); if ($name === '') { @@ -220,13 +221,16 @@ function market_management_create_market(PDO $pdo, array $input): array if (!market_management_category_exists($pdo, $categoryId)) { throw new InvalidArgumentException('category_id verweist nicht auf eine aktive Kategorie.'); } + if (get_market_organizer_reference($pdo, $organizerPartyId) === null) { + throw new InvalidArgumentException('organizer_party_id verweist nicht auf einen aktiven Veranstalter.'); + } $stmt = $pdo->prepare( - 'INSERT INTO market_definition (name, category_id, website_url) - VALUES (:name, :category_id, :website_url) - RETURNING id, name, category_id, website_url, archived_at, created_at, updated_at' + 'INSERT INTO market_definition (name, category_id, organizer_party_id, website_url) + VALUES (:name, :category_id, :organizer_party_id, :website_url) + RETURNING id, name, category_id, organizer_party_id, website_url, archived_at, created_at, updated_at' ); - $stmt->execute([':name' => $name, ':category_id' => $categoryId, ':website_url' => $websiteUrl]); + $stmt->execute([':name' => $name, ':category_id' => $categoryId, ':organizer_party_id' => $organizerPartyId, ':website_url' => $websiteUrl]); return (array) $stmt->fetch(); } @@ -294,16 +298,18 @@ function market_management_search_markets(PDO $pdo, array $input): array } $stmt = $pdo->prepare( - 'SELECT m.id, m.name, m.category_id, m.website_url, c.code AS category_code, c.name AS category_name, + 'SELECT m.id, m.name, m.category_id, m.organizer_party_id, m.website_url, c.code AS category_code, c.name AS category_name, + p.name AS organizer_name, m.archived_at, m.created_at, m.updated_at, MIN(d.date) AS earliest_date, MAX(d.date) AS latest_date, COUNT(DISTINCT o.id) AS occurrence_count FROM market_definition m INNER JOIN market_category c ON c.id = m.category_id + INNER JOIN party p ON p.id = m.organizer_party_id LEFT JOIN market_occurrence o ON o.market_definition_id = m.id AND ' . $occurrenceArchivePredicate . ' LEFT JOIN market_occurrence_day d ON d.occurrence_id = o.id WHERE ' . ($where === [] ? 'TRUE' : implode(' AND ', $where)) . ' - GROUP BY m.id, m.category_id, c.code, c.name + GROUP BY m.id, m.category_id, m.organizer_party_id, c.code, c.name, p.name ORDER BY ' . $sortColumn . ' ' . $sortDirection . ', m.id ASC LIMIT :limit OFFSET :offset' ); @@ -318,10 +324,12 @@ function market_management_get_market(PDO $pdo, int $marketId, bool $includeArch { $marketArchivePredicate = $includeArchived ? 'TRUE' : 'm.archived_at IS NULL'; $stmt = $pdo->prepare( - 'SELECT m.id, m.name, m.category_id, m.website_url, c.code AS category_code, c.name AS category_name, + 'SELECT m.id, m.name, m.category_id, m.organizer_party_id, m.website_url, c.code AS category_code, c.name AS category_name, + p.name AS organizer_name, m.archived_at, m.created_at, m.updated_at FROM market_definition m INNER JOIN market_category c ON c.id = m.category_id + INNER JOIN party p ON p.id = m.organizer_party_id WHERE m.id = :id AND ' . $marketArchivePredicate ); $stmt->execute([':id' => $marketId]); @@ -359,7 +367,7 @@ function market_management_get_market(PDO $pdo, int $marketId, bool $includeArch function market_management_list_active_market_definitions(PDO $pdo): array { $stmt = $pdo->query( - 'SELECT m.id, m.name, m.category_id, c.name AS category_name + 'SELECT m.id, m.name, m.category_id, m.organizer_party_id, c.name AS category_name FROM market_definition m INNER JOIN market_category c ON c.id = m.category_id WHERE m.archived_at IS NULL @@ -493,6 +501,14 @@ function market_management_update_market(PDO $pdo, int $marketId, array $input): $sets[] = 'category_id = :category_id'; $params[':category_id'] = $categoryId; } + if (array_key_exists('organizer_party_id', $input)) { + $organizerPartyId = market_management_require_positive_id($input['organizer_party_id'], 'organizer_party_id'); + if (get_market_organizer_reference($pdo, $organizerPartyId) === null) { + throw new InvalidArgumentException('organizer_party_id verweist nicht auf einen aktiven Veranstalter.'); + } + $sets[] = 'organizer_party_id = :organizer_party_id'; + $params[':organizer_party_id'] = $organizerPartyId; + } if (array_key_exists('website_url', $input)) { $sets[] = 'website_url = :website_url'; $params[':website_url'] = market_management_optional_url($input, 'website_url'); @@ -504,7 +520,7 @@ function market_management_update_market(PDO $pdo, int $marketId, array $input): $sets[] = 'updated_at = NOW()'; $stmt = $pdo->prepare( 'UPDATE market_definition SET ' . implode(', ', $sets) . ' WHERE id = :id - RETURNING id, name, category_id, website_url, archived_at, created_at, updated_at' + RETURNING id, name, category_id, organizer_party_id, website_url, archived_at, created_at, updated_at' ); $stmt->execute($params); $market = $stmt->fetch(); @@ -520,7 +536,7 @@ function market_management_set_market_archive(PDO $pdo, int $marketId, bool $arc 'UPDATE market_definition SET archived_at = ' . ($archive ? 'COALESCE(archived_at, NOW())' : 'NULL') . ', updated_at = NOW() WHERE id = :id - RETURNING id, name, category_id, website_url, archived_at, created_at, updated_at' + RETURNING id, name, category_id, organizer_party_id, website_url, archived_at, created_at, updated_at' ); $stmt->execute([':id' => $marketId]); $market = $stmt->fetch(); @@ -621,6 +637,12 @@ function market_management_create_occurrence(PDO $pdo, array $input): array $day['occurrence_id'] = (int) $occurrence['id']; market_management_create_day($pdo, $day); } + market_management_set_occurrence_contact($pdo, [ + 'occurrence_id' => (int) $occurrence['id'], + 'contact_person_name' => null, + 'contact_email' => null, + 'contact_phone' => null, + ]); if ($startedTransaction) { $pdo->commit(); } @@ -724,18 +746,26 @@ function market_management_get_occurrence(PDO $pdo, int $occurrenceId, bool $inc $organizer->execute([':occurrence_id' => $occurrenceId]); $organizer = $organizer->fetch(); if (is_array($organizer)) { - $organizer['organization'] = get_party_reference($pdo, (int) $organizer['organization_contact_id']); - $organizer['contact_person'] = get_contact_reference($pdo, (int) $organizer['contact_person_id']); + $market = market_management_get_market($pdo, (int) $occurrence['market_definition_id'], true); + $organizer['organization'] = is_array($market) + ? get_market_organizer_reference($pdo, (int) $market['organizer_party_id']) + : null; + $organizer['contact_person'] = [ + 'first_name' => (string) ($organizer['contact_person_name'] ?? ''), + 'last_name' => '', + 'email' => (string) ($organizer['contact_email'] ?? ''), + 'phone' => (string) ($organizer['contact_phone'] ?? ''), + ]; } $occurrence['organizer'] = is_array($organizer) ? $organizer : null; - $occurrence['organization_contact_id'] = is_array($organizer) ? (int) $organizer['organization_contact_id'] : null; - $occurrence['contact_person_id'] = is_array($organizer) ? (int) $organizer['contact_person_id'] : null; + $occurrence['organizer_party_id'] = is_array($occurrence['organizer']) && is_array($occurrence['organizer']['organization'] ?? null) + ? (int) $occurrence['organizer']['organization']['id'] + : null; $referenceData = market_management_list_reference_data($pdo, []); $occurrence['reference_options'] = [ 'categories' => market_management_list_categories($pdo), 'municipalities' => $referenceData['municipalities'] ?? [], - 'parties' => get_party_options($pdo), - 'contacts' => get_contact_options($pdo), + 'market_organizers' => get_market_organizer_options($pdo), ]; $costs = $pdo->prepare('SELECT * FROM market_occurrence_cost_item WHERE occurrence_id = :occurrence_id ORDER BY id ASC'); $costs->execute([':occurrence_id' => $occurrenceId]); @@ -1005,24 +1035,33 @@ function market_management_delete_day(PDO $pdo, int $dayId): array return $day; } -function market_management_set_organizer(PDO $pdo, array $input): array +function market_management_set_occurrence_contact(PDO $pdo, array $input): array { $occurrenceId = market_management_require_positive_id($input['occurrence_id'] ?? null, 'occurrence_id'); - $organizationId = market_management_require_positive_id($input['organization_contact_id'] ?? null, 'organization_contact_id'); - $contactPersonId = market_management_require_positive_id($input['contact_person_id'] ?? null, 'contact_person_id'); + $name = array_key_exists('contact_person_name', $input) + ? market_management_optional_string($input, 'contact_person_name') + : null; + $email = array_key_exists('contact_email', $input) + ? market_management_optional_string($input, 'contact_email') + : null; + $phone = array_key_exists('contact_phone', $input) + ? market_management_optional_string($input, 'contact_phone') + : null; $stmt = $pdo->prepare( - 'INSERT INTO market_occurrence_organizer (occurrence_id, organization_contact_id, contact_person_id) - VALUES (:occurrence_id, :organization_contact_id, :contact_person_id) + 'INSERT INTO market_occurrence_organizer (occurrence_id, contact_person_name, contact_email, contact_phone) + VALUES (:occurrence_id, :contact_person_name, :contact_email, :contact_phone) ON CONFLICT (occurrence_id) DO UPDATE - SET organization_contact_id = EXCLUDED.organization_contact_id, - contact_person_id = EXCLUDED.contact_person_id, + SET contact_person_name = EXCLUDED.contact_person_name, + contact_email = EXCLUDED.contact_email, + contact_phone = EXCLUDED.contact_phone, updated_at = NOW() RETURNING *' ); $stmt->execute([ ':occurrence_id' => $occurrenceId, - ':organization_contact_id' => $organizationId, - ':contact_person_id' => $contactPersonId, + ':contact_person_name' => $name, + ':contact_email' => $email, + ':contact_phone' => $phone, ]); return (array) $stmt->fetch(); } diff --git a/modules/shared/auth/ui/home.php b/modules/shared/auth/ui/home.php index 698af14..b8afeb6 100644 --- a/modules/shared/auth/ui/home.php +++ b/modules/shared/auth/ui/home.php @@ -164,13 +164,14 @@ function auth_render_market_definitions_content(array $marketTable): string { $rows = $marketTable['rows'] ?? $marketTable; $categories = $marketTable['categories'] ?? []; + $organizers = $marketTable['organizers'] ?? []; $html = []; $html[] = '
'; $html[] = '
'; $html[] = '
'; $html[] = '
Märkte
'; $html[] = '
'; - foreach (['Markt', 'Kategorie', 'Website', 'Durchführungen', 'Status'] as $label) { + foreach (['Markt', 'Kategorie', 'Veranstalter', 'Website', 'Durchführungen', 'Status'] as $label) { $html[] = '
' . auth_escape_html($label) . '
'; } $html[] = '
'; @@ -184,9 +185,11 @@ function auth_render_market_definitions_content(array $marketTable): string $status = ($row['archived_at'] ?? null) === null ? 'Aktiv' : 'Archiviert'; $marketId = (int) ($row['id'] ?? 0); $marketName = (string) ($row['name'] ?? ''); + $organizerName = (string) ($row['organizer_name'] ?? ''); $html[] = '
'; - $html[] = ''; + $html[] = ''; $html[] = '
' . auth_escape_html((string) ($row['category_name'] ?? $row['category_code'] ?? '')) . '
'; + $html[] = '
' . auth_escape_html($organizerName) . '
'; $html[] = '
' . auth_escape_html($websiteValue) . '
'; $html[] = '
' . auth_escape_html((string) ($row['occurrence_count'] ?? 0)) . '
'; $html[] = '
' . auth_escape_html($status) . '
'; @@ -194,11 +197,15 @@ function auth_render_market_definitions_content(array $marketTable): string } $html[] = '
'; $html[] = ''; - $html[] = '
'; + $html[] = '

'; return implode('', $html); } @@ -1192,6 +1199,8 @@ function render_auth_home_page(array $user, array $otcProducts = [], array $otcM 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 " bindTable();"; echo "}"; echo "function initMarketDefinitionsBindings() {"; @@ -1264,18 +1273,20 @@ JS; echo " const sharedCard = drawer.querySelector('[data-market-definition-card]');"; echo " if (window.SharedEditableActionCard) { window.SharedEditableActionCard.init(definitionContent); }"; echo " const setDrawerState = (open) => { drawer.dataset.open = String(open); drawer.setAttribute('aria-hidden', String(!open)); if (activeMarket?.trigger) { activeMarket.trigger.setAttribute('aria-expanded', String(open)); } const backdrop = definitionContent.querySelector('[data-market-definition-drawer-backdrop]'); if (backdrop) { backdrop.dataset.open = String(open); backdrop.setAttribute('aria-hidden', String(!open)); } document.body.classList.toggle('sg-card-list-page-drawer-open', open); };"; - echo " const resetForm = () => { form.reset(); const status = form.querySelector('[data-market-definition-status]'); if (status) { status.textContent = ''; } };"; + echo " const resetForm = () => { form.reset(); const status = form.querySelector('[data-market-definition-status]'); if (status) { status.textContent = ''; } syncNewOrganizerFields(); };"; + echo " const syncNewOrganizerFields = () => { const select = form.querySelector('[data-market-definition-organizer]'); const fields = form.querySelector('[data-market-definition-new-organizer]'); if (!select || !fields) { return; } fields.hidden = select.value !== '__new__'; };"; echo " const closeDrawer = () => { resetForm(); if (sharedCard && window.SharedEditableActionCard) { window.SharedEditableActionCard.resetDeleteConfirmation(sharedCard); window.SharedEditableActionCard.setEditMode(sharedCard, false); } activeMarket = null; setDrawerState(false); };"; echo " const deleteMarketDefinition = async (submit) => { if (submit.disabled || !activeMarket?.id) { return; } const card = submit.closest('[data-shared-editable-action-card]'); const input = card?.querySelector('[data-shared-editable-card-delete-input]'); const status = card?.querySelector('[data-market-definition-delete-status]'); if (!card || !input || input.value !== 'DELETE') { return; } if (status) { status.textContent = 'Löschen läuft …'; } submit.disabled = true; submit.setAttribute('aria-disabled', 'true'); try { const response = await fetch('/api/market-definition.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': definitionContent.dataset.marketDefinitionsCsrfToken || '' }, body: JSON.stringify({ action: 'delete', id: activeMarket.id, confirmation: input.value }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Löschen fehlgeschlagen.'); } const row = activeMarket.trigger.closest('[data-market-definition-row]'); const statusCell = row?.querySelectorAll('[role=\"cell\"]')[4]; if (statusCell) { statusCell.textContent = 'Archiviert'; } closeDrawer(); } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Löschen fehlgeschlagen.'; } submit.disabled = false; submit.setAttribute('aria-disabled', 'false'); } };"; - echo " const showReadCard = (trigger) => { activeMarket = { id: trigger.dataset.marketDefinitionId || '', trigger }; drawer.querySelector('[data-market-definition-read-title]').textContent = trigger.dataset.marketDefinitionName || 'Markt'; readCard.querySelector('[data-market-definition-read-name]').textContent = trigger.dataset.marketDefinitionName || 'Nicht hinterlegt'; readCard.querySelector('[data-market-definition-read-category]').textContent = trigger.dataset.marketDefinitionCategoryName || 'Nicht hinterlegt'; readCard.querySelector('[data-market-definition-read-website]').textContent = trigger.dataset.marketDefinitionWebsite || 'Nicht hinterlegt'; const title = form.querySelector('[data-market-definition-form-title]'); if (title) { title.textContent = 'Markt bearbeiten'; } const name = form.querySelector('[data-market-definition-name]'); const category = form.querySelector('[data-market-definition-category]'); const website = form.querySelector('[data-market-definition-website]'); if (name) { name.value = trigger.dataset.marketDefinitionName || ''; } if (category) { category.value = trigger.dataset.marketDefinitionCategoryId || ''; } if (website) { website.value = trigger.dataset.marketDefinitionWebsite === 'Nicht hinterlegt' ? '' : (trigger.dataset.marketDefinitionWebsite || ''); } if (sharedCard && window.SharedEditableActionCard) { window.SharedEditableActionCard.setEditMode(sharedCard, false); } setDrawerState(true); };"; + echo " const showReadCard = (trigger) => { activeMarket = { id: trigger.dataset.marketDefinitionId || '', trigger }; drawer.querySelector('[data-market-definition-read-title]').textContent = trigger.dataset.marketDefinitionName || 'Markt'; readCard.querySelector('[data-market-definition-read-name]').textContent = trigger.dataset.marketDefinitionName || 'Nicht hinterlegt'; readCard.querySelector('[data-market-definition-read-category]').textContent = trigger.dataset.marketDefinitionCategoryName || 'Nicht hinterlegt'; readCard.querySelector('[data-market-definition-read-organizer]').textContent = trigger.dataset.marketDefinitionOrganizerName || 'Nicht hinterlegt'; readCard.querySelector('[data-market-definition-read-website]').textContent = trigger.dataset.marketDefinitionWebsite || 'Nicht hinterlegt'; const title = form.querySelector('[data-market-definition-form-title]'); if (title) { title.textContent = 'Markt bearbeiten'; } const name = form.querySelector('[data-market-definition-name]'); const category = form.querySelector('[data-market-definition-category]'); const organizer = form.querySelector('[data-market-definition-organizer]'); const website = form.querySelector('[data-market-definition-website]'); if (name) { name.value = trigger.dataset.marketDefinitionName || ''; } if (category) { category.value = trigger.dataset.marketDefinitionCategoryId || ''; } if (organizer) { organizer.value = trigger.dataset.marketDefinitionOrganizerId || ''; } if (website) { website.value = trigger.dataset.marketDefinitionWebsite === 'Nicht hinterlegt' ? '' : (trigger.dataset.marketDefinitionWebsite || ''); } syncNewOrganizerFields(); if (sharedCard && window.SharedEditableActionCard) { window.SharedEditableActionCard.setEditMode(sharedCard, false); } setDrawerState(true); };"; echo " definitionContent.addEventListener('click', (event) => { const trigger = event.target.closest('[data-market-definition-open]'); if (!trigger || !definitionContent.contains(trigger)) { return; } event.preventDefault(); event.stopPropagation(); showReadCard(trigger); }, true);"; echo " definitionContent.addEventListener('click', (event) => { const deleteSubmit = event.target.closest('[data-market-definition-delete-submit]'); if (deleteSubmit) { void deleteMarketDefinition(deleteSubmit); return; } const trigger = event.target.closest('[data-market-definition-open]'); if (trigger) { const deleteButton = drawer.querySelector('[data-market-definition-delete]'); if (deleteButton) { deleteButton.hidden = false; } } if (event.target.closest('[data-market-definition-create]')) { const deleteButton = drawer.querySelector('[data-market-definition-delete]'); if (deleteButton) { deleteButton.hidden = true; } } });"; - echo " const openEditCard = () => { if (!activeMarket) { return; } const title = form.querySelector('[data-market-definition-form-title]'); if (title) { title.textContent = 'Markt bearbeiten'; } const name = form.querySelector('[data-market-definition-name]'); const category = form.querySelector('[data-market-definition-category]'); const website = form.querySelector('[data-market-definition-website]'); if (name) { name.value = activeMarket.trigger.dataset.marketDefinitionName || ''; } if (category) { category.value = activeMarket.trigger.dataset.marketDefinitionCategoryId || ''; } if (website) { website.value = activeMarket.trigger.dataset.marketDefinitionWebsite === 'Nicht hinterlegt' ? '' : (activeMarket.trigger.dataset.marketDefinitionWebsite || ''); } };"; + echo " const openEditCard = () => { if (!activeMarket) { return; } const title = form.querySelector('[data-market-definition-form-title]'); if (title) { title.textContent = 'Markt bearbeiten'; } const name = form.querySelector('[data-market-definition-name]'); const category = form.querySelector('[data-market-definition-category]'); const organizer = form.querySelector('[data-market-definition-organizer]'); const website = form.querySelector('[data-market-definition-website]'); if (name) { name.value = activeMarket.trigger.dataset.marketDefinitionName || ''; } if (category) { category.value = activeMarket.trigger.dataset.marketDefinitionCategoryId || ''; } if (organizer) { organizer.value = activeMarket.trigger.dataset.marketDefinitionOrganizerId || ''; } if (website) { website.value = activeMarket.trigger.dataset.marketDefinitionWebsite === 'Nicht hinterlegt' ? '' : (activeMarket.trigger.dataset.marketDefinitionWebsite || ''); } syncNewOrganizerFields(); };"; echo " editButton.addEventListener('click', (event) => { event.preventDefault(); openEditCard(); });"; - echo " const appendMarketRow = (market) => { const table = contentRoot.querySelector('[data-market-definitions-table]'); if (!table) { return; } const row = document.createElement('div'); row.className = 'sg-card-segment sg-card-segment--body sg-large-table__row'; row.setAttribute('role', 'row'); row.dataset.componentPart = 'large-table-row'; row.dataset.marketDefinitionRow = ''; row.dataset.marketDefinitionId = String(market.id || ''); const category = form.querySelector('[data-market-definition-category]')?.selectedOptions[0]?.textContent || ''; const website = market.website_url || 'Nicht hinterlegt'; const cells = document.createElement('div'); cells.className = 'sg-large-table__cell'; const trigger = document.createElement('a'); trigger.className = 'sg-hyperlink'; trigger.href = '#'; trigger.setAttribute('aria-haspopup', 'dialog'); trigger.setAttribute('aria-expanded', 'false'); trigger.dataset.marketDefinitionOpen = ''; trigger.dataset.marketDefinitionId = String(market.id || ''); trigger.dataset.marketDefinitionName = market.name || ''; trigger.dataset.marketDefinitionCategoryId = String(market.category_id || ''); trigger.dataset.marketDefinitionCategoryName = category; trigger.dataset.marketDefinitionWebsite = website; trigger.textContent = market.name || ''; cells.appendChild(trigger); row.appendChild(cells); [category, website, '0', 'Aktiv'].forEach((value) => { const cell = document.createElement('div'); cell.className = 'sg-large-table__cell'; cell.setAttribute('role', 'cell'); cell.textContent = value; row.appendChild(cell); }); table.appendChild(row); };"; + echo " form.querySelector('[data-market-definition-organizer]')?.addEventListener('change', syncNewOrganizerFields);"; + echo " const appendMarketRow = (market) => { const table = contentRoot.querySelector('[data-market-definitions-table]'); if (!table) { return; } const row = document.createElement('div'); row.className = 'sg-card-segment sg-card-segment--body sg-large-table__row'; row.setAttribute('role', 'row'); row.dataset.componentPart = 'large-table-row'; row.dataset.marketDefinitionRow = ''; row.dataset.marketDefinitionId = String(market.id || ''); const category = form.querySelector('[data-market-definition-category]')?.selectedOptions[0]?.textContent || ''; const organizer = form.querySelector('[data-market-definition-organizer]')?.selectedOptions[0]?.textContent || ''; const website = market.website_url || 'Nicht hinterlegt'; const cells = document.createElement('div'); cells.className = 'sg-large-table__cell'; const trigger = document.createElement('a'); trigger.className = 'sg-hyperlink'; trigger.href = '#'; trigger.setAttribute('aria-haspopup', 'dialog'); trigger.setAttribute('aria-expanded', 'false'); trigger.dataset.marketDefinitionOpen = ''; trigger.dataset.marketDefinitionId = String(market.id || ''); trigger.dataset.marketDefinitionName = market.name || ''; trigger.dataset.marketDefinitionCategoryId = String(market.category_id || ''); trigger.dataset.marketDefinitionCategoryName = category; trigger.dataset.marketDefinitionOrganizerId = String(market.organizer_party_id || ''); trigger.dataset.marketDefinitionOrganizerName = organizer; trigger.dataset.marketDefinitionWebsite = website; trigger.textContent = market.name || ''; cells.appendChild(trigger); row.appendChild(cells); [category, organizer, website, '0', 'Aktiv'].forEach((value) => { const cell = document.createElement('div'); cell.className = 'sg-large-table__cell'; cell.setAttribute('role', 'cell'); cell.textContent = value; row.appendChild(cell); }); table.appendChild(row); };"; echo " document.addEventListener('click', (event) => { if (event.target.closest('[data-market-definition-create]')) { event.preventDefault(); activeMarket = null; resetForm(); const title = form.querySelector('[data-market-definition-form-title]'); if (title) { title.textContent = 'Neuen Markt anlegen'; } if (sharedCard && window.SharedEditableActionCard) { window.SharedEditableActionCard.setEditMode(sharedCard, true); } setDrawerState(true); form.querySelector('[data-market-definition-name]')?.focus(); return; } if (event.target.closest('[data-market-definition-edit]')) { event.preventDefault(); openEditCard(); return; } if (event.target.closest('[data-market-definition-cancel]')) { event.preventDefault(); if (!activeMarket) { closeDrawer(); } return; } if (event.target.closest('[data-market-definition-drawer-backdrop]')) { event.preventDefault(); closeDrawer(); } });"; echo " document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && drawer.dataset.open === 'true') { closeDrawer(); } });"; - echo " form.addEventListener('submit', async (event) => { event.preventDefault(); const status = form.querySelector('[data-market-definition-status]'); const submit = form.querySelector('[type=submit]'); const fields = Object.fromEntries(new FormData(form).entries()); const isUpdate = Boolean(activeMarket?.id); if (status) { status.textContent = 'Speichern läuft …'; } if (submit) { submit.disabled = true; } try { const response = await fetch('/api/market-definition.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': definitionContent.dataset.marketDefinitionsCsrfToken || '' }, body: JSON.stringify({ action: isUpdate ? 'update' : 'create', id: isUpdate ? activeMarket.id : null, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } if (isUpdate) { const trigger = activeMarket.trigger; const category = form.querySelector('[data-market-definition-category]')?.selectedOptions[0]; const website = result.market.website_url || 'Nicht hinterlegt'; trigger.textContent = result.market.name; trigger.dataset.marketDefinitionName = result.market.name; trigger.dataset.marketDefinitionCategoryId = String(result.market.category_id); trigger.dataset.marketDefinitionCategoryName = category?.textContent || ''; trigger.dataset.marketDefinitionWebsite = website; showReadCard(trigger); } else { appendMarketRow(result.market); closeDrawer(); } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'; } } finally { if (submit) { submit.disabled = false; } } });"; + echo " form.addEventListener('submit', async (event) => { event.preventDefault(); const status = form.querySelector('[data-market-definition-status]'); const submit = form.querySelector('[type=submit]'); const fields = Object.fromEntries(new FormData(form).entries()); const isUpdate = Boolean(activeMarket?.id); if (fields.organizer_party_id === '__new__' && !String(fields.organizer_name || '').trim()) { if (status) { status.textContent = 'Name des neuen Veranstalters ist erforderlich.'; } return; } if (status) { status.textContent = 'Speichern läuft …'; } if (submit) { submit.disabled = true; } try { const response = await fetch('/api/market-definition.php', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': definitionContent.dataset.marketDefinitionsCsrfToken || '' }, body: JSON.stringify({ action: isUpdate ? 'update' : 'create', id: isUpdate ? activeMarket.id : null, fields }) }); const result = await response.json(); if (!response.ok || !result.ok) { throw new Error(result.error || 'Speichern fehlgeschlagen.'); } if (isUpdate) { const trigger = activeMarket.trigger; const category = form.querySelector('[data-market-definition-category]')?.selectedOptions[0]; const organizer = form.querySelector('[data-market-definition-organizer]')?.selectedOptions[0]; const website = result.market.website_url || 'Nicht hinterlegt'; trigger.textContent = result.market.name; trigger.dataset.marketDefinitionName = result.market.name; trigger.dataset.marketDefinitionCategoryId = String(result.market.category_id); trigger.dataset.marketDefinitionCategoryName = category?.textContent || ''; trigger.dataset.marketDefinitionOrganizerId = String(result.market.organizer_party_id); trigger.dataset.marketDefinitionOrganizerName = organizer?.textContent || ''; trigger.dataset.marketDefinitionWebsite = website; showReadCard(trigger); } else { appendMarketRow(result.market); closeDrawer(); } } catch (error) { if (status) { status.textContent = error instanceof Error ? error.message : 'Speichern fehlgeschlagen.'; } } finally { if (submit) { submit.disabled = false; } } });"; echo "}"; echo "function initBestellungenBindings() {"; echo " const contentRoot = document.querySelector('[data-left-navigation-content-body]');"; diff --git a/public/api/market-definition.php b/public/api/market-definition.php index bbb1b03..97c90bf 100644 --- a/public/api/market-definition.php +++ b/public/api/market-definition.php @@ -3,6 +3,7 @@ declare(strict_types=1); require_once __DIR__ . '/../../modules/shared/db.php'; require_once __DIR__ . '/../../modules/shared/auth/service.php'; +require_once __DIR__ . '/../../modules/erp/kontakte/service.php'; require_once __DIR__ . '/../../modules/market_management/service.php'; header('Content-Type: application/json; charset=utf-8'); @@ -40,6 +41,15 @@ if (!is_array($payload)) { try { $fields = is_array($payload['fields'] ?? null) ? $payload['fields'] : []; + if (($fields['organizer_party_id'] ?? '') === '__new__') { + $organizer = create_market_organizer( + $pdo, + (string) ($fields['organizer_name'] ?? ''), + (string) ($fields['organizer_email'] ?? ''), + (string) ($fields['organizer_phone'] ?? '') + ); + $fields['organizer_party_id'] = $organizer['id']; + } if (($payload['action'] ?? 'create') === 'delete') { $marketId = (int) ($payload['id'] ?? 0); if ($marketId < 1) { @@ -57,12 +67,14 @@ try { $market = market_management_update_market($pdo, $marketId, [ 'name' => $fields['name'] ?? null, 'category_id' => $fields['category_id'] ?? null, + 'organizer_party_id' => $fields['organizer_party_id'] ?? null, 'website_url' => $fields['website_url'] ?? null, ]); } else { $market = market_management_create_market($pdo, [ 'name' => $fields['name'] ?? null, 'category_id' => $fields['category_id'] ?? null, + 'organizer_party_id' => $fields['organizer_party_id'] ?? null, 'website_url' => $fields['website_url'] ?? null, ]); } diff --git a/public/api/market-occurrence.php b/public/api/market-occurrence.php index 83413fc..0f83eba 100644 --- a/public/api/market-occurrence.php +++ b/public/api/market-occurrence.php @@ -67,43 +67,19 @@ if ($requestMethod === 'POST') { market_management_update_day($pdo, $id, $fields); } elseif (in_array($scope, ['occurrence', 'status', 'overview', 'participation', 'infrastructure', 'travel', 'notes'], true)) { $contactFields = []; - foreach (['contact_email', 'contact_phone'] as $contactField) { + foreach (['contact_person_name', 'contact_email', 'contact_phone'] as $contactField) { if (array_key_exists($contactField, $fields)) { - $contactFields[substr($contactField, 8)] = $fields[$contactField]; + $contactFields[$contactField] = $fields[$contactField]; unset($fields[$contactField]); } } $existingOccurrence = null; - if ($contactFields !== [] || array_key_exists('organization_contact_id', $fields) || array_key_exists('contact_person_id', $fields)) { + if ($contactFields !== []) { $existingOccurrence = market_management_get_occurrence($pdo, $id, true); } if ($contactFields !== []) { - $submittedContactId = trim((string) ($fields['contact_person_id'] ?? '')); - $contactId = (int) ($submittedContactId !== '' ? $submittedContactId : ($existingOccurrence['contact_person_id'] ?? 0)); - $hasContactValue = array_reduce($contactFields, static fn (bool $carry, mixed $value): bool => $carry || trim((string) $value) !== '', false); - if ($contactId > 0 && ($hasContactValue || $submittedContactId !== '')) { - update_contact_details($pdo, $contactId, $contactFields); - } elseif ($hasContactValue) { - throw new InvalidArgumentException('Für E-Mail oder Telefon muss eine Kontaktperson ausgewählt sein.'); - } - } - $organizerFields = []; - foreach (['organization_contact_id', 'contact_person_id'] as $organizerField) { - if (array_key_exists($organizerField, $fields)) { - $value = trim((string) $fields[$organizerField]); - if ($value !== '') { - $organizerFields[$organizerField] = $value; - } - unset($fields[$organizerField]); - } - } - if ($organizerFields !== []) { - $existingOccurrence ??= market_management_get_occurrence($pdo, $id, true); - $existingOrganizer = $existingOccurrence['organizer'] ?? null; - $organizerFields['occurrence_id'] = $id; - $organizerFields['organization_contact_id'] ??= $existingOrganizer['organization_contact_id'] ?? null; - $organizerFields['contact_person_id'] ??= $existingOrganizer['contact_person_id'] ?? null; - market_management_set_organizer($pdo, $organizerFields); + $contactFields['occurrence_id'] = $id; + market_management_set_occurrence_contact($pdo, $contactFields); } if ($fields !== []) { market_management_update_occurrence($pdo, $id, $fields); diff --git a/public/index.php b/public/index.php index 04d04ff..c822381 100644 --- a/public/index.php +++ b/public/index.php @@ -88,6 +88,7 @@ if ($currentUser !== null) { 'offset' => 0, ]), 'categories' => market_management_list_categories($pdo), + 'organizers' => get_market_organizer_options($pdo), ]; if ((string) ($_GET['bestellungen_fragment'] ?? '') === '1') { echo auth_render_bestellungen_content($bestellungenTable);