From fc8bcc2e5d2eebda0361f20df8ad57b4b43480a0 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 20 Oct 2025 13:55:49 +0000 Subject: [PATCH 01/22] updated rml workorder --- .../WorkorderAdminController.php | 30 ++++++++++++++++++ .../js/pages/WorkorderBase/WorkorderBase.js | 31 +++++++++++++++++-- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/application/WorkorderAdmin/WorkorderAdminController.php b/application/WorkorderAdmin/WorkorderAdminController.php index 07f82e930..c0771036f 100644 --- a/application/WorkorderAdmin/WorkorderAdminController.php +++ b/application/WorkorderAdmin/WorkorderAdminController.php @@ -311,5 +311,35 @@ class WorkorderAdminController extends WorkorderBaseController } return true; } + + protected function revertDocumentedStatusAction() + { + if (empty($this->postData['workorderId'])) { + self::sendError("Arbeitsauftrags-ID fehlt."); + } + + $workorder = WorkorderModel::get($this->postData['workorderId']); + if (!$workorder) { + self::sendError("Arbeitsauftrag nicht gefunden."); + } + + if ($workorder->status !== 'documented') { + self::sendError("Nur Aufträge mit Status 'Dokumentiert' können zurückgesetzt werden."); + } + + $oldStatus = $workorder->status; + $workorder->status = 'assigned'; // Revert to 'assigned' status + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => 'Status von Admin von "' . $this->getStatusText($oldStatus) . '" auf "' . $this->getStatusText('assigned') . '" zurückgesetzt.', + 'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('assigned'), + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + self::returnJson(['success' => true, 'message' => 'Status erfolgreich auf "Zugewiesen" zurückgesetzt.']); + } //endregion } \ No newline at end of file diff --git a/public/js/pages/WorkorderBase/WorkorderBase.js b/public/js/pages/WorkorderBase/WorkorderBase.js index 37b1c0781..47770f6f9 100644 --- a/public/js/pages/WorkorderBase/WorkorderBase.js +++ b/public/js/pages/WorkorderBase/WorkorderBase.js @@ -205,6 +205,10 @@ Vue.component('workorder-details-manager', {
+ @@ -229,7 +233,7 @@ Vue.component('workorder-details-manager', {
-
+
Neues Dokument hochladen
@@ -280,6 +284,9 @@ Vue.component('workorder-details-manager', { Soll die Dokumentation für diesen Arbeitsauftrag wirklich akzeptiert und der Auftrag abgeschlossen werden? + + Möchten Sie den Status dieses Auftrags wirklich von 'Dokumentiert' auf 'Zugewiesen' zurücksetzen? Die Firma muss den Auftrag dann erneut einreichen. +
`, data: () => ({ loading: true, loadingConfig: true, workorder: null, docs: [], journals: [], tenantDocTypes: null, @@ -290,10 +297,10 @@ Vue.component('workorder-details-manager', { interventionData: null, interventionTypes: [], // Admin state - selectedDocs: [], correctionText: '', correctionLoading: false, showAcceptModal: false, + selectedDocs: [], correctionText: '', correctionLoading: false, showAcceptModal: false, showRevertModal: false, }), computed: { - isReadOnly() { return ['documented', 'completed', 'cancelled'].includes(this.workorder?.status); }, + isReadOnly() { return ['completed', 'cancelled'].includes(this.workorder?.status); }, requiredDocTypes() { return this.tenantDocTypes ?? []; }, @@ -459,6 +466,24 @@ Vue.component('workorder-details-manager', { this.showAcceptModal = false; }, getInterventionLabel(type) { return this.interventionTypes.find(t => t.value === type)?.text || type; }, + async revertDocumentedStatus() { + // Optional: Add loading state if needed + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/revertDocumentedStatus`, { + workorderId: this.workorderId + }); + if (data.success) { + window.notify('success', data.message); + this.showRevertModal = false; + await this.fetchData(); // Refresh data to show new status + this.$emit('workorder-updated'); // Or a more specific event if needed + } else { + window.notify('error', data.message || 'Status konnte nicht zurückgesetzt werden.'); + } + } catch (e) { + window.notify('error', 'Netzwerkfehler beim Zurücksetzen des Status.'); + } + }, }, async mounted() { await this.loadTenantConfig(); From ba4964f2bd68765fab34be59681d2d04d79819d3 Mon Sep 17 00:00:00 2001 From: Daniel Spitzer Date: Wed, 22 Oct 2025 09:41:48 +0200 Subject: [PATCH 02/22] =?UTF-8?q?Kalender=20Update=20*=20=C3=9Cbergang=20S?= =?UTF-8?q?ommerzeit=20Winterzeit=20Bugfix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/Calendar/CalendarModel.php | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/application/Calendar/CalendarModel.php b/application/Calendar/CalendarModel.php index 01e9cd613..d68c9ecbf 100644 --- a/application/Calendar/CalendarModel.php +++ b/application/Calendar/CalendarModel.php @@ -92,6 +92,19 @@ class CalendarModel $unixtimestamp = $date->getTimestamp() + $cest; return date('Y-m-d H:i:s', $unixtimestamp); } + public static function convertMillisecondsToTimestamp($milliseconds) + { + $timestamp = $milliseconds / 1000; + $date = new DateTime(); + $date->setTimestamp($timestamp); + $date->setTimezone(new DateTimeZone('Europe/Berlin')); + if ($date->format('I') == 1) { + $offset = 7200; + } else { + $offset = 3600; + } + return $timestamp - $offset; + } public static function replace_unicode_sequences($string) { @@ -646,12 +659,8 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda public static function updateCalendarEvent($r, $me) { - $description = ($r->description); - $attachments = ($r->attachments); - $location = ($r->location); - $title = ($r->title); - $start = ((($r->start - 7200000) / 1000)); - $end = ((($r->end - 7200000) / 1000)); + $start = CalendarModel::convertMillisecondsToTimestamp($r->start); + $end = CalendarModel::convertMillisecondsToTimestamp($r->end); if ($title) { From 6bc10967004e3394c2833c3f08425b458e76a309 Mon Sep 17 00:00:00 2001 From: Daniel Spitzer Date: Wed, 22 Oct 2025 13:13:59 +0200 Subject: [PATCH 03/22] =?UTF-8?q?Kalender=20Update=20*=20=C3=9Cbergang=20S?= =?UTF-8?q?ommerzeit=20Winterzeit=20Bugfix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/Calendar/CalendarModel.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/application/Calendar/CalendarModel.php b/application/Calendar/CalendarModel.php index d68c9ecbf..d3fe6f931 100644 --- a/application/Calendar/CalendarModel.php +++ b/application/Calendar/CalendarModel.php @@ -659,17 +659,18 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda public static function updateCalendarEvent($r, $me) { + $description = ($r->description); + $attachments = ($r->attachments); + $location = ($r->location); + $title = ($r->title); + $start = CalendarModel::convertMillisecondsToTimestamp($r->start); $end = CalendarModel::convertMillisecondsToTimestamp($r->end); - - if ($title) { $start = strtotime($r->start); $end = strtotime($r->end); } - $originalend = $end; - $allday = ($r->allday); if ($allday) { $start = $start + 7200; From 0131f472f01546265cc220994e98bfe4c7cc3aaa Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Thu, 23 Oct 2025 09:21:21 +0000 Subject: [PATCH 04/22] added billed for xinon campaigns aswell --- Layout/default/Preorder/include/preorder-detail.php | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/Layout/default/Preorder/include/preorder-detail.php b/Layout/default/Preorder/include/preorder-detail.php index 725c69d01..571fc6747 100644 --- a/Layout/default/Preorder/include/preorder-detail.php +++ b/Layout/default/Preorder/include/preorder-detail.php @@ -110,15 +110,7 @@ - - - campaign->network->owner_id === "209"): ?> + campaign->network->owner_id, ["209", "1"])): ?> Verrechnet: @@ -1175,4 +1167,3 @@
- From 6af9d8dc3257e622eca461c1a579edb794a5a97b Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Fri, 24 Oct 2025 09:30:48 +0200 Subject: [PATCH 05/22] added file uploads --- Layout/default/Preorder/Index.php | 491 ++++++++++++++++-- .../Preorder/include/preorder-detail.php | 120 +++++ application/File/FileController.php | 1 + application/Preorder/PreorderController.php | 52 ++ .../20251024091253_preorder_add_files.php | 20 + 5 files changed, 654 insertions(+), 30 deletions(-) create mode 100644 db/migrations/20251024091253_preorder_add_files.php diff --git a/Layout/default/Preorder/Index.php b/Layout/default/Preorder/Index.php index f5939034d..7660a9dc1 100644 --- a/Layout/default/Preorder/Index.php +++ b/Layout/default/Preorder/Index.php @@ -100,53 +100,484 @@ $pagination_entity_name = "Vorbestellungen"; } } + + /* styles for documents */ + .document-upload-wrapper { + background: #fdfdfd; + border: 1px solid #e9ecef; + border-radius: .25rem; + } + .document-dropzone { + border: 2px dashed #ced4da; + border-radius: 0.25rem; + padding: 1.5rem; + text-align: center; + background-color: #f8f9fa; + transition: all 0.3s ease; + cursor: pointer; + } + .document-dropzone:hover { + border-color: #007bff; + background-color: #e9ecef; + } + .document-dropzone.active { + border-color: #007bff; + border-style: solid; + } + + .document-staging-area { + max-height: 250px; + overflow-y: auto; + } + .document-staging-item { + display: flex; + align-items: flex-start; + padding: 0.75rem; + border: 1px solid #dee2e6; + border-radius: 0.25rem; + margin-bottom: 0.5rem; + background-color: #fff; + } + .doc-staging-icon { + flex-shrink: 0; + width: 30px; + text-align: center; + padding-top: 0.25rem; + } + .doc-staging-details { + flex-grow: 1; + padding: 0 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .doc-staging-filename { + font-size: 0.9em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .doc-staging-filesize { + font-size: 0.8em; + } + .doc-staging-actions { + flex-shrink: 0; + } + + .document-list-wrapper { + min-height: 300px; + } + .doc-spinner { + display: inline-block; + width: 3rem; + height: 3rem; + vertical-align: text-bottom; + border: 0.25em solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: doc-spin 0.75s linear infinite; + color: #007bff; + } + @keyframes doc-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + .doc-row-icon i { font-size: 1.5rem; } + .doc-row-icon .fa-file-pdf { color: #dc3545; } + .doc-row-icon .fa-file-image { color: #28a745; } + .doc-row-icon .fa-file-word { color: #007bff; } + .doc-row-icon .fa-file-excel { color: #207245; } + .doc-row-icon .fa-file-archive { color: #ffc107; } + .doc-row-icon .fa-file { color: #6c757d; } + + .doc-preview-modal-body img, + .doc-preview-modal-body embed, + .doc-preview-modal-body iframe { + max-width: 100%; + max-height: 75vh; + border: none; + } + - + + initPreorderDocumentTabs(); + }); +
diff --git a/Layout/default/Preorder/include/preorder-detail.php b/Layout/default/Preorder/include/preorder-detail.php index 571fc6747..31849a571 100644 --- a/Layout/default/Preorder/include/preorder-detail.php +++ b/Layout/default/Preorder/include/preorder-detail.php @@ -22,6 +22,7 @@ +
@@ -1162,6 +1163,125 @@
+
+ +
+
+
+ +
+
Hochgeladene Dokumente
+
+ + + + + +
+
+ +
+
Neuer Upload
+
+
+
+ +

Dateien hier ablegen oder

+ + +
+
+ + + + +
+
+
+
+
+ + + + + + + +
+ diff --git a/application/File/FileController.php b/application/File/FileController.php index ee57bcc33..9d73018b1 100644 --- a/application/File/FileController.php +++ b/application/File/FileController.php @@ -35,6 +35,7 @@ class FileController extends mfBaseController { } if(preg_match('/\.([^.]+)/',$filename,$m)) { + if (!isset($ext)) $ext = ''; $ext .= $m[1]; } else { throw new Exception("File not found", 4042); diff --git a/application/Preorder/PreorderController.php b/application/Preorder/PreorderController.php index 279298cf5..6466a3be5 100644 --- a/application/Preorder/PreorderController.php +++ b/application/Preorder/PreorderController.php @@ -1985,4 +1985,56 @@ class PreorderController extends mfBaseController { unlink($filename); exit; } + + protected function uploadDocumentsAction() { + if (empty($_FILES['files']) || empty($_POST['preorderId']) || empty($_POST['descriptions'])) { + self::sendError('Erforderliche Daten fehlen (Dateien, preorderId oder Beschreibungen).'); + } + + $preorderId = $_POST['preorderId']; + $descriptions = $_POST['descriptions']; + + if (count($_FILES['files']['name']) !== count($descriptions)) { + self::sendError('Anzahl der Dateien und Beschreibungen stimmt nicht überein.'); + } + + $preorder = new Preorder($preorderId); + if (!$preorder->id) { + self::sendError('Bestellung nicht gefunden.'); + } + + $fileObjects = json_decode($preorder->files, true); + if (!is_array($fileObjects)) $fileObjects = []; + + foreach ($_FILES['files']['name'] as $index => $name) { + if ($_FILES['files']['error'][$index] !== UPLOAD_ERR_OK) continue; + + $_FILES['file'] = [ + 'name' => $name, + 'type' => $_FILES['files']['type'][$index], + 'tmp_name' => $_FILES['files']['tmp_name'][$index], + 'error' => $_FILES['files']['error'][$index], + 'size' => $_FILES['files']['size'][$index] + ]; + + $description = trim($descriptions[$index]); + if (empty($description)) continue; + + try { + $uploaded = mfUpload::handleFormUpload("file", false, "/PreorderDocuments"); + $fileObjects[] = ["id" => $uploaded->id, "description" => $description]; + } catch (Exception $e) {} + } + + $db = FronkDB::singleton(); + $escapedFilesJson = $db->escape(json_encode($fileObjects)); + $sql = "UPDATE `" . FRONKDB_DBNAME . "`.Preorder SET files = '$escapedFilesJson' WHERE id = " . (int)$preorder->id; + $db->query($sql); + + self::returnJson([ + 'success' => true, + 'message' => "Datei(en) erfolgreich hochgeladen.", + 'fileObjects' => $fileObjects + ]); + } } diff --git a/db/migrations/20251024091253_preorder_add_files.php b/db/migrations/20251024091253_preorder_add_files.php new file mode 100644 index 000000000..8fac5627f --- /dev/null +++ b/db/migrations/20251024091253_preorder_add_files.php @@ -0,0 +1,20 @@ +getEnvironment() == "thetool") { + $Preorder = $this->table("Preorder"); + $Preorder->addColumn("files", "text", ['null' => true, 'after' => 'notes']); + $Preorder->update(); + } + } + + public function down(): void { + if($this->getEnvironment() == "thetool") { + $this->table("Preorder")->removeColumn("files")->save(); + } + } +} From b03c8c4c7078106da2988ac9249c034bcd8a5210 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Fri, 24 Oct 2025 07:32:19 +0000 Subject: [PATCH 06/22] fixed migrations --- db/migrations/20251024091253_preorder_add_files.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrations/20251024091253_preorder_add_files.php b/db/migrations/20251024091253_preorder_add_files.php index 8fac5627f..ab6b0ca14 100644 --- a/db/migrations/20251024091253_preorder_add_files.php +++ b/db/migrations/20251024091253_preorder_add_files.php @@ -7,7 +7,7 @@ final class PreorderAddFiles extends AbstractMigration { public function up(): void { if($this->getEnvironment() == "thetool") { $Preorder = $this->table("Preorder"); - $Preorder->addColumn("files", "text", ['null' => true, 'after' => 'notes']); + $Preorder->addColumn("files", "text", ['null' => true, 'after' => 'note']);s $Preorder->update(); } } From e569d4fa13f366aa85ca046cf6b2727abd40d081 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Fri, 24 Oct 2025 07:32:59 +0000 Subject: [PATCH 07/22] fixed migration --- db/migrations/20251024091253_preorder_add_files.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/db/migrations/20251024091253_preorder_add_files.php b/db/migrations/20251024091253_preorder_add_files.php index ab6b0ca14..1b604a80c 100644 --- a/db/migrations/20251024091253_preorder_add_files.php +++ b/db/migrations/20251024091253_preorder_add_files.php @@ -6,9 +6,9 @@ use Phinx\Migration\AbstractMigration; final class PreorderAddFiles extends AbstractMigration { public function up(): void { if($this->getEnvironment() == "thetool") { - $Preorder = $this->table("Preorder"); - $Preorder->addColumn("files", "text", ['null' => true, 'after' => 'note']);s - $Preorder->update(); + $table = $this->table("Preorder"); + $table->addColumn("files", "text", ['null' => true, 'after' => 'note']);s + $table->update(); } } From 55cdfbecc5255a16c182e5c57fb94a4e6d34ce56 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Fri, 24 Oct 2025 07:33:21 +0000 Subject: [PATCH 08/22] fixed typo --- db/migrations/20251024091253_preorder_add_files.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrations/20251024091253_preorder_add_files.php b/db/migrations/20251024091253_preorder_add_files.php index 1b604a80c..e0902091c 100644 --- a/db/migrations/20251024091253_preorder_add_files.php +++ b/db/migrations/20251024091253_preorder_add_files.php @@ -7,7 +7,7 @@ final class PreorderAddFiles extends AbstractMigration { public function up(): void { if($this->getEnvironment() == "thetool") { $table = $this->table("Preorder"); - $table->addColumn("files", "text", ['null' => true, 'after' => 'note']);s + $table->addColumn("files", "text", ['null' => true, 'after' => 'note']); $table->update(); } } From 87a0b01e576d354f23035d4b8db2bffe0f8e380f Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Fri, 24 Oct 2025 10:16:35 +0200 Subject: [PATCH 09/22] added file uploads --- application/Preorder/PreorderController.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/application/Preorder/PreorderController.php b/application/Preorder/PreorderController.php index 6466a3be5..087affdc5 100644 --- a/application/Preorder/PreorderController.php +++ b/application/Preorder/PreorderController.php @@ -2008,6 +2008,11 @@ class PreorderController extends mfBaseController { foreach ($_FILES['files']['name'] as $index => $name) { if ($_FILES['files']['error'][$index] !== UPLOAD_ERR_OK) continue; + $extension = pathinfo($name, PATHINFO_EXTENSION); + $name = preg_replace('/[^a-zA-Z0-9_-]/', '_', substr($descriptions[$index], 0, 50)); + if ($extension) { + $name .= '.' . $extension; + } $_FILES['file'] = [ 'name' => $name, From 21326d4757ee2759e959ae20f9101743cfee176c Mon Sep 17 00:00:00 2001 From: Daniel Spitzer Date: Tue, 28 Oct 2025 17:54:18 +0100 Subject: [PATCH 10/22] =?UTF-8?q?Zeiterfassung=20Update=20*=20Neues=20Gene?= =?UTF-8?q?hmigungsverfahren=20f=C3=BCr=20Buchhaltung=20eingef=C3=BChrt=20?= =?UTF-8?q?*=20Buchungsarten=20f=C3=BCr=20Mitarbeiter=20erweitert=20Arztbe?= =?UTF-8?q?such/Beh=C3=B6rde/Weiterbildung=20*=20Neues=20Flag=20in=20Perso?= =?UTF-8?q?naladministration=20f=C3=BCr=20von=20Buchhaltung=20zu=20genehmi?= =?UTF-8?q?gen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Layout/default/Timerecording/Index.php | 2 +- .../default/TimerecordingCategories/Form.php | 14 +- .../default/TimerecordingCategories/Index.php | 4 +- .../default/TimerecordingPermitFibu/Index.php | 211 ++++++++++++++++++ Layout/default/menu.php | 3 +- .../Timerecording/TimerecordingController.php | 32 ++- .../Timerecording/TimerecordingModel.php | 19 ++ .../TimerecordingCategoryController.php | 1 + .../TimerecordingCategoryModel.php | 1 + .../TimerecordingPermitFibuController.php | 123 ++++++++++ .../TimerecordingReportController.php | 4 +- ...rding_category_add_field_approval_fibu.php | 31 +++ 12 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 Layout/default/TimerecordingPermitFibu/Index.php create mode 100644 application/TimerecordingPermitFibu/TimerecordingPermitFibuController.php create mode 100644 db/migrations/20251026103124_timerecording_category_add_field_approval_fibu.php diff --git a/Layout/default/Timerecording/Index.php b/Layout/default/Timerecording/Index.php index e2c277573..a0ef105e5 100644 --- a/Layout/default/Timerecording/Index.php +++ b/Layout/default/Timerecording/Index.php @@ -149,7 +149,7 @@ $mindate = date("Y-m-d", strtotime("+ 1 Month", $closedmonth)); data-comment="require_comment ?>" data-hourday="hourday ?>" data-businesstrip="businesstrip ?>" - data-homeoffice="hourday == 1) ? 1 : 0 ?>">name ?> + data-homeoffice="hourday == 1 && $timerecordingCategories->approval_fibu == 0) ? 1 : 0 ?>">name ?>
- +
approval) echo 'checked="checked"'; ?> - type="checkbox" name="approval" value="1" id="olt"> + type="checkbox" name="approval" value="1"> +
+
+
+
+ +
+
+ approval_fibu) echo 'checked="checked"'; ?> + type="checkbox" name="approval_fibu" value="1" >
diff --git a/Layout/default/TimerecordingCategories/Index.php b/Layout/default/TimerecordingCategories/Index.php index 81014cbd4..880ae1fa0 100644 --- a/Layout/default/TimerecordingCategories/Index.php +++ b/Layout/default/TimerecordingCategories/Index.php @@ -41,7 +41,7 @@ Beizeichnung BMD KZ Buchungszeitraum - Genehmigungspflichtig + Genehmigungspf. GF/BH Dienstreisemöglichkeit Anmerkung Pflichtfeld Nur Buchhaltung @@ -66,7 +66,7 @@ name ?> short ?> hourday] ?> - approval] ?> + approval] ." / ".$timerecordingcategoriesapproval[$timerecordingcategories->approval_fibu] ?> businesstrip] ?> require_comment] ?> only_admin] ?> diff --git a/Layout/default/TimerecordingPermitFibu/Index.php b/Layout/default/TimerecordingPermitFibu/Index.php new file mode 100644 index 000000000..a7e83620d --- /dev/null +++ b/Layout/default/TimerecordingPermitFibu/Index.php @@ -0,0 +1,211 @@ + + + + + + + + +
+
+
+
+ +
+

Buchungen

+
+
+
+ +
+
+
+
+
+

Liste aller Buchungen

+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + start; + if ($timerecording->timerecordingCategory->hourday == 1) { + $date = date("d.m.Y", $timerecording->start); + $datadate = date("Y-m-d", $timerecording->start); + $start = date("H:i", $timerecording->start); + $end = date("H:i", $timerecording->end); + $seconds = $timerecording->end - $timerecording->start; + $minutes = floor(($seconds % 3600) / 60); + $hours = floor($seconds / 3600); + $sum = sprintf("%02d", $hours) . ":" . sprintf("%02d", $minutes); + $day = $daysgerm[date("w", $timerecording->start)]; + } else if ($timerecording->timerecordingCategory->hourday == 2) { + $date = date("d.m.", $timerecording->start) . " - " . $daysgerm[date("w", $timerecording->end)] . " " . date("d.m.Y", $timerecording->end); + $datadate = date("Y-m-d", $timerecording->start); + $enddate = date("Y-m-d", $timerecording->end); + $start = "-"; + $end = "-"; + $day = $daysgerm[date("w", $timerecording->start)]; + } else if ($timerecording->timerecordingCategory->hourday == 3 || $timerecording->timerecordingCategory->hourday == 4) { + $date = date("d.m.Y", $timerecording->start); + $datadate = date("Y-m-d", $timerecording->start); + $start = "-"; + $end = "-"; + $day = $daysgerm[date("w", $timerecording->start)]; + } else if ($timerecording->timerecordingCategory->hourday == 6) { + $date = date("d.m.Y", $timerecording->start); + $datadate = date("Y-m-d", $timerecording->start); + $start = date("H:i", $timerecording->start); + $end = date("H:i", $timerecording->end); + $seconds = ($timerecording->end - $timerecording->start); + $minutes = floor(($seconds % 3600) / 60); + $hours = floor($seconds / 3600); + $sum = sprintf("%02d", $hours) . ":" . sprintf("%02d", $minutes); + $day = $daysgerm[date("w", $timerecording->start)]; + $isSeconds = $isSeconds + $seconds; + } + if ($timerecording->timerecordingCategory->approval_fibu == 1 && $timerecording->approved == 0) { + $state = ''; + } else if ($timerecording->timerecordingCategory->approval_fibu == 1 && $timerecording->approved == 1) { + $state = ''; + } + $approved = 'Offen'; + if ($timerecording->approved == 1) $approved = 'Genehmigt'; + $completed = 'Genehmigt'; +// if ($timerecording->completed == 1) $completed = 'Genehmigt'; + ?> + + + + + + + + + + + + + +
DatumMitarbeiterVonBisSummeBuchungsartAnmerkungFreigabe
user->name ?>timerecordingCategory->name ?>comment ?> + completed == 0): + if ($timerecording->approved == 0) : ?> + $timerecording->id]) ?>" + onclick="if(!confirm('Buchung genehmigen?')) return false;"> + $timerecording->id]) ?>" + onclick="if(!confirm('Buchung wirklich ablehnen?')) return false;"> + + +
+ + +
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/Layout/default/menu.php b/Layout/default/menu.php index 69308eba0..4169b5266 100644 --- a/Layout/default/menu.php +++ b/Layout/default/menu.php @@ -41,7 +41,8 @@
  • "> Buchungen
  • "> Abwesenheitskalender
  • can('Fibu')): ?> -
  • "> Freigaben
  • +
  • "> Freigaben Urlaub/ZA
  • +
  • "> Freigaben Buchhaltung
  • "> Auswertung/Korrektur
  • "> Verrechnung
  • "> Reports
  • diff --git a/application/Timerecording/TimerecordingController.php b/application/Timerecording/TimerecordingController.php index a4a697b64..60b64969a 100644 --- a/application/Timerecording/TimerecordingController.php +++ b/application/Timerecording/TimerecordingController.php @@ -474,6 +474,34 @@ class TimerecordingController extends mfBaseController $email->setTo(TT_TIMERECORDING_EMAIL); $email->send(); } + else if ($timerecordingCategoriess[0]->approval_fibu == "1" && !$r->user_id) + { + $body = 'Beantrag von: ' . $this->me->name . ' +'; + $body .= 'Buchungsart: ' . $timerecordingCategoriess[0]->name . ' +'; + if ($timerecordingCategoriess[0]->hourday == "1") { + $body .= 'von: ' . date("d.m.Y H:i", $data['start']) . ' bis: ' . date("H:i", $data['end']); + } else if ($timerecordingCategoriess[0]->hourday == "2") { + $body .= 'von: ' . date("d.m.Y", $data['start']) . ' bis: ' . date("d.m.Y", $data['end']); + } else if ($timerecordingCategoriess[0]->hourday == "6") { + $body .= 'von: ' . date("d.m.Y H:i", $data['start']) . ' bis: ' . date("H:i", $data['end']); + } + /* + $email = new Emailnotification(); + $email->setSubject('Antrag für ' . $timerecordingCategoriess[0]->name . ' erstellt'); + $email->setBody($body); + $email->setFrom(TT_TIMERECORDING_EMAIL, TT_TIMERECORDING_EMAIL_NAME); + $email->setTo($this->me->email); + $email->send(); +*/ + $email = new Emailnotification(); + $email->setSubject('Antrag für ' . $timerecordingCategoriess[0]->name . ' erstellt (' . $this->me->name . ')'); + $email->setBody($body); + $email->setFrom(TT_TIMERECORDING_EMAIL_FIBU, TT_TIMERECORDING_EMAIL_FIBU_NAME); + $email->setTo(TT_TIMERECORDING_EMAIL_FIBU); + $email->send(); + } if ($data['timerecordingCategory_id'] == "3" || $timerecordingCategoriess[0]->hourday == "5") { $this->updateHolidays($data['user_id']); } @@ -1091,9 +1119,9 @@ class TimerecordingController extends mfBaseController } } - if ($timerecording->timerecordingCategory->approval == 1 && $timerecording->approved == 0) { + if (($timerecording->timerecordingCategory->approval == 1 && $timerecording->approved == 0) || ($timerecording->timerecordingCategory->approval_fibu == 1 && $timerecording->approved == 0)) { $state = ''; - } else if ($timerecording->timerecordingCategory->approval == 1 && $timerecording->approved == 1) { + } else if (($timerecording->timerecordingCategory->approval == 1 && $timerecording->approved == 1) || ($timerecording->timerecordingCategory->approval_fibu == 1 && $timerecording->approved == 1 )) { $state = ''; } $edit = ""; diff --git a/application/Timerecording/TimerecordingModel.php b/application/Timerecording/TimerecordingModel.php index f4ab93074..1e61bcf60 100644 --- a/application/Timerecording/TimerecordingModel.php +++ b/application/Timerecording/TimerecordingModel.php @@ -125,6 +125,25 @@ class TimerecordingModel } return $items; + } + public static function getAllPermitsFibu() + { + $items = []; + + $db = FronkDB::singleton(); + + $sql = "SELECT Timerecording.* FROM `Timerecording` + INNER JOIN `TimerecordingCategory` ON (`Timerecording`.`timerecordingCategory_id` = `TimerecordingCategory`.`id`) + WHERE `TimerecordingCategory`.`approval_fibu`='1'"; + + $res = $db->query($sql); + if ($db->num_rows($res)) { + while ($data = $db->fetch_object($res)) { + $items[] = new Timerecording($data); + } + } + return $items; + } public static function getFirst() diff --git a/application/TimerecordingCategory/TimerecordingCategoryController.php b/application/TimerecordingCategory/TimerecordingCategoryController.php index 1b4f115d1..bfa193681 100644 --- a/application/TimerecordingCategory/TimerecordingCategoryController.php +++ b/application/TimerecordingCategory/TimerecordingCategoryController.php @@ -84,6 +84,7 @@ class TimerecordingCategoryController extends mfBaseController $data['short'] = trim($r->short); $data['hourday'] = trim($r->hourday); $data['approval'] = trim($r->approval); + $data['approval_fibu'] = trim($r->approval_fibu); $data['require_comment'] = trim($r->require_comment); $data['only_admin'] = trim($r->only_admin); $data['businesstrip'] = trim($r->businesstrip); diff --git a/application/TimerecordingCategory/TimerecordingCategoryModel.php b/application/TimerecordingCategory/TimerecordingCategoryModel.php index e57ca7191..6bee3d49d 100644 --- a/application/TimerecordingCategory/TimerecordingCategoryModel.php +++ b/application/TimerecordingCategory/TimerecordingCategoryModel.php @@ -6,6 +6,7 @@ class TimerecordingCategoryModel private $short; private $hourday; private $approval; + private $approval_fibu; private $require_comment; private $only_admin; private $businesstrip; diff --git a/application/TimerecordingPermitFibu/TimerecordingPermitFibuController.php b/application/TimerecordingPermitFibu/TimerecordingPermitFibuController.php new file mode 100644 index 000000000..8f9767907 --- /dev/null +++ b/application/TimerecordingPermitFibu/TimerecordingPermitFibuController.php @@ -0,0 +1,123 @@ +needlogin = true; + $me = new User(); + $me->loadMe(); + $this->me = $me; + $this->layout()->set("me", $me); + + if (!$me->can(["Fibu"])) { + $this->redirect("Dashboard"); + } + } + + protected function indexAction() + { + + $this->layout()->setTemplate("TimerecordingPermitFibu/Index"); + $timerecordingCategoriess = TimerecordingCategoryModel::getAll(); + $this->layout()->set("timerecordingCategoriess", $timerecordingCategoriess); + $timerecordings = TimerecordingModel::getAllPermitsFibu(); + $this->layout()->set("timerecordings", $timerecordings); + + } + + protected function addAction() + { + + + } + + protected function editAction() + { + + } + + protected function saveAction() + { + } + + protected function sendMail($timerecordings, $type) + { + if ($type == "deny") { + $sendtext = "abgelehnt"; + } else if ($type == "approve") { + $sendtext = "genehmigt"; + } + $user = UserModel::getOne($timerecordings->user_id); + $timerecordingCategoriess = TimerecordingCategoryModel::search(['id' => $timerecordings->timerecordingCategory_id]); + $body = 'Beantrag von: ' . $user->name . ' +'; + $body .= 'Buchungsart: ' . $timerecordingCategoriess[0]->name . ' +'; + if ($timerecordingCategoriess[0]->hourday == "1") { + $body .= 'von: ' . date("d.m.Y H:i", $timerecordings->start) . ' bis: ' . date("H:i", $timerecordings->end) . ' + +'; + } else if ($timerecordingCategoriess[0]->hourday == "2") { + $body .= 'von: ' . date("d.m.Y", $timerecordings->start) . ' bis: ' . date("d.m.Y", $timerecordings->end) . ' + +'; + } + else if ($timerecordingCategoriess[0]->hourday == "6") { + $body .= 'von: ' . date("d.m.Y H:i", $timerecordings->start) . ' bis: ' . date("H:i", $timerecordings->end) . ' + +'; + } + $body .= ucfirst($sendtext) . ' von: ' . $this->me->name . ' + '; + $email = new Emailnotification(); + $email->setSubject('Antrag für ' . $timerecordingCategoriess[0]->name . ' ' . $sendtext); + $email->setBody($body); + $email->setFrom(TT_TIMERECORDING_EMAIL, TT_TIMERECORDING_EMAIL_NAME); + $email->setTo($user->email); + $email->send(); + + $email = new Emailnotification(); + $email->setSubject('Antrag für ' . $timerecordingCategoriess[0]->name . ' ' . $sendtext . ' (' . $user->name . ')'); + $email->setBody($body); + $email->setFrom(TT_TIMERECORDING_EMAIL, TT_TIMERECORDING_EMAIL_NAME); + $email->setTo(TT_TIMERECORDING_EMAIL); + $email->send(); + } + + protected function approveAction() + { + $id = $this->request->id; + $timerecordings = new Timerecording($id); + if (!$timerecordings->id || $timerecordings->id != $id) { + $this->layout()->setFlash("Buchung nicht gefunden.", "error"); + $this->redirect("TimerecordingPermitFibu"); + } + $data = []; + $data['approved'] = 1; + $timerecordings->update($data); + $timerecordings->save(); + //$this->sendMail($timerecordings, "approve"); + $this->redirect("TimerecordingPermitFibu"); + } + + protected function denyAction() + { + $id = $this->request->id; + $timerecordings = new Timerecording($id); + if (!$timerecordings->id || $timerecordings->id != $id) { + $this->layout()->setFlash("Buchung nicht gefunden.", "error"); + $this->redirect("TimerecordingPermitFibu"); + } + //$this->sendMail($timerecordings, "deny"); + $timerecordings->delete(); + + + if ($this->request->ajax == 1) { + die(); + } + $this->redirect("TimerecordingPermitFibu"); + } + +} diff --git a/application/TimerecordingReport/TimerecordingReportController.php b/application/TimerecordingReport/TimerecordingReportController.php index 6a2676032..6124eb66c 100644 --- a/application/TimerecordingReport/TimerecordingReportController.php +++ b/application/TimerecordingReport/TimerecordingReportController.php @@ -433,9 +433,9 @@ class TimerecordingReportController extends mfBaseController $day = $daysgerm[date("w", $timerecording->start)]; } - if ($timerecording->timerecordingCategory->approval == 1 && $timerecording->approved == 0) { + if (($timerecording->timerecordingCategory->approval == 1 && $timerecording->approved == 0 )|| ($timerecording->timerecordingCategory->approval_fibu == 1 && $timerecording->approved == 0)) { $state = ''; - } else if ($timerecording->timerecordingCategory->approval == 1 && $timerecording->approved == 1) { + } else if (($timerecording->timerecordingCategory->approval == 1 && $timerecording->approved == 1) || ($timerecording->timerecordingCategory->approval_fibu == 1 && $timerecording->approved == 1 )) { $state = ''; } $edit = ""; diff --git a/db/migrations/20251026103124_timerecording_category_add_field_approval_fibu.php b/db/migrations/20251026103124_timerecording_category_add_field_approval_fibu.php new file mode 100644 index 000000000..777e1084e --- /dev/null +++ b/db/migrations/20251026103124_timerecording_category_add_field_approval_fibu.php @@ -0,0 +1,31 @@ +getEnvironment() == "thetool") { + $table = $this->table("TimerecordingCategory", ["signed" => true]); + $table->addColumn("approval_fibu", "integer", ["null" => false, "default" => '0', "after" => "approval"]); + $table->update(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("TimerecordingCategory")->removeColumn("approval_fibu")->save(); + } + + if($this->getEnvironment() == "addressdb") { + + } + } +} From 7c450fb83fa8c225013e9263d0432dc3c4391c2d Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 29 Oct 2025 08:12:17 +0100 Subject: [PATCH 11/22] added partner number --- application/Cpeprovisioning/CpeprovisioningController.php | 2 +- public/js/pages/Cpeprovisioning/Cpeprovisioning.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/Cpeprovisioning/CpeprovisioningController.php b/application/Cpeprovisioning/CpeprovisioningController.php index f1f010ce1..7ef383c45 100644 --- a/application/Cpeprovisioning/CpeprovisioningController.php +++ b/application/Cpeprovisioning/CpeprovisioningController.php @@ -477,7 +477,7 @@ class CpeprovisioningController extends mfBaseController { 'spin' => $order->owner->spin, 'customer' => $order->owner->getCompanyOrName(), 'owner_email' => $order->owner->email, 'owner_phone' => $order->owner->phone, - 'owner_customer_number' => $order->owner->customer_number, + 'owner_customer_number' => $order->owner->customer_number ?? $order->partner_number, 'owner_full_address' => $order->owner->street . ", " . $order->owner->zip . " " . $order->owner->city, 'product_name' => $product->product->name, 'product_code' => $term->code ?? '', 'access_type' => $attrs['bras_type']->value, diff --git a/public/js/pages/Cpeprovisioning/Cpeprovisioning.js b/public/js/pages/Cpeprovisioning/Cpeprovisioning.js index 384489736..ec93ede76 100644 --- a/public/js/pages/Cpeprovisioning/Cpeprovisioning.js +++ b/public/js/pages/Cpeprovisioning/Cpeprovisioning.js @@ -38,7 +38,7 @@ Vue.component('Cpeprovisioning', { {{ item.customer }}#{{ item.owner_customer_number }} - SPIN: {{ item.spin }} + SPIN: {{ item.spin }}
    Netzgebiet: {{ item.network || 'N/A' }}
    From 1b3859d60a6e496a276bb267d96438b779cb0641 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Wed, 29 Oct 2025 08:56:08 +0100 Subject: [PATCH 12/22] improve search --- application/Order/OrderModel.php | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/application/Order/OrderModel.php b/application/Order/OrderModel.php index caee15c8a..f5e8ce0f2 100644 --- a/application/Order/OrderModel.php +++ b/application/Order/OrderModel.php @@ -240,15 +240,28 @@ class OrderModel { $where .= " AND `Order`.owner_id=$ownerid"; } } - - if(array_key_exists("owner", $filter)) { - $owner = FronkDB::singleton()->escape($filter['owner']); - if($owner) { - $where .= " AND (Address.customer_number like '$owner' OR Address.company like '%$owner%' OR Address.firstname like '%$owner%' OR Address.lastname like '%$owner%' OR Address.customer_number like '%$owner%')"; + + if (!empty($filter['owner'])) { + $db = FronkDB::singleton(); + $fields = [ + 'Address.customer_number', + 'Address.company', + 'Address.firstname', + 'Address.lastname', + 'Order.partner_number' + ]; + + $searchTerms = preg_split('/\s+/', $filter['owner'], -1, PREG_SPLIT_NO_EMPTY); + + foreach ($searchTerms as $term) { + if ($escapedTerm = $db->escape($term)) { + $likes = array_map(fn($field) => "$field LIKE '%$escapedTerm%'", $fields); + $where .= " AND (" . implode(' OR ', $likes) . ")"; + } + } } - } - - if(array_key_exists("owner_address", $filter)) { + + if(array_key_exists("owner_address", $filter)) { $owner_address = FronkDB::singleton()->escape($filter['owner_address']); if($owner_address) { $where .= " AND (Address.street like '%$owner_address%' OR Address.zip like '%$owner_address%' OR Address.city like '%$owner_address%')"; From f4a8a88d7efc6400f10eb548f6780a92bdae22ba Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Fri, 31 Oct 2025 08:53:38 +0000 Subject: [PATCH 13/22] Update RadiusUsers.js --- public/js/pages/Radius/RadiusUsers.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/public/js/pages/Radius/RadiusUsers.js b/public/js/pages/Radius/RadiusUsers.js index 0330b990c..7866dabdc 100644 --- a/public/js/pages/Radius/RadiusUsers.js +++ b/public/js/pages/Radius/RadiusUsers.js @@ -72,7 +72,16 @@ Vue.component('radius-users', { allMonths() { return Array.from({ length: 12 }, (_, i) => ({ month: i + 1, name: new Date(2000, i, 1).toLocaleString('de-DE', { month: 'short' }) })); }, isValidEmail() { if (!this.recipientEmail) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail); } }, - mounted() { this.observer = new IntersectionObserver(([e]) => { if (e && e.isIntersecting) this.loadMore(); }, { root: this.$refs.tableWrap, threshold: 0.1 }); if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel); }, + mounted() { + const urlParams = new URLSearchParams(window.location.search); + const infoParam = urlParams.get('info'); + if (infoParam) { + this.info = infoParam; + this.loadRadiusUsers(); + } + this.observer = new IntersectionObserver(([e]) => { if (e && e.isIntersecting) this.loadMore(); }, { root: this.$refs.tableWrap, threshold: 0.1 }); + if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel); + }, beforeDestroy() { if (this.observer) this.observer.disconnect(); if (this.transferChartInstance) this.transferChartInstance.destroy(); }, updated() { if (this.observer && this.$refs.sentinel) { this.observer.disconnect(); this.observer.observe(this.$refs.sentinel); } }, methods: { @@ -96,4 +105,4 @@ Vue.component('radius-users', { processChartData(details) { if (!details || !details.length) return { labels: [], datasets: [] }; const daily = details.reduce((a, s) => { const d = s.startTime.split(' ')[0]; if (!a[d]) a[d] = { downloadBytes: 0, uploadBytes: 0 }; a[d].downloadBytes += Number(s.downloadBytes) || 0; a[d].uploadBytes += Number(s.uploadBytes) || 0; return a; }, {}); const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b)); return { labels: dates, datasets: [ { label: 'Download', data: dates.map(d => daily[d].downloadBytes), borderColor: 'rgba(15, 157, 88, 0.8)', backgroundColor: 'rgba(15, 157, 88, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 }, { label: 'Upload', data: dates.map(d => daily[d].uploadBytes), borderColor: 'rgba(0, 83, 132, 0.8)', backgroundColor: 'rgba(0, 83, 132, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 } ] }; }, renderTransferChart() { if (this.transferChartInstance) this.transferChartInstance.destroy(); if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return; const d = this.processChartData(this.transferMonthlyData.details); if (!d.labels.length) return; const chartBackgroundColorPlugin = { id: 'customCanvasBackgroundColor', beforeDraw: (chart) => { const { ctx } = chart; ctx.save(); ctx.globalCompositeOperation = 'destination-over'; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, chart.width, chart.height); ctx.restore(); } }; this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), { type: 'line', data: d, options: { responsive: true, maintainAspectRatio: false, scales: { x: { type: 'time', time: { unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: { day: 'DD.MM' } }, grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } }, y: { beginAtZero: true, ticks: { callback: (v) => window.RadiusUtils.formatBytes(v, 0) }, grid: { color: 'rgba(0,0,0,0.05)' } } }, plugins: { tooltip: { callbacks: { label: (c) => `${c.dataset.label || ''}: ${window.RadiusUtils.formatBytes(c.parsed.y)}` } }, legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } } }, interaction: { mode: 'index', intersect: false } }, plugins: [chartBackgroundColorPlugin] }); } } -}); \ No newline at end of file +}); From 37be99b284aad4ffb73a818e13a7eb8775cdab63 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Fri, 31 Oct 2025 10:51:07 +0000 Subject: [PATCH 14/22] Update RadiusUsers.js --- public/js/pages/Radius/RadiusUsers.js | 240 +++++++++++++++----------- 1 file changed, 135 insertions(+), 105 deletions(-) diff --git a/public/js/pages/Radius/RadiusUsers.js b/public/js/pages/Radius/RadiusUsers.js index 7866dabdc..f2798be36 100644 --- a/public/js/pages/Radius/RadiusUsers.js +++ b/public/js/pages/Radius/RadiusUsers.js @@ -1,108 +1,138 @@ /* ===== RadiusUsers.js ===== */ Vue.component('radius-users', { - template: ` -
    -
    -
    -
    z.B. nat* für lazy Suche
    -
    Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt)
    -
    -
    -
    - - -
    -
    -
    - - - - - - -
    Suche läuft...{{ radiusUsers.length }} Treffer gefunden
    -
    - -
    -
    Status
    {{ radacctData.online ? 'Online' : 'Offline' }}
    -
    IP
    {{ radacctData.ip || '—' }}
    - - - -
    -
    - - - - -
    -

    Bitte geben Sie eine gültige E-Mail-Adresse ein.

    -
    -
    -
    -
    - `, - data: () => ({ window: window, billAddrDisplay: '', billAddrCustnum: '', username: '', ip: '', info: '', searchMode: 'autocomplete', radiusUsers: [], checkOnlineState: false, isLoading: false, showRadacctModal: false, radacctData: null, searchCount: 0, hasSearched: false, visibleCount: 50, observer: null, showTransferModal: false, transferInitialLoading: false, transferMonthlyLoading: false, transferModalUsername: '', transferYear: new Date().getFullYear(), transferMonth: new Date().getMonth() + 1, transferYearlyData: null, transferMonthlyData: null, transferChartInstance: null, showYearDropdown: false, isSendingEmail: false, showEmailModal: false, recipientEmail: '' }), - computed: { - hasFilters() { return this.billAddrDisplay || this.username || this.ip || this.info; }, - visibleUsers() { return this.radiusUsers.slice(0, this.visibleCount); }, - availableYears() { const c = new Date().getFullYear(), s = 2021; if (s > c) return [c]; return Array.from({length: c - s + 1}, (_, i) => c - i); }, - allMonths() { return Array.from({ length: 12 }, (_, i) => ({ month: i + 1, name: new Date(2000, i, 1).toLocaleString('de-DE', { month: 'short' }) })); }, - isValidEmail() { if (!this.recipientEmail) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail); } - }, - mounted() { - const urlParams = new URLSearchParams(window.location.search); - const infoParam = urlParams.get('info'); - if (infoParam) { - this.info = infoParam; - this.loadRadiusUsers(); - } - this.observer = new IntersectionObserver(([e]) => { if (e && e.isIntersecting) this.loadMore(); }, { root: this.$refs.tableWrap, threshold: 0.1 }); - if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel); - }, - beforeDestroy() { if (this.observer) this.observer.disconnect(); if (this.transferChartInstance) this.transferChartInstance.destroy(); }, - updated() { if (this.observer && this.$refs.sentinel) { this.observer.disconnect(); this.observer.observe(this.$refs.sentinel); } }, - methods: { - onAddrSelect({ custnum, display }) { this.billAddrCustnum = custnum || ''; this.billAddrDisplay = display || ''; }, - onModeChange(newMode) { this.searchMode = newMode; }, - async loadRadiusUsers() { this.isLoading = true; this.radiusUsers = []; this.hasSearched = true; this.visibleCount = 50; try { const p = new URLSearchParams({ username: this.username || '', info: this.info || '', ip: this.ip || '' }); if (this.searchMode === 'text') p.set('estmk_nr', this.billAddrDisplay || ''); else p.set('custnum', this.billAddrCustnum || ''); const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?${p.toString()}`); if (r.ok) { const u = await r.json(); if (Array.isArray(u) && u.length < 6) this.checkOnlineState = true; this.radiusUsers = Array.isArray(u) ? u : []; } } catch (e) { console.error(e); } this.isLoading = false; this.searchCount++; }, - async fetchRadacctData(username) { this.showRadacctModal = true; this.radacctData = null; try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(username)}`); if (r.ok) this.radacctData = await r.json(); } catch (e) { console.error(e); this.radacctData = {}; } }, - async copy(text, event) { if (!event || !event.currentTarget) return; const btn = event.currentTarget; if (btn.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(text); btn.classList.add('is-copied'); btn.disabled = true; setTimeout(() => { btn.classList.remove('is-copied'); btn.disabled = false; }, 1500); }, - clearFilters() { this.billAddrDisplay = ''; this.billAddrCustnum = ''; this.username = ''; this.ip = ''; this.info = ''; this.radiusUsers = []; this.hasSearched = false; this.searchCount++; this.visibleCount = 50; }, - loadMore() { if (this.visibleCount < this.radiusUsers.length) this.visibleCount += 50; }, - async openTransferModal(username) { this.showTransferModal = true; this.transferModalUsername = username; this.transferYear = new Date().getFullYear(); this.transferMonth = new Date().getMonth() + 1; await this.fetchTransferYearData(); }, - closeTransferModal() { this.showTransferModal = false; this.transferModalUsername = ''; this.transferYearlyData = null; this.transferMonthlyData = null; this.showYearDropdown = false; this.showEmailModal = false; this.recipientEmail = ''; this.isSendingEmail = false; if (this.transferChartInstance) { this.transferChartInstance.destroy(); this.transferChartInstance = null; } }, - async fetchTransferYearData() { this.transferInitialLoading = true; this.transferYearlyData = null; try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=0`); if (r.ok) { const d = await r.json(); if(d && d.monthlySummary) { this.transferYearlyData = d; const last = [...d.monthlySummary].reverse().find(m => m.grandTotalBytes > 0); this.transferMonth = last ? last.month : new Date().getMonth() + 1; await this.fetchTransferMonthData(); }} else this.transferYearlyData = null; } catch (e) { console.error(e); this.transferYearlyData = null; } this.transferInitialLoading = false; }, - async fetchTransferMonthData() { this.transferMonthlyLoading = true; this.transferMonthlyData = null; if (this.transferChartInstance) this.transferChartInstance.destroy(); try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=${this.transferMonth}`); this.transferMonthlyData = r.ok ? await r.json() : null; } catch (e) { console.error(e); this.transferMonthlyData = null; } this.transferMonthlyLoading = false; this.$nextTick(() => { if(this.showTransferModal) this.renderTransferChart(); }); }, - prepareEmailModal() { if (this.transferInitialLoading || !this.transferYearlyData || !this.transferYearlyData.yearlySummary || this.transferYearlyData.yearlySummary.grandTotalBytes === 0) return; this.recipientEmail = ''; this.showEmailModal = true; }, - async sendTransferEmail() { if (!this.transferMonthlyData || !this.transferChartInstance || !this.isValidEmail) return; this.isSendingEmail = true; try { const chartImageBase64 = this.transferChartInstance.toBase64Image(); const payload = { username: this.transferModalUsername, year: this.transferYear, month: this.transferMonth, monthlySummary: this.transferMonthlyData.summary, monthlyDetails: this.transferMonthlyData.details, chartImage: chartImageBase64, recipient: this.recipientEmail }; const res = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { if (window.notify) window.notify('success', 'E-Mail wurde erfolgreich versendet.'); this.showEmailModal = false; } else { throw new Error('Server responded with an error.'); } } catch (e) { console.error("Failed to send transfer email:", e); if (window.notify) window.notify('error', 'Fehler beim Senden der E-Mail.'); } finally { this.isSendingEmail = false; } }, - isMonthDisabled(month) { if (this.transferInitialLoading || this.transferMonthlyLoading) return true; if (!this.transferYearlyData?.monthlySummary) return true; const m = this.transferYearlyData.monthlySummary.find(m => m.month === month); return !m || m.grandTotalBytes === 0; }, - selectYear(year) { this.showYearDropdown = false; if (this.transferYear !== year) this.changeTransferYear(year); }, - async changeTransferYear(year) { this.transferYear = year; await this.fetchTransferYearData(); }, - async changeTransferMonth(month) { this.transferMonth = month; await this.fetchTransferMonthData(); }, - processChartData(details) { if (!details || !details.length) return { labels: [], datasets: [] }; const daily = details.reduce((a, s) => { const d = s.startTime.split(' ')[0]; if (!a[d]) a[d] = { downloadBytes: 0, uploadBytes: 0 }; a[d].downloadBytes += Number(s.downloadBytes) || 0; a[d].uploadBytes += Number(s.uploadBytes) || 0; return a; }, {}); const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b)); return { labels: dates, datasets: [ { label: 'Download', data: dates.map(d => daily[d].downloadBytes), borderColor: 'rgba(15, 157, 88, 0.8)', backgroundColor: 'rgba(15, 157, 88, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 }, { label: 'Upload', data: dates.map(d => daily[d].uploadBytes), borderColor: 'rgba(0, 83, 132, 0.8)', backgroundColor: 'rgba(0, 83, 132, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 } ] }; }, - renderTransferChart() { if (this.transferChartInstance) this.transferChartInstance.destroy(); if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return; const d = this.processChartData(this.transferMonthlyData.details); if (!d.labels.length) return; const chartBackgroundColorPlugin = { id: 'customCanvasBackgroundColor', beforeDraw: (chart) => { const { ctx } = chart; ctx.save(); ctx.globalCompositeOperation = 'destination-over'; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, chart.width, chart.height); ctx.restore(); } }; this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), { type: 'line', data: d, options: { responsive: true, maintainAspectRatio: false, scales: { x: { type: 'time', time: { unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: { day: 'DD.MM' } }, grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } }, y: { beginAtZero: true, ticks: { callback: (v) => window.RadiusUtils.formatBytes(v, 0) }, grid: { color: 'rgba(0,0,0,0.05)' } } }, plugins: { tooltip: { callbacks: { label: (c) => `${c.dataset.label || ''}: ${window.RadiusUtils.formatBytes(c.parsed.y)}` } }, legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } } }, interaction: { mode: 'index', intersect: false } }, plugins: [chartBackgroundColorPlugin] }); } - } +    template: ` +     
    +       
    +         
    +         
    z.B. nat* für lazy Suche
    +         
    Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt)
    +         
    +         
    +           
    +            +            +         
    +       
    +       
    +          +            +            +            +            +          +         
    Suche läuft...{{ radiusUsers.length }} Treffer gefunden
    +       
    +        +         
    +           
    Status
    {{ radacctData.online ? 'Online' : 'Offline' }}
    +                        +                        +            +            +         
    +       
    +        +          +        +        +         
    +           

    Bitte geben Sie eine gültige E-Mail-Adresse ein.

    +           
    +         
    +       
    +     
    +    `, +    data: () => ({ window: window, billAddrDisplay: '', billAddrCustnum: '', username: '', ip: '', info: '', searchMode: 'autocomplete', radiusUsers: [], checkOnlineState: false, isLoading: false, showRadacctModal: false, radacctData: null, searchCount: 0, hasSearched: false, visibleCount: 50, observer: null, showTransferModal: false, transferInitialLoading: false, transferMonthlyLoading: false, transferModalUsername: '', transferYear: new Date().getFullYear(), transferMonth: new Date().getMonth() + 1, transferYearlyData: null, transferMonthlyData: null, transferChartInstance: null, showYearDropdown: false, isSendingEmail: false, showEmailModal: false, recipientEmail: '' }), +    computed: { +        hasFilters() { return this.billAddrDisplay || this.username || this.ip || this.info; }, +        visibleUsers() { return this.radiusUsers.slice(0, this.visibleCount); }, +        availableYears() { const c = new Date().getFullYear(), s = 2021; if (s > c) return [c]; return Array.from({length: c - s + 1}, (_, i) => c - i); }, +        allMonths() { return Array.from({ length: 12 }, (_, i) => ({ month: i + 1, name: new Date(2000, i, 1).toLocaleString('de-DE', { month: 'short' }) })); }, +        isValidEmail() { if (!this.recipientEmail) return false; return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(this.recipientEmail); } +    }, +    mounted() { +        const urlParams = new URLSearchParams(window.location.search); +        const infoParam = urlParams.get('info'); +        if (infoParam) { +            this.info = infoParam; +            this.loadRadiusUsers(); +        } +        this.observer = new IntersectionObserver(([e]) => { if (e && e.isIntersecting) this.loadMore(); }, { root: this.$refs.tableWrap, threshold: 0.1 }); +        if (this.$refs.sentinel) this.observer.observe(this.$refs.sentinel); +    }, +    beforeDestroy() { if (this.observer) this.observer.disconnect(); if (this.transferChartInstance) this.transferChartInstance.destroy(); }, +    updated() { if (this.observer && this.$refs.sentinel) { this.observer.disconnect(); this.observer.observe(this.$refs.sentinel); } }, +    methods: { +        onAddrSelect({ custnum, display }) { this.billAddrCustnum = custnum || ''; this.billAddrDisplay = display || ''; }, +        onModeChange(newMode) { this.searchMode = newMode; }, +        async loadRadiusUsers() { this.isLoading = true; this.radiusUsers = []; this.hasSearched = true; this.visibleCount = 50; try { const p = new URLSearchParams({ username: this.username || '', info: this.info || '', ip: this.ip || '' }); if (this.searchMode === 'text') p.set('estmk_nr', this.billAddrDisplay || ''); else p.set('custnum', this.billAddrCustnum || ''); const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?${p.toString()}`); if (r.ok) { const u = await r.json(); if (Array.isArray(u) && u.length < 6) this.checkOnlineState = true; this.radiusUsers = Array.isArray(u) ? u : []; } } catch (e) { console.error(e); } this.isLoading = false; this.searchCount++; }, +        async fetchRadacctData(username) { this.showRadacctModal = true; this.radacctData = null; try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(username)}`); if (r.ok) this.radacctData = await r.json(); } catch (e) { console.error(e); this.radacctData = {}; } }, +        async copy(text, event) { if (!event || !event.currentTarget) return; const btn = event.currentTarget; if (btn.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(text); btn.classList.add('is-copied'); btn.disabled = true; setTimeout(() => { btn.classList.remove('is-copied'); btn.disabled = false; }, 1500); }, +        clearFilters() { this.billAddrDisplay = ''; this.billAddrCustnum = ''; this.username = ''; this.ip = ''; this.info = ''; this.radiusUsers = []; this.hasSearched = false; this.searchCount++; this.visibleCount = 50; }, +        loadMore() { if (this.visibleCount < this.radiusUsers.length) this.visibleCount += 50; }, +        // ===== NEW METHOD ===== +        async scanIp(ip) { +            if (!ip) return; +            if (window.notify) { +                window.notify('info', `Starte Scan für ${ip}...`); +            } +            try { +                const response = await fetch(`http://localhost:8094/scan?ip=${ip}`); +                if (!response.ok) { +                    throw new Error(`HTTP-Fehler! Status: ${response.status}`); +                } +                const data = await response.json(); + +                if (data.status === 'success' && data.url) { +                    window.open(data.url, '_blank'); +                } else if (data.status === 'not_found') { +                    if (window.notify) { +                        window.notify('warning', `Kein Gerät für ${ip} gefunden.`); +                    } +                } else { +                    throw new Error('Ungültige oder unerwartete Antwort vom Scan-Server.'); +                } +            } catch (error) { +                console.error('IP-Scan fehlgeschlagen:', error); +                if (window.notify) { +                    window.notify('error', `Scan für ${ip} fehlgeschlagen.`); +                } +            } +        }, +        // ===== END NEW METHOD ===== +        async openTransferModal(username) { this.showTransferModal = true; this.transferModalUsername = username; this.transferYear = new Date().getFullYear(); this.transferMonth = new Date().getMonth() + 1; await this.fetchTransferYearData(); }, +        closeTransferModal() { this.showTransferModal = false; this.transferModalUsername = ''; this.transferYearlyData = null; this.transferMonthlyData = null; this.showYearDropdown = false; this.showEmailModal = false; this.recipientEmail = ''; this.isSendingEmail = false; if (this.transferChartInstance) { this.transferChartInstance.destroy(); this.transferChartInstance = null; } }, +        async fetchTransferYearData() { this.transferInitialLoading = true; this.transferYearlyData = null; try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=0`); if (r.ok) { const d = await r.json(); if(d && d.monthlySummary) { this.transferYearlyData = d; const last = [...d.monthlySummary].reverse().find(m => m.grandTotalBytes > 0); this.transferMonth = last ? last.month : new Date().getMonth() + 1; await this.fetchTransferMonthData(); }} else this.transferYearlyData = null; } catch (e) { console.error(e); this.transferYearlyData = null; } this.transferInitialLoading = false; }, +        async fetchTransferMonthData() { this.transferMonthlyLoading = true; this.transferMonthlyData = null; if (this.transferChartInstance) this.transferChartInstance.destroy(); try { const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=transferStatistic&username=${this.transferModalUsername}&year=${this.transferYear}&month=${this.transferMonth}`); this.transferMonthlyData = r.ok ? await r.json() : null; } catch (e) { console.error(e); this.transferMonthlyData = null; } this.transferMonthlyLoading = false; this.$nextTick(() => { if(this.showTransferModal) this.renderTransferChart(); }); }, +        prepareEmailModal() { if (this.transferInitialLoading || !this.transferYearlyData || !this.transferYearlyData.yearlySummary || this.transferYearlyData.yearlySummary.grandTotalBytes === 0) return; this.recipientEmail = ''; this.showEmailModal = true; }, +        async sendTransferEmail() { if (!this.transferMonthlyData || !this.transferChartInstance || !this.isValidEmail) return; this.isSendingEmail = true; try { const chartImageBase64 = this.transferChartInstance.toBase64Image(); const payload = { username: this.transferModalUsername, year: this.transferYear, month: this.transferMonth, monthlySummary: this.transferMonthlyData.summary, monthlyDetails: this.transferMonthlyData.details, chartImage: chartImageBase64, recipient: this.recipientEmail }; const res = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/sendCustomerEmail`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (res.ok) { if (window.notify) window.notify('success', 'E-Mail wurde erfolgreich versendet.'); this.showEmailModal = false; } else { throw new Error('Server responded with an error.'); } } catch (e) { console.error("Failed to send transfer email:", e); if (window.notify) window.notify('error', 'Fehler beim Senden der E-Mail.'); } finally { this.isSendingEmail = false; } }, +        isMonthDisabled(month) { if (this.transferInitialLoading || this.transferMonthlyLoading) return true; if (!this.transferYearlyData?.monthlySummary) return true; const m = this.transferYearlyData.monthlySummary.find(m => m.month === month); return !m || m.grandTotalBytes === 0; }, +        selectYear(year) { this.showYearDropdown = false; if (this.transferYear !== year) this.changeTransferYear(year); }, +        async changeTransferYear(year) { this.transferYear = year; await this.fetchTransferYearData(); }, +        async changeTransferMonth(month) { this.transferMonth = month; await this.fetchTransferMonthData(); }, +        processChartData(details) { if (!details || !details.length) return { labels: [], datasets: [] }; const daily = details.reduce((a, s) => { const d = s.startTime.split(' ')[0]; if (!a[d]) a[d] = { downloadBytes: 0, uploadBytes: 0 }; a[d].downloadBytes += Number(s.downloadBytes) || 0; a[d].uploadBytes += Number(s.uploadBytes) || 0; return a; }, {}); const dates = Object.keys(daily).sort((a, b) => new Date(a) - new Date(b)); return { labels: dates, datasets: [ { label: 'Download', data: dates.map(d => daily[d].downloadBytes), borderColor: 'rgba(15, 157, 88, 0.8)', backgroundColor: 'rgba(15, 157, 88, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 }, { label: 'Upload', data: dates.map(d => daily[d].uploadBytes), borderColor: 'rgba(0, 83, 132, 0.8)', backgroundColor: 'rgba(0, 83, 132, 0.1)', fill: true, tension: 0.3, pointRadius: 2, borderWidth: 1.5 } ] }; }, +        renderTransferChart() { if (this.transferChartInstance) this.transferChartInstance.destroy(); if (!this.$refs.transferChartCanvas || !this.transferMonthlyData?.details?.length || !window.Chart) return; const d = this.processChartData(this.transferMonthlyData.details); if (!d.labels.length) return; const chartBackgroundColorPlugin = { id: 'customCanvasBackgroundColor', beforeDraw: (chart) => { const { ctx } = chart; ctx.save(); ctx.globalCompositeOperation = 'destination-over'; ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, chart.width, chart.height); ctx.restore(); } }; this.transferChartInstance = new Chart(this.$refs.transferChartCanvas.getContext('2d'), { type: 'line', data: d, options: { responsive: true, maintainAspectRatio: false, scales: { x: { type: 'time', time: { unit: 'day', tooltipFormat: 'DD.MM.YYYY', displayFormats: { day: 'DD.MM' } }, grid: { display: false }, ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: 15 } }, y: { beginAtZero: true, ticks: { callback: (v) => window.RadiusUtils.formatBytes(v, 0) }, grid: { color: 'rgba(0,0,0,0.05)' } } }, plugins: { tooltip: { callbacks: { label: (c) => `${c.dataset.label || ''}: ${window.RadiusUtils.formatBytes(c.parsed.y)}` } }, legend: { position: 'bottom', labels: { usePointStyle: true, boxWidth: 8, padding: 20 } } }, interaction: { mode: 'index', intersect: false } }, plugins: [chartBackgroundColorPlugin] }); } +    } }); From 887552a734ab6fbcedec954af74a7b2cd0135b6b Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 3 Nov 2025 12:59:00 +0100 Subject: [PATCH 15/22] added new rml stuff --- .../default/VueViews/WorkorderCompanyPWA.php | 102 ++++++++++++++++-- application/Workorder/WorkorderModel.php | 2 + .../WorkorderCompanyController.php | 68 +++++++++++- .../WorkorderTenantConfigModel.php | 5 +- ...51103125000_workorder_add_cable_fields.php | 55 ++++++++++ .../js/pages/WorkorderBase/WorkorderBase.js | 74 ++++++++++++- .../WorkorderTenantConfig.js | 20 +++- 7 files changed, 306 insertions(+), 20 deletions(-) create mode 100644 db/migrations/20251103125000_workorder_add_cable_fields.php diff --git a/Layout/default/VueViews/WorkorderCompanyPWA.php b/Layout/default/VueViews/WorkorderCompanyPWA.php index 27ef3c832..37634e7dd 100644 --- a/Layout/default/VueViews/WorkorderCompanyPWA.php +++ b/Layout/default/VueViews/WorkorderCompanyPWA.php @@ -115,6 +115,7 @@ const isSettingsOpen = ref(false); const theme = ref('system'); // 'light', 'dark', 'system' const showThemePicker = ref(false); + const savingData = ref(false); // <-- ADDED const API_BASE_URL = window.TT_CONFIG.BASE_PATH || '/WorkorderCompany'; @@ -211,9 +212,24 @@ }); }); + // MODIFIED const isChecklistComplete = computed(() => { - if (checklist.value.length === 0) return true; - return checklist.value.every(item => item.completed); + // Check documents + if (checklist.value.length > 0) { + if (!checklist.value.every(item => item.completed)) { + return false; + } + } + + // Check new fields + if (tenantConfig.value?.requireCableLength && (!selectedWorkorder.value.cableLength || !selectedWorkorder.value.cableLength.trim())) { + return false; + } + if (tenantConfig.value?.requireCableType && (!selectedWorkorder.value.cableType || !selectedWorkorder.value.cableType.trim())) { + return false; + } + + return true; // All checks passed }); const translatedDocs = computed(() => { @@ -291,7 +307,7 @@ documentation.docs = docRes.data.docs.map(d => ({...d, isPdf: d.mimetype === 'application/pdf'})); documentation.journals = docRes.data.journals; if (configRes.data.success) { - tenantConfig.value = configRes.data; + tenantConfig.value = configRes.data; // <-- MODIFIED: This will now contain all flags } } catch (e) { console.error("Could not load details", e); } finally { isDetailsLoading.value = false; } @@ -329,6 +345,36 @@ finally { isEditingInfo.value = false; } }; + // START ADDED + const saveWorkorderData = async () => { + savingData.value = true; + try { + const response = await api.post('/updateWorkorderData', { + workorderId: selectedWorkorder.value.id, + cableLength: selectedWorkorder.value.cableLength, + cableType: selectedWorkorder.value.cableType + }); + if (response.data.success) { + alert('Daten gespeichert.'); // PWA uses alert() + documentation.journals = response.data.journals; // Update journal + // Also update the main list item + const woInList = workorders.value.find(w => w.id === selectedWorkorder.value.id); + if (woInList) { + woInList.cableLength = selectedWorkorder.value.cableLength; + woInList.cableType = selectedWorkorder.value.cableType; + } + } else { + alert(response.data.message || 'Speichern fehlgeschlagen.'); + } + } catch (e) { + console.error("Failed to save data", e); + alert('Ein Netzwerkfehler ist aufgetreten.'); + } finally { + savingData.value = false; + } + }; + // END ADDED + const addJournalEntry = async () => { if (!newJournalEntry.value.trim()) return; try { @@ -401,13 +447,23 @@ finally { problemModal.show = false; problemModal.selectedInterventions = []; problemModal.details = {}; } }; + // MODIFIED const handleCompleteClick = () => { if (isChecklistComplete.value) { if (confirm("Möchten Sie diesen Auftrag wirklich abschließen?")) { completeWorkorder(); } } else { - missingTasksPopover.tasks = checklist.value.filter(t => !t.completed).map(t => t.text); + const missingDocs = checklist.value.filter(t => !t.completed).map(t => t.text); + const missingData = []; + if (tenantConfig.value?.requireCableLength && (!selectedWorkorder.value.cableLength || !selectedWorkorder.value.cableLength.trim())) { + missingData.push("Kabellänge"); + } + if (tenantConfig.value?.requireCableType && (!selectedWorkorder.value.cableType || !selectedWorkorder.value.cableType.trim())) { + missingData.push("Kabeltyp"); + } + + missingTasksPopover.tasks = [...missingDocs, ...missingData]; missingTasksPopover.show = true; setTimeout(() => missingTasksPopover.show = false, 4000); } @@ -415,10 +471,18 @@ const completeWorkorder = async () => { try { - await api.post('/completeWorkorder', { workorderId: selectedWorkorder.value.id }); - await fetchWorkorders(); - closeDetails(); - } catch(e) { console.error("Failed to complete workorder", e); } + // Server-side validation will catch errors if client-side check fails + const response = await api.post('/completeWorkorder', { workorderId: selectedWorkorder.value.id }); + if (response.data.success) { + await fetchWorkorders(); + closeDetails(); + } else { + alert(response.data.message); // Show validation error from server + } + } catch(e) { + console.error("Failed to complete workorder", e); + alert(e.response?.data?.message || 'Fehler beim Abschließen.'); + } }; const selectFcp = (fcpValue) => { @@ -456,8 +520,10 @@ checklist, fullscreenViewer, missingTasksPopover, translatedDocs, filteredJournals, installModal, isStandalone, selectedFcp, isFcpSelectOpen, fcpOptions, selectedFcpText, fcpSearchTerm, filteredFcpOptions, fcpInputRef, isSettingsOpen, theme, showThemePicker, + savingData, // <-- ADDED fetchWorkorders, openDetails, closeDetails, getStatusInfo, formatDate, googleMapsLink, startEditInfo, saveAdditionalInfo, - handleFileSelect, executeUpload, addJournalEntry, submitProblem, handleCompleteClick, selectFcp, setTheme + handleFileSelect, executeUpload, addJournalEntry, submitProblem, handleCompleteClick, selectFcp, setTheme, + saveWorkorderData // <-- ADDED }; }, template: ` @@ -593,6 +659,20 @@

    {{ selectedWorkorder.additionalInfo || 'Keine Notiz.' }}

    +
    +

    Zusatzdaten

    +
    + + +
    +
    + + +
    + +

    Checkliste

    @@ -672,7 +752,7 @@
    -

    Fehlende Checklisten-Punkte:

    +

    Fehlende Punkte:

    • {{ task }}
    @@ -870,4 +950,4 @@ - + \ No newline at end of file diff --git a/application/Workorder/WorkorderModel.php b/application/Workorder/WorkorderModel.php index 6ec80a964..541b86191 100644 --- a/application/Workorder/WorkorderModel.php +++ b/application/Workorder/WorkorderModel.php @@ -14,6 +14,8 @@ class WorkorderModel extends TTCrudBaseModel public ?int $deadlineDate; public ?int $appointmentDate; public ?string $additionalInfo; + public ?string $cableLength; + public ?string $cableType; public int $create; public int $createBy; diff --git a/application/WorkorderCompany/WorkorderCompanyController.php b/application/WorkorderCompany/WorkorderCompanyController.php index 7c501f946..e0ab6266a 100644 --- a/application/WorkorderCompany/WorkorderCompanyController.php +++ b/application/WorkorderCompany/WorkorderCompanyController.php @@ -143,6 +143,18 @@ class WorkorderCompanyController extends WorkorderBaseController { $workorder = WorkorderModel::get($this->postData['workorderId']); if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + // START ADDED VALIDATION + $tenantConfig = $this->getTenantConfigFromWorkorder($workorder->id); + if ($tenantConfig) { + if ($tenantConfig->requireCableLength && empty(trim($workorder->cableLength))) { + self::sendError("Bitte geben Sie die Kabellänge an, um den Auftrag abzuschließen."); + } + if ($tenantConfig->requireCableType && empty(trim($workorder->cableType))) { + self::sendError("Bitte geben Sie den Kabeltyp an, um den Auftrag abzuschließen."); + } + } + // END ADDED VALIDATION + $workorder->status = 'documented'; WorkorderModel::update((array)$workorder); self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag zur Prüfung eingereicht.']); @@ -155,7 +167,14 @@ class WorkorderCompanyController extends WorkorderBaseController { self::returnJson(['success' => false, 'message' => 'Keine Mandantenkonfiguration gefunden.']); return; } - self::returnJson(['success' => true, 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true), 'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired, 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true)]); + self::returnJson([ + 'success' => true, + 'documentationTypes' => json_decode($tenantConfig->documentationTypes, true), + 'civilEngineeringDocsRequired' => $tenantConfig->civilEngineeringDocsRequired, + 'interventionTypes' => json_decode($tenantConfig->interventionTypes, true), + 'requireCableLength' => $tenantConfig->requireCableLength, + 'requireCableType' => $tenantConfig->requireCableType + ]); } protected function uploadDocumentationAction() { @@ -237,5 +256,52 @@ class WorkorderCompanyController extends WorkorderBaseController { ]); self::returnJson(['success' => true, 'message' => 'Tiefbau erfolgreich abgeschlossen.']); } + + protected function updateWorkorderDataAction() { + if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt."); + $workorder = WorkorderModel::get($this->postData['workorderId']); + if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden."); + + $journalText = "Zusatzdaten aktualisiert:\n"; + $changed = false; + + if (isset($this->postData['cableLength'])) { + if ($workorder->cableLength != $this.postData['cableLength']) { + $journalText .= "Kabellänge: '{$workorder->cableLength}' -> '{$this->postData['cableLength']}'\n"; + $workorder->cableLength = $this.postData['cableLength']; + $changed = true; + } + } + + if (isset($this->postData['cableType'])) { + if ($workorder->cableType != $this.postData['cableType']) { + $journalText .= "Kabeltyp: '{$workorder->cableType}' -> '{$this->postData['cableType']}'\n"; + $workorder->cableType = $this.postData['cableType']; + $changed = true; + } + } + + if (!$changed) { + self::returnJson(['success' => true, 'message' => 'Keine Änderungen vorgenommen.']); + return; + } + + WorkorderModel::update((array)$workorder); + + WorkorderJournalModel::create([ + 'workorderId' => $workorder->id, + 'text' => $journalText, + 'create' => time(), + 'createBy' => $this->user->id, + ]); + + // Re-fetch journals to return + $journals = WorkorderJournalModel::getAll(['workorderId' => intval($workorder->id)], null, 0, ['key' => 'create', 'order' => 'DESC']); + foreach ($journals as $journal) { + $journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt'; + } + + self::returnJson(['success' => true, 'message' => 'Daten gespeichert.', 'journals' => $journals]); + } //endregion } \ No newline at end of file diff --git a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php index 862642472..6454d6502 100644 --- a/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php +++ b/application/WorkorderTenantConfig/WorkorderTenantConfigModel.php @@ -10,6 +10,8 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel { public ?string $workorderActiveFilters; // JSON public ?string $interventionTypes; // JSON public int $civilEngineeringDocsRequired; + public int $requireCableLength; + public int $requireCableType; public int $create; public int $createBy; @@ -31,4 +33,5 @@ class WorkorderTenantConfigModel extends TTCrudBaseModel { $row = $result ? $result->fetch_assoc() : null; return $row ? new self($row) : null; - }} \ No newline at end of file + } +} \ No newline at end of file diff --git a/db/migrations/20251103125000_workorder_add_cable_fields.php b/db/migrations/20251103125000_workorder_add_cable_fields.php new file mode 100644 index 000000000..a15ad1206 --- /dev/null +++ b/db/migrations/20251103125000_workorder_add_cable_fields.php @@ -0,0 +1,55 @@ +getEnvironment() == "thetool") { + $tableWorkorder = $this->table("Workorder"); + $tableWorkorder + ->addColumn("cableLength", "string", [ + 'null' => true, + 'default' => null, + 'after' => 'additionalInfo' + ]) + ->addColumn("cableType", "string", [ + 'null' => true, + 'default' => null, + 'after' => 'cableLength' + ]) + ->update(); + + $tableConfig = $this->table("WorkorderTenantConfig"); + $tableConfig + ->addColumn("requireCableLength", "boolean", [ + 'null' => false, + 'default' => 0, + 'after' => 'civilEngineeringDocsRequired' + ]) + ->addColumn("requireCableType", "boolean", [ + 'null' => false, + 'default' => 0, + 'after' => 'requireCableLength' + ]) + ->update(); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + $this->table("Workorder") + ->removeColumn("cableLength") + ->removeColumn("cableType") + ->save(); + + $this->table("WorkorderTenantConfig") + ->removeColumn("requireCableLength") + ->removeColumn("requireCableType") + ->save(); + } + } +} \ No newline at end of file diff --git a/public/js/pages/WorkorderBase/WorkorderBase.js b/public/js/pages/WorkorderBase/WorkorderBase.js index 47770f6f9..3972717c4 100644 --- a/public/js/pages/WorkorderBase/WorkorderBase.js +++ b/public/js/pages/WorkorderBase/WorkorderBase.js @@ -183,7 +183,7 @@ Vue.component('workorder-details-manager', { :disabled="!canComplete || isReadOnly" :loading="completing" additional-class="btn-success w-100" icon="fas fa-check-double"/> - Bitte laden Sie alle benötigten Dokumente hoch, um den Auftrag abzuschließen. + Bitte laden Sie alle benötigten Dokumente hoch und füllen Sie alle Zusatzdaten (z.B. Kabellänge/-typ) aus, um den Auftrag abzuschließen.
    Auftrag bereits abgeschlossen oder storniert. Keine Aktionen mehr möglich. @@ -245,6 +245,35 @@ Vue.component('workorder-details-manager', {
    +
    +
    +
    Zusatzdaten
    +

    Diese Daten werden für den Abschluss benötigt.

    + + + +
    +
    +
    Korrektur anfordern
    @@ -296,6 +325,9 @@ Vue.component('workorder-details-manager', { uploadData: { files: [], documentType: 'photo_hup_mounted', description: '' }, interventionData: null, interventionTypes: [], + requireCableLength: false, + requireCableType: false, + savingData: false, // Admin state selectedDocs: [], correctionText: '', correctionLoading: false, showAcceptModal: false, showRevertModal: false, }), @@ -305,7 +337,19 @@ Vue.component('workorder-details-manager', { return this.tenantDocTypes ?? []; }, allDocTypes() { return [...this.requiredDocTypes, {value: 'other', text: 'Sonstiges Dokument (optional)'}]; }, - canComplete() { return this.requiredDocTypes.every(docType => this.isUploaded(docType.value)); }, + canComplete() { + const docsUploaded = this.requiredDocTypes.every(docType => this.isUploaded(docType.value)); + if (!docsUploaded) return false; + + if (this.requireCableLength && (!this.workorder.cableLength || !this.workorder.cableLength.trim())) { + return false; + } + if (this.requireCableType && (!this.workorder.cableType || !this.workorder.cableType.trim())) { + return false; + } + + return true; // All checks passed + }, docsWithStatus() { if (!this.journals?.length) return this.docs; const correctionJournal = [...this.journals].sort((a, b) => b.create - a.create).find(j => j.statusChange?.includes('correction_requested')); @@ -348,6 +392,8 @@ Vue.component('workorder-details-manager', { if (data.success) { this.tenantDocTypes = data.documentationTypes; this.interventionTypes = data.interventionTypes; + this.requireCableLength = data.requireCableLength || false; + this.requireCableType = data.requireCableType || false; } } catch (e) { console.error("Mandantenkonfiguration nicht geladen", e); } finally { this.loadingConfig = false; } @@ -404,6 +450,28 @@ Vue.component('workorder-details-manager', { } else window.notify('error', data.message); } catch (e) { window.notify('error', 'Netzwerkfehler'); } }, + async saveWorkorderData() { + this.savingData = true; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/updateWorkorderData`, { + workorderId: this.workorderId, + cableLength: this.workorder.cableLength, + cableType: this.workorder.cableType + }); + if (data.success) { + window.notify('success', data.message); + if (data.journals) { + this.journals = data.journals; // Update journal with new entry + } + } else { + window.notify('error', data.message || 'Speichern fehlgeschlagen.'); + } + } catch (e) { + window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.'); + } finally { + this.savingData = false; + } + }, openInterventionModal() { this.interventionData = { types: [], details: { stuck: {}, stuck_fcp: {}, stuck_hup: {}, other: {} } }; }, @@ -489,4 +557,4 @@ Vue.component('workorder-details-manager', { await this.loadTenantConfig(); await this.fetchData(); } -}); +}); \ No newline at end of file diff --git a/public/js/pages/WorkorderTenantConfig/WorkorderTenantConfig.js b/public/js/pages/WorkorderTenantConfig/WorkorderTenantConfig.js index 2b3e29821..76406c561 100644 --- a/public/js/pages/WorkorderTenantConfig/WorkorderTenantConfig.js +++ b/public/js/pages/WorkorderTenantConfig/WorkorderTenantConfig.js @@ -77,9 +77,19 @@ Vue.component('workorder-tenant-config', {
    Optionen
    - -

    Tiefbau-Doku: {{ config.civilEngineeringDocsRequired ? 'Ja' : 'Nein' }}

    +
    + + + +
    +
    +

    Tiefbau-Doku: {{ config.civilEngineeringDocsRequired ? 'Ja' : 'Nein' }}

    +

    Kabellänge-Doku: {{ config.requireCableLength ? 'Ja' : 'Nein' }}

    +

    Kabeltyp-Doku: {{ config.requireCableType ? 'Ja' : 'Nein' }}

    +
    Zugeordnete Firmen
    @@ -312,7 +322,9 @@ Vue.component('workorder-tenant-config', { interventionTypes: [], workorderCreationFilters: '{}', workorderActiveFilters: '{}', - civilEngineeringDocsRequired: 0 + civilEngineeringDocsRequired: 0, + requireCableLength: 0, + requireCableType: 0 } : {visibleForAddressId: []}; this.showModal = true; From 5dac241ca2de1a06ff83ef5b820f6a82f3699a5d Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Mon, 3 Nov 2025 13:23:17 +0100 Subject: [PATCH 16/22] added new rml stuff --- application/Workorder/WorkorderModel.php | 2 +- application/WorkorderCompany/WorkorderCompanyController.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/Workorder/WorkorderModel.php b/application/Workorder/WorkorderModel.php index 541b86191..b5762a147 100644 --- a/application/Workorder/WorkorderModel.php +++ b/application/Workorder/WorkorderModel.php @@ -163,7 +163,7 @@ class WorkorderModel extends TTCrudBaseModel $orderBy = ""; if (!empty($order['key'])) { - $sortableColumns = ['id', 'status', 'deadlineDate', 'appointmentDate', 'additionalInfo', 'preordercampaign_id']; + $sortableColumns = ['id', 'status', 'deadlineDate', 'rimo_fcp_name', 'appointmentDate', 'additionalInfo', 'preordercampaign_id']; if (in_array($order['key'], $sortableColumns)) { $sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC'; $orderBy = " ORDER BY " . $db->real_escape_string($order['key']) . " " . $sortOrder; diff --git a/application/WorkorderCompany/WorkorderCompanyController.php b/application/WorkorderCompany/WorkorderCompanyController.php index e0ab6266a..6125b6e81 100644 --- a/application/WorkorderCompany/WorkorderCompanyController.php +++ b/application/WorkorderCompany/WorkorderCompanyController.php @@ -10,7 +10,7 @@ class WorkorderCompanyController extends WorkorderBaseController { ['key' => 'networkOwnerName', 'text' => 'Auftraggeber', 'table' => ['sortable' => false]], ['key' => 'preordercampaign_id', 'text' => 'Kampagne', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => true]], ['key' => 'preorderInfo', 'text' => 'Kunde', 'modal' => false, 'table' => ['sortable' => false]], - ['key' => 'rimo_fcp_name', 'text' => 'FCP', 'modal' => false, 'table' => ['sortable' => false]], + ['key' => 'rimo_fcp_name', 'text' => 'FCP', 'modal' => false, 'table' => ['sortable' => true]], // Status column is now inherited via prepareCrudConfig ['key' => 'additionalInfo', 'text' => 'Notiz', 'modal' => false, 'table' => ['sortable' => true]], ['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]], From 98be5e1614685ec7a558a40a78baac452c22a85a Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 4 Nov 2025 09:45:26 +0100 Subject: [PATCH 17/22] Add context menu for missing buildings and update fault handling --- docker/php/Dockerfile | 74 ++-- .../PreorderRimoTypeMap.css | 10 +- .../PreorderRimoTypeMap.js | 348 ++++++++++++++++-- public/plugins/vue/tt-components/tt-map.js | 6 +- 4 files changed, 362 insertions(+), 76 deletions(-) diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 8561a7318..5d0fe7974 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -1,50 +1,42 @@ -# Use Debian Bookworm as base image -FROM debian:bookworm +# Use Debian 13 “Trixie” as base image +FROM debian:trixie -# Install wkhtmltopdf -RUN apt update -RUN apt install wget libfontenc1 xfonts-75dpi xfonts-base xfonts-encodings xfonts-utils openssl build-essential libssl-dev libxrender-dev git-core libx11-dev libxext-dev libfontconfig1-dev libfreetype6-dev fontconfig -y -RUN wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.stretch_amd64.deb -RUN dpkg --force-all -i wkhtmltox_0.12.6-1.stretch_amd64.deb -RUN wget https://www.mytaxexpress.com/download/libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb -RUN dpkg -i libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb -RUN wget https://archive.debian.org/debian/pool/main/libj/libjpeg8/libjpeg8_8b-1_amd64.deb -RUN dpkg -i libjpeg8_8b-1_amd64.deb - -# Install apache2 and PHP and PHP modules +# Install ALL native packages from Debian 13 first RUN apt update && \ - apt install -y poppler-utils apache2 curl cron unzip php8.2 php8.2-imap php8.2-curl php8.2-cli php8.2-mysqli php8.2-gd php8.2-zip php8.2-dom php8.2-mbstring && \ + apt install -y \ + # wkhtmltopdf prerequisites + wget libfontenc1 xfonts-75dpi xfonts-base xfonts-encodings xfonts-utils openssl build-essential libssl-dev libxrender-dev git-core libx11-dev libxext-dev libfontconfig1-dev libfreetype6-dev fontconfig \ + \ + # Apache + PHP + Utils + poppler-utils apache2 curl cron unzip \ + php8.4 php8.4-curl php8.4-cli php8.4-mysqli php8.4-gd php8.4-zip php8.4-dom php8.4-mbstring && \ + \ + # Install Composer curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer && \ - apt clean && \ - rm -rf /var/lib/apt/lists/* + \ + # Clean up apt cache + apt clean && rm -rf /var/lib/apt/lists/* -# Enable PHP in Apache2 -RUN a2enmod php8.2 -RUN a2enmod rewrite +# --- Now, install the old/insecure libraries for wkhtmltopdf --- -# Composer install +RUN wget https://www.mytaxexpress.com/download/libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb && \ + dpkg -i libssl1.1_1.1.1f-1ubuntu2.17_amd64.deb + +RUN wget https://archive.debian.org/debian/pool/main/libj/libjpeg8/libjpeg8_8b-1_amd64.deb && \ + dpkg -i libjpeg8_8b-1_amd64.deb + +# Finally, install wkhtmltopdf itself, forcing it over the broken dependencies +RUN wget https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.stretch_amd64.deb && \ + dpkg --force-all -i wkhtmltox_0.12.6-1.stretch_amd64.deb + +# Enable PHP in Apache2 and enable rewrite +RUN a2enmod php8.4 \ + && a2enmod rewrite + +# Set working directory and copy composer.json, then run composer install WORKDIR /var/www/html COPY ../../composer.json ./ RUN composer install --no-interaction -COPY ./docker/php/clean_logs.sh /root/clean_logs.sh -RUN chmod +x /root/clean_logs.sh - -# Add cron job for log cleanup -RUN echo "* * * * * /root/clean_old_logs.sh" > /etc/cron.d/clean_old_logs && \ - chmod 0644 /etc/cron.d/clean_old_logs && \ - crontab /etc/cron.d/clean_old_logs - -# Start Apache in the foreground -CMD ["apachectl", "-D", "FOREGROUND"] - - -# Install XDEBUG -# apt install -y php8.2-xdebug -# -# cat <<'EOF' > /etc/php/8.2/apache2/conf.d/99-xdebug-custom.ini - #[xdebug] - #xdebug.mode=profile - #xdebug.start_with_request=trigger - #xdebug.output_dir="/tmp/xdebug_profiles" - #EOF \ No newline at end of file +# Expose port 80 and start Apache in foreground +CMD ["apachectl", "-D", "FOREGROUND"] \ No newline at end of file diff --git a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css index 15c27b513..ecebcc660 100644 --- a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css +++ b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css @@ -215,6 +215,14 @@ div.leaflet-marker-icon.custom-div-icon { animation: pulse-red 2s infinite; } +/* New style for missing building markers */ +.marker-missing-building .rimo-marker { + border-style: dashed; + border-width: 3px; + box-shadow: 0 0 8px rgba(220, 53, 69, 0.8); +} + + @keyframes pulse-red { 0% { box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.7); @@ -348,4 +356,4 @@ div.leaflet-marker-icon.custom-div-icon { .marker-multiple-dwelling { background-color: #6f42c1; } .marker-public { background-color: #17a2b8; } .marker-other { background-color: #bf2d69; } -.marker-not-to-connect { background-color: #6c757d !important; } \ No newline at end of file +.marker-not-to-connect { background-color: #6c757d !important; } diff --git a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js index 2e8e79cb5..ecb29c303 100644 --- a/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js +++ b/public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js @@ -3,6 +3,7 @@ Vue.component('PreorderRimoTypeMap', { rawRimoData: [], mapMarkers: [], fcpMarkers: [], + missingBuildingMarkers: [], // For manually added faults faults: {}, isLoading: false, window, @@ -19,12 +20,15 @@ Vue.component('PreorderRimoTypeMap', { showOnlyFaults: false, showFcps: true, showFaultsModal: false, + showMissingBuildingModal: false, // For new context menu modal + missingBuildingData: null, // For new context menu modal userIdToNameMap: new Map(), editingFault: null, 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: 'missing_building', text: 'Gebäude fehlt (manuell markiert)'}, // New reason {value: 'other', text: 'Sonstiges/Bemerkung'} ], rimoTypeDefs: { @@ -44,6 +48,10 @@ Vue.component('PreorderRimoTypeMap', { filterOptions() { return Object.entries(this.rimoTypeDefs).map(([value, defs]) => ({value, ...defs})); }, + // Options for the new "missing building" modal + rimoTypeOptionsForMissing() { + return Object.entries(this.rimoTypeDefs).map(([value, defs]) => ({value, text: defs.text})); + }, filteredMapMarkers() { let rimoMarkers = this.mapMarkers; @@ -52,24 +60,48 @@ Vue.component('PreorderRimoTypeMap', { const fault = this.faults[marker.hausnummerId]; return fault && !fault.done; }); + // Also filter missing building markers if showOnlyFaults is true + this.missingBuildingMarkers = this.missingBuildingMarkers.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)); + // We don't filter missingBuildingMarkers by rimoType unless we want to + this.missingBuildingMarkers = this.missingBuildingMarkers.filter(marker => { + const fault = this.faults[marker.hausnummerId]; + return fault && this.activeFilters.includes(fault.rimo_type); + }); } - return this.showFcps ? [...rimoMarkers, ...this.fcpMarkers] : rimoMarkers; + const allMarkers = [...rimoMarkers, ...this.missingBuildingMarkers]; + return this.showFcps ? [...allMarkers, ...this.fcpMarkers] : allMarkers; }, faultsForModal() { - if (!this.rawRimoData.length) return []; + if (!this.rawRimoData && !this.faults) return []; return Object.entries(this.faults).map(([hausnummerId, faultData]) => { - const rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId); - if (!rimoItem) return null; + let rimoItem = null; + let address = ''; + let rimo_id = ''; + + if (hausnummerId.startsWith('missing-')) { + const typeDef = this.rimoTypeDefs[faultData.rimo_type] || this.rimoTypeDefs.other; + address = `Fehlendes Gebäude (${typeDef.text}) bei ${faultData.lat.toFixed(5)}, ${faultData.lng.toFixed(5)}`; + rimo_id = 'N/A (Fehlend)'; + } else { + rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId); + if (!rimoItem) return null; // Don't show faults for buildings not in the current dataset + address = `${rimoItem.strasse_name} ${rimoItem.hausnummer}, ${rimoItem.plz_name} ${rimoItem.ortschaft_name}`; + rimo_id = rimoItem.rimo_id; + } + return { ...faultData, hausnummerId, - rimo_id: rimoItem.rimo_id, - address: `${rimoItem.strasse_name} ${rimoItem.hausnummer}, ${rimoItem.plz_name} ${rimoItem.ortschaft_name}`, + rimo_id, + address, 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}` }; @@ -87,7 +119,7 @@ Vue.component('PreorderRimoTypeMap', { this.fetchAllMapData(); } else { localStorage.removeItem('rimoMapSelectedCampaign'); - this.mapMarkers = []; this.fcpMarkers = []; this.faults = {}; this.activeFilters = []; + this.mapMarkers = []; this.fcpMarkers = []; this.faults = {}; this.activeFilters = []; this.missingBuildingMarkers = []; } }, showFcps(newVal) { @@ -114,28 +146,45 @@ Vue.component('PreorderRimoTypeMap', { } const storedShowFcps = localStorage.getItem('rimoMapShowFcps'); this.showFcps = storedShowFcps !== null ? JSON.parse(storedShowFcps) : true; + + // Register window functions for popup buttons window.updateEditingFault = this.updateTempFault.bind(this); - window.saveEditingFault = this.saveFaults.bind(this); + window.saveEditingFault = this.saveEditingFault.bind(this); + // --- FIX: Renamed function and window property to avoid reference errors --- + window.markFaultDonePopup = this.markFaultDonePopup.bind(this); }, beforeDestroy() { + // Unregister window functions delete window.updateEditingFault; delete window.saveEditingFault; + // --- FIX: Use matching name for deletion --- + delete window.markFaultDonePopup; }, methods: { async fetchAllMapData() { if (!this.selectedCampaign) return; this.isLoading = true; - this.mapMarkers = []; this.fcpMarkers = []; this.faults = {}; this.activeFilters = []; + this.mapMarkers = []; this.fcpMarkers = []; this.faults = {}; this.activeFilters = []; this.missingBuildingMarkers = []; try { - await this.fetchFaultData(); + await this.fetchFaultData(); // This will now also populate missingBuildingMarkers await Promise.all([this.fetchRimoData(), this.fetchFCPData()]); } finally { this.isLoading = false; } }, async fetchFaultData() { + this.missingBuildingMarkers = []; // Reset missing markers const res = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Preorder/RimoTypeMapGetFaults`, {params: {preordercampaign_id: this.selectedCampaign}}); - if (res.data.success) this.faults = res.data.faults && typeof res.data.faults === 'object' && !Array.isArray(res.data.faults) ? res.data.faults : {}; + if (res.data.success) { + this.faults = res.data.faults && typeof res.data.faults === 'object' && !Array.isArray(res.data.faults) ? res.data.faults : {}; + + // Process missing building faults into markers + Object.entries(this.faults).forEach(([faultId, faultData]) => { + if (faultId.startsWith('missing-')) { + this.addMissingBuildingMarker(faultId, faultData); + } + }); + } }, async fetchRimoData() { const res = await axios.post(this.fetchUrl, {campaignId: this.selectedCampaign}); @@ -224,24 +273,62 @@ Vue.component('PreorderRimoTypeMap', { const otherText = faultData.other ? `
  • Sonstiges: ${faultData.other}
  • ` : ''; return `
    Gemeldeter Fehler:
      ${reasonsList}${otherText}
    `; }, - _generateBuildingFaultFormHtml(itemGroup, faultData) { + _generateBuildingFaultFormHtml(itemGroup, faultData, isExistingFault) { const formInputs = this.faultReasons.map(reason => { const isChecked = faultData.reasons.includes(reason.value); + const isDisabled = reason.value === 'missing_building'; // Disable 'missing_building' checkbox const otherInput = (reason.value === 'other') ? `` : ''; - return `${otherInput}`; + return `${otherInput}`; }).join(''); - return `
    Fehler melden/bearbeiten:
    ${formInputs}`; + + // --- FIX: Updated logic to only show button if fault exists and is not done --- + const doneButton = isExistingFault && !faultData.done + ? `` + : ''; + + return `
    Fehler melden/bearbeiten:
    ${formInputs}${doneButton}`; }, generateBuildingPopupHtml(itemGroup) { + // --- FIX: Check if the fault actually exists --- + const isExistingFault = !!this.faults[itemGroup.hausnummer_id]; const currentFault = this.faults[itemGroup.hausnummer_id] || {reasons: [], other: '', done: false}; this.editingFault = {hausnummerId: itemGroup.hausnummer_id, data: JSON.parse(JSON.stringify(currentFault))}; + const detailsHtml = this._generateBuildingDetailsHtml(itemGroup); - const faultFormHtml = this._generateBuildingFaultFormHtml(itemGroup, this.editingFault.data); - const faultDisplayHtml = this.editingFault.data.done ? `
    Dieser Fehler wurde von ${this.userIdToNameMap.get(String(this.editingFault.data.done_by)) || `User #${this.editingFault.data.done_by}`} am ${new Date(this.editingFault.data.done_at).toLocaleDateString()} als erledigt markiert.
    ` : this._generateBuildingFaultDisplayHtml(this.editingFault.data); + // --- FIX: Pass the isExistingFault flag --- + const faultFormHtml = this._generateBuildingFaultFormHtml(itemGroup, this.editingFault.data, isExistingFault); + const faultDisplayHtml = this.editingFault.data.done + ? `
    Dieser Fehler wurde von ${this.userIdToNameMap.get(String(this.editingFault.data.done_by)) || `User #${this.editingFault.data.done_by}`} am ${new Date(this.editingFault.data.done_at).toLocaleDateString()} als erledigt markiert.
    ` + : this._generateBuildingFaultDisplayHtml(this.editingFault.data); + return `
    ${detailsHtml}${faultDisplayHtml}
    ${faultFormHtml}
    `; }, + // New method for "missing building" popups + generateMissingBuildingPopupHtml(faultId) { + const currentFault = this.faults[faultId] || {reasons: [], other: '', done: false}; + const isExistingFault = !!this.faults[faultId]; + this.editingFault = {hausnummerId: faultId, data: JSON.parse(JSON.stringify(currentFault))}; + + const typeDef = this.rimoTypeDefs[currentFault.rimo_type] || this.rimoTypeDefs.other; + const detailsHtml = ` +
    +
    Fehlendes Gebäude
    + Gemeldeter Typ: ${typeDef.text}
    + Koordinaten: ${currentFault.lat.toFixed(6)}, ${currentFault.lng.toFixed(6)}
    + Links: + Karte +
    `; + +// --- FIX: Pass the isExistingFault flag --- + const faultFormHtml = this._generateBuildingFaultFormHtml({}, this.editingFault.data, isExistingFault); // Pass empty itemGroup + const faultDisplayHtml = this.editingFault.data.done + ? `
    Dieser Fehler wurde von ${this.userIdToNameMap.get(String(this.editingFault.data.done_by)) || `User #${this.editingFault.data.done_by}`} am ${new Date(this.editingFault.data.done_at).toLocaleDateString()} als erledigt markiert.
    ` + : this._generateBuildingFaultDisplayHtml(this.editingFault.data); + + return `
    ${detailsHtml}${faultDisplayHtml}
    ${faultFormHtml}
    `; }, updateTempFault(reason, value) { if (!this.editingFault) return; + // If user interacts with a "done" fault, mark it as "not done" again if (this.editingFault.data.done) { this.editingFault.data.done = false; this.editingFault.data.done_by = null; this.editingFault.data.done_at = null; } @@ -251,6 +338,8 @@ Vue.component('PreorderRimoTypeMap', { const index = fault.reasons.indexOf(reason); if (value && index === -1) fault.reasons.push(reason); else if (!value && index > -1) fault.reasons.splice(index, 1); + + // Toggle visibility of 'other' textarea if (reason === 'other') { const popup = this.$refs.ttMap?.map?._popup; if (popup?.isOpen()) { @@ -264,54 +353,202 @@ Vue.component('PreorderRimoTypeMap', { } } }, - async saveFaults(hausnummerIdToUpdate) { + // --- FIX: Renamed this method --- + async markFaultDonePopup() { + if (!this.editingFault) return; + const hausnummerId = this.editingFault.hausnummerId; + const faultData = { + ...this.editingFault.data, + done: true, + done_by: window.TT_CONFIG.USER_ID, + done_at: new Date().toISOString() + }; + + this.$set(this.faults, hausnummerId, faultData); + this.editingFault.data = faultData; // Update local state for popup refresh + + await this.saveFaults(hausnummerId, true); // Save and stay in place + }, + // Modified saveFaults to handle "stayInPlace" and "missing" buildings + async saveFaults(hausnummerIdToUpdate, stayInPlace = false) { const hausnummerId = hausnummerIdToUpdate || this.editingFault?.hausnummerId; if (!hausnummerId) { if (this.editingFault) this.editingFault = null; return; } - if (this.editingFault && this.editingFault.hausnummerId === hausnummerId) this.$set(this.faults, hausnummerId, this.editingFault.data); + + // Store current view to prevent map from moving + const center = this.$refs.ttMap?.map.getCenter(); + const zoom = this.$refs.ttMap?.map.getZoom(); + + if (this.editingFault && this.editingFault.hausnummerId === hausnummerId) { + this.$set(this.faults, hausnummerId, this.editingFault.data); + } + const res = await axios.post(`${window.TT_CONFIG.BASE_PATH}/Preorder/RimoTypeMapSaveFaults`, {campaignId: this.selectedCampaign, faults: this.faults}); + if (res.data.success) { window.notify('success', 'Fehlerbericht gespeichert.'); - this.$refs.ttMap?.map.closePopup(); + + // Refetch fault data to be in sync (backend might have changed something) + await this.fetchFaultData(); + + // Update marker icon in place const mapComponent = this.$refs.ttMap; if (mapComponent && mapComponent.markerLayer) { mapComponent.markerLayer.eachLayer(marker => { if (marker.tt_hausnummerId == hausnummerId) { - const rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId); - if (rimoItem) { - const isNot2Connect = rimoItem.rimo_op_state === 'Not2Connect'; - const rimoType = this.getNormalizedRimoType(rimoItem.rimo_type); - const fault = this.faults[hausnummerId]; - const hasFault = fault && !fault.done; + const fault = this.faults[hausnummerId]; + const hasFault = fault && !fault.done; + + if (marker.options.isMissingBuilding) { + // Logic for missing building marker + const rimoType = fault.rimo_type || 'other'; const markerIconDef = this.getMarkerIcon(rimoType); const newIcon = L.divIcon({ - className: `custom-div-icon marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''}`, - html: `
    `, + className: `custom-div-icon marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''} marker-missing-building`, + html: `
    `, iconSize: [30, 30], iconAnchor: [15, 30] }); marker.setIcon(newIcon); + + } else { + // Logic for existing building marker + const rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId); + if (rimoItem) { + const isNot2Connect = rimoItem.rimo_op_state === 'Not2Connect'; + const rimoType = this.getNormalizedRimoType(rimoItem.rimo_type); + const markerIconDef = this.getMarkerIcon(rimoType); + const newIcon = L.divIcon({ + className: `custom-div-icon marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''}`, + html: `
    `, + iconSize: [30, 30], + iconAnchor: [15, 30] + }); + marker.setIcon(newIcon); + } } } }); } - } else window.notify('error', 'Fehlerbericht konnte nicht gespeichert werden.'); - this.editingFault = null; + + // Handle popup state + if (stayInPlace) { + const markerInstance = mapComponent?.markerLayer?.getLayers().find(m => m.tt_hausnummerId == hausnummerId); + + if (markerInstance && mapComponent.map._popup && mapComponent.map._popup.isOpen() && mapComponent.map._popup._source === markerInstance) { + let newPopupContent; + if(markerInstance.options.isMissingBuilding) { + newPopupContent = await this.generateMissingBuildingPopupHtml(hausnummerId); + } else { + const rimoItem = this.rawRimoData.find(item => item.hausnummer_id == hausnummerId); + if(rimoItem) newPopupContent = await this.generateBuildingPopupHtml(rimoItem); + } + + if (newPopupContent) { + markerInstance.setPopupContent(newPopupContent); // Refresh popup content + } else { + mapComponent.map.closePopup(); // Close if we can't refresh + } + } else if (!markerInstance) { + mapComponent.map.closePopup(); // Close if marker somehow disappeared (e.g. filtered out) + } + } else { + this.$refs.ttMap?.map.closePopup(); + } + + // Restore view + if (center && zoom) { + this.$refs.ttMap?.map.setView(center, zoom, { animate: false, noMoveStart: true }); + } + + } else { + window.notify('error', 'Fehlerbericht konnte nicht gespeichert werden.'); + } + + if(!stayInPlace) { + this.editingFault = null; + } }, 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(hausnummerId); + await this.saveFaults(hausnummerId, false); // Save, don't stay in place (closes modal) }, 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.'); + + // Find marker (works for normal and missing building markers) 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); }, + // New method to handle right-click context menu + handleMapContextMenu(e) { + if (!this.selectedCampaign) return; // Don't allow if no campaign is selected + e.originalEvent.preventDefault(); + this.missingBuildingData = { lat: e.latlng.lat, lng: e.latlng.lng, rimo_type: null }; + this.showMissingBuildingModal = true; + }, + // New method to save the missing building + async saveMissingBuilding() { + if (!this.missingBuildingData || !this.missingBuildingData.rimo_type) { + window.notify('warning', 'Bitte einen RIMO-Typ auswählen.'); + return; + } + const newFaultId = 'missing-' + Math.random().toString(36).substr(2, 9); + const newFaultData = { + reasons: ['missing_building'], + other: 'Vom Benutzer als fehlend markiert.', + done: false, + created_by: window.TT_CONFIG.USER_ID, + created_at: new Date().toISOString(), + lat: this.missingBuildingData.lat, + lng: this.missingBuildingData.lng, + rimo_type: this.missingBuildingData.rimo_type + }; + + this.$set(this.faults, newFaultId, newFaultData); + this.addMissingBuildingMarker(newFaultId, newFaultData); // Add marker locally + + this.showMissingBuildingModal = false; + this.missingBuildingData = null; + + await this.saveFaults(newFaultId, false); // Save to backend + }, + // New method to add marker for missing building + addMissingBuildingMarker(faultId, faultData) { + const rimoType = faultData.rimo_type || 'other'; + const markerIconDef = this.getMarkerIcon(rimoType); + const hasFault = faultData && !faultData.done; + const newIcon = L.divIcon({ + className: `custom-div-icon marker-missing-building marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''}`, + html: `
    `, + iconSize: [30, 30], + iconAnchor: [15, 30] + }); + const newMarker = { + lat: faultData.lat, + lng: faultData.lng, + hausnummerId: faultId, // Root property for tt-map to find + options: { + icon: newIcon, + isMissingBuilding: true, // Custom flag + asyncPopupContent: () => this.generateMissingBuildingPopupHtml(faultId) + } + }; + + // Avoid duplicates - update existing if found + const existingIndex = this.missingBuildingMarkers.findIndex(m => m.hausnummerId === faultId); + if (existingIndex > -1) { + this.$set(this.missingBuildingMarkers, existingIndex, newMarker); + } else { + this.missingBuildingMarkers.push(newMarker); + } + }, getNormalizedRimoType(type) { const lowerType = (type || '').toLowerCase(); if (lowerType.includes('greenfield')) return 'greenfield'; @@ -336,10 +573,20 @@ Vue.component('PreorderRimoTypeMap', { const color = this.rimoTypeDefs[filterValue]?.color || '#6c757d'; return this.isFilterActive(filterValue) ? {backgroundColor: color, borderColor: color, color: 'white'} : {color: color, backgroundColor: 'white', borderColor: color}; }, + saveEditingFault() { + this.saveFaults(); + }, }, template: `
    - +