diff --git a/Layout/default/Pop/Detail.php b/Layout/default/Pop/Detail.php index 58b3bae68..b3b12f296 100644 --- a/Layout/default/Pop/Detail.php +++ b/Layout/default/Pop/Detail.php @@ -686,7 +686,9 @@ if (!empty(trim($pops->vlan_ipv6)))  - Vorderseite diff --git a/Layout/default/Pop/_rack_body.php b/Layout/default/Pop/_rack_body.php index 5f35c4ed8..20ff5f38d 100644 --- a/Layout/default/Pop/_rack_body.php +++ b/Layout/default/Pop/_rack_body.php @@ -107,9 +107,9 @@ for ($i = 1; $i <= $rack_he; $i++) : ?> } if ($slots['width'] == "12") $width = "85%"; - else if ($slots['width'] == "6") $width = "42%"; - else if ($slots['width'] == "4") $width = "28%"; - else if ($slots['width'] == "3") $width = "21%"; + else if ($slots['width'] == "6") $width = "42.5%"; + else if ($slots['width'] == "4") $width = "28.33%"; + else if ($slots['width'] == "3") $width = "21.25%"; while ($position < $slots['position']) { @@ -118,8 +118,7 @@ for ($i = 1; $i <= $rack_he; $i++) : ?> $calcwidth += $slots['width']; } - echo '' . $slots['modulname'] . $extTextspan . ''; - + echo '' . $slots['modulname'] . $extTextspan . ''; $position++; $calcwidth += $slots['width']; } 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 725c69d01..31849a571 100644 --- a/Layout/default/Preorder/include/preorder-detail.php +++ b/Layout/default/Preorder/include/preorder-detail.php @@ -22,6 +22,7 @@ +
@@ -110,15 +111,7 @@ - - - campaign->network->owner_id === "209"): ?> + campaign->network->owner_id, ["209", "1"])): ?> Verrechnet: @@ -1170,9 +1163,127 @@ +
+ +
+
+
+ +
+
Hochgeladene Dokumente
+
+ + + + + +
+
+ +
+
Neuer Upload
+
+
+
+ +

Dateien hier ablegen oder

+ + +
+
+ + + + +
+
+
+
+
+ + + + + + + +
+ - 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/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/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/Calendar/CalendarModel.php b/application/Calendar/CalendarModel.php index 01e9cd613..d3fe6f931 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) { @@ -650,17 +663,14 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda $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) { $start = strtotime($r->start); $end = strtotime($r->end); } - $originalend = $end; - $allday = ($r->allday); if ($allday) { $start = $start + 7200; 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/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/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%')"; diff --git a/application/Preorder/PreorderController.php b/application/Preorder/PreorderController.php index 279298cf5..087affdc5 100644 --- a/application/Preorder/PreorderController.php +++ b/application/Preorder/PreorderController.php @@ -1985,4 +1985,61 @@ 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; + $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, + '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/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/application/Workorder/WorkorderModel.php b/application/Workorder/WorkorderModel.php index 6ec80a964..b5762a147 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; @@ -161,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/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/application/WorkorderCompany/WorkorderCompanyController.php b/application/WorkorderCompany/WorkorderCompanyController.php index 7c501f946..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]], @@ -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/20251024091253_preorder_add_files.php b/db/migrations/20251024091253_preorder_add_files.php new file mode 100644 index 000000000..e0902091c --- /dev/null +++ b/db/migrations/20251024091253_preorder_add_files.php @@ -0,0 +1,20 @@ +getEnvironment() == "thetool") { + $table = $this->table("Preorder"); + $table->addColumn("files", "text", ['null' => true, 'after' => 'note']); + $table->update(); + } + } + + public function down(): void { + if($this->getEnvironment() == "thetool") { + $this->table("Preorder")->removeColumn("files")->save(); + } + } +} 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") { + + } + } +} 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/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/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' }}
    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: `
    - +