Merge branch 'master' into fronkdev

This commit is contained in:
Frank Schubert
2025-11-05 16:55:32 +01:00
39 changed files with 2807 additions and 250 deletions

View File

@@ -686,7 +686,9 @@ if (!empty(trim($pops->vlan_ipv6)))
<th colspan="13"
class="text-center border-bottom font-15 pt-1 pb-1"
data-rackhe="<?= $poprack['rack']['he'] ?>"
data-rackid="<?= $poprack['rack']['id']; ?>"><span
data-rackid="<?= $poprack['rack']['id']; ?>"
data-rackname="<?= $poprack['rack']['name']; ?>"
><span
class="rack-name"><i
class="fa-regular fa-arrows-up-down-left-right move-handle float-left"></i><?= $poprack['rack']['name']; ?>&nbsp;<span
class="rack-side-indicator font-weight-normal">-&nbsp;Vorderseite</span></span>

View File

@@ -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 '<td title="' . strip_tags($displayName) . $extText . '" colspan="' . $slots['width'] . '" class="text-center border-top border-bottom border-right ' . $colorclass . '" data-id="' . $slots['moduleid'] . '" data-type="' . $slots['type'] . '" data-name="' . strip_tags($originalName) . '" data-status="' . ($slots['status'] ?? 'productive') . '" data-starthe="' . $module['start_he'] . '" data-ports="' . $slots['ports'] . '" data-plug="' . $slots['plug'] . '" rowspan="' . $modulelenght . '" style="width: ' . $width . '; border-color: #797979 !important;">' . $slots['modulname'] . $extTextspan . '</td>';
echo '<td title="' . strip_tags($displayName) . $extText . '" colspan="' . $slots['width'] . '" class="text-center border-top border-bottom border-right ' . $colorclass . '" data-id="' . $slots['moduleid'] . '" data-type="' . $slots['type'] . '" data-name="' . strip_tags($originalName) . '" data-status="' . ($slots['status'] ?? 'productive') . '" data-starthe="' . $module['start_he'] . '" data-ports="' . $slots['ports'] . '" data-plug="' . $slots['plug'] . '" data-width="' . $slots['width'] . '" rowspan="' . $modulelenght . '" style="width: ' . $width . '; border-color: #797979 !important;">' . $slots['modulname'] . $extTextspan . '</td>';
$position++;
$calcwidth += $slots['width'];
}

View File

@@ -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;
}
</style>
<script>
$(document).ready(function () {
if (window.matchMedia('(min-width: 576px)').matches) {
return;
}
<script>
$(function() {
if (window.matchMedia('(min-width: 576px)').matches) return;
// in ul.pagination
// if .page-item.disabled.text-secondary exists
// only keep the first 2 .page-item, the last 2 .page-item and the .page-item.disabled.text-secondary and the one before and the one after that
const $pagination = $("ul.pagination");
const $disabled = $pagination.find(".page-item.disabled.points");
const pagination = $("ul.pagination");
const disabled = pagination.find(".page-item.disabled.points");
if ($disabled.length) {
const $first = $pagination.find(".page-item").first();
const $firstNext = $first.next();
const $last = $pagination.find(".page-item").last();
const $lastPrev = $last.prev();
const $prev = $disabled.prev();
const $next = $disabled.next();
if (disabled.length) {
const first = pagination.find(".page-item").first();
const firstNext = first.next();
const last = pagination.find(".page-item").last();
const lastNext = last.prev();
const prev = disabled.prev();
const next = disabled.next();
const notToDelete = [first, firstNext, last, lastNext, prev, next, disabled];
const $keep = $().add($first).add($firstNext).add($last).add($lastPrev).add($prev).add($next).add($disabled);
// loop through pagination.find(".page-item") and remove all but the first, last, prev, next, firstNext, lastNext
$pagination.find(".page-item").not($keep).remove();
}
});
pagination.find(".page-item").each(function (index, item) {
// if (!notToDelete.includes($(item))) {
// $(item).remove();
// } fix this becaues of we need [0] of notToDelete to compare
let check = false;
notToDelete.forEach(function (n) {
if (n[0] === item) {
check = true;
$(function() {
function initPreorderDocumentTabs() {
$('.preorder-documents-tab').each(function() {
const tabPane = $(this);
const preorderId = tabPane.data('preorder-id');
if (!preorderId) return;
const dropzone = tabPane.find('.doc-dropzone');
const browseBtn = tabPane.find('.doc-browse-btn');
const fileInput = tabPane.find('.doc-file-input');
const stagingArea = tabPane.find('.doc-staging-area');
const stagingTemplate = tabPane.find('.doc-staging-item-template')[0];
const uploadBtn = tabPane.find('.doc-upload-btn');
const uploadBtnText = tabPane.find('.doc-upload-btn-text');
const uploadSpinner = tabPane.find('.doc-upload-spinner');
const listLoader = tabPane.find('.document-list-loader');
const emptyState = tabPane.find('.document-empty-state');
const listContainer = tabPane.find('.document-list-container');
const listTbody = tabPane.find('.doc-list-tbody');
const listRowTemplate = tabPane.find('.doc-list-row-template')[0];
const modal = tabPane.find('.doc-preview-modal');
const modalTitle = modal.find('.doc-preview-modal-title');
const modalBody = modal.find('.doc-preview-modal-body');
let stagedFiles = new Map();
// --- START FIX 1: Helper function to decode HTML entities ---
function htmlDecode(input) {
if (!input || typeof input !== 'string') return input;
var doc = new DOMParser().parseFromString(input, "text/html");
return doc.documentElement.textContent;
}
// --- END FIX 1 ---
function formatFileSize(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
function guessMimeType(filename) {
const ext = (filename || '').split('.').pop().toLowerCase();
const mimeTypes = {
'pdf': 'application/pdf',
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'gif': 'image/gif',
'doc': 'application/msword',
'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls': 'application/vnd.ms-excel',
'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'zip': 'application/zip',
};
return mimeTypes[ext] || 'application/octet-stream';
}
function getFileIcon(mimeType) {
if (!mimeType) return '<i class="fas fa-file fa-fw"></i>';
const iconMap = {
'image/': 'fa-file-image',
'application/pdf': 'fa-file-pdf',
'word': 'fa-file-word',
'excel': 'fa-file-excel',
'spreadsheet': 'fa-file-excel',
'zip': 'fa-file-archive',
'archive': 'fa-file-archive'
};
for (const key in iconMap) {
if (mimeType.includes(key)) return `<i class="fas ${iconMap[key]} fa-fw"></i>`;
}
return '<i class="fas fa-file fa-fw"></i>';
}
function isPreviewable(mimeType) {
return mimeType.startsWith('image/') || mimeType === 'application/pdf';
}
async function fetchFileMetaData(fileId, description) {
try {
const response = await fetch(`/File/getById?id=${fileId}`);
if (!response.ok) return null;
const data = await response.json();
const mimetype = guessMimeType(data.filename);
return {
id: fileId,
fileId: fileId,
fileName: data.filename,
description: description,
mimetype: mimetype
};
} catch (error) {
return null;
}
}
async function loadDocuments() {
listLoader.show();
listContainer.hide();
emptyState.hide();
listTbody.empty();
let fileObjects = [];
try {
const rawData = tabPane.data('file-objects');
if (typeof rawData === 'string') {
const decodedData = htmlDecode(rawData);
fileObjects = JSON.parse(decodedData);
} else if (Array.isArray(rawData)) {
fileObjects = rawData;
}
} catch (e) {
console.error("Error parsing file objects:", e, tabPane.data('file-objects'));
fileObjects = [];
}
if (fileObjects.length === 0) {
emptyState.show();
listLoader.hide();
return;
}
try {
const fetchPromises = fileObjects.map(obj => fetchFileMetaData(obj.id, obj.description));
const documents = (await Promise.all(fetchPromises)).filter(doc => doc !== null);
if (documents.length === 0) {
emptyState.show();
} else {
documents.forEach(addDocumentRow);
listContainer.show();
}
} catch (error) {
emptyState.find('h5').text('Fehler beim Laden');
emptyState.find('p').text('Die Dokumente konnten nicht geladen werden.');
emptyState.show();
} finally {
listLoader.hide();
}
}
function addDocumentRow(docData, prepend = false) {
const rowClone = listRowTemplate.content.cloneNode(true);
const $row = $(rowClone).find('tr');
$row.attr('data-doc-id', docData.id);
$row.find('.doc-row-icon').html(getFileIcon(docData.mimetype));
$row.find('.doc-row-filename').text(docData.fileName);
$row.find('.doc-row-description').text(docData.description || '');
const previewUrl = `/File/show?id=${docData.fileId}`;
const downloadUrl = `/File/download?id=${docData.fileId}`;
$row.find('.doc-preview-btn').data({
url: previewUrl,
type: docData.mimetype,
filename: docData.fileName
});
$row.find('.doc-download-btn').attr({
href: downloadUrl,
download: docData.fileName
});
if (!isPreviewable(docData.mimetype)) {
$row.find('.doc-preview-btn').addClass('disabled').attr('title', 'Vorschau nicht verfügbar');
}
prepend ? listTbody.prepend($row) : listTbody.append($row);
emptyState.hide();
listContainer.show();
}
tabPane.data('loadDocumentsFunction', loadDocuments);
function handleFiles(files) {
for (const file of files) {
const fileId = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
stagedFiles.set(fileId, file);
const itemClone = stagingTemplate.content.cloneNode(true);
const item = $(itemClone).find('.document-staging-item');
item.attr('data-file-id', fileId);
item.find('.doc-staging-filename').text(file.name);
item.find('.doc-staging-filesize').text(formatFileSize(file.size));
item.find('.doc-staging-icon').html(getFileIcon(file.type));
stagingArea.append(item).show();
}
updateUploadButton();
}
function updateUploadButton() {
if (stagedFiles.size > 0) {
uploadBtnText.text(`${stagedFiles.size} Datei(en) hochladen`);
uploadBtn.show();
} else {
uploadBtn.hide();
stagingArea.hide();
}
}
dropzone.on('dragover', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.addClass('active');
});
dropzone.on('dragleave', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.removeClass('active');
});
dropzone.on('drop', (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.removeClass('active');
handleFiles(e.originalEvent.dataTransfer.files);
});
// --- START FIX 2: Modify click handlers ---
browseBtn.on('click', (e) => {
e.preventDefault();
e.stopPropagation(); // Stop this click from bubbling up to the dropzone
fileInput.click();
});
dropzone.on('click', function(e) {
// If the click target was the button or the file input, do nothing.
if ($(e.target).closest('.doc-browse-btn').length > 0 || $(e.target).hasClass('doc-file-input')) {
return;
}
// Otherwise, trigger the file input click.
fileInput.click();
});
// --- END FIX 2 ---
fileInput.on('change', (e) => {
handleFiles(e.target.files);
fileInput.val(null);
});
stagingArea.on('click', '.doc-staging-remove', function() {
const item = $(this).closest('.document-staging-item');
const fileId = item.data('file-id');
stagedFiles.delete(fileId);
item.fadeOut(300, function() {
$(this).remove();
updateUploadButton();
});
});
uploadBtn.on('click', async function() {
let allValid = true;
const formData = new FormData();
formData.append('preorderId', preorderId);
stagingArea.find('.document-staging-item').each(function() {
const item = $(this);
const fileId = item.data('file-id');
const file = stagedFiles.get(fileId);
const descriptionInput = item.find('.doc-staging-description');
const description = descriptionInput.val().trim();
if (!description) {
descriptionInput.addClass('is-invalid');
allValid = false;
} else {
descriptionInput.removeClass('is-invalid');
formData.append('files[]', file, file.name);
formData.append('descriptions[]', description);
}
});
if (!check) {
$(item).remove();
if (!allValid) {
if (window.notify) window.notify('error', 'Bitte füllen Sie alle Beschreibungsfelder aus.');
return;
}
uploadBtn.prop('disabled', true);
uploadSpinner.show();
try {
const response = await fetch('/Preorder/uploadDocuments', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok && result.success) {
if (window.notify) window.notify('success', result.message || 'Dateien erfolgreich hochgeladen.');
stagedFiles.clear();
stagingArea.empty().hide();
updateUploadButton();
if (result.fileObjects) tabPane.data('file-objects', result.fileObjects);
loadDocuments();
} else {
throw new Error(result.message || 'Unbekannter Fehler beim Upload.');
}
} catch (err) {
if (window.notify) window.notify('error', `Upload fehlgeschlagen: ${err.message}`);
} finally {
uploadBtn.prop('disabled', false);
uploadSpinner.hide();
}
});
listTbody.on('click', '.doc-preview-btn', function() {
if ($(this).hasClass('disabled')) return;
const url = $(this).data('url');
const type = $(this).data('type');
const filename = $(this).data('filename');
modalTitle.text(filename);
modalBody.empty();
if (type.startsWith('image/')) {
modalBody.html(`<img src="${url}" class="img-fluid" alt="Vorschau von ${filename}">`);
} else if (type === 'application/pdf') {
window.open(url, '_blank');
return;
} else {
modalBody.html(`<div class="text-center p-5"><i class="fas fa-file-excel fa-3x text-muted mb-3"></i><p>Keine Vorschau für den Dateityp "${type}" verfügbar.</p></div>`);
}
modal.modal('show');
});
});
}
$('a[data-toggle="tab"]').on('shown.bs.tab', function(e) {
const targetId = $(e.target).attr('href');
if (!targetId || !targetId.endsWith('-documents')) return;
const tabPane = $(targetId);
if (tabPane.hasClass('preorder-documents-tab') && tabPane.data('loaded') === false) {
const loaderFunc = tabPane.data('loadDocumentsFunction');
if (typeof loaderFunc === 'function') {
loaderFunc();
tabPane.data('loaded', true);
}
}
});
</script>
initPreorderDocumentTabs();
});
</script>
<!-- start page title -->
<div class="row">

View File

@@ -22,6 +22,7 @@
<li class="nav-item"><a class="nav-link" href="#preorder-detail-<?=$preorder->id?>-history" data-toggle="tab" aria-expanded="false">History</a></li>
<li class="nav-item"><a class="nav-link" href="#preorder-detail-<?=$preorder->id?>-emails" data-toggle="tab" aria-expanded="false">E-Mails</a></li>
<li class="nav-item"><a class="nav-link" href="#preorder-detail-<?=$preorder->id?>-documents" data-toggle="tab" aria-expanded="false">Dokumente</a></li>
</ul>
</div>
@@ -110,15 +111,7 @@
<?php endif; ?>
</td>
</tr>
<!-- add new checkbox field here called "Verrechnet" and if $preorder->billed is not null or 0 show the unix date, also only show this when
preordercampaign -> network_id -> network -> owner_id === 209
then show billed = timestamp (no input as the checkbox is the only thing that can be changed)
also show billed_by
-->
<?php if($preorder->campaign->network->owner_id === "209"): ?>
<?php if(in_array($preorder->campaign->network->owner_id, ["209", "1"])): ?>
<tr>
<th>Verrechnet:</th>
<td>
@@ -1170,9 +1163,127 @@
</div>
</div>
<div id="preorder-detail-<?=$preorder->id?>-documents"
class="tab-pane preorder-documents-tab"
data-preorder-id="<?=$preorder->id?>"
data-file-objects="<?=htmlentities($preorder->files ?? '[]')?>"
data-loaded="false">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-lg-8">
<div class="card-header bg-info text-white pl-2 pr-2 pt-1 pb-1">Hochgeladene Dokumente</div>
<div class="document-list-wrapper">
<div class="document-list-loader text-center p-5" style="display: none;">
<div class="doc-spinner"></div>
<p class="mt-3 text-muted">Lade Dokumente...</p>
</div>
<div class="document-empty-state text-center p-5" style="display: none;">
<i class="fas fa-file-excel fa-3x text-muted mb-3"></i>
<h5>Keine Dokumente gefunden</h5>
<p class="text-muted">Für diese Bestellung wurden noch keine Dokumente hochgeladen.</p>
</div>
<div class="document-list-container table-responsive" style="display: none;">
<table class="table table-sm table-striped table-hover">
<thead class="thead-light">
<tr>
<th style="width: 5%;">Typ</th>
<th>Dateiname</th>
<th>Beschreibung</th>
<th style="width: 15%;" class="text-right">Aktionen</th>
</tr>
</thead>
<tbody class="doc-list-tbody">
</tbody>
</table>
</div>
</div>
</div>
<div class="col-lg-4 mt-3 mt-lg-0">
<div class="card-header bg-secondary text-white pl-2 pr-2 pt-1 pb-1">Neuer Upload</div>
<div class="document-upload-wrapper p-3">
<div class="doc-dropzone document-dropzone">
<div class="document-dropzone-internal">
<i class="fas fa-cloud-upload-alt fa-2x text-muted mb-2"></i>
<p class="mb-2">Dateien hier ablegen oder</p>
<button type="button" class="btn btn-primary btn-sm doc-browse-btn">Datei auswählen</button>
<input type="file" class="doc-file-input" multiple style="display: none;" />
</div>
</div>
<div class="doc-staging-area document-staging-area mt-3" style="display: none;">
</div>
<button class="btn btn-success btn-block mt-2 doc-upload-btn" style="display: none;">
<i class="fas fa-fw fa-upload"></i> <span class="doc-upload-btn-text">Hochladen</span>
<i class="fas fa-spinner fa-spin ml-1 doc-upload-spinner" style="display: none;"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<template class="doc-staging-item-template">
<div class="document-staging-item">
<div class="doc-staging-icon">
<i class="fas fa-file-alt text-muted"></i>
</div>
<div class="doc-staging-details">
<div class="doc-staging-filename">filename.jpg</div>
<div class="doc-staging-filesize text-muted small">1.2 MB</div>
<input type="text" class="form-control form-control-sm mt-1 doc-staging-description" placeholder="Beschreibung (erforderlich)" required />
<div class="invalid-feedback">Beschreibung fehlt.</div>
</div>
<div class="doc-staging-actions">
<button type="button" class="btn btn-sm btn-outline-danger doc-staging-remove" title="Entfernen">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</template>
<template class="doc-list-row-template">
<tr class="document-list-row">
<td class="doc-row-icon text-center align-middle">
</td>
<td class="doc-row-filename align-middle">filename.pdf</td>
<td class="doc-row-description align-middle small">Beschreibung...</td>
<td class="doc-row-actions text-right align-middle">
<button type="button" class="btn btn-sm btn-outline-info doc-preview-btn" title="Vorschau">
<i class="fas fa-eye"></i>
</button>
<a href="#" class="btn btn-sm btn-outline-secondary doc-download-btn" title="Download" download>
<i class="fas fa-download"></i>
</a>
</td>
</tr>
</template>
<div class="modal fade doc-preview-modal" id="doc-preview-modal-<?=$preorder->id?>" tabindex="-1" role="dialog" aria-labelledby="doc-preview-modal-title-<?=$preorder->id?>" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title doc-preview-modal-title" id="doc-preview-modal-title-<?=$preorder->id?>">Vorschau</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body doc-preview-modal-body" id="doc-preview-modal-body-<?=$preorder->id?>" style="min-height: 70vh; display: flex; justify-content: center; align-items: center;">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -149,7 +149,7 @@ $mindate = date("Y-m-d", strtotime("+ 1 Month", $closedmonth));
data-comment="<?= $timerecordingCategories->require_comment ?>"
data-hourday="<?= $timerecordingCategories->hourday ?>"
data-businesstrip="<?= $timerecordingCategories->businesstrip ?>"
data-homeoffice="<?= ($timerecordingCategories->hourday == 1) ? 1 : 0 ?>"><?= $timerecordingCategories->name ?></option>
data-homeoffice="<?= ($timerecordingCategories->hourday == 1 && $timerecordingCategories->approval_fibu == 0) ? 1 : 0 ?>"><?= $timerecordingCategories->name ?></option>
</option>
<?php
endif;

View File

@@ -66,12 +66,22 @@
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="approval">Genehmigungspflichtig</label>
<label class="col-lg-2 col-form-label" for="approval">Genehmigungspf. GF</label>
<div class="col-lg-3">
<div class="form-check">
<input id="approval"
class="form-check-input" <?php if ($timerecordingcategoriess->approval) echo 'checked="checked"'; ?>
type="checkbox" name="approval" value="1" id="olt">
type="checkbox" name="approval" value="1">
</div>
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="approval_fibu">Genehmigungspfl. Buchhaltung</label>
<div class="col-lg-3">
<div class="form-check">
<input id="approval_fibu"
class="form-check-input" <?php if ($timerecordingcategoriess->approval_fibu) echo 'checked="checked"'; ?>
type="checkbox" name="approval_fibu" value="1" >
</div>
</div>
</div>

View File

@@ -41,7 +41,7 @@
<th class="text-center">Beizeichnung</th>
<th class="text-center">BMD KZ</th>
<th class="text-center">Buchungszeitraum</th>
<th class="text-center">Genehmigungspflichtig</th>
<th class="text-center">Genehmigungspf. GF/BH</th>
<th class="text-center">Dienstreisemöglichkeit</th>
<th class="text-center">Anmerkung Pflichtfeld</th>
<th class="text-center">Nur Buchhaltung</th>
@@ -66,7 +66,7 @@
<td class="text-center"><?= $timerecordingcategories->name ?></td>
<td class="text-center"><?= $timerecordingcategories->short ?></td>
<td class="text-center"><?= $timerecordingcategorieshourday[$timerecordingcategories->hourday] ?></td>
<td class="text-center"><?= $timerecordingcategoriesapproval[$timerecordingcategories->approval] ?></td>
<td class="text-center"><?= $timerecordingcategoriesapproval[$timerecordingcategories->approval] ." / ".$timerecordingcategoriesapproval[$timerecordingcategories->approval_fibu] ?></td>
<td class="text-center"><?= $timerecordingcategoriesbusinesstrip[$timerecordingcategories->businesstrip] ?></td>
<td class="text-center"><?= $timerecordingcategoriesrequire_comment[$timerecordingcategories->require_comment] ?></td>
<td class="text-center"><?= $timerecordingcategoriesrequire_only_admin[$timerecordingcategories->only_admin] ?></td>

View File

@@ -0,0 +1,211 @@
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php");
$daysgerm = array("So", "Mo", "Di", "Mi", "Do", "Fr", "Sa");
?>
<link href="<?= self::getResourcePath() ?>assets/css/select2-cstm.css?<?= $git_merge_ts ?>" rel="stylesheet"
type="text/css"/>
<link href="<?= self::getResourcePath() ?>assets/css/datatables-std.css?<?= $git_merge_ts ?>" rel="stylesheet"
type="text/css"/>
<link href="<?= self::getResourcePath() ?>datatables/DataTables-2x/datatables.min.css?<?= $git_merge_ts ?>" rel="stylesheet"
type="text/css"/>
<style>
.edit-button {
color: #007bff;
cursor: pointer;
}
.approved-open {
background-color: #fdb751 !important;
color: #000;
border-radius: 5px;
}
.approved-closed {
background-color: #96ff68 !important;
color: #000;
border-radius: 5px;
padding-top: 2px;
}
.fa-clock {
color: #ff9b00;
font-size: 15px;
}
.edit-placeholder {
height: 15px;
width: 22px;
display: inline-block;
}
.fa-square-check {
color: #23b900;
font-size: 17px;
vertical-align: middle;
margin-bottom: 2px;
margin-right: 3px;
}
</style>
<link href="<?= self::getResourcePath() ?>assets/css/datatables-std.css?<?= date('U') ?>" rel="stylesheet"
type="text/css"/>
<!-- start page title -->
<div class="row">
<div class="col-12">
<div class="page-title-box">
<div class="page-title-right">
<ol class="breadcrumb m-0">
<li class="breadcrumb-item"><a href="<?= self::getUrl("Dashboard") ?>"><?= MFAPPNAME_SLUG ?></a>
</li>
<li class="breadcrumb-item active">Buchungen</li>
</ol>
</div>
<h4 class="page-title">Buchungen</h4>
</div>
</div>
</div>
<!-- end page title -->
<div class="card">
<div class="card-body mb-3">
<div class="row">
<div class="col-12">
<div class="float-left">
<h4 class="header-title">Liste aller Buchungen</h4>
</div>
</div>
</div>
<table id="datatable" class="table table-hover table-sm">
<thead>
<tr class="bg-white">
<th style="width: 200px;" class="text-center text-nowrap ">Datum</th>
<th style="width: 200px;" class="text-nowrap">Mitarbeiter</th>
<th class="text-nowrap text-center edit-width">Von</th>
<th class="text-nowrap text-center edit-width">Bis</th>
<th class="text-nowrap edit-width text-center">Summe</th>
<th class="text-center text-nowrap">Buchungsart</th>
<th class="text-center">Anmerkung</th>
<th class="text-center edit-width-large">Freigabe</th>
<th class="edit-width-w70 text-center"></th>
</tr>
<tr id="filterrow">
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($timerecordings as $timerecording):
$state = "";
$enddate = "";
$sum = "-";
$day = "";
$orderdate = $timerecording->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 = '<i class="fa-regular fa-clock mr-1"></i>';
} else if ($timerecording->timerecordingCategory->approval_fibu == 1 && $timerecording->approved == 1) {
$state = '<i class="fa-regular fa-circle-check mr-1"></i>';
}
$approved = 'Offen';
if ($timerecording->approved == 1) $approved = 'Genehmigt';
$completed = 'Genehmigt';
// if ($timerecording->completed == 1) $completed = 'Genehmigt';
?>
<tr class="">
<td data-order="<?= $orderdate ?>"
class="text-nowrap text-left "><?= $state ?><?= $day . " " . $date ?></td>
<td class="text-nowrap "><?= $timerecording->user->name ?></td>
<td class="text-nowrap text-center"><?= $start ?></td>
<td class="text-nowrap text-center"><?= $end ?></td>
<td class="text-nowrap text-center"><?= $sum ?></td>
<td class="text-nowrap"><?= $timerecording->timerecordingCategory->name ?></td>
<td><?= $timerecording->comment ?></td>
<td class="text-center"><?= $approved ?></td>
<td style="text-align: left; letter-spacing: 4px; font-size: 1.1em;">
<?php if ($timerecording->completed == 0):
if ($timerecording->approved == 0) : ?>
<a href="<?= self::getUrl("TimerecordingPermitFibu", "approve", ["id" => $timerecording->id]) ?>"
onclick="if(!confirm('Buchung genehmigen?')) return false;"> <i
class="fa-regular fa-square-check permit-button" title="genehmigen"
data-id="<?= $timerecording->id ?>"></i></a>
<a href="<?= self::getUrl("TimerecordingPermitFibu", "deny", ["id" => $timerecording->id]) ?>"
onclick="if(!confirm('Buchung wirklich ablehnen?')) return false;"> <i
class="fas fa-ban deny-button" title="genehmigen"
data-id="<?= $timerecording->id ?>"></i></a>
<?php else : ?>
<div class="edit-placeholder"></div>
<?php endif; ?>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script type="text/javascript"
src="<?= self::getResourcePath() ?>datatables/DataTables-2x/datatables.min.js?<?= $git_merge_ts ?>"></script>
<script type="text/javascript">
var hidesearch = [2, 3, 4, 8];
var pageLength = 100;
var columnfilter = [7];
var columnoptions = '<option value=""></option><option selected="selected" value="Offen">Offen</option><option value="Genehmigt">Genehmigt</option><option value="Abgelehnt">Abgelehnt</option>';
$(document).ready(function () {
$('#selectsearch').change();
});
</script>
<script type="text/javascript"
src="<?= self::getResourcePath() ?>assets/js/datatables-std2.js?<?= $git_merge_ts ?>"></script>
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>

View File

@@ -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 @@
<p v-else class="text-sm whitespace-pre-wrap text-slate-800 dark:text-slate-200">{{ selectedWorkorder.additionalInfo || 'Keine Notiz.' }}</p>
</div>
<div v-if="tenantConfig && (tenantConfig.requireCableLength || tenantConfig.requireCableType)" class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800 space-y-3 text-sm">
<h3 class="font-bold text-slate-700 dark:text-secondary">Zusatzdaten</h3>
<div v-if="tenantConfig.requireCableLength">
<label class="text-xs text-slate-500 dark:text-slate-300 font-semibold">Kabellänge (m)</label>
<input v-model="selectedWorkorder.cableLength" type="text" class="mt-1 w-full p-2 border rounded-md dark:bg-slate-800 dark:border-slate-700 dark:text-white" placeholder="z.B. 20m">
</div>
<div v-if="tenantConfig.requireCableType">
<label class="text-xs text-slate-500 dark:text-slate-300 font-semibold">Kabeltyp</label>
<input v-model="selectedWorkorder.cableType" type="text" class="mt-1 w-full p-2 border rounded-md dark:bg-slate-800 dark:border-slate-700 dark:text-white" placeholder="z.B. LWL-Kabel 4F">
</div>
<button @click="saveWorkorderData" :disabled="savingData" class="mt-2 w-full px-4 py-2 bg-secondary text-primary font-bold rounded-md text-sm disabled:bg-slate-300">
{{ savingData ? 'Speichert...' : 'Daten speichern' }}
</button>
</div>
<div class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
<h3 class="font-bold text-slate-700 dark:text-secondary mb-3">Checkliste</h3>
<div v-if="isDetailsLoading" class="space-y-3 animate-pulse">
@@ -672,7 +752,7 @@
<button @click="handleCompleteClick" class="w-full px-4 py-3 bg-green-600 text-white font-bold rounded-md text-center disabled:bg-slate-300">Abschließen</button>
<transition name="fade">
<div v-if="missingTasksPopover.show" class="absolute bottom-full right-0 mb-2 w-72 bg-red-700 text-white text-sm rounded-lg shadow-lg p-3">
<h4 class="font-bold mb-1">Fehlende Checklisten-Punkte:</h4>
<h4 class="font-bold mb-1">Fehlende Punkte:</h4>
<ul class="list-disc list-inside space-y-1">
<li v-for="task in missingTasksPopover.tasks" :key="task">{{ task }}</li>
</ul>
@@ -870,4 +950,4 @@
</script>
<script src="/js/pages/WorkorderBase/WorkorderServiceWorker.js"></script>
</body>
</html>
</html>

View File

@@ -41,7 +41,8 @@
<li><a href="<?=self::getUrl("Timerecording")?>"><i class="far fa-fw fa-calendar text-info"></i> Buchungen</a></li>
<li><a href="<?=self::getUrl("TimerecordingCalendar")?>"><i class="far fa-fw fa-calendar-days text-info"></i> Abwesenheitskalender</a></li>
<?php if ($me->can('Fibu')): ?>
<li><a href="<?=self::getUrl("TimerecordingPermit")?>"><i class="far fa-fw fa-calendar-check text-info"></i> Freigaben</a></li>
<li><a href="<?=self::getUrl("TimerecordingPermit")?>"><i class="far fa-fw fa-calendar-check text-info"></i> Freigaben Urlaub/ZA</a></li>
<li><a href="<?=self::getUrl("TimerecordingPermitFibu")?>"><i class="far fa-fw fa-calendar-check text-info"></i> Freigaben Buchhaltung</a></li>
<li><a href="<?=self::getUrl("TimerecordingReport")?>"><i class="far fa-fw fa-chart-pie text-info"></i> Auswertung/Korrektur</a></li>
<li><a href="<?=self::getUrl("TimerecordingBilling")?>"><i class="far fa-fw fa-money-bill-1-wave text-info"></i> Verrechnung</a></li>
<li><a href="<?=self::getUrl("TimerecordingReportExport")?>"><i class="far fa-fw fa-chart-simple text-info"></i> Reports</a></li>

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);

View File

@@ -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%')";

View File

@@ -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
]);
}
}

View File

@@ -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 = '<i class="fa-regular fa-clock mr-1"></i>';
} 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 = '<i class="fa-regular fa-circle-check mr-1"></i>';
}
$edit = "";

View File

@@ -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()

View File

@@ -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);

View File

@@ -6,6 +6,7 @@ class TimerecordingCategoryModel
private $short;
private $hourday;
private $approval;
private $approval_fibu;
private $require_comment;
private $only_admin;
private $businesstrip;

View File

@@ -0,0 +1,123 @@
<?php
class TimerecordingPermitFibuController extends mfBaseController
{
protected function init()
{
$this->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");
}
}

View File

@@ -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 = '<i class="fa-regular fa-clock mr-1"></i>';
} 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 = '<i class="fa-regular fa-circle-check mr-1"></i>';
}
$edit = "";

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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;
}}
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
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']);
$table->update();
}
}
public function down(): void {
if($this->getEnvironment() == "thetool") {
$this->table("Preorder")->removeColumn("files")->save();
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class TimerecordingCategoryAddFieldApprovalFibu extends AbstractMigration
{
public function up(): void
{
if($this->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") {
}
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class WorkorderAddCableFields extends AbstractMigration
{
public function up(): void
{
if ($this->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();
}
}
}

View File

@@ -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
# Expose port 80 and start Apache in foreground
CMD ["apachectl", "-D", "FOREGROUND"]

View File

@@ -38,7 +38,7 @@ Vue.component('Cpeprovisioning', {
<span style="display: ruby;">
<strong>{{ item.customer }}<small v-if="item.owner_customer_number" class="text-muted ml-2">#{{ item.owner_customer_number }}</small></strong>
</span>
<small class="text-muted">SPIN: <span class="text-pink">{{ item.spin }}</span></small>
<small v-if="item.spin" class="text-muted">SPIN: <span class="text-pink">{{ item.spin }}</span></small>
</div>
<div class="location-contact-header">
<div><strong>Netzgebiet:</strong> {{ item.network || 'N/A' }}</div>

View File

@@ -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; }
.marker-not-to-connect { background-color: #6c757d !important; }

View File

@@ -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 ? `<li>Sonstiges: ${faultData.other}</li>` : '';
return `<div class="fault-indicator-popup"><strong><i class="fas fa-exclamation-triangle"></i> Gemeldeter Fehler:</strong><ul>${reasonsList}${otherText}</ul></div>`;
},
_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') ? `<textarea class="fault-other-textarea ${isChecked ? '' : 'hidden'}" oninput="window.updateEditingFault('other_text', this.value)">${faultData.other || ''}</textarea>` : '';
return `<label><input type="checkbox" onchange="window.updateEditingFault('${reason.value}', this.checked)" ${isChecked ? 'checked' : ''}> ${reason.text}</label>${otherInput}`;
return `<label><input type="checkbox" onchange="window.updateEditingFault('${reason.value}', this.checked)" ${isChecked ? 'checked' : ''} ${isDisabled ? 'disabled' : ''}> ${reason.text}</label>${otherInput}`;
}).join('');
return `<h6>Fehler melden/bearbeiten:</h6>${formInputs}<button class="btn btn-primary btn-sm mt-2" onclick="window.saveEditingFault()">Fehler Speichern</button>`;
// --- FIX: Updated logic to only show button if fault exists and is not done ---
const doneButton = isExistingFault && !faultData.done
? `<button class="btn btn-success btn-sm mt-2 ml-2" onclick="window.markFaultDonePopup()">Als erledigt markieren</button>`
: '';
return `<h6>Fehler melden/bearbeiten:</h6>${formInputs}<button class="btn btn-primary btn-sm mt-2" onclick="window.saveEditingFault()">Änderungen Speichern</button>${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 ? `<div class="alert alert-success"><i class="fas fa-check-circle"></i> Dieser Fehler wurde von <strong>${this.userIdToNameMap.get(String(this.editingFault.data.done_by)) || `User #${this.editingFault.data.done_by}`}</strong> am ${new Date(this.editingFault.data.done_at).toLocaleDateString()} als erledigt markiert.</div>` : 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
? `<div class="alert alert-success"><i class="fas fa-check-circle"></i> Dieser Fehler wurde von <strong>${this.userIdToNameMap.get(String(this.editingFault.data.done_by)) || `User #${this.editingFault.data.done_by}`}</strong> am ${new Date(this.editingFault.data.done_at).toLocaleDateString()} als erledigt markiert.</div>`
: this._generateBuildingFaultDisplayHtml(this.editingFault.data);
return `<div class="building-popup-content">${detailsHtml}${faultDisplayHtml}<hr class="my-2"><div class="fault-reporting-form">${faultFormHtml}</div></div>`;
},
// 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 = `
<div class="mb-2">
<h5 class="mb-2 mt-1"><i class="fas fa-map-pin mr-2"></i>Fehlendes Gebäude</h5>
<strong>Gemeldeter Typ:</strong> ${typeDef.text}<br>
<strong>Koordinaten:</strong> ${currentFault.lat.toFixed(6)}, ${currentFault.lng.toFixed(6)}<br>
<strong>Links:</strong>
<a href="https://www.google.com/maps?q=${currentFault.lat},${currentFault.lng}" target="_blank" class="text-primary"><i class="fas fa-map-marker-alt mr-1"></i>Karte</a>
</div>`;
// --- FIX: Pass the isExistingFault flag ---
const faultFormHtml = this._generateBuildingFaultFormHtml({}, this.editingFault.data, isExistingFault); // Pass empty itemGroup
const faultDisplayHtml = this.editingFault.data.done
? `<div class="alert alert-success"><i class="fas fa-check-circle"></i> Dieser Fehler wurde von <strong>${this.userIdToNameMap.get(String(this.editingFault.data.done_by)) || `User #${this.editingFault.data.done_by}`}</strong> am ${new Date(this.editingFault.data.done_at).toLocaleDateString()} als erledigt markiert.</div>`
: this._generateBuildingFaultDisplayHtml(this.editingFault.data);
return `<div class="building-popup-content">${detailsHtml}${faultDisplayHtml}<hr class="my-2"><div class="fault-reporting-form">${faultFormHtml}</div></div>`; },
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: `<div class="rimo-marker ${markerIconDef.class} ${isNot2Connect ? 'marker-not-to-connect' : ''}"><i class="${markerIconDef.icon} rimo-icon"></i></div>`,
className: `custom-div-icon marker-${rimoType} ${hasFault ? 'marker-has-fault' : ''} marker-missing-building`,
html: `<div class="rimo-marker ${markerIconDef.class}"><i class="${markerIconDef.icon} rimo-icon"></i></div>`,
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: `<div class="rimo-marker ${markerIconDef.class} ${isNot2Connect ? 'marker-not-to-connect' : ''}"><i class="${markerIconDef.icon} rimo-icon"></i></div>`,
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: `<div class="rimo-marker ${markerIconDef.class}"><i class="${markerIconDef.icon} rimo-icon"></i></div>`,
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: `
<div id="PreorderRimoTypeMap">
<tt-map ref="ttMap" :markers-data="filteredMapMarkers" :loading="isLoading" :config="mapConfig" :show-logo="true">
<tt-map ref="ttMap"
:markers-data="filteredMapMarkers"
:loading="isLoading"
:config="mapConfig"
:show-logo="true"
:contextmenu="true"
@contextmenu="handleMapContextMenu"
>
<template v-slot:tools>
<div class="main-filter-container">
<div class="map-filter-container">
@@ -387,6 +634,12 @@ Vue.component('PreorderRimoTypeMap', {
</div>
<span class="legend-text">Not2Connect</span>
</div>
<div class="legend-item">
<div class="legend-icon">
<div class="rimo-marker marker-residential marker-missing-building" style="width: 24px; height: 24px; border-width: 2px;"><i class="fas fa-home rimo-icon" style="font-size: 12px;"></i></div>
</div>
<span class="legend-text">Fehlendes Gebäude</span>
</div>
<div class="mobile-only-legend-buttons mt-3" v-if="selectedCampaign">
<tt-button :href="window.TT_CONFIG.BASE_PATH + '/Preorder/RimoTypeMapFaultsPDFAction?preordercampaign_id=' + selectedCampaign"
@@ -397,6 +650,7 @@ Vue.component('PreorderRimoTypeMap', {
</template>
</tt-map>
<!-- Faults List Modal -->
<div v-if="showFaultsModal" class="modal fade show" style="display: block; background-color: rgba(0,0,0,0.5);" tabindex="-1">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
@@ -411,7 +665,7 @@ Vue.component('PreorderRimoTypeMap', {
<div class="d-flex justify-content-between align-items-center">
<div class="pr-3">
<strong :class="{ 'text-muted': fault.done }">{{ fault.address }}</strong>
<div class="text-muted mb-1">Rimo ID: {{ fault.rimo_id }}</div>
<div class="text-muted mb-1" v-if="!fault.hausnummerId.startsWith('missing-')">Rimo ID: {{ fault.rimo_id }}</div>
<div v-if="!fault.done" class="mt-1">
<ul class="mb-0 small pl-3 text-danger font-weight-bold">
<li v-for="reason in fault.translated_reasons" :key="reason">{{ reason }}</li>
@@ -437,6 +691,34 @@ Vue.component('PreorderRimoTypeMap', {
</div>
</div>
</div>
<!-- New Missing Building Modal -->
<div v-if="showMissingBuildingModal" class="modal fade show" style="display: block; background-color: rgba(0,0,0,0.5);" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Fehlendes Gebäude markieren</h5>
<button type="button" class="close" @click="showMissingBuildingModal = false; missingBuildingData = null;" aria-label="Close"><span aria-hidden="true">×</span></button>
</div>
<div class="modal-body">
<p>Markieren Sie ein neues, fehlendes Gebäude an der Position:</p>
<p v-if="missingBuildingData"><strong>Lat:</strong> {{ missingBuildingData.lat.toFixed(6) }}, <strong>Lng:</strong> {{ missingBuildingData.lng.toFixed(6) }}</p>
<tt-select v-if="missingBuildingData"
v-model="missingBuildingData.rimo_type"
:options="rimoTypeOptionsForMissing"
label="RIMO-Typ des fehlenden Gebäudes"
:row="false"
/>
</div>
<div class="modal-footer">
<tt-button text="Abbrechen" @click="showMissingBuildingModal = false; missingBuildingData = null;" additional-class="btn-secondary"/>
<tt-button text="Speichern" @click="saveMissingBuilding" additional-class="btn-primary" icon="fas fa-save"/>
</div>
</div>
</div>
</div>
</div>
`
});
});

View File

@@ -147,18 +147,128 @@ Vue.component('radius-processing-indicator', {
/* ---------- Online state chip (fetches radacct when visible) ---------- */
Vue.component('radius-online-state', {
props: { username: String }, data: () => ({ data: null, observed: false, ob: null }),
props: { username: String },
data: () => ({
data: null,
observed: false,
ob: null,
isHovering: false,
ctrlPressed: false,
tooltipText: 'IP-Adresse kopieren'
}),
template: `
<div class="radius-scope ros-wrap" ref="root">
<template v-if="data===null"><span class="ros-chip skeleton"><span class="dot"></span><span class="skeleton-line" style="width: 80px; height: 18px; margin: auto;"></span></span></template>
<template v-else-if="data!==null"><span class="ros-chip" :class="[data.online ? 'on' : 'off', {'is-clickable': data.ip}]" :data-tooltip="data.ip ? 'IP-Adresse kopieren' : null" @click="copyIp($event)"><span class="dot"></span><span class="ip">{{ data.ip || '—' }}</span></span></template>
<template v-if="data===null">
<span class="ros-chip skeleton"><span class="dot"></span><span class="skeleton-line" style="width: 80px; height: 18px; margin: auto;"></span></span>
</template>
<template v-else-if="data!==null">
<span class="ros-chip"
:class="[data.online ? 'on' : 'off', {'is-clickable': data.ip}]"
:data-tooltip="tooltipText"
@click="onClickIp"
@mouseover="onIpMouseOver"
@mouseout="onIpMouseOut"
>
<span class="dot"></span>
<span class="ip">{{ data.ip || '—' }}</span>
</span>
</template>
</div>
`,
mounted() { this.ob = new IntersectionObserver((en) => { if (en[0].isIntersecting && !this.observed) { this.observed = true; this.fetchState(); } }, { threshold: 0.1 }); if (this.$refs.root) this.ob.observe(this.$refs.root); },
beforeDestroy() { this.ob?.disconnect(); },
watch: {
data(newData) {
// Update tooltip text when data is loaded
if (newData && newData.ip) {
this.tooltipText = 'IP-Adresse kopieren';
} else {
this.tooltipText = null;
}
}
},
mounted() {
this.ob = new IntersectionObserver((en) => { if (en[0].isIntersecting && !this.observed) { this.observed = true; this.fetchState(); } }, { threshold: 0.1 });
if (this.$refs.root) this.ob.observe(this.$refs.root);
// Listen for Ctrl/Meta key presses globally
document.addEventListener('keydown', this.handleKey);
document.addEventListener('keyup', this.handleKey);
},
beforeDestroy() {
this.ob?.disconnect();
// Clean up global listeners
document.removeEventListener('keydown', this.handleKey);
document.removeEventListener('keyup', this.handleKey);
},
methods: {
async fetchState() { try { const r = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`); this.data = r.ok ? await r.json() : { online: false, ip: null }; } catch { this.data = { online: false, ip: null }; } },
async copyIp(event) { if (!this.data?.ip) return; const c = event.currentTarget; if (!c || c.classList.contains('is-copied')) return; await window.RadiusUtils.copyToClipboard(this.data.ip); c.classList.add('is-copied'); const o = c.dataset.tooltip; if (o) c.dataset.tooltip = 'Kopiert!'; setTimeout(() => { c.classList.remove('is-copied'); if (o) c.dataset.tooltip = o; }, 1500); }
async fetchState() {
try {
const r = await fetch(`${window.TT_CONFIG['BASE_PATH']}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.username)}`);
this.data = r.ok ? await r.json() : { online: false, ip: null };
} catch {
this.data = { online: false, ip: null };
}
},
async copyIp(event) {
if (!this.data?.ip) return;
const c = event.currentTarget;
if (!c || c.classList.contains('is-copied')) return;
await window.RadiusUtils.copyToClipboard(this.data.ip);
c.classList.add('is-copied');
// Temporarily change tooltip to "Kopiert!"
const originalTooltip = this.tooltipText;
this.tooltipText = 'Kopiert!';
setTimeout(() => {
c.classList.remove('is-copied');
// Restore original tooltip
this.tooltipText = originalTooltip;
// Re-run updateTooltip in case Ctrl is still pressed
this.updateTooltip();
}, 1500);
},
// --- New methods for Ctrl+Click ---
handleKey(event) {
const newCtrlPressed = event.ctrlKey || event.metaKey;
if (newCtrlPressed !== this.ctrlPressed) {
this.ctrlPressed = newCtrlPressed;
// If hovering, update tooltip live
if (this.isHovering) {
this.updateTooltip();
}
}
},
onIpMouseOver(event) {
this.isHovering = true;
this.ctrlPressed = event.ctrlKey || event.metaKey;
this.updateTooltip();
},
onIpMouseOut() {
this.isHovering = false;
this.ctrlPressed = false; // Reset on mouse out
this.updateTooltip();
},
updateTooltip() {
if (!this.data?.ip) {
this.tooltipText = null;
} else if (this.isHovering && this.ctrlPressed) {
this.tooltipText = 'Scan starten & verbinden';
} else {
this.tooltipText = 'IP-Adresse kopieren';
}
},
onClickIp(event) {
if (!this.data?.ip) return;
if (event.ctrlKey || event.metaKey) {
// Ctrl+Click or Meta+Click
event.preventDefault();
this.$emit('scan-ip', { ip: this.data.ip });
} else {
// Normal click
this.copyIp(event);
}
}
// --- End new methods ---
}
});
@@ -247,4 +357,4 @@ Vue.component('radius', {
methods: {
switchView(v){ this.view=v; if(!this._initFlags||this._initFlags[v])return; let r=''; if(v==='free')r='freeView';else if(v==='unused')r='unusedView'; if(r){this.$nextTick(()=>{const c=this.$refs[r];if(c&&typeof c.initIfNeeded==='function'){c.initIfNeeded();this._initFlags[v]=true;}});}}
}
});
});

View File

@@ -1,99 +1,826 @@
/* ===== RadiusUsers.js ===== */
Vue.component('radius-users', {
template: `
<div class="radius-scope">
<div class="filters-layout">
<div class="field"><radius-autocomplete v-model="billAddrDisplay" :wide="true" placeholder="Kunde suchen" @select="onAddrSelect" @enter="loadRadiusUsers" @mode-change="onModeChange"/></div>
<div class="field"><div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">z.B. nat* für lazy Suche</span><i class="fa-duotone fa-user input-icon"></i><input class="ri" v-model="username" placeholder="Username" @keydown.enter="loadRadiusUsers" autocomplete="off" autocapitalize="none" autocorrect="off" inputmode="text" name="radius-username"/></div></div>
<div class="field"><div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt)</span><div class="input-wrap"><i class="fa-duotone fa-network-wired input-icon"></i><input class="ri" v-model="ip" placeholder="IP-Adresse" @keydown.enter="loadRadiusUsers" autocomplete="off" autocapitalize="none" autocorrect="off" inputmode="decimal" name="radius-ip"/></div></div></div>
<div class="field"><div class="input-wrap"><i class="fa-duotone fa-note-sticky input-icon"></i><input class="ri" v-model="info" placeholder="Info" @keydown.enter="loadRadiusUsers"/></div></div>
<div class="field">
<radius-autocomplete v-model="billAddrDisplay" :wide="true" placeholder="Kunde suchen"
@select="onAddrSelect" @enter="loadRadiusUsers" @mode-change="onModeChange"/>
</div>
<div class="field">
<div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">z.B. nat* für lazy Suche</span><i
class="fa-duotone fa-user input-icon"></i><input class="ri" v-model="username" placeholder="Username"
@keydown.enter="loadRadiusUsers" autocomplete="off"
autocapitalize="none" autocorrect="off"
inputmode="text" name="radius-username"/></div>
</div>
<div class="field">
<div class="input-wrap ip-field-wrapper"><span class="ip-focus-tooltip">Prefixe: '=' exakt, '*' Verlauf (lazy), '*=' Verlauf (exakt)</span>
<div class="input-wrap"><i class="fa-duotone fa-network-wired input-icon"></i><input class="ri"
v-model="ip"
placeholder="IP-Adresse"
@keydown.enter="loadRadiusUsers"
autocomplete="off"
autocapitalize="none"
autocorrect="off"
inputmode="decimal"
name="radius-ip"/>
</div>
</div>
</div>
<div class="field">
<div class="input-wrap"><i class="fa-duotone fa-note-sticky input-icon"></i><input class="ri" v-model="info"
placeholder="Info"
@keydown.enter="loadRadiusUsers"/>
</div>
</div>
<div class="cluster" style="gap: 8px;">
<div class="field"><label class="switch-field"><span class="mini muted">Online-Status</span><span class="switch"><input type="checkbox" v-model="checkOnlineState"><span class="switch-track"><i class="fa-duotone fa-signal-bars-good on"></i><i class="fa-duotone fa-signal-bars-slash off"></i></span></span></label></div>
<button class="primary-btn" @click="loadRadiusUsers" :disabled="isLoading" style="flex-grow: 1;"><span v-if="!isLoading"><i class="fa-duotone fa-magnifying-glass"></i></span><span v-else class="btn-loader"></span></button>
<button class="danger-btn" @click="clearFilters" :disabled="!hasFilters" data-tooltip="Eingaben leeren" data-tooltip-align="left"><i class="fa-duotone fa-xmark"></i></button>
<div class="field"><label class="switch-field"><span class="mini muted">Online-Status</span><span
class="switch"><input type="checkbox" v-model="checkOnlineState"><span class="switch-track"><i
class="fa-duotone fa-signal-bars-good on"></i><i class="fa-duotone fa-signal-bars-slash off"></i></span></span></label>
</div>
<button class="primary-btn" @click="loadRadiusUsers" :disabled="isLoading" style="flex-grow: 1;"><span
v-if="!isLoading"><i class="fa-duotone fa-magnifying-glass"></i></span><span v-else
class="btn-loader"></span>
</button>
<button class="danger-btn" @click="clearFilters" :disabled="!hasFilters" data-tooltip="Eingaben leeren"
data-tooltip-align="left"><i class="fa-duotone fa-xmark"></i></button>
</div>
</div>
<div class="results-container mt-between">
<radius-table-view :items="visibleUsers" :is-loading="isLoading" :has-searched="hasSearched" :skeleton-row-count="6" initial-placeholder-text="Beginnen Sie Ihre Suche, indem Sie Filter eingeben.">
<template #head><thead><tr><th style="text-align: center; width: 170px;">Kundennummer</th><th style="text-align: center; width: 183px;">Username</th><th style="text-align: center">Info</th><th style="text-align: center; width: 190px;">Status</th><th style="text-align: center; width: 115px;">Aktionen</th></tr></thead></template>
<template #skeleton-row><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line" style="height: 36px;"></div></td></template>
<template #row="{ item }">
<td><a class="link" target="_blank" :href="window.TT_CONFIG.BASE_PATH + '/Address?filter%5Bcustomer_number%5D=' + item.customerNumber" data-tooltip="Kunden in neuem Tab öffnen" data-tooltip-align="right">{{ item.customerNumber }}</a></td>
<td class="nowrap"><a class="link" target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + item.username" data-tooltip="User in Radius öffnen" data-tooltip-align="right">{{ item.username }}</a><button class="icon-btn sm" data-tooltip="Kopieren" data-tooltip-align="right" @click="copy(item.username, $event)"><i class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button></td>
<td class="mono clamp-2">{{ item.info }}</td>
<td><radius-online-state v-if="checkOnlineState" :username="item.username" :key="item.username + '_'+searchCount"/></td>
<td class="nowrap cluster" style="gap: 4px; justify-content: center;"><button class="ghost-btn" @click="fetchRadacctData(item.username)" data-tooltip="Details"><i class="fa-duotone fa-circle-info"></i></button><button class="ghost-btn" @click="openTransferModal(item.username)" data-tooltip="Transfer Statistik" data-tooltip-align="left"><i class="fa-duotone fa-chart-line"></i></button></td>
<radius-table-view :items="visibleUsers" :is-loading="isLoading" :has-searched="hasSearched"
:skeleton-row-count="6"
initial-placeholder-text="Beginnen Sie Ihre Suche, indem Sie Filter eingeben.">
<template #head>
<thead>
<tr>
<th style="text-align: center; width: 170px;">Kundennummer</th>
<th style="text-align: center; width: 183px;">Username</th>
<th style="text-align: center">Info</th>
<th style="text-align: center; width: 190px;">Status</th>
<th style="text-align: center; width: 115px;">Aktionen</th>
</tr>
</thead>
</template>
<template #skeleton-row>
<td>
<div class="skeleton-line"></div>
</td>
<td>
<div class="skeleton-line"></div>
</td>
<td>
<div class="skeleton-line"></div>
</td>
<td>
<div class="skeleton-line"></div>
</td>
<td>
<div class="skeleton-line" style="height: 36px;"></div>
</td>
</template>
<template #row="{ item }">
<td><a class="link" target="_blank"
:href="window.TT_CONFIG.BASE_PATH + '/Address?filter%5Bcustomer_number%5D=' + item.customerNumber"
data-tooltip="Kunden in neuem Tab öffnen" data-tooltip-align="right">{{ item.customerNumber }}</a>
</td>
<td class="nowrap"><a class="link" target="_blank"
:href="'http://radius.xinon.at/edit_user.php?user=' + item.username"
data-tooltip="User in Radius öffnen"
data-tooltip-align="right">{{ item.username }}</a>
<button class="icon-btn sm" data-tooltip="Kopieren" data-tooltip-align="right"
@click="copy(item.username, $event)"><i class="fa-duotone fa-copy copy-icon"></i><i
class="fa-duotone fa-check check-icon"></i></button>
</td>
<td class="mono clamp-2">{{ item.info || '—' }}</td>
<td>
<radius-online-state
v-if="checkOnlineState"
:username="item.username"
:key="item.username + '_'+searchCount"
@scan-ip="onScanIp($event, item)"
/>
</td>
<td class="nowrap cluster" style="gap: 4px; justify-content: center;">
<button class="ghost-btn" @click="fetchRadacctData(item.username)" data-tooltip="Details"><i
class="fa-duotone fa-circle-info"></i></button>
<button class="ghost-btn" @click="openTransferModal(item.username)" data-tooltip="Transfer Statistik"
data-tooltip-align="left"><i class="fa-duotone fa-chart-line"></i></button>
</td>
</template>
<template #observer>
<div ref="sentinel" style="height: 1px;"></div>
</template>
<template #observer><div ref="sentinel" style="height: 1px;"></div></template>
</radius-table-view>
<div v-if="hasSearched" class="results-summary"><span v-if="isLoading">Suche läuft...</span><span v-else-if="radiusUsers.length">{{ radiusUsers.length }} Treffer gefunden</span></div>
<div v-if="hasSearched" class="results-summary"><span v-if="isLoading">Suche läuft...</span><span
v-else-if="radiusUsers.length">{{ radiusUsers.length }} Treffer gefunden</span></div>
</div>
<radius-modal :show="showRadacctModal" title="RADIUS Daten" @close="showRadacctModal=false">
<div class="kv-redesign">
<div class="kv-row"><span class="kv-label">Status</span><div class="kv-value"><div v-if="radacctData"><strong class="chip" :class="radacctData.online ? 'ok' : 'bad'">{{ radacctData.online ? 'Online' : 'Offline' }}</strong></div><div v-else><div class="skeleton-line" style="width: 80px; --h: 24px; margin-left: auto;"></div></div></div></div>
<div class="kv-row"><span class="kv-label">IP</span><div class="kv-value"><div v-if="radacctData" class="inline-copy"><code>{{ radacctData.ip || '—' }}</code><button v-if="radacctData.ip" class="icon-btn sm" @click="copy(radacctData.ip, $event)" data-tooltip="Kopieren"><i class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button></div><div v-else><div class="skeleton-line" style="width: 120px; margin-left: auto;"></div></div></div></div>
<div class="kv-row"><span class="kv-label">Username</span><div class="kv-value"><div v-if="radacctData" class="inline-copy"><a class="link" target="_blank" :href="'http://radius.xinon.at/edit_user.php?user=' + radacctData.username" data-tooltip="User in Radius öffnen">{{ radacctData.username }}</a><button class="icon-btn sm" @click="copy(radacctData.username, $event)" data-tooltip="Kopieren"><i class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button></div><div v-else><div class="skeleton-line" style="width: 150px; margin-left: auto;"></div></div></div></div>
<template v-if="radacctData"><div class="kv-row"><span class="kv-label">Kundennummer</span><code class="kv-value">{{ radacctData.customerNumber || '—' }}</code></div><div class="kv-row"><span class="kv-label">Kundenname</span><div class="kv-value clamp-2">{{ radacctData.customerName || '—' }}</div></div><div class="kv-row"><span class="kv-label">Info</span><div class="kv-value clamp-3 mono small">{{ radacctData.info || '—' }}</div></div><div class="kv-row"><span class="kv-label">WLAN Password</span><code class="kv-value mono">{{ radacctData.wlanPassword || '—' }}</code></div><div class="kv-row"><span class="kv-label">Bandbreite</span><code class="kv-value">{{ radacctData.actualBandwidth || '—' }}</code></div></template>
<template v-else><div class="kv-row"><span class="kv-label">Kundennummer</span><div class="kv-value"><div class="skeleton-line" style="width: 70px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">Kundenname</span><div class="kv-value"><div class="skeleton-line" style="width: 200px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">Info</span><div class="kv-value"><div class="skeleton-line" style="--h:14px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">WLAN Password</span><div class="kv-value"><div class="skeleton-line" style="width: 100px; margin-left: auto;"></div></div></div><div class="kv-row"><span class="kv-label">Bandbreite</span><div class="kv-value"><div class="skeleton-line" style="width: 180px; margin-left: auto;"></div></div></div></template>
<div class="kv-row"><span class="kv-label">Status</span>
<div class="kv-value">
<div v-if="radacctData"><strong class="chip"
:class="radacctData.online ? 'ok' : 'bad'">{{ radacctData.online ? 'Online' : 'Offline' }}</strong>
</div>
<div v-else>
<div class="skeleton-line" style="width: 80px; --h: 24px; margin-left: auto;"></div>
</div>
</div>
</div>
 
<div class="kv-row"><span class="kv-label">IP</span>
<div class="kv-value">
<div v-if="radacctData" class="inline-copy"><code v-if="radacctData.ip">{{ radacctData.ip }}</code><code
v-else>—</code>
<button v-if="radacctData.ip" class="icon-btn sm" @click="copy(radacctData.ip, $event)"
data-tooltip="Kopieren"><i class="fa-duotone fa-copy copy-icon"></i><i
class="fa-duotone fa-check check-icon"></i></button>
</div>
<div v-else>
<div class="skeleton-line" style="width: 120px; margin-left: auto;"></div>
</div>
</div>
</div>
 
<div class="kv-row"><span class="kv-label">Username</span>
<div class="kv-value">
<div v-if="radacctData" class="inline-copy"><a class="link" target="_blank"
:href="'http://radius.xinon.at/edit_user.php?user=' + radacctData.username"
data-tooltip="User in Radius öffnen">{{ radacctData.username }}</a>
<button class="icon-btn sm" @click="copy(radacctData.username, $event)" data-tooltip="Kopieren"><i
class="fa-duotone fa-copy copy-icon"></i><i class="fa-duotone fa-check check-icon"></i></button>
</div>
<div v-else>
<div class="skeleton-line" style="width: 150px; margin-left: auto;"></div>
</div>
</div>
</div>
<template v-if="radacctData">
<div class="kv-row"><span class="kv-label">Kundennummer</span><code
class="kv-value">{{ radacctData.customerNumber || '—' }}</code></div>
<div class="kv-row"><span class="kv-label">Kundenname</span>
<div class="kv-value clamp-2">{{ radacctData.customerName || '—' }}</div>
</div>
<div class="kv-row"><span class="kv-label">Info</span>
<div class="kv-value clamp-3 mono small">{{ radacctData.info || '—' }}</div>
</div>
<div class="kv-row"><span class="kv-label">WLAN Password</span><code
class="kv-value mono">{{ radacctData.wlanPassword || '—' }}</code></div>
<div class="kv-row"><span class="kv-label">Bandbreite</span><code
class="kv-value">{{ radacctData.actualBandwidth || '—' }}</code></div>
</template>
<template v-else>
<div class="kv-row"><span class="kv-label">Kundennummer</span>
<div class="kv-value">
<div class="skeleton-line" style="width: 70px; margin-left: auto;"></div>
</div>
</div>
<div class="kv-row"><span class="kv-label">Kundenname</span>
<div class="kv-value">
<div class="skeleton-line" style="width: 200px; margin-left: auto;"></div>
</div>
</div>
<div class="kv-row"><span class="kv-label">Info</span>
<div class="kv-value">
<div class="skeleton-line" style="--h:14px; margin-left: auto;"></div>
</div>
</div>
<div class="kv-row"><span class="kv-label">WLAN Password</span>
<div class="kv-value">
<div class="skeleton-line" style="width: 100px; margin-left: auto;"></div>
</div>
</div>
<div class="kv-row"><span class="kv-label">Bandbreite</span>
<div class="kv-value">
<div class="skeleton-line" style="width: 180px; margin-left: auto;"></div>
</div>
</div>
</template>
</div>
</radius-modal>
<radius-modal :show="showTransferModal" :title="'Transfer Statistik für ' + transferModalUsername" @close="closeTransferModal" modal-class="modal-card-wide">
<radius-modal :show="showTransferModal" :title="'Transfer Statistik für ' + transferModalUsername"
@close="closeTransferModal" modal-class="modal-card-wide">
<div class="modal-body-scrollable">
<div v-if="transferYearlyData || transferInitialLoading">
<div class="unselectable">
<div class="cluster" style="justify-content: space-between; margin-bottom: 16px; flex-wrap: nowrap; margin-top: 4px; padding-left: 8px;">
<div class="cluster"><div class="custom-dropdown"><button class="dropdown-toggle" @click="!transferInitialLoading && (showYearDropdown = !showYearDropdown)" :class="{'is-open': showYearDropdown}"><span>{{ transferYear }}</span><i class="fa-solid fa-chevron-down"></i></button><transition name="ac-pop"><div v-if="showYearDropdown" class="dropdown-panel"><div v-for="y in availableYears" :key="y" class="dropdown-item" @click="selectYear(y)">{{ y }}</div></div></transition></div><div class="cluster" style="gap: 4px;"><button v-for="m in allMonths" :key="m.month" class="tab-btn" :class="{active: transferMonth === m.month}" :disabled="isMonthDisabled(m.month)" @click="changeTransferMonth(m.month)">{{ m.name }}</button></div></div>
<div class="cluster" style="gap: 16px;"><div class="muted small mono" style="text-align: right; flex-shrink: 0;">Gesamt {{ transferYear }}:<br><strong v-if="transferInitialLoading || !transferYearlyData"><div class="skeleton-line" style="width: 110px; height: 16px; margin-left:auto;"></div></strong><strong v-else>{{ window.RadiusUtils.formatBytes(transferYearlyData.yearlySummary.grandTotalBytes) }}</strong></div><button class="ghost-btn" @click="prepareEmailModal" :disabled="transferInitialLoading || !transferYearlyData || !transferYearlyData.yearlySummary || transferYearlyData.yearlySummary.grandTotalBytes === 0" data-tooltip="Statistik per E-Mail senden" data-tooltip-align="bottom-left" data-tooltip-wrap="true"><i class="fa-duotone fa-paper-plane"></i></button></div>
<div class="cluster"
style="justify-content: space-between; margin-bottom: 16px; flex-wrap: nowrap; margin-top: 4px; padding-left: 8px;">
<div class="cluster">
<div class="custom-dropdown">
<button class="dropdown-toggle"
@click="!transferInitialLoading && (showYearDropdown = !showYearDropdown)"
:class="{'is-open': showYearDropdown}"><span>{{ transferYear }}</span><i
class="fa-solid fa-chevron-down"></i></button>
<transition name="ac-pop">
<div v-if="showYearDropdown" class="dropdown-panel">
<div v-for="y in availableYears" :key="y" class="dropdown-item" @click="selectYear(y)">
{{ y }}
</div>
</div>
</transition>
</div>
<div class="cluster" style="gap: 4px;">
<button v-for="m in allMonths" :key="m.month" class="tab-btn"
:class="{active: transferMonth === m.month}" :disabled="isMonthDisabled(m.month)"
@click="changeTransferMonth(m.month)">{{ m.name }}
</button>
</div>
</div>
<div class="cluster" style="gap: 16px;">
<div class="muted small mono" style="text-align: right; flex-shrink: 0;">Gesamt {{ transferYear }}
:<br><strong v-if="transferInitialLoading || !transferYearlyData">
<div class="skeleton-line" style="width: 110px; height: 16px; margin-left:auto;"></div>
</strong><strong
v-else>{{ window.RadiusUtils.formatBytes(transferYearlyData.yearlySummary.grandTotalBytes) }}</strong>
</div>
<button class="ghost-btn" @click="prepareEmailModal"
:disabled="transferInitialLoading || !transferYearlyData || !transferYearlyData.yearlySummary || transferYearlyData.yearlySummary.grandTotalBytes === 0"
data-tooltip="Statistik per E-Mail senden" data-tooltip-align="bottom-left"
data-tooltip-wrap="true"><i class="fa-duotone fa-paper-plane"></i></button>
</div>
</div>
<div class="grid g-4 cols-4">
<div class="stat-card-v2 stat-total">
<div class="stat-icon"><i class="fa-duotone fa-grid-2"></i></div>
<div>
<div class="stat-label">Monat gesamt</div>
<div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div
class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span
v-else>{{ window.RadiusUtils.formatBytes(transferMonthlyData?.summary?.grandTotalBytes || 0) }}</span>
</div>
</div>
</div>
<div class="stat-card-v2 stat-download">
<div class="stat-icon"><i class="fa-duotone fa-arrow-down-to-line"></i></div>
<div>
<div class="stat-label">Download</div>
<div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div
class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span
v-else>{{ window.RadiusUtils.formatBytes(transferMonthlyData?.summary?.totalDownloadBytes || 0) }}</span>
</div>
</div>
</div>
<div class="stat-card-v2 stat-upload">
<div class="stat-icon"><i class="fa-duotone fa-arrow-up-from-line"></i></div>
<div>
<div class="stat-label">Upload</div>
<div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div
class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span
v-else>{{ window.RadiusUtils.formatBytes(transferMonthlyData?.summary?.totalUploadBytes || 0) }}</span>
</div>
</div>
</div>
<div class="stat-card-v2 stat-duration">
<div class="stat-icon"><i class="fa-duotone fa-hourglass-clock"></i></div>
<div>
<div class="stat-label">Dauer</div>
<div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div
class="skeleton-line" style="width: 80px; height: 18px;"></div></span><span
v-else>{{ window.RadiusUtils.formatDuration(transferMonthlyData?.summary?.totalDurationSeconds || 0) }}</span>
</div>
</div>
</div>
</div>
<div class="chart-card mt-3" style="height: 250px;">
<div v-if="transferMonthlyLoading || transferInitialLoading" class="chart-placeholder">
<div class="skeleton-line" style="width: 100%; height: 100%; border-radius: var(--radius);"></div>
</div>
<div
v-else-if="!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length"
class="chart-placeholder"><i class="fa-duotone fa-chart-pie"></i><span>Keine Daten in diesem Monat verfügbar</span>
</div>
<canvas
v-show="!transferMonthlyLoading && !transferInitialLoading && transferMonthlyData?.details?.length"
ref="transferChartCanvas"></canvas>
</div>
<div class="grid g-4 cols-4"><div class="stat-card-v2 stat-total"><div class="stat-icon"><i class="fa-duotone fa-grid-2"></i></div><div><div class="stat-label">Monat gesamt</div><div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.formatBytes(transferMonthlyData?.summary?.grandTotalBytes || 0) }}</span></div></div></div><div class="stat-card-v2 stat-download"><div class="stat-icon"><i class="fa-duotone fa-arrow-down-to-line"></i></div><div><div class="stat-label">Download</div><div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.formatBytes(transferMonthlyData?.summary?.totalDownloadBytes || 0) }}</span></div></div></div><div class="stat-card-v2 stat-upload"><div class="stat-icon"><i class="fa-duotone fa-arrow-up-from-line"></i></div><div><div class="stat-label">Upload</div><div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div class="skeleton-line" style="width: 100px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.formatBytes(transferMonthlyData?.summary?.totalUploadBytes || 0) }}</span></div></div></div><div class="stat-card-v2 stat-duration"><div class="stat-icon"><i class="fa-duotone fa-hourglass-clock"></i></div><div><div class="stat-label">Dauer</div><div class="stat-value"><span v-if="transferInitialLoading || transferMonthlyLoading"><div class="skeleton-line" style="width: 80px; height: 18px;"></div></span><span v-else>{{ window.RadiusUtils.formatDuration(transferMonthlyData?.summary?.totalDurationSeconds || 0) }}</span></div></div></div></div>
<div class="chart-card mt-3" style="height: 250px;"><div v-if="transferMonthlyLoading || transferInitialLoading" class="chart-placeholder"><div class="skeleton-line" style="width: 100%; height: 100%; border-radius: var(--radius);"></div></div><div v-else-if="!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length" class="chart-placeholder"><i class="fa-duotone fa-chart-pie"></i><span>Keine Daten in diesem Monat verfügbar</span></div><canvas v-show="!transferMonthlyLoading && !transferInitialLoading && transferMonthlyData?.details?.length" ref="transferChartCanvas"></canvas></div>
</div>
<div class="table-wrap mt-3" style="height: 350px;">
<div v-if="!transferInitialLoading && !transferMonthlyLoading && (!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length)" class="table-placeholder-fixed-height"><i class="fa-duotone fa-database"></i><span>Keine detaillierten Daten für diesen Monat.</span></div>
<table v-else class="tt-table compact"><thead><tr><th>Startzeit</th><th>Dauer</th><th>IP-Adresse</th><th style="text-align: right;">Download</th><th style="text-align: right;">Upload</th><th style="text-align: right;">Gesamt</th></tr></thead><tbody><template v-if="transferInitialLoading || transferMonthlyLoading"><tr v-for="n in 10" :key="'skel'+n"><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td><td><div class="skeleton-line"></div></td></tr></template><template v-else><tr v-for="(d, i) in transferMonthlyData.details" :key="i"><td class="mono small">{{ d.startTime }}</td><td class="mono small">{{ window.RadiusUtils.formatDuration(d.durationSeconds) }}</td><td class="mono small">{{ d.ipAddress }}</td><td class="mono small" style="text-align: right;">{{ window.RadiusUtils.formatBytes(d.downloadBytes) }}</td><td class="mono small" style="text-align: right;">{{ window.RadiusUtils.formatBytes(d.uploadBytes) }}</td><td class="mono small" style="text-align: right;"><strong>{{ window.RadiusUtils.formatBytes(d.totalBytes) }}</strong></td></tr></template></tbody></table>
<div
v-if="!transferInitialLoading && !transferMonthlyLoading && (!transferMonthlyData || !transferMonthlyData.details || !transferMonthlyData.details.length)"
class="table-placeholder-fixed-height"><i class="fa-duotone fa-database"></i><span>Keine detaillierten Daten für diesen Monat.</span>
</div>
<table v-else class="tt-table compact">
<thead>
<tr>
<th>Startzeit</th>
<th>Dauer</th>
<th>IP-Adresse</th>
<th style="text-align: right;">Download</th>
<th style="text-align: right;">Upload</th>
<th style="text-align: right;">Gesamt</th>
</tr>
</thead>
<tbody>
<template v-if="transferInitialLoading || transferMonthlyLoading">
<tr v-for="n in 10" :key="'skel'+n">
<td>
<div class="skeleton-line"></div>
</td>
<td>
<div class="skeleton-line"></div>
</td>
<td>
<div class="skeleton-line"></div>
</td>
<td>
<div class="skeleton-line"></div>
</td>
<td>
<div class="skeleton-line"></div>
</td>
<td>
<div class="skeleton-line"></div>
</td>
</tr>
</template>
<template v-else>
<tr v-for="(d, i) in transferMonthlyData.details" :key="i">
<td class="mono small">{{ d.startTime }}</td>
<td class="mono small">{{ window.RadiusUtils.formatDuration(d.durationSeconds) }}</td>
<td class="mono small">{{ d.ipAddress }}</td>
<td class="mono small" style="text-align: right;">
{{ window.RadiusUtils.formatBytes(d.downloadBytes) }}
</td>
<td class="mono small" style="text-align: right;">
{{ window.RadiusUtils.formatBytes(d.uploadBytes) }}
</td>
<td class="mono small" style="text-align: right;">
<strong>{{ window.RadiusUtils.formatBytes(d.totalBytes) }}</strong></td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<div v-else-if="!transferInitialLoading" class="table-placeholder" style="min-height: 400px;"><i class="fa-duotone fa-wifi-slash"></i><div>Daten konnten nicht geladen werden.</div></div>
<div v-else-if="!transferInitialLoading" class="table-placeholder" style="min-height: 400px;"><i
class="fa-duotone fa-wifi-slash"></i>
<div>Daten konnten nicht geladen werden.</div>
</div>
</div>
</radius-modal>
<radius-modal :show="showEmailModal" title="Statistik per E-Mail senden" @close="showEmailModal=false">
<div>
<div class="field"><label style="margin-bottom: 8px; font-size: 14px;">Empfänger-E-Mail</label><div class="input-wrap"><i class="fa-duotone fa-envelope input-icon"></i><input class="ri" type="email" v-model.trim="recipientEmail" placeholder="name@domain.com" @keydown.enter="isValidEmail && !isSendingEmail && sendTransferEmail()" autocomplete="nope" data-1p-ignore data-lpignore="true" data-form-type="other" data-bwignore></div><p v-if="recipientEmail && !isValidEmail" class="muted small" style="color: var(--bad); margin-top: 4px;">Bitte geben Sie eine gültige E-Mail-Adresse ein.</p></div>
<div class="cluster" style="justify-content: flex-end; margin-top: 24px; gap: 12px;"><button class="ghost-btn" @click="showEmailModal=false" :disabled="isSendingEmail">Abbrechen</button><button class="primary-btn" @click="sendTransferEmail" :disabled="!isValidEmail || isSendingEmail" style="min-width: 100px;"><span v-if="!isSendingEmail">Senden</span><span v-else class="btn-loader"></span></button></div>
<div class="field"><label style="margin-bottom: 8px; font-size: 14px;">Empfänger-E-Mail</label>
<div class="input-wrap"><i class="fa-duotone fa-envelope input-icon"></i><input class="ri" type="email"
v-model.trim="recipientEmail"
placeholder="name@domain.com"
@keydown.enter="isValidEmail && !isSendingEmail && sendTransferEmail()"
autocomplete="nope"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
data-bwignore></div>
<p v-if="recipientEmail && !isValidEmail" class="muted small" style="color: var(--bad); margin-top: 4px;">
Bitte geben Sie eine gültige E-Mail-Adresse ein.</p></div>
<div class="cluster" style="justify-content: flex-end; margin-top: 24px; gap: 12px;">
<button class="ghost-btn" @click="showEmailModal=false" :disabled="isSendingEmail">Abbrechen</button>
<button class="primary-btn" @click="sendTransferEmail" :disabled="!isValidEmail || isSendingEmail"
style="min-width: 100px;"><span v-if="!isSendingEmail">Senden</span><span v-else
class="btn-loader"></span>
</button>
</div>
</div>
</radius-modal>
<radius-modal :show="showExtensionIdModal" title="Extension ID Konfigurieren" @close="showExtensionIdModal=false">
<div>
<div class="field"><label style="margin-bottom: 8px; font-size: 14px;">Chrome Extension ID</label>
<div class="input-wrap"><i class="fa-duotone fa-puzzle-piece input-icon"></i><input class="ri" type="text"
v-model.trim="extensionId"
placeholder="z.B. jglijfiddilckddlmbnlojmmlahboffh">
</div>
</div>
<div class="cluster" style="justify-content: flex-end; margin-top: 24px; gap: 12px;">
<button class="primary-btn" @click="saveExtensionId">Speichern</button>
</div>
</div>
</radius-modal>
</div>
`,
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: '' }),
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: '',
showExtensionIdModal: false,
extensionId: 'jglijfiddilckddlmbnlojmmlahboffh'
}),
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); }
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);
const savedExtensionId = localStorage.getItem('radiusExtensionId');
if (savedExtensionId) {
this.extensionId = savedExtensionId;
}
window.addEventListener('keydown', this.handleKeydown);
},
beforeDestroy() {
if (this.observer) this.observer.disconnect();
if (this.transferChartInstance) this.transferChartInstance.destroy();
window.removeEventListener('keydown', this.handleKeydown);
},
updated() {
if (this.observer && this.$refs.sentinel) {
this.observer.disconnect();
this.observer.observe(this.$refs.sentinel);
}
},
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); },
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] }); }
handleKeydown(e) {
console.log(e);
if (e.code === 'KeyE' && e.ctrlKey && e.altKey) {
e.preventDefault();
this.openExtensionIdModal();
}
},
openExtensionIdModal() {
this.showExtensionIdModal = true;
},
saveExtensionId() {
localStorage.setItem('radiusExtensionId', this.extensionId);
this.showExtensionIdModal = false;
window.notify('success', 'Extension ID gespeichert.');
},
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;
const urlParams = new URLSearchParams(window.location.search);
const connectParam = urlParams.get('connect');
if (connectParam && connectParam.toLowerCase() === 'true' && this.radiusUsers.length === 1) {
const r = await fetch(`${window.TT_CONFIG.BASE_PATH}/Radius/proxyUnsecureHTTPRequestToRadius?action2=fetchRadacct&username=${encodeURIComponent(this.radiusUsers[0].username)}`);
if (r.ok) {
const radacct = await r.json();
if (radacct && radacct.ip) {
this.onScanIp({ip: radacct.ip}, this.radiusUsers[0]);
}
}
}
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 onScanIp(payload, item) {
const {ip} = payload;
const info = item.info;
if (!ip) return;
window.notify('info', `Starte Scan für ${ip}...`);
try {
const response = await fetch(`http://localhost:8094/scan?ip=${ip}`);
if (!response.ok) {
window.notify('error', `Scan-Server-Fehler: ${response.status}`);
return;
}
const data = await response.json();
if (data.status === 'success' && data.url) {
const extensionId = this.extensionId;
const message = {
type: "INITIATE_ROUTER_LOGIN",
payload: {
ip: ip,
url: data.url,
info: info
}
};
if (window.chrome && chrome.runtime && chrome.runtime.sendMessage) {
try {
chrome.runtime.sendMessage(extensionId, message, (response) => {
if (chrome.runtime.lastError) {
console.warn("Senden an Erweiterung fehlgeschlagen:", chrome.runtime.lastError.message);
window.notify('warning', 'Scan-Daten konnten nicht an die Erweiterung gesendet werden.');
} else {
console.log("Erweiterung hat geantwortet:", response);
}
});
} catch (e) {
console.error("Fehler beim Senden an die Erweiterung:", e);
window.notify('error', 'Fehler beim Senden an die Erweiterung.');
}
} else {
console.warn("Chrome Extension Messaging API nicht verfügbar.");
window.notify('warning', 'Chrome Messaging API nicht gefunden.');
}
} else if (data.status === 'not_found') {
window.notify('warning', `Kein Gerät für ${ip} gefunden.`);
} else {
window.notify('error', 'Ungültige Antwort vom Scan-Server.');
}
} catch (error) {
console.error('IP-Scan fehlgeschlagen:', error);
window.notify('error', `Scan für ${ip} fehlgeschlagen.`);
}
},
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) {
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);
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]
});
}
}
});
});

View File

@@ -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"/>
<small v-if="!canComplete && !isReadOnly" class="form-text text-muted text-center mt-2">
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.
</small>
<div v-if="isReadOnly" class="alert alert-secondary text-center mt-2 p-2">
Auftrag bereits abgeschlossen oder storniert. Keine Aktionen mehr möglich.
@@ -205,6 +205,10 @@ Vue.component('workorder-details-manager', {
<hr>
<tt-button text="Dokumentation akzeptieren" @click="showAcceptModal = true"
additional-class="btn-success w-100" icon="fas fa-check"/>
<tt-button v-if="workorder.status === 'documented'"
text="Status zurücksetzen (auf Zugewiesen)"
@click="showRevertModal = true"
additional-class="btn-warning w-100 mt-1" icon="fas fa-undo"/>
</div>
</div>
@@ -229,7 +233,7 @@ Vue.component('workorder-details-manager', {
</div>
<div class="col-lg-7">
<div class="card mb-3" v-if="!isAdmin && !isReadOnly">
<div class="card mb-3" v-if="isAdmin || !isReadOnly">
<div class="card-body">
<h5 class="card-title">Neues Dokument hochladen</h5>
<tt-select label="Dokumententyp" :options="allDocTypes" v-model="uploadData.documentType" sm row/>
@@ -241,6 +245,35 @@ Vue.component('workorder-details-manager', {
</div>
</div>
<div v-if="(requireCableLength || requireCableType)" class="card mb-3">
<div class="card-body">
<h5 class="card-title">Zusatzdaten</h5>
<p class="small text-muted">Diese Daten werden für den Abschluss benötigt.</p>
<tt-input
v-if="requireCableLength"
label="Kabellänge (m)"
v-model="workorder.cableLength"
:disabled="isReadOnly"
sm row
/>
<tt-input
v-if="requireCableType"
label="Kabeltyp"
v-model="workorder.cableType"
:disabled="isReadOnly"
sm row
/>
<tt-button
text="Daten speichern"
@click="saveWorkorderData"
:loading="savingData"
:disabled="isReadOnly || savingData"
additional-class="btn-info float-right"
icon="fas fa-save"
/>
</div>
</div>
<div class="card mb-3" v-if="isAdmin && selectedDocs.length > 0">
<div class="card-header bg-warning"><h5><i class="fas fa-exclamation-triangle mr-2"></i>Korrektur anfordern</h5></div>
<div class="card-body">
@@ -280,6 +313,9 @@ Vue.component('workorder-details-manager', {
<tt-modal :show.sync="showAcceptModal" title="Dokumentation akzeptieren" @submit="acceptDocumentation" :delete="false">
Soll die Dokumentation für diesen Arbeitsauftrag wirklich akzeptiert und der Auftrag abgeschlossen werden?
</tt-modal>
<tt-modal :show.sync="showRevertModal" title="Status zurücksetzen" @submit="revertDocumentedStatus" :delete="false">
Möchten Sie den Status dieses Auftrags wirklich von 'Dokumentiert' auf 'Zugewiesen' zurücksetzen? Die Firma muss den Auftrag dann erneut einreichen.
</tt-modal>
</div>`,
data: () => ({
loading: true, loadingConfig: true, workorder: null, docs: [], journals: [], tenantDocTypes: null,
@@ -289,16 +325,31 @@ 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,
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 ?? [];
},
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'));
@@ -341,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; }
@@ -397,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: {} } };
},
@@ -459,9 +534,27 @@ 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();
await this.fetchData();
}
});
});

View File

@@ -77,9 +77,19 @@ Vue.component('workorder-tenant-config', {
<div class="row mt-3">
<div class="col-md-6">
<h6 class="mb-3">Optionen</h6>
<tt-checkbox label="Dokumentation für Tiefbau erforderlich"
v-model="editableItem.civilEngineeringDocsRequired" sm v-if="editingId === config.id"/>
<p v-else>Tiefbau-Doku: {{ config.civilEngineeringDocsRequired ? 'Ja' : 'Nein' }}</p>
<div v-if="editingId === config.id">
<tt-checkbox label="Dokumentation für Tiefbau erforderlich"
v-model="editableItem.civilEngineeringDocsRequired" sm/>
<tt-checkbox label="Kabellänge erforderlich"
v-model="editableItem.requireCableLength" sm/>
<tt-checkbox label="Kabeltyp erforderlich"
v-model="editableItem.requireCableType" sm/>
</div>
<div v-else>
<p>Tiefbau-Doku: <strong>{{ config.civilEngineeringDocsRequired ? 'Ja' : 'Nein' }}</strong></p>
<p>Kabellänge-Doku: <strong>{{ config.requireCableLength ? 'Ja' : 'Nein' }}</strong></p>
<p>Kabeltyp-Doku: <strong>{{ config.requireCableType ? 'Ja' : 'Nein' }}</strong></p>
</div>
</div>
<div class="col-md-6">
<h6>Zugeordnete Firmen</h6>
@@ -312,7 +322,9 @@ Vue.component('workorder-tenant-config', {
interventionTypes: [],
workorderCreationFilters: '{}',
workorderActiveFilters: '{}',
civilEngineeringDocsRequired: 0
civilEngineeringDocsRequired: 0,
requireCableLength: 0,
requireCableType: 0
}
: {visibleForAddressId: []};
this.showModal = true;

View File

@@ -28,7 +28,7 @@ $(document).ready(function () {
var thisclick = $(event.relatedTarget);
var rackhe = thisclick.closest('table').find('th').data('rackhe');
var rackid = thisclick.closest('table').find('th').data('rackid');
var rackname = $.trim(thisclick.closest('table').find('th').text());
var rackname = thisclick.closest('table').find('th').data('rackname');
var minhe = 1;
var modal = $(this);
var edit = 0;
@@ -74,6 +74,8 @@ $(document).ready(function () {
modal.find('.modal-title').html('<span id="module-info">Modul (' + destinationname + ')</span>');
modal.find('.alert').text('');
modal.find('.alert').hide();
$('#module-slot-div').hide();
$('#module-position-div').hide();
var options;
var selected;
var hemaxcount = 1;
@@ -96,23 +98,40 @@ $(document).ready(function () {
if (parent.find('td').eq(1).data('id') || parent.find('td').eq(2).data('id') || parent.find('td').eq(3).data('id') || parent.find('td').eq(4).data('id')) {
var counttd = parent.find('td').length - 1;
var newmodule = false;
if (counttd > 1) {
var modwidth;
var totalPositions;
if (parent.find('td').eq(1).data('width')) {
modwidth = parent.find('td').eq(1).data('width');
totalPositions = 12 / modwidth;
} else {
modwidth = 12 / counttd;
totalPositions = counttd;
}
$('#module-width').val(modwidth);
if (totalPositions > 1) {
var options;
for (let i = 1; i <= counttd; i++) {
for (let i = 1; i <= totalPositions; i++) {
options = options + '<option value="' + i + '">' + i + '</option>';
}
$('#module-slot').html(options);
$('#module-position').html(options);
$('#module-slot-div').show();
}
else
{
}
$('#module-width').attr('disabled', 'disabled');
$('#he-count-div').html(`<select required="required" id="module-he-count" name="module-he-count" class="form-control" disabled="disabled"><option value="` + parent.find('td').eq(1).attr('rowspan') + `">` + parent.find('td').eq(1).attr('rowspan') + `</option><select>`);
$('#he-start-div').html(`<select required="required" id="module-he-start" name="module-he-start" class="form-control" disabled="disabled"><option value="` + parent.find('td').eq(0).data('he') + `">` + parent.find('td').eq(0).data('he') + `</option></select>`);
if (parent.find('td').eq(1).data('id') === undefined) {
newmodule = true;
}
var modwidth = 12 / counttd;
$('#module-width').val(modwidth);
if (!newmodule) {
$('#module-remove').show();
@@ -133,7 +152,7 @@ $(document).ready(function () {
}
$('#module-name').val(parent.find('td').eq(1).data('name'));
const status = parent.find('td').eq(1).data('status');
$('#module-type').val(parent.find('td').eq(1).data('type')).change(); // .change() ist hier wichtig!
$('#module-type').val(parent.find('td').eq(1).data('type')).change();
$('#module-status').val(status);
$('#module-type option[value="1"]').prop('disabled', true);

View File

@@ -1,3 +1,8 @@
if (!localStorage.getItem('bookstack_cache_cleared')) {
localStorage.removeItem('bookstack_article_Radius');
localStorage.setItem('bookstack_cache_cleared', 'true');
}
document.addEventListener('DOMContentLoaded', async () => {
const linkEl = document.getElementById('bookstackLink');
if (!linkEl) return;

View File

@@ -3,7 +3,8 @@ Vue.component('tt-map', {
markersData: { type: Array, default: () => [] },
config: { type: Object, default: () => ({}) },
loading: { type: Boolean, default: false },
showLogo: { type: Boolean, default: false }
showLogo: { type: Boolean, default: false },
contextmenu: { type: Boolean, default: false }
},
data: () => ({
map: null,
@@ -61,6 +62,7 @@ Vue.component('tt-map', {
window.removeEventListener('resize', this.handleResize);
if (this.map) {
this.map.off('zoomend moveend', this.updateTooltipVisibility);
if (this.contextmenu) this.map.off('contextmenu', this.onMapContextMenu);
this.map.remove();
this.map = null;
}
@@ -115,6 +117,7 @@ Vue.component('tt-map', {
this.map.addLayer(this.markerLayer);
this.map.addLayer(this.nonClusteredLayer);
this.map.on('zoomend moveend', this.updateTooltipVisibility);
if (this.contextmenu) { this.map.on('contextmenu', this.onMapContextMenu); }
if (this.showLogo) {
const LogoControl = L.Control.extend({ onAdd: map => {
const container = L.DomUtil.create('div', 'leaflet-control-logo');
@@ -225,6 +228,7 @@ Vue.component('tt-map', {
},
handleSearchFocus() { this.showSearchResults = true; },
handleSearchBlur() { setTimeout(() => { this.showSearchResults = false; }, 200); },
onMapContextMenu(e) { e.originalEvent.preventDefault(); this.$emit('contextmenu', e); },
},
watch: {
markersData: { handler() { this.updateMarkers(); }, deep: true },