From 7a71c9fdd8349c19303720022c75ee4eaabf6997 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 17 Sep 2025 13:23:03 +0200 Subject: [PATCH] updated rimotype map --- Layout/default/Preorder/PDF_FOOTER.html | 36 + Layout/default/Preorder/PDF_HEADER.html | 30 + Layout/default/Preorder/PDF_MAIN.php | 94 +++ application/ADBRimoFcp/ADBRimoFcp.php | 116 +-- application/Preorder/PreorderController.php | 144 +++- application/Preorder/PreorderModel.php | 36 +- .../20250917103000_adb_add_fcp_index.php | 23 + ...20250917103000_modify_preordercampaign.php | 27 + lib/TTCrud/TTCrud.php | 1 + public/cssbundler.php | 1 + .../PreorderRimoTypeMap.css | 146 +++- .../PreorderRimoTypeMap.js | 707 +++++++++++------- .../plugins/vue/tt-components/css/tt-map.css | 77 ++ public/plugins/vue/tt-components/tt-map.js | 245 ++++-- 14 files changed, 1258 insertions(+), 425 deletions(-) create mode 100644 Layout/default/Preorder/PDF_FOOTER.html create mode 100644 Layout/default/Preorder/PDF_HEADER.html create mode 100644 Layout/default/Preorder/PDF_MAIN.php create mode 100644 db/migrations/20250917103000_adb_add_fcp_index.php create mode 100644 db/migrations/20250917103000_modify_preordercampaign.php create mode 100644 public/plugins/vue/tt-components/css/tt-map.css diff --git a/Layout/default/Preorder/PDF_FOOTER.html b/Layout/default/Preorder/PDF_FOOTER.html new file mode 100644 index 000000000..2b5f38e17 --- /dev/null +++ b/Layout/default/Preorder/PDF_FOOTER.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + +
+ XINON GmbH | Fladnitz 150 | 8322 Studenzen | Tel.: +43 3115 40800 | E-Mail: office@xinon.at
+ UID: ATU68711968 | FN: 416556h | LG: Feldbach +
+ Seite von +
+ + \ No newline at end of file diff --git a/Layout/default/Preorder/PDF_HEADER.html b/Layout/default/Preorder/PDF_HEADER.html new file mode 100644 index 000000000..d9f5bd7d9 --- /dev/null +++ b/Layout/default/Preorder/PDF_HEADER.html @@ -0,0 +1,30 @@ + + + + + + + + + + + + +
+ Xinon Logo + +

Fehlerprotokoll

+
+ Kampagne: {{ campaignName }}
+ Datum: {{ creationDate }} +
+
+ + \ No newline at end of file diff --git a/Layout/default/Preorder/PDF_MAIN.php b/Layout/default/Preorder/PDF_MAIN.php new file mode 100644 index 000000000..ce938e5b5 --- /dev/null +++ b/Layout/default/Preorder/PDF_MAIN.php @@ -0,0 +1,94 @@ + + + + + Fehlerprotokoll + + + + + +
+ Für die Kampagne "" wurden keine Fehler gemeldet. +
+ + +
+

+ + + + + + + + + + + + + + + + + + + +
AddressDB ID: (Ansehen)RIMO Type:
Externe Referenz:RIMO Op/Ex State: /
Wohneinheiten:Koordinaten:, (Karte)
+ +
+

Gemeldete Fehler

+ +

Keine spezifischen Fehlerdetails angegeben.

+ + +
    + +
  • + +
+ + +
+ Sonstige Anmerkungen:
+ +
+ + +
+
+ + + + + \ No newline at end of file diff --git a/application/ADBRimoFcp/ADBRimoFcp.php b/application/ADBRimoFcp/ADBRimoFcp.php index c5bce7d2c..f6b9afbcc 100644 --- a/application/ADBRimoFcp/ADBRimoFcp.php +++ b/application/ADBRimoFcp/ADBRimoFcp.php @@ -15,66 +15,66 @@ class ADBRimoFcp extends TTCrudBaseModel public int $create; public int $edit; - public static function getRimoFcpStatistics(): array { - $db = self::getDB(); - $fronkDbName = defined('FRONKDB_DBNAME') ? FRONKDB_DBNAME : 'thetool'; - $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + public static function getRimoFcpStatistics(array $fcpIds = []): array { + $fronkDbName = FRONKDB_DBNAME ?? 'thetool'; + $addressDbName = ADDRESSDB_DBNAME ?? 'addressdb'; + + $fcps = self::getAll($fcpIds ? ['id' => $fcpIds] : [], null, 0, ['key' => 'name', 'order' => 'ASC']); + if (!$fcps) return []; + + $fcpResultMap = []; + foreach ($fcps as $fcp) { + $fcpResultMap[$fcp->id] = [ + 'fcp_id' => $fcp->id, + 'fcp_name' => $fcp->name, + 'fcp_rimo_id' => $fcp->rimo_id, + 'total_hausnummer_count' => 0, + 'total_wohneinheit_count' => 0, + 'total_active_preorders' => 0, + 'counts_by_rimo_type' => new stdClass(), + ]; + } + + $idList = implode(',', array_keys($fcpResultMap)); + if (empty($idList)) return array_values($fcpResultMap); $sql = " - -- Use a Common Table Expression (CTE) to pre-calculate counts for each combination of FCP and rimo_type. - WITH RimoTypeCounts AS ( - SELECT - hn.fcp_id, - -- Group NULL rimo_types into an 'UNKNOWN' category for clarity. - COALESCE(hn.rimo_type, 'UNKNOWN') AS rimo_type, - COUNT(DISTINCT hn.id) AS hausnummer_count, - COUNT(DISTINCT we.id) AS wohneinheit_count, - COUNT(DISTINCT CASE WHEN ps.code < 899 THEN p.id ELSE NULL END) AS preorder_count - FROM - `{$addressDbName}`.`Hausnummer` AS hn - LEFT JOIN - `{$addressDbName}`.`Wohneinheit` AS we ON hn.id = we.hausnummer_id - LEFT JOIN - `{$fronkDbName}`.`Preorder` AS p ON hn.id = p.adb_hausnummer_id - LEFT JOIN - `{$fronkDbName}`.`Preorderstatus` AS ps ON p.status_id = ps.id - WHERE - hn.fcp_id IS NOT NULL - GROUP BY - hn.fcp_id, - COALESCE(hn.rimo_type, 'UNKNOWN') - ) - -- Final SELECT statement to assemble the data for each FCP. - SELECT - fcp.id AS fcp_id, - fcp.name AS fcp_name, - fcp.rimo_id AS fcp_rimo_id, - -- Aggregate total counts for the entire FCP. - SUM(rtc.hausnummer_count) AS total_hausnummer_count, - SUM(rtc.wohneinheit_count) AS total_wohneinheit_count, - SUM(rtc.preorder_count) AS total_active_preorders, - -- Create a single JSON object from all the rimo_type groups for the current FCP. - JSON_OBJECTAGG( - rtc.rimo_type, - JSON_OBJECT( - 'hausnummer_count', rtc.hausnummer_count, - 'wohneinheit_count', rtc.wohneinheit_count, - 'preorder_count', rtc.preorder_count - ) - ) AS counts_by_rimo_type - FROM - `{$addressDbName}`.`RimoFcp` AS fcp - LEFT JOIN - RimoTypeCounts AS rtc ON fcp.id = rtc.fcp_id - WHERE - rtc.fcp_id IS NOT NULL - GROUP BY - fcp.id, fcp.name, fcp.rimo_id - ORDER BY - fcp.name; - "; + SELECT + hn.fcp_id, + COALESCE(hn.rimo_type, 'UNKNOWN') AS rimo_type, + COUNT(DISTINCT hn.id) AS hausnummer_count, + COUNT(DISTINCT we.id) AS wohneinheit_count, + COUNT(DISTINCT CASE WHEN ps.code < 899 THEN p.id END) AS preorder_count + FROM `{$addressDbName}`.`Hausnummer` AS hn + LEFT JOIN `{$addressDbName}`.`Wohneinheit` AS we ON hn.id = we.hausnummer_id + LEFT JOIN `{$fronkDbName}`.`Preorder` AS p ON p.adb_hausnummer_id = hn.id + LEFT JOIN `{$fronkDbName}`.`Preorderstatus` AS ps ON p.status_id = ps.id + WHERE hn.fcp_id IN ({$idList}) + GROUP BY hn.fcp_id, rimo_type + "; - $result = $db->query($sql); - return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + $result = self::getDB()->query($sql); + if ($result) { + while ($row = $result->fetch_assoc()) { + $fcpId = $row['fcp_id']; + $rimoType = $row['rimo_type']; + + $hCount = (int)$row['hausnummer_count']; + $wCount = (int)$row['wohneinheit_count']; + $pCount = (int)$row['preorder_count']; + + $fcpResultMap[$fcpId]['counts_by_rimo_type']->{$rimoType} = (object)[ + 'hausnummer_count' => $hCount, + 'wohneinheit_count' => $wCount, + 'preorder_count' => $pCount, + ]; + + $fcpResultMap[$fcpId]['total_hausnummer_count'] += $hCount; + $fcpResultMap[$fcpId]['total_wohneinheit_count'] += $wCount; + $fcpResultMap[$fcpId]['total_active_preorders'] += $pCount; + } + } + + return array_values($fcpResultMap); } } \ No newline at end of file diff --git a/application/Preorder/PreorderController.php b/application/Preorder/PreorderController.php index 6efa1cadb..dd3deee6e 100644 --- a/application/Preorder/PreorderController.php +++ b/application/Preorder/PreorderController.php @@ -1229,14 +1229,12 @@ class PreorderController extends mfBaseController { ); } - public function getRimoFcpStatsApi() { - $this->postData = json_decode(file_get_contents("php://input")); - $stats = ADBRimoFcp::getRimoFcpStatistics(); + public function getRimoFcpStatsApi() + { + $this->postData = json_decode(file_get_contents("php://input"), true); + $fcpIds = $this->postData['fcp_ids'] ?? []; + $stats = ADBRimoFcp::getRimoFcpStatistics($fcpIds); - if (!empty($this->postData->fcp_ids)) { - $fcpIds = (array) $this->postData->fcp_ids; - $stats = array_filter($stats, fn($item) => in_array($item['fcp_id'], $fcpIds)); - } foreach ($stats as &$item) if (isset($item['counts_by_rimo_type']) && is_string($item['counts_by_rimo_type'])) @@ -1797,7 +1795,11 @@ class PreorderController extends mfBaseController { $this->redirect("Preorder", "Index"); } - Helper::renderVue($this, "PreorderRimoTypeMap", "PreorderRimoTypeMap", ["MAPBOX_KEY" => TT_MAPBOX_TILE_API_TOKEN]); + Helper::renderVue($this, "PreorderRimoTypeMap", "PreorderRimoTypeMap", [ + "MAPBOX_KEY" => TT_MAPBOX_TILE_API_TOKEN, + "USER_ID" => $this->me->id, + "ALL_USERS" => array_map(fn($u) => ["id" => $u->id, "name" => $u->name], UserModel::getAll()) + ]); } public function RimoTypeMapDataAction() { @@ -1810,4 +1812,130 @@ class PreorderController extends mfBaseController { $data = PreorderModel::getPreorderRimoTypeData($campaignId); self::returnJson(['success' => true, 'data' => $data]); } + + public function RimoTypeMapSaveFaultsAction() { + $input = json_decode(file_get_contents('php://input'), true); + $campaignId = $input['campaignId'] ?? null; + $faults = $input['faults'] ?? []; + $allowedCampaigns = Helper::getPreorderCampaignFromUser($this->me); + + if (!$campaignId || !in_array($campaignId, $allowedCampaigns)) self::sendError('Ungültige oder keine Kampagne ausgewählt.'); + if (!is_array($faults) || !count($faults)) self::sendError('Keine Fehlerdaten übermittelt.'); + + $campaign = new Preordercampaign($campaignId); + if (!$campaign->id) self::sendError('Kampagne nicht gefunden.'); + + $campaign->rimo_type_map_faults = json_encode($faults); + if (!$campaign->save()) self::sendError('Fehler beim Speichern der Fehlerdaten.'); + + self::returnJson(['success' => true, 'message' => 'Fehlerdaten erfolgreich gespeichert.']); + } + + public function RimoTypeMapGetFaultsAction() { + $campaignId = $this->request->preordercampaign_id ?? null; + $allowedCampaigns = Helper::getPreorderCampaignFromUser($this->me); + + if (!$campaignId || !in_array($campaignId, $allowedCampaigns)) self::sendError('Ungültige oder keine Kampagne ausgewählt.'); + + $campaign = new Preordercampaign($campaignId); + if (!$campaign->id) self::sendError('Kampagne nicht gefunden.'); + + $faults = $campaign->rimo_type_map_faults ? json_decode($campaign->rimo_type_map_faults, true) : []; + self::returnJson(['success' => true, 'faults' => $faults]); + } + + protected function generateTemplate(string $templateName, array $replacements): string { + $path = BASEDIR . "/Layout/default/{$templateName}.html"; + if (!file_exists($path)) self::sendError("Template nicht gefunden: {$templateName}"); + + $content = file_get_contents($path); + foreach ($replacements as $key => $value) $content = str_replace("{{ {$key} }}", $value ?? '', $content); + + return $content; + } + + public function RimoTypeMapFaultsPDFAction() { + if (empty($this->request->preordercampaign_id)) self::sendError('Kampagnen-ID fehlt.'); + $campaignId = $this->request->preordercampaign_id; + + if (!in_array($campaignId, Helper::getPreorderCampaignFromUser($this->me))) self::sendError('Zugriff auf diese Kampagne verweigert.'); + + $campaign = new Preordercampaign($campaignId); + if (!$campaign->id) self::sendError('Kampagne nicht gefunden.'); + + $faults = json_decode($campaign->rimo_type_map_faults, true) ?? []; + $allAddressesById = array_column(PreorderModel::getPreorderRimoFaultsData((int)$campaignId), null, 'hausnummer_id'); + + $faultReasonMap = [ + 'building_type' => 'Gebäudetyp falsch', + 'home_count' => 'Homeanzahl falsch', + 'not_existent' => 'Gebäude nicht existent', + 'other' => 'Sonstiges', + 'graz_umgebung' => 'Ort/Gemeinde nicht existent' + ]; + + $faultyEntriesData = []; + + foreach ($faults as $hausnummerId => $faultData) { + if (!isset($allAddressesById[$hausnummerId])) continue; + if (!empty($faultData['done']) && ($faultData['done'] === true || $faultData['done'] === 1 || $faultData['done'] === 'true')) continue; + + $addressInfo = $allAddressesById[$hausnummerId]; + $reasons = array_map(fn($key) => $faultReasonMap[$key] ?? $key, $faultData['reasons'] ?? []); + + $faultyEntriesData[$hausnummerId] = [ + 'address' => $addressInfo, + 'faults' => [ + 'reasons' => $reasons, + 'other' => htmlspecialchars($faultData['other'] ?? '') + ] + ]; + } + + $faultyEntries = []; + foreach ($faultyEntriesData as $hausnummerId => $data) { + $addressInfo = $allAddressesById[$hausnummerId]; + $faultyEntries[] = [ + 'address' => $addressInfo, + 'faults' => $data['faults'], + 'addressDbLink' => "https://thetool.xinon.at/AddressDB/View?id={$hausnummerId}", + 'googleMapsLink' => "https://maps.google.com/?q={$addressInfo['gps_lat']},{$addressInfo['gps_long']}" + ]; + } + + $tempDir = BASEDIR . "/var/temp"; + is_dir($tempDir) || mkdir($tempDir, 0775, true); + + $replacements = [ + 'basedir' => BASEDIR, + 'campaignName' => htmlspecialchars($campaign->name), + 'creationDate' => date("d.m.Y"), + ]; + + $headerFile = tempnam($tempDir, 'pdf_header_') . '.html'; + $footerFile = tempnam($tempDir, 'pdf_footer_') . '.html'; + file_put_contents($headerFile, $this->generateTemplate('Preorder/PDF_HEADER', $replacements)); + file_put_contents($footerFile, $this->generateTemplate('Preorder/PDF_FOOTER', $replacements)); + + $pdf = new PdfForm("Preorder/PDF_MAIN", [ + "campaignName" => $campaign->name, + "faultyEntries" => $faultyEntries, + ]); + + $options = "--header-html {$headerFile} --footer-html {$footerFile} --margin-top 35 --margin-bottom 25"; + $filename = $pdf->render($options); + + unlink($headerFile); + unlink($footerFile); + + if (!file_exists($filename)) self::sendError('Generierte PDF-Datei nicht gefunden.'); + + $outputFilename = "Fehlerprotokoll_" . preg_replace('/[^a-zA-Z0-9_-]/', '_', $campaign->name) . ".pdf"; + header('Content-Type: application/pdf'); + header('Content-Disposition: inline; filename="' . $outputFilename . '"'); + header('Content-Length: ' . filesize($filename)); + readfile($filename); + unlink($filename); + exit; + } } diff --git a/application/Preorder/PreorderModel.php b/application/Preorder/PreorderModel.php index 32321710e..4bcf95cbc 100644 --- a/application/Preorder/PreorderModel.php +++ b/application/Preorder/PreorderModel.php @@ -1357,7 +1357,7 @@ ORDER BY $sql = " SELECT h.id AS hausnummer_id, h.gps_lat, h.gps_long, h.rimo_type, h.rimo_op_state, h.rimo_ex_state, h.hausnummer, - s.name AS strasse_name, plz.plz AS plz_name, o.name AS ortschaft_name, + s.name AS strasse_name, plz.plz AS plz_name, o.name AS ortschaft_name, h.rimo_id, COUNT(DISTINCT we.id) AS wohneinheit_count, COUNT(DISTINCT ps.id) AS preorder_count FROM `{$addressDbName}`.`Hausnummer` AS h @@ -1379,4 +1379,38 @@ ORDER BY $result = $db->query($sql); return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; } + + public static function getPreorderRimoFaultsData(int $campaignId): array { + $db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME); + $fronkDbName = defined('FRONKDB_DBNAME') ? FRONKDB_DBNAME : 'thetool'; + $addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb'; + $safeCampaignId = (int)$campaignId; + + $sql = " +SELECT + h.id AS hausnummer_id, h.rimo_id as extref, h.gps_lat, h.gps_long, h.rimo_type, + h.rimo_op_state, h.rimo_ex_state, h.hausnummer, + s.name AS strasse_name, + plz.plz AS plz_name, + o.name AS ortschaft_name, + g.name as gemeinde_name, + COUNT(DISTINCT we.id) AS wohneinheit_count +FROM `{$addressDbName}`.`Hausnummer` AS h +LEFT JOIN `{$addressDbName}`.`Wohneinheit` AS we ON h.id = we.hausnummer_id +LEFT JOIN `{$addressDbName}`.`Strasse` AS s ON h.strasse_id = s.id +LEFT JOIN `{$addressDbName}`.`Gemeinde` AS g ON s.gemeinde_id = g.id +LEFT JOIN `{$addressDbName}`.`Plz` AS plz ON h.plz_id = plz.id +LEFT JOIN `{$addressDbName}`.`Ortschaft` AS o ON h.ortschaft_id = o.id +WHERE h.netzgebiet_id = ( + SELECT n.adb_netzgebiet_id FROM `{$fronkDbName}`.`Preordercampaign` pc + JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id + WHERE pc.id = {$safeCampaignId} + ) AND h.gps_lat IS NOT NULL AND h.gps_long IS NOT NULL +GROUP BY h.id +ORDER BY s.name, h.hausnummer +"; + + $result = $db->query($sql); + return $result ? $result->fetch_all(MYSQLI_ASSOC) : []; + } } diff --git a/db/migrations/20250917103000_adb_add_fcp_index.php b/db/migrations/20250917103000_adb_add_fcp_index.php new file mode 100644 index 000000000..8348c9dc2 --- /dev/null +++ b/db/migrations/20250917103000_adb_add_fcp_index.php @@ -0,0 +1,23 @@ +getEnvironment() == 'addressdb') + { + $this->execute("ALTER TABLE `RimoFcp` ADD INDEX `idx_name` (`name`);"); + } + } + + public function down(): void + { + if ($this->getEnvironment() == 'addressdb') { + $this->execute("ALTER TABLE `RimoFcp` DROP INDEX `idx_name`;"); + } + } +} diff --git a/db/migrations/20250917103000_modify_preordercampaign.php b/db/migrations/20250917103000_modify_preordercampaign.php new file mode 100644 index 000000000..7f56ff405 --- /dev/null +++ b/db/migrations/20250917103000_modify_preordercampaign.php @@ -0,0 +1,27 @@ +getEnvironment() !== 'thetool') return; + + $sql = <<execute($sql); + } + + public function down(): void { + if ($this->getEnvironment() !== 'thetool') return; + + $this->table('Preordercampaign') + ->removeColumn('rimo_type_map_faults') + ->save(); + } +} \ No newline at end of file diff --git a/lib/TTCrud/TTCrud.php b/lib/TTCrud/TTCrud.php index e8f74e82c..0046ca7a0 100644 --- a/lib/TTCrud/TTCrud.php +++ b/lib/TTCrud/TTCrud.php @@ -95,6 +95,7 @@ class TTCrud extends mfBaseController { * @return array */ protected function getCheckArray(): array { + if (!$this->columns || count($this->columns) === 0) return []; $checkArray = []; foreach ($this->columns as $column) { diff --git a/public/cssbundler.php b/public/cssbundler.php index 79bdf2724..c39a62fa1 100644 --- a/public/cssbundler.php +++ b/public/cssbundler.php @@ -47,6 +47,7 @@ $cssFiles = [ 'plugins/vue/tt-components/css/tt-switch.css', 'plugins/vue/tt-components/css/tt-file-gallery.css', 'plugins/vue/tt-components/css/tt-position-manager.css', + 'plugins/vue/tt-components/css/tt-map.css', ]; // Output the combined and minified CSS diff --git a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css index d48227030..1b487c45b 100644 --- a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css +++ b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css @@ -1,22 +1,64 @@ -.preorder-map-container { - height: 100%; - width: 100%; +.map-filter-container, +.map-legend-container { + padding: 0.5rem 0.75rem; + background-color: rgba(255, 255, 255, 0.9); + border: 1px solid rgba(0,0,0,0.1); + border-radius: 6px; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); } .map-filter-container { display: flex; gap: 0.5rem; - padding: 0.5rem 1rem; - background-color: #f8f9fa; - border-bottom: 1px solid #dee2e6; flex-wrap: wrap; + align-items: center; +} + +.map-filter-container h6 { + line-height: 1; /* Align header text with buttons */ } .map-filter-container .btn { - transition: opacity 0.15s ease-in-out; + transition: all 0.15s ease-in-out; } .map-filter-container .btn:hover { - opacity: 0.85; + transform: translateY(-1px); + opacity: 0.9; +} + +.map-filter-container .filter-separator { + width: 1px; + height: 20px; + background-color: #ccc; + margin: 0 0.25rem; +} + +/* Move legend to the left to make space for the logo */ +.tt-map-bottom-controls { + bottom: 90px !important; +} + +/* Style for the custom logo control container */ +.leaflet-control-logo { + background-color: rgba(255, 255, 255, 0.7); + padding: 2px 5px; + border-radius: 5px; + box-shadow: 0 1px 5px rgba(0,0,0,0.4); +} +.leaflet-control-logo img { + display: block; +} + +.map-legend-container { + font-size: 0.8rem; + line-height: 1.4; +} + +.map-legend-container h6 { + margin: 0 0 0.25rem 0; + font-weight: bold; + border-bottom: 1px solid #ddd; + padding-bottom: 0.25rem; } .marker-label { @@ -26,7 +68,6 @@ padding: 0 !important; z-index: 1000; pointer-events: none; - transition: visibility 0.2s, opacity 0.2s linear; } .tooltip-content-wrapper { @@ -77,28 +118,54 @@ div.leaflet-marker-icon.custom-div-icon { } .fcp-marker { + position: relative; display: flex; justify-content: center; align-items: center; - width: 100%; - height: 100%; + width: 30px; + height: 30px; background-color: #ffc107; - color: #333; - font-size: 11px; + color: #212529; + font-size: 10px; font-weight: bold; border-radius: 50%; - border: 2px solid white; - box-shadow: 0 0 5px rgba(0, 0, 0, 0.4); + border: 3px solid white; + box-shadow: 0 4px 8px rgba(0,0,0,0.3); } -/* --- Styles for FCP Popup --- */ -.fcp-popup-content { - font-family: Arial, sans-serif; +.fcp-marker::after { + content: ''; + position: absolute; + bottom: -10px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 12px solid #ffc107; + filter: drop-shadow(0 4px 2px rgba(0,0,0,0.3)); +} + +/* --- Fault Indicator on Marker --- */ +.marker-has-fault .rimo-marker { + animation: pulse-red 2s infinite; +} + +@keyframes pulse-red { + 0% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7); } + 70% { box-shadow: 0 0 0 10px rgba(220, 53, 69, 0); } + 100% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0); } +} + +/* --- Styles for Building & FCP Popups --- */ +.building-popup-content, .fcp-popup-content { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; width: 320px; font-size: 0.8rem; line-height: 1.4; } -.fcp-popup-content h5 { +.building-popup-content h5, .fcp-popup-content h5 { margin-top: 0; margin-bottom: 10px; color: #333; @@ -151,6 +218,47 @@ div.leaflet-marker-icon.custom-div-icon { text-align: center; } +/* --- Fault Reporting Specific Styles --- */ +.fault-indicator-popup { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; + border-radius: 4px; + padding: 0.5rem 0.75rem; + font-size: 0.8rem; + margin-top: 0.5rem; +} +.fault-indicator-popup ul { + margin: 0.5rem 0 0 1rem; + padding: 0; +} +.fault-reporting-form h6 { + font-size: 0.9rem; + font-weight: bold; + margin-bottom: 0.5rem; +} +.fault-reporting-form label { + display: block; + margin-bottom: 0.5rem; + font-weight: normal; + cursor: pointer; +} +.fault-reporting-form input[type="checkbox"] { + margin-right: 0.5rem; +} +.fault-reporting-form .fault-other-textarea { + width: 100%; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + border-radius: 4px; + border: 1px solid #ccc; + margin-top: 0.25rem; +} +.fault-reporting-form .fault-other-textarea.hidden { + display: none; +} + + /* RIMO Marker Colors */ .marker-greenfield { background-color: #28a745; } .marker-residential { background-color: #007bff; } diff --git a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js index e5d31483e..a43625492 100644 --- a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js +++ b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js @@ -1,234 +1,258 @@ Vue.component('PreorderRimoTypeMap', { - data() { - return { - mapMarkers: [], - fcpMarkers: [], - isLoading: false, - window, - fetchUrl: window.TT_CONFIG.BASE_PATH + '/Preorder/RimoTypeMapData', - selectedCampaign: null, - mapConfig: { - clusterOptions: { - spiderfyOnMaxZoom: false, - disableClusteringAtZoom: 17, - } - }, - mapInstance: null, - activeFilters: [], - rimoTypeDefs: { - greenfield: { text: 'Greenfield', icon: 'fas fa-tree', color: '#28a745' }, - residential: { text: 'Wohngebiet', icon: 'fas fa-home', color: '#007bff' }, - company: { text: 'Gewerbe', icon: 'fas fa-building', color: '#ffc107' }, - 'multiple-dwelling': { text: 'Mehrfamilienhaus', icon: 'fas fa-city', color: '#6f42c1' }, - public: { text: 'Öffentlich', icon: 'fas fa-school', color: '#17a2b8' }, - other: { text: 'Andere', icon: 'fas fa-question-circle', color: '#6c757d' } + data: () => ({ + rawRimoData: [], + mapMarkers: [], + fcpMarkers: [], + faults: {}, + isLoading: false, + window, + fetchUrl: window.TT_CONFIG.BASE_PATH + '/Preorder/RimoTypeMapData', + selectedCampaign: null, + mapConfig: { + clusterOptions: { + spiderfyOnMaxZoom: false, + disableClusteringAtZoom: 17, } - }; - }, + }, + activeFilters: [], + showOnlyFaults: false, + showFcps: true, + showFaultsModal: false, + logoControlAdded: false, + userIdToNameMap: new Map(), + faultReasons: [ + { value: 'building_type', text: 'Gebäudetyp ist falsch' }, + { value: 'home_count', text: 'Anzahl der Wohneinheiten ist falsch' }, + { value: 'not_existent', text: 'Gebäude existiert nicht' }, + { value: 'other', text: 'Sonstiges' } + ], + rimoTypeDefs: { + greenfield: { text: 'Greenfield', icon: 'fas fa-tree', color: '#28a745' }, + residential: { text: 'Wohngebiet', icon: 'fas fa-home', color: '#007bff' }, + company: { text: 'Gewerbe', icon: 'fas fa-building', color: '#ffc107' }, + 'multiple-dwelling': { text: 'Mehrfamilienhaus', icon: 'fas fa-city', color: '#6f42c1' }, + public: { text: 'Öffentlich', icon: 'fas fa-school', color: '#17a2b8' }, + other: { text: 'Andere', icon: 'fas fa-question-circle', color: '#6c757d' } + } + }), computed: { filterOptions() { - return Object.entries(this.rimoTypeDefs).map(([value, defs]) => ({ - value, - text: defs.text, - icon: defs.icon - })); + return Object.entries(this.rimoTypeDefs).map(([value, defs]) => ({ value, ...defs })); }, filteredMapMarkers() { - const rimoMarkers = this.activeFilters.length === 0 - ? this.mapMarkers - : this.mapMarkers.filter(marker => this.activeFilters.includes(marker.rimoType)); + let rimoMarkers = this.mapMarkers; - return [...rimoMarkers, ...this.fcpMarkers]; + if (this.showOnlyFaults) + rimoMarkers = rimoMarkers.filter(marker => { + const fault = this.faults[marker.hausnummerId]; + return fault && !fault.done; + }); + + if (this.activeFilters.length > 0) + rimoMarkers = rimoMarkers.filter(marker => this.activeFilters.includes(marker.rimoType)); + + return this.showFcps ? [...rimoMarkers, ...this.fcpMarkers] : rimoMarkers; + }, + faultsForModal() { + if (!this.rawRimoData.length) return []; + + return Object.entries(this.faults) + .map(([hausnummerId, faultData]) => { + const rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId); + if (!rimoItem) return null; + + return { + ...faultData, + hausnummerId, + rimo_id: rimoItem.rimo_id, + address: `${rimoItem.strasse_name} ${rimoItem.hausnummer}, ${rimoItem.plz_name} ${rimoItem.ortschaft_name}`, + translated_reasons: faultData.reasons.map(r => this.faultReasons.find(fr => fr.value === r)?.text || r), + done_by_user: this.userIdToNameMap.get(String(faultData.done_by)) || `User #${faultData.done_by}` + }; + }) + .filter(Boolean) + .sort((a, b) => { + if (a.done && !b.done) return 1; + if (!a.done && b.done) return -1; + return a.address.localeCompare(b.address); + }); } }, async created() { const urlParams = new URLSearchParams(window.location.search); this.selectedCampaign = urlParams.get('preordercampaign_id'); - if (this.selectedCampaign) { - await this.fetchAllMapData(); - } + if (this.selectedCampaign) await this.fetchAllMapData(); }, mounted() { - this.$nextTick(() => { - const ttMapComponent = this.$refs.ttMap; - if (ttMapComponent && ttMapComponent.map) { - this.mapInstance = ttMapComponent.map; - this.mapInstance.on('zoomend', this.checkZoomLevel); - this.checkZoomLevel(); - } - }); + if (window.TT_CONFIG?.ALL_USERS) { + window.TT_CONFIG.ALL_USERS.forEach(user => this.userIdToNameMap.set(String(user.id), user.name)); + } + const storedShowFcps = localStorage.getItem('rimoMapShowFcps'); + this.showFcps = storedShowFcps !== null ? JSON.parse(storedShowFcps) : true; + window.updateMapFault = this.handleFaultUpdate.bind(this); + window.saveMapFaults = this.saveFaults.bind(this); }, beforeDestroy() { - if (this.mapInstance) { - this.mapInstance.off('zoomend', this.checkZoomLevel); - } + delete window.updateMapFault; + delete window.saveMapFaults; }, methods: { + addLogoToMap() { + if (this.$refs.ttMap?.map && !this.logoControlAdded) { + const LogoControl = L.Control.extend({ + onAdd: function(map) { + const container = L.DomUtil.create('div', 'leaflet-control-logo'); + container.innerHTML = ``; + L.DomEvent.disableClickPropagation(container); + return container; + } + }); + new LogoControl({ position: 'bottomright' }).addTo(this.$refs.ttMap.map); + this.logoControlAdded = true; + } + }, async fetchAllMapData() { if (!this.selectedCampaign) return; this.isLoading = true; this.mapMarkers = []; this.fcpMarkers = []; + try { - await Promise.all([ - this.fetchRimoData(), - this.fetchFCPData() - ]); - } catch (err) { - console.error("Failed to load map data:", err); + await this.fetchFaultData(); + await Promise.all([this.fetchRimoData(), this.fetchFCPData()]); } finally { this.isLoading = false; + this.$nextTick(() => this.addLogoToMap()); + } + }, + async fetchFaultData() { + const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Preorder/RimoTypeMapGetFaults`, { + params: { preordercampaign_id: this.selectedCampaign } + }); + if (response.data.success) { + this.faults = response.data.faults && typeof response.data.faults === 'object' && !Array.isArray(response.data.faults) ? response.data.faults : {}; } }, - async fetchRimoData() { - try { - const response = await axios.post(this.fetchUrl, { campaignId: this.selectedCampaign }); - if (response.data.success && Array.isArray(response.data.data)) { - this.mapMarkers = this.processData(response.data.data); - } else { - window.notify('error', 'Ungültiges RIMO-Datenformat von der API empfangen.'); - } - } catch (err) { - window.notify('error', 'Laden der RIMO-Kartendaten fehlgeschlagen.'); - throw err; - } - }, - - async fetchFCPData() { - try { - // Step 1: Fetch FCP locations - const fcpLocationUrl = `${window.TT_CONFIG.BASE_PATH}/Preorder/Api?do=getFCPsForCampaign&campaign_id=${this.selectedCampaign}`; - const fcpResponse = await axios.get(fcpLocationUrl); - - if (fcpResponse.data.status !== "OK" || !fcpResponse.data.result?.length) { - console.warn('No FCP locations found or API error.'); - return; - } - const fcpLocations = fcpResponse.data.result; - - // Step 2: Fetch FCP stats using the IDs from the first call - const fcpIds = fcpLocations.map(fcp => fcp.real_id); - const statsUrl = `${window.TT_CONFIG.BASE_PATH}/Preorder/Api?do=getRimoFcpStats`; - const statsResponse = await axios.post(statsUrl, { fcp_ids: fcpIds }); - const fcpStats = statsResponse.data.status === "OK" ? statsResponse.data.result : []; - const statsMap = new Map(fcpStats.map(s => [s.fcp_id, s])); - - // Step 3: Create markers with detailed popup content - this.fcpMarkers = fcpLocations.map(fcp => { - const stat = statsMap.get(String(fcp.real_id)); - return { - lat: fcp.lat, - lng: fcp.lng, - options: { - icon: { - className: 'custom-div-icon', - html: `
${fcp.text}
`, - iconSize: [28, 28], - iconAnchor: [14, 14], - }, - asyncPopupContent: () => this.generateFcpPopupHtml(fcp, stat), - zIndexOffset: 500 - }, - }; - }); - } catch (err) { - window.notify('warning', 'Laden der FCP-Daten fehlgeschlagen. Die Karte wird ohne sie angezeigt.'); - console.error("FCP data fetch failed:", err); - } - }, - - generateFcpPopupHtml(fcp, fcpStat) { - const googleMapsLink = `https://www.google.com/maps/search/?api=1&query=${fcp.lat},${fcp.lng}`; - let statsHtml; - - if (!fcpStat) { - statsHtml = `

Keine Statistiken für diesen FCP gefunden.

`; + const response = await axios.post(this.fetchUrl, { campaignId: this.selectedCampaign }); + if (response.data.success && Array.isArray(response.data.data)) { + this.rawRimoData = response.data.data; + this.mapMarkers = this.processData(this.rawRimoData); } else { - const tableRows = Object.entries(fcpStat.counts_by_rimo_type || {}) - .map(([type, counts]) => { - const normalizedType = this.getNormalizedRimoType(type); - const typeDef = this.rimoTypeDefs[normalizedType] || this.rimoTypeDefs.other; - const typeDisplay = `${typeDef.text}`; - return ` - - ${typeDisplay} - ${counts.hausnummer_count} - ${counts.wohneinheit_count} - ${counts.preorder_count} - `; - }).join(''); - - const table = tableRows ? ` - - - - - - - - - - ${tableRows} -
TypGEBWEBE
` : '

Keine Detail-Statistiken verfügbar.

'; - - statsHtml = ` -
- Zusammenfassung - Gebäude: ${fcpStat.total_hausnummer_count}
- Wohneinheiten: ${fcpStat.total_wohneinheit_count}
- Bestellungen: ${fcpStat.total_active_preorders} -
- ${table}`; + window.notify('error', 'Ungültiges RIMO-Datenformat von der API empfangen.'); } - - return ` -
-
FCP: ${fcp.text}
- - In Google Maps anzeigen - - ${statsHtml} -
`; }, + async fetchFCPData() { + const fcpLocationUrl = `${window.TT_CONFIG.BASE_PATH}/Preorder/Api?do=getFCPsForCampaign&campaign_id=${this.selectedCampaign}`; + const fcpResponse = await axios.get(fcpLocationUrl); + if (fcpResponse.data.status !== "OK" || !fcpResponse.data.result?.length) return; + + const fcpLocations = fcpResponse.data.result; + const fcpIds = fcpLocations.map(fcp => fcp.real_id); + if (fcpIds.length === 0) return; + + const statsUrl = `${window.TT_CONFIG.BASE_PATH}/Preorder/Api?do=getRimoFcpStats`; + const statsResponse = await axios.post(statsUrl, { fcp_ids: fcpIds }); + const fcpStats = statsResponse.data.status === "OK" ? statsResponse.data.result : []; + const statsMap = new Map(fcpStats.map(s => [s.fcp_id, s])); + + this.fcpMarkers = fcpLocations.map(fcp => ({ + lat: fcp.lat, + lng: fcp.lng, + options: { + noCluster: true, + icon: { + className: 'custom-div-icon', + html: `
${fcp.text}
`, + iconSize: [30, 42], + iconAnchor: [15, 42], + }, + asyncPopupContent: () => this.generateFcpPopupHtml(fcp, statsMap.get(fcp.real_id)) + }, + })); + }, + _generateFcpStatsTable(fcpStat) { + if (!fcpStat?.counts_by_rimo_type) return '

Keine Detail-Statistiken verfügbar.

'; + + const tableRows = Object.entries(fcpStat.counts_by_rimo_type) + .map(([type, counts]) => { + const normalizedType = this.getNormalizedRimoType(type); + const typeDef = this.rimoTypeDefs[normalizedType] || this.rimoTypeDefs.other; + const typeDisplay = `${typeDef.text}`; + return ` + ${typeDisplay} + ${counts.hausnummer_count} + ${counts.wohneinheit_count} + ${counts.preorder_count} + `; + }).join(''); + + if (!tableRows) return '

Keine Detail-Statistiken verfügbar.

'; + + return ` + + + + + + + + + ${tableRows} +
TypGEBWEBE
`; + }, + generateFcpPopupHtml(fcp, fcpStat) { + const googleMapsLink = `http://googleusercontent.com/maps.google.com/4{fcp.lat},${fcp.lng}`; + const summaryHtml = fcpStat ? + `Gebäude: ${fcpStat.total_hausnummer_count}
+ Wohneinheiten: ${fcpStat.total_wohneinheit_count}
+ Bestellungen: ${fcpStat.total_active_preorders}` : + 'Keine Statistiken für diesen FCP gefunden.'; + + return `
+
FCP: ${fcp.text}
+ In Google Maps anzeigen +
+ Zusammenfassung + ${summaryHtml} +
+ ${this._generateFcpStatsTable(fcpStat)} +
`; + }, processData(data) { const groupedData = {}; - data.forEach(item => { + if (!item) return; const latLngKey = `${item.gps_lat},${item.gps_long}`; if (!groupedData[latLngKey]) { - groupedData[latLngKey] = { - ...item, - wohneinheit_count: parseInt(item.wohneinheit_count, 10) || 0, - preorder_count: parseInt(item.preorder_count, 10) || 0, - original_items: [item], - }; - } else { - groupedData[latLngKey].wohneinheit_count += parseInt(item.wohneinheit_count, 10) || 0; - groupedData[latLngKey].preorder_count += parseInt(item.preorder_count, 10) || 0; - groupedData[latLngKey].original_items.push(item); + groupedData[latLngKey] = { ...item, wohneinheit_count: 0, preorder_count: 0, original_items: [] }; } + groupedData[latLngKey].wohneinheit_count += parseInt(item.wohneinheit_count, 10) || 0; + groupedData[latLngKey].preorder_count += parseInt(item.preorder_count, 10) || 0; + groupedData[latLngKey].original_items.push(item); }); return Object.values(groupedData).map(group => { const rimoType = this.getNormalizedRimoType(group.rimo_type); const markerIcon = this.getMarkerIcon(rimoType); + const fault = this.faults[group.hausnummer_id]; + const hasFault = fault && !fault.done; let tooltipInnerClass = ''; - if (rimoType !== 'greenfield' && group.wohneinheit_count > 0 && group.wohneinheit_count === group.preorder_count) { + if (rimoType !== 'greenfield' && group.wohneinheit_count > 0 && group.wohneinheit_count === group.preorder_count) tooltipInnerClass = ' marker-label-saturated'; - } else if (rimoType === 'greenfield' && group.preorder_count > 0) { + else if (rimoType === 'greenfield' && group.preorder_count > 0) tooltipInnerClass = ' marker-label-highlight'; - } return { lat: group.gps_lat, lng: group.gps_long, - rimoType: rimoType, + rimoType, + hausnummerId: group.hausnummer_id, options: { icon: { - className: `custom-div-icon marker-${rimoType}`, + className: `custom-div-icon marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''}`, html: `
`, iconSize: [30, 30], iconAnchor: [15, 30], @@ -238,122 +262,273 @@ Vue.component('PreorderRimoTypeMap', { direction: 'bottom', className: 'marker-label', permanent: true, + minZoom: 18 }, - asyncPopupContent: async () => { - let content = `
`; - group.original_items.forEach(item => { - content += ` -
-
${item.strasse_name} ${item.hausnummer}, ${item.plz_name} ${item.ortschaft_name}
- Rimo Type: ${item.rimo_type || 'N/A'}
- Rimo Op State: ${item.rimo_op_state || 'N/A'}
- Rimo Ex State: ${item.rimo_ex_state || 'N/A'}
- Wohn. gesamt: ${item.wohneinheit_count}
- Bestellungen: ${item.preorder_count}
- Koordinaten: - - Karte - - - AddressDB - -

`; - }); - return content.slice(0, -16) + `
`; - }, + asyncPopupContent: async () => this.generateBuildingPopupHtml(group), }, }; }); }, - - getNormalizedRimoType(type) { - const lowerType = (type || '').toLowerCase(); - if (lowerType.includes('2/3')) return 'residential'; - if (lowerType.includes('greenfield')) return 'greenfield'; - if (lowerType.includes('residential')) return 'residential'; - if (lowerType.includes('multiple dwelling') || lowerType.includes('multiple dwellings')) return 'multiple-dwelling'; - if (lowerType.includes('company') || lowerType.includes('commercial')) return 'company'; - if (lowerType.includes('public') || lowerType.includes('school')) return 'public'; - if (lowerType.includes('unknown')) return 'other'; - return 'other'; + _generateBuildingDetailsHtml(itemGroup) { + return itemGroup.original_items.map(item => { + const googleMapsLink = `http://googleusercontent.com/maps/contrib/117320308544975743452/reviews/@${item.gps_lat},${item.gps_long}`; + const addressDbLink = `https://thetool.xinon.at/AddressDB/View?id=${item.hausnummer_id}`; + return `
+
${item.strasse_name} ${item.hausnummer}, ${item.plz_name} ${item.ortschaft_name}
+ Rimo Type: ${item.rimo_type || 'N/A'}
+ Rimo Op State: ${item.rimo_op_state || 'N/A'}
+ Rimo Ex State: ${item.rimo_ex_state || 'N/A'}
+ Wohneinheiten gesamt: ${item.wohneinheit_count}
+ Bestellungen: ${item.preorder_count}
+ Links: + Karte + AddressDB +
`; + }).join('
'); }, + _generateBuildingFaultDisplayHtml(faultData) { + if (!faultData.reasons?.length && !faultData.other) return ''; - getMarkerIcon(rimoType) { - const def = this.rimoTypeDefs[rimoType] || this.rimoTypeDefs.other; - return { - class: `marker-${rimoType}`, - icon: def.icon, - }; + const reasonsList = faultData.reasons.map(r => `
  • ${this.faultReasons.find(fr => fr.value === r)?.text || r}
  • `).join(''); + const otherText = faultData.other ? `
  • Sonstiges: ${faultData.other}
  • ` : ''; + + return `
    + Gemeldeter Fehler: +
      ${reasonsList}${otherText}
    +
    `; }, + _generateBuildingFaultFormHtml(itemGroup, faultData) { + const formInputs = this.faultReasons.map(reason => { + const isChecked = faultData.reasons.includes(reason.value); + const otherInput = (reason.value === 'other') ? + `` : + ''; - checkZoomLevel() { - if (!this.mapInstance) return; - const currentZoom = this.mapInstance.getZoom(); - const minZoomForLabel = 16; - const visibility = currentZoom >= minZoomForLabel ? 'visible' : 'hidden'; - const opacity = currentZoom >= minZoomForLabel ? '1' : '0'; + return ` + ${otherInput}`; + }).join(''); - document.querySelectorAll('.marker-label').forEach(el => { - el.style.visibility = visibility; - el.style.opacity = opacity; - }); + return `
    Fehler melden/bearbeiten:
    + ${formInputs} + `; }, + generateBuildingPopupHtml(itemGroup) { + const faultData = this.faults[itemGroup.hausnummer_id] || { reasons: [], other: '' }; + const detailsHtml = this._generateBuildingDetailsHtml(itemGroup); + const faultFormHtml = this._generateBuildingFaultFormHtml(itemGroup, faultData); + const faultDisplayHtml = faultData.done ? + `
    Dieser Fehler wurde von ${this.userIdToNameMap.get(String(faultData.done_by)) || `User #${faultData.done_by}`} am ${new Date(faultData.done_at).toLocaleDateString()} als erledigt markiert.
    ` : + this._generateBuildingFaultDisplayHtml(faultData); - toggleFilter(filterValue) { - const index = this.activeFilters.indexOf(filterValue); - if (index > -1) { - this.activeFilters.splice(index, 1); - } else { - this.activeFilters.push(filterValue); + return `
    + ${detailsHtml} + ${faultDisplayHtml} +
    +
    ${faultFormHtml}
    +
    `; + }, + handleFaultUpdate(hausnummerId, reason, value, from_mark_as_done = false) { + if (!this.faults[hausnummerId]) + this.$set(this.faults, hausnummerId, { reasons: [], other: '', done: false }); + else if (this.faults[hausnummerId].done && !from_mark_as_done) + this.$set(this.faults, hausnummerId, { reasons: [], other: '', done: false }); + + + const fault = this.faults[hausnummerId]; + + if (reason === 'other_text') { + fault.other = value; + } else if (reason) { + const index = fault.reasons.indexOf(reason); + if (value && index === -1) fault.reasons.push(reason); + else if (!value && index > -1) fault.reasons.splice(index, 1); + + const popup = this.$refs.ttMap?.map?._popup; + if (popup?.isOpen()) { + const otherTextarea = popup.getElement().querySelector('.fault-other-textarea'); + if (otherTextarea) otherTextarea.classList.toggle('hidden', !fault.reasons.includes('other')); + } + } + + const markerInstance = this.$refs.ttMap.markerLayer.getLayers().find(m => m.tt_hausnummerId == hausnummerId); + if (markerInstance?.getElement) { + const hasOpenFault = fault && !fault.done && (fault.reasons.length > 0 || fault.other); + markerInstance.getElement().classList.toggle('marker-has-fault', hasOpenFault); } }, + async saveFaults() { + const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Preorder/RimoTypeMapSaveFaults`, { + campaignId: this.selectedCampaign, + faults: this.faults + }); + if (response.data.success) { + window.notify('success', 'Fehlerbericht gespeichert.'); + this.$refs.ttMap?.map.closePopup(); + } else { + window.notify('error', 'Fehlerbericht konnte nicht gespeichert werden.'); + } + }, + async markFaultAsDone(hausnummerId) { + if (!hausnummerId || !this.faults[hausnummerId]) return; + this.$set(this.faults, hausnummerId, { + ...this.faults[hausnummerId], + done: true, + done_by: window.TT_CONFIG.USER_ID, + done_at: new Date().toISOString() + }); + + await this.saveFaults(); + this.handleFaultUpdate(hausnummerId, null, null, true); + }, + zoomToFaultMarker(hausnummerId) { + const map = this.$refs.ttMap?.map; + const markerLayer = this.$refs.ttMap?.markerLayer; + + if (!map || !markerLayer) return window.notify('error', 'Kartenkomponente ist nicht bereit.'); + + const markerInstance = markerLayer.getLayers().find(m => m.tt_hausnummerId == hausnummerId); + if (!markerInstance) return window.notify('warning', 'Marker konnte nicht auf der Karte gefunden werden.'); + + this.showFaultsModal = false; + map.flyTo(markerInstance.getLatLng(), 19, { duration: 1 }); + + setTimeout(() => markerLayer.zoomToShowLayer(markerInstance, () => markerInstance.openPopup()), 1100); + }, + toggleShowFcps() { + this.showFcps = !this.showFcps; + localStorage.setItem('rimoMapShowFcps', JSON.stringify(this.showFcps)); + }, + getNormalizedRimoType(type) { + const lowerType = (type || '').toLowerCase(); + if (lowerType.includes('greenfield')) return 'greenfield'; + if (lowerType.includes('residential') || lowerType.includes('2/3')) return 'residential'; + if (lowerType.includes('multiple dwelling')) return 'multiple-dwelling'; + if (lowerType.includes('company') || lowerType.includes('commercial')) return 'company'; + if (lowerType.includes('public') || lowerType.includes('school')) return 'public'; + return 'other'; + }, + getMarkerIcon(rimoType) { + const def = this.rimoTypeDefs[rimoType] || this.rimoTypeDefs.other; + return { class: `marker-${rimoType}`, icon: def.icon }; + }, + toggleFilter(filterValue) { + const index = this.activeFilters.indexOf(filterValue); + if (index > -1) this.activeFilters.splice(index, 1); + else this.activeFilters.push(filterValue); + }, isFilterActive(filterValue) { return this.activeFilters.includes(filterValue); }, - - /** - * Generates the style for a filter button based on its state (active/inactive). - */ getFilterButtonStyle(filterValue) { const color = this.rimoTypeDefs[filterValue]?.color || '#6c757d'; - if (this.isFilterActive(filterValue)) { - return { - backgroundColor: color, - borderColor: color, - color: 'white', - }; - } - return { - color: color, - backgroundColor: 'white', - borderColor: color, - }; + return this.isFilterActive(filterValue) ? + { backgroundColor: color, borderColor: color, color: 'white' } : + { color: color, backgroundColor: 'white', borderColor: color }; }, }, template: ` - +
    Bitte eine Kampagne über den URL-Parameter 'preordercampaign_id' auswählen (z.B. ?preordercampaign_id=44).
    - - +
    + ` }); \ No newline at end of file diff --git a/public/plugins/vue/tt-components/css/tt-map.css b/public/plugins/vue/tt-components/css/tt-map.css new file mode 100644 index 000000000..61b021f8c --- /dev/null +++ b/public/plugins/vue/tt-components/css/tt-map.css @@ -0,0 +1,77 @@ +/* tt-map.css */ +.tt-map-wrapper { + position: relative; + width: 100%; + height: 100%; + background-color: #f0f0f0; /* Placeholder color while map loads */ +} + +.tt-map-container { + width: 100%; + height: 100%; + z-index: 1; +} + +.tt-map-loader { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 1001; + background: rgba(255, 255, 255, 0.7); + display: flex; + justify-content: center; + align-items: center; +} + +/* Slot for top controls like filters */ +.tt-map-top-controls { + position: absolute; + top: 10px; + left: 55px; /* Moved to avoid overlapping with zoom controls */ + z-index: 401; +} + +/* Slot for bottom controls like legend */ +.tt-map-bottom-controls { + position: absolute; + bottom: 30px; + right: 10px; + z-index: 401; +} + +/* Container for built-in map buttons (zoom, layer toggle) */ +.tt-map-builtin-controls { + position: absolute; + top: 10px; + right: 10px; + z-index: 401; + display: flex; + gap: 0.5rem; + flex-direction: column; + align-items: flex-end; +} + +.tt-map-builtin-controls .btn { + min-width: 120px; + text-align: left; + box-shadow: 0 1px 5px rgba(0,0,0,0.4); +} + +.tt-map-settings-panel { + background-color: white; + padding: 0.75rem; + border-radius: 5px; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); + border: 1px solid rgba(0,0,0,0.1); +} + +.tt-map-settings-panel .btn-group .btn { + font-size: 0.8rem; +} + +.popup-loader { + text-align: center; + padding: 10px; +} \ No newline at end of file diff --git a/public/plugins/vue/tt-components/tt-map.js b/public/plugins/vue/tt-components/tt-map.js index 3fdd93aa9..0c6578523 100644 --- a/public/plugins/vue/tt-components/tt-map.js +++ b/public/plugins/vue/tt-components/tt-map.js @@ -2,7 +2,7 @@ Vue.component('tt-map', { props: { markersData: { type: Array, - default: () => [] // Expecting [{ lat: Number, lng: Number, options: { maki?: Object, popup?: String, asyncPopupContent?: Function, icon?: Object, tooltip?: Object } }, ...] + default: () => [] // Expecting [{ lat: Number, lng: Number, options: { ..., noCluster?: Boolean, tooltip: { content: '...', minZoom: 18, ... } } }, ...] }, config: { type: Object, @@ -16,11 +16,17 @@ Vue.component('tt-map', { data() { return { map: null, - markerLayer: null, - tileLayers: { streets: null, satellite: null }, - mapType: localStorage.getItem('tt-map-type') || 'streets', // Default to 'streets' or stored preference + markerLayer: null, // For clustered markers + nonClusteredLayer: null, // For important, always-visible markers + tileLayers: { + mapbox: { streets: null, satellite: null }, + basemap: { streets: null, satellite: null } + }, + mapProvider: localStorage.getItem('tt-map-provider') || 'basemap', + mapType: localStorage.getItem('tt-map-type') || 'streets', internalLoading: true, scriptsLoaded: false, + showSettings: false, }; }, computed: { @@ -29,14 +35,17 @@ Vue.component('tt-map', { }, mapConfig() { const defaults = { - center: [46.9, 15.4995], - zoom: 11, + center: [47.07, 15.44], // Centered on Graz, Styria + zoom: 9, mapboxKey: window.TT_CONFIG?.MAPBOX_KEY, streetsTileUrl: 'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', streetsTileId: 'mapbox/streets-v11', satelliteTileUrl: 'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', satelliteTileId: 'mapbox/satellite-streets-v12', tileAttribution: '© Mapbox © OpenStreetMap', + basemapStreetsTileUrl: 'https://maps.wien.gv.at/basemap/geolandbasemap/normal/google3857/{z}/{y}/{x}.png', + basemapSatelliteTileUrl: 'https://maps.wien.gv.at/basemap/bmaporthofoto30cm/normal/google3857/{z}/{y}/{x}.jpeg', + basemapAttribution: 'Datenquelle: basemap.at, Stadt Wien - data.wien.gv.at', clusterOptions: {}, makiMarkerOptions: { icon: "marker", color: "#3b82f6", size: "m" } }; @@ -73,7 +82,12 @@ Vue.component('tt-map', { el.src = s.url; el.async = false; el.onload = resolve; el.onerror = reject; } else { el = document.createElement('link'); - el.rel = 'stylesheet'; el.href = s.url; el.onload = resolve; el.onerror = reject; + el.rel = 'stylesheet'; el.href = s.url; + el.onload = resolve; + el.onerror = () => { + console.warn(`Could not load stylesheet: ${s.url}`); + resolve(); + }; } document.head.appendChild(el); })); @@ -90,85 +104,145 @@ Vue.component('tt-map', { L.MakiMarkers.accessToken = this.mapConfig.mapboxKey; } - this.tileLayers.streets = L.tileLayer(this.mapConfig.streetsTileUrl, { - attribution: this.mapConfig.tileAttribution, maxZoom: 20, id: this.mapConfig.streetsTileId, - tileSize: 512, zoomOffset: -1, accessToken: this.mapConfig.mapboxKey - }); - this.tileLayers.satellite = L.tileLayer(this.mapConfig.satelliteTileUrl, { - attribution: this.mapConfig.tileAttribution, maxZoom: 20, id: this.mapConfig.satelliteTileId, - tileSize: 512, zoomOffset: -1, accessToken: this.mapConfig.mapboxKey - }); + this.map.createPane('fcpPane'); + this.map.getPane('fcpPane').style.zIndex = 599; // Default markerPane is 600 - this.tileLayers[this.mapType].addTo(this.map); + this.tileLayers.mapbox.streets = L.tileLayer(this.mapConfig.streetsTileUrl, { attribution: this.mapConfig.tileAttribution, maxZoom: 22, id: this.mapConfig.streetsTileId, tileSize: 512, zoomOffset: -1, accessToken: this.mapConfig.mapboxKey }); + this.tileLayers.mapbox.satellite = L.tileLayer(this.mapConfig.satelliteTileUrl, { attribution: this.mapConfig.tileAttribution, maxZoom: 22, id: this.mapConfig.satelliteTileId, tileSize: 512, zoomOffset: -1, accessToken: this.mapConfig.mapboxKey }); + this.tileLayers.basemap.streets = L.tileLayer(this.mapConfig.basemapStreetsTileUrl, { attribution: this.mapConfig.basemapAttribution, maxZoom: 20 }); + this.tileLayers.basemap.satellite = L.tileLayer(this.mapConfig.basemapSatelliteTileUrl, { attribution: this.mapConfig.basemapAttribution, maxZoom: 20 }); + this.setActiveTileLayer(); this.markerLayer = L.markerClusterGroup(this.mapConfig.clusterOptions); + this.nonClusteredLayer = L.layerGroup(); this.map.addLayer(this.markerLayer); + this.map.addLayer(this.nonClusteredLayer); - this.$nextTick(() => { - this.map.invalidateSize(); - }); + this.map.on('zoomend moveend', this.updateTooltipVisibility); + + this.$nextTick(() => { this.map.invalidateSize(); }); window.addEventListener('resize', this.handleResize); }, + setActiveTileLayer() { + if (!this.map) return; + Object.values(this.tileLayers).forEach(provider => { + Object.values(provider).forEach(layer => { + if (layer && this.map.hasLayer(layer)) { + this.map.removeLayer(layer); + } + }); + }); + + const activeLayer = this.tileLayers[this.mapProvider]?.[this.mapType]; + if (activeLayer) { + activeLayer.addTo(this.map); + } else { + this.tileLayers.mapbox.streets.addTo(this.map); + } + }, updateMarkers() { if (!this.map || !this.markerLayer || !this.scriptsLoaded) return; this.markerLayer.clearLayers(); - const markersToAdd = []; + this.nonClusteredLayer.clearLayers(); + const markersToCluster = []; + this.markersData.forEach(data => { - if (data.lat != null && data.lng != null) { - let icon; - if (data.options?.icon instanceof L.Icon) { - icon = data.options.icon; - } else if (data.options?.icon) { - icon = L.divIcon(data.options.icon); - } else if (typeof L.MakiMarkers !== 'undefined') { - const makiOptions = { ...this.mapConfig.makiMarkerOptions, ...(data.options?.maki || {}) }; - icon = L.MakiMarkers.icon(makiOptions); - } + if (data.lat == null || data.lng == null) return; - const marker = L.marker([data.lat, data.lng], { icon: icon }); + let icon; + if (data.options?.icon instanceof L.Icon) icon = data.options.icon; + else if (data.options?.icon) icon = L.divIcon(data.options.icon); + else if (typeof L.MakiMarkers !== 'undefined') { + const makiOptions = { ...this.mapConfig.makiMarkerOptions, ...(data.options?.maki || {}) }; + icon = L.MakiMarkers.icon(makiOptions); + } - if (data.options?.popup) { - marker.bindPopup(data.options.popup); - } else if (data.options?.asyncPopupContent && typeof data.options.asyncPopupContent === 'function') { - marker.bindPopup(() => ''); - marker.on('popupopen', async (e) => { - const popup = e.popup; - try { - const content = await data.options.asyncPopupContent(data); - popup.setContent(content); - } catch (error) { - console.error("Error loading popup content:", error); - popup.setContent('
    Failed to load content.
    '); - } - popup.update(); - }); - } + const markerOptions = { icon }; + if (data.options?.zIndexOffset) markerOptions.zIndexOffset = data.options.zIndexOffset; - if (data.options?.tooltip) { - // Check if it's an object with content and options, or just a string - if (typeof data.options.tooltip === 'object') { - const tooltipContent = data.options.tooltip.content; - const tooltipOptions = { ...data.options.tooltip }; - delete tooltipOptions.content; // Remove content to prevent conflicts - marker.bindTooltip(tooltipContent, tooltipOptions); - } else { - marker.bindTooltip(data.options.tooltip); - } - } + if (data.options?.noCluster) { + markerOptions.pane = 'fcpPane'; + } - markersToAdd.push(marker); + const marker = L.marker([data.lat, data.lng], markerOptions); + + // Attach a unique identifier if provided + if (data.hausnummerId) { + marker.tt_hausnummerId = data.hausnummerId; + } + + if (data.options?.tooltip) { + marker.tt_tooltip_options = data.options.tooltip; + } + + if (data.options?.popup) marker.bindPopup(data.options.popup); + else if (data.options?.asyncPopupContent) { + marker.bindPopup(() => ''); + marker.on('popupopen', async (e) => { + const content = await data.options.asyncPopupContent(data); + e.popup.setContent(content).update(); + }); + } + + if (data.options?.noCluster) { + this.nonClusteredLayer.addLayer(marker); + } else { + markersToCluster.push(marker); } }); - if (markersToAdd.length > 0) { - this.markerLayer.addLayers(markersToAdd); - this.map.fitBounds(this.markerLayer.getBounds()); + + if (markersToCluster.length > 0) { + this.markerLayer.addLayers(markersToCluster); + } + + this.updateTooltipVisibility(); + + const allMarkers = this.markerLayer.getLayers().concat(this.nonClusteredLayer.getLayers()); + if (allMarkers.length > 0) { + const bounds = L.latLngBounds(allMarkers.map(marker => marker.getLatLng())); + if (bounds.isValid()) { + this.map.fitBounds(bounds, { padding: [50, 50], maxZoom: 17 }); + } } }, + updateTooltipVisibility() { + if (!this.map) return; + const currentZoom = this.map.getZoom(); + + const processMarker = (marker) => { + const tooltipOptions = marker.tt_tooltip_options; + if (!tooltipOptions || !tooltipOptions.minZoom) return; + + const isBound = marker.getTooltip(); + + if (currentZoom >= tooltipOptions.minZoom) { + if (!isBound) { + const { content, minZoom, ...options } = tooltipOptions; + marker.bindTooltip(content, options); + if (options.permanent) { + marker.openTooltip(); + } + } + } else { + if (isBound) { + marker.unbindTooltip(); + } + } + }; + + this.markerLayer.eachLayer(processMarker); + this.nonClusteredLayer.eachLayer(processMarker); + }, toggleMapType() { - this.map.removeLayer(this.tileLayers[this.mapType]); this.mapType = this.mapType === 'streets' ? 'satellite' : 'streets'; - this.tileLayers[this.mapType].addTo(this.map); localStorage.setItem('tt-map-type', this.mapType); + this.setActiveTileLayer(); + }, + setMapProvider(provider) { + this.mapProvider = provider; + localStorage.setItem('tt-map-provider', provider); + this.setActiveTileLayer(); + this.showSettings = false; }, handleResize() { if (this.map) { @@ -187,22 +261,47 @@ Vue.component('tt-map', { beforeDestroy() { window.removeEventListener('resize', this.handleResize); if (this.map) { + this.map.off('zoomend moveend', this.updateTooltipVisibility); this.map.remove(); this.map = null; } }, template: ` -
    -
    +
    +
    -
    - +
    + +
    + +
    + +
    +
    + + +
    +
    +
    + +
    + + +
    +
    +
    +
    + +
    + +
    ` }); \ No newline at end of file