Merge branch 'master' into fronkdev
This commit is contained in:
@@ -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']; ?> <span
|
||||
class="rack-side-indicator font-weight-normal">- Vorderseite</span></span>
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">×</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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
211
Layout/default/TimerecordingPermitFibu/Index.php
Normal file
211
Layout/default/TimerecordingPermitFibu/Index.php
Normal 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"); ?>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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%')";
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -6,6 +6,7 @@ class TimerecordingCategoryModel
|
||||
private $short;
|
||||
private $hourday;
|
||||
private $approval;
|
||||
private $approval_fibu;
|
||||
private $require_comment;
|
||||
private $only_admin;
|
||||
private $businesstrip;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}}
|
||||
}
|
||||
}
|
||||
20
db/migrations/20251024091253_preorder_add_files.php
Normal file
20
db/migrations/20251024091253_preorder_add_files.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
55
db/migrations/20251103125000_workorder_add_cable_fields.php
Normal file
55
db/migrations/20251103125000_workorder_add_cable_fields.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;}});}}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user