Merge branch 'master' into 'fronkdev'

# Conflicts:
#   application/Preorder/PreorderController.php
This commit is contained in:
Frank Schubert
2025-09-17 10:17:18 +00:00
123 changed files with 9982 additions and 3945 deletions

View File

@@ -171,7 +171,14 @@
<label class="form-label" for="filter_home_oaid_rimo_id">Home OAID / Rimo ID</label>
<input type="text" class="form-control" name="filter[home_oaid_rimo_id]" id="filter_home_oaid_rimo_id" value="<?=(array_key_exists("home_oaid_rimo_id", $filter)) ? $filter['home_oaid_rimo_id'] : ""?>" />
</div>
<div class="col-sm-12 col-md-1">
<label class="form-label" for="filter_fcp">FCP</label>
<select name="filter[rimo_fcp_name][]" id="filter_fcp" multiple class="form-control">
<option value="">Kein FCP gefunden</option>
</select>
</div>
</div>
<div class="row mt-2">
<div class="col">
@@ -225,6 +232,7 @@
<th>Straße</th>
<th>Hausnr.</th>
<th>Stiege</th>
<th>FCP</th>
<th>Homes/<wbr>Preorders</th>
<th>Rimo-ID</th>
<th>Rollout Jahr</th>
@@ -244,6 +252,7 @@
<td><?=$address->strasse->name?></td>
<td><?=$address->hausnummer?></td>
<td><?=$address->stiege?></td>
<td><?=$address->rimo_fcp_name ?? 'N/A'?></td>
<td><?=count($address->wohneinheiten)?>
<span class="text-secondary" title="<?=($address->tool_building_type == 0) ? "Unbekannt" : (($address->tool_building_type == 1) ? "EFH" : "MPH")?>">
<i class="fas fa-fw <?=($address->tool_building_type == 0) ? "fa-question" : (($address->tool_building_type == 1) ? "fa-home" : "fa-building")?>"></i>
@@ -276,29 +285,66 @@
</div>
</div>
<script>
$("#filter_status_id").select2({closeOnSelect: false});
$("#filter_status_flag").select2({closeOnSelect: false});
$("#filter_network_id").select2({closeOnSelect: false});
<script>
$(document).ready(function() {
$("#filter_status_id, #filter_status_flag, #filter_network_id").select2({ closeOnSelect: false });
$('#filter_network_id').change(function() {
if($('#filter_network_id').val() === "null") {
$('#filter-gemeinde-id').hide();
$('#filter-gemeinde-text').show();
$('#filter-ortschaft-id').hide();
$('#filter-ortschaft-text').show();
$('#filter-gemeinde-id option:first').prop("selected", "selected");
$('#filter-ortschaft-id option:first').prop("selected", "selected");
} else {
$('#filter-gemeinde-text').hide();
$('#filter-gemeinde-id').show();
$('#filter-ortschaft-text').hide();
$('#filter-ortschaft-id').show();
}
$('#filter_gemeinde').val("");
$('#filter_ortschaft').val("");
});
</script>
const fcpSelect = $("#filter_fcp");
const networkSelect = $("#filter_network_id");
const apiUrl = "<?=self::getUrl("AddressDB", "api")?>";
const updateFcpSelect = (placeholder, data = []) => {
fcpSelect.empty().select2({ data, placeholder, allowClear: true });
};
updateFcpSelect("Bitte ein Netzgebiet auswählen");
networkSelect.on('change', function() {
const selectedNets = $(this).val() || [];
const hasNull = Array.isArray(selectedNets) && selectedNets.includes("null");
$('#filter-gemeinde-text, #filter-ortschaft-text').toggle(hasNull);
$('#filter-gemeinde-id, #filter-ortschaft-id').toggle(!hasNull);
$('#filter_gemeinde, #filter_ortschaft').val("");
if (hasNull) {
$('#filter-gemeinde-id, #filter-ortschaft-id').find('option:first').prop("selected", "selected");
}
if (selectedNets.length !== 1) {
updateFcpSelect(selectedNets.length > 1 ? "Bitte genau ein Netzgebiet auswählen" : "Kein Netzgebiet ausgewählt");
return;
}
const networkId = selectedNets[0];
if (networkId === 'null') {
updateFcpSelect("Kein Netzgebiet ausgewählt");
return;
}
$.get(apiUrl, { do: "getFCPsForNetwork", network_id: networkId }, (response) => {
if (response?.status === "OK" && Array.isArray(response.result)) {
let fcpData = response.result;
fcpData.unshift({ id: "", text: "" });
fcpData.sort((a, b) => {
const aN = a.text.replace(/\D/g, ""), bN = b.text.replace(/\D/g, "");
return aN && bN ? parseInt(aN, 10) - parseInt(bN, 10) : a.text.localeCompare(b.text);
});
updateFcpSelect("FCP auswählen", fcpData);
const fcpValues = new URLSearchParams(window.location.search).getAll("filter[rimo_fcp_name][]");
if (fcpValues.length > 0) {
fcpSelect.val(fcpValues).trigger("change");
}
} else {
updateFcpSelect("Keine FCPs gefunden");
}
}, "json").fail(() => {
updateFcpSelect("Fehler beim Laden");
});
}).trigger('change');
});
</script>
<?php include(realpath(dirname(__FILE__)."/../../$mfLayoutPackage")."/footer.php"); ?>

View File

@@ -48,9 +48,12 @@
<th>Extref</th>
<td><?=$address->extref?></td>
</tr><tr>
<th>Rimo External ID</th>
<td><?=$address->rimo_id?></td>
</tr><tr>
<th>Rimo External ID</th>
<td><?=$address->rimo_id?></td>
</tr><tr>
<th>Rimo Type</th>
<td><?=$address->rimo_type?></td>
</tr><tr>
<th>Netzgebiet</th>
<td><?=$address->netzgebiet->name?></td>
</tr><tr>
@@ -176,7 +179,10 @@
</tr>
<?php foreach($address->wohneinheiten as $unit): ?>
<tr>
<td><a href="<?=self::getUrl("ADBWohneinheit", "edit", ["id" => $unit->id])?>"><i class="fas fa-edit"></i></a></td>
<td>
<a href="#" data-home-id="<?=$unit->id?>" data-home-contact title="Kontakte bearbeiten"><i class="fas fa-users-cog text-primary"></i></a>
<a href="<?=self::getUrl("ADBWohneinheit", "edit", ["id" => $unit->id])?>"><i class="fas fa-edit"></i></a>
</td>
<td><?=$unit->id?></td>
<td class="text-pink">
<?php if($unit->oaid): ?>
@@ -388,4 +394,7 @@
'json');
}
</script>
<?php include(realpath(dirname(__FILE__)."/../../$mfLayoutPackage")."/footer.php"); ?>
<script src="<?= self::getResourcePath() ?>js/pages/AddressDB/ADBWohneinheitContactManager.js"></script>
<script src="<?= self::getResourcePath() ?>plugins/axios/axios.min.js"></script>
<script src="<?= self::getResourcePath() ?>plugins/axios/axios.inject.js"></script>
<?php include(realpath(dirname(__FILE__)."/../../$mfLayoutPackage")."/footer.php"); ?>

View File

@@ -483,13 +483,35 @@ foreach ($owners as $owner):
</tr>
</table>
<div class="signature-line" style="margin-top: 128px">
<div class="float-left" style="width: 25%;">Ort, Datum</div>
<div class="float-right" style="width: 75%;">
<strong><?= ($owner->title) ? $owner->title . " " : "" ?><?= $owner->company ? $owner->company : $owner->firstname . ' ' . $owner->lastname ?></strong>
<br>Unterschrift mit Geburtsdatum bzw. firmenmäßige Zeichnung des/r Liegenschaftseigentümer(s)
<?php if ($owner->signature): ?>
<table style="width: 100%; margin-top: 80px; border-collapse: collapse; page-break-inside: avoid;">
<tr>
<td style="width: 33%; vertical-align: bottom; border-bottom: 1px solid #000; padding-bottom: 2px;">
<?php if ($owner->signature_date): ?>
<span style="font-size: 9px;">Graz, <?= date("d.m.Y", $owner->signature_date) ?></span>
<?php endif; ?>
</td>
<td style="width: 67%; vertical-align: bottom; border-bottom: 1px solid #000; padding-bottom: 2px; text-align: center;">
<img src="<?= $owner->signature ?>" style="max-height: 60px; max-width: 250px;" />
</td>
</tr>
<tr>
<td style="font-size: 9px; padding-top: 4px;">Ort, Datum</td>
<td style="font-size: 9px; padding-top: 4px; text-align: center;">
<strong><?= $owner->signature_name ?></strong>
<br>Unterschrift bzw. firmenmäßige Zeichnung des/r Liegenschaftseigentümer(s)
</td>
</tr>
</table>
<?php else: ?>
<div class="signature-line" style="margin-top: 128px; page-break-inside: avoid;">
<div class="float-left" style="width: 25%;">Ort, Datum</div>
<div class="float-right" style="width: 75%;">
<strong><?= ($owner->title) ? $owner->title . " " : "" ?><?= $owner->company ? $owner->company : $owner->firstname . ' ' . $owner->lastname ?></strong>
<br>Unterschrift mit Geburtsdatum bzw. firmenmäßige Zeichnung des/r Liegenschaftseigentümer(s)
</div>
</div>
</div>
<?php endif; ?>
<?php endforeach; ?>
</body>

View File

@@ -216,7 +216,7 @@
<div class="card">
<h5 class="card-header">Oder Plan hochladen</h5>
<div class="card-body">
<input type="file" class="form-control" name="consent_plan_image" id="consent_plan_image" />
<input type="file" class="form-control" name="consent_plan_image" id="consent_plan_image" accept="image/png, image/jpeg, image/jpg" />
</div>
</div>

View File

@@ -212,10 +212,14 @@ $pagination_entity_name = "Adressen";
</tr><tr>
<th>Plan/Skizze</th>
<td>
<?php if($item->file && $item->file->file && $item->file->file->fileExists()): ?>
<!--img src="<?=self::getUrl("File", "Download", ["id" => $item->file->file_id])?>" style="max-width: 480px;"/-->
<img src="<?=$item->file->file->asDataUrl()?>" style="max-width: 480px;" />
<?php endif; ?>
<?php if($item->file && $item->file->file && $item->file->file->fileExists()):
$dataUrl = $item->file->file->asDataUrl();
if (str_contains($dataUrl, 'application/pdf')) {
echo '<a href="' . $dataUrl . '" download="your-file-name.pdf" class="btn btn-primary" aria-label="Download PDF"><i class="fas fa-download"></i> Download PDF</a>';
} else {
echo '<img src="' . $dataUrl . '" style="max-width: 480px;" alt="File preview"/>';
}
endif; ?>
</td>
</tr><tr>
<th></th>

View File

@@ -1,10 +1,10 @@
<?php
$maxLength = max(mb_strlen($firstline ?? ''), mb_strlen($secondline ?? ''));
$maxLength = max(mb_strlen($firstline ?? ''), mb_strlen($secondline ?? ''), mb_strlen($thirdline ?? ''));
$fontSize = '12px';
if ($maxLength <= 15) $fontSize = '24px';
elseif ($maxLength <= 24) $fontSize = '18px';
elseif ($maxLength <= 50) $fontSize = '16px';
$fontSize = '13px';
if ($maxLength <= 11) $fontSize = '28px';
elseif ($maxLength <= 20) $fontSize = '18px';
elseif ($maxLength <= 45) $fontSize = '16px';
$this->setReturnValue(['filename' => "xyz." . time() . "pdf"]);
?>
@@ -42,4 +42,4 @@ $this->setReturnValue(['filename' => "xyz." . time() . "pdf"]);
<div><?= $fourthline ?></div>
</div>
</body>
</html>
</html>

View File

@@ -2,6 +2,12 @@
<link href="<?=self::getResourcePath()?>assets/css/select2-cstm.css?<?=date('U')?>" rel="stylesheet" type="text/css" />
<link href="<?= self::getResourcePath() ?>assets/css/datatables-std.css?<?= date('U') ?>" rel="stylesheet" type="text/css"/>
<!-- start page title -->
<style type="text/css">
.tool-border-spacer
{
border-right: 2px solid #868686;
}
</style>
<div class="row">
<div class="col-12">
<div class="page-title-box">
@@ -78,7 +84,18 @@
value="<?= $devicetypes->power ?>">
</div>
</div>
<div class="form-group row">
<label class="col-lg-2 col-form-label" for="price">Temperatur Warnung | Kritisch</label>
<div class="col-lg-2 tool-border-spacer">
<input type="number" min="0" step="1" class="form-control" name="temp_warning" id="temp_warning" placeholder="80"
value="<?= $devicetypes->temp_warning ?>" >
</div>
<div class="col-lg-2">
<input type="number" min="0" step="1" class="form-control" name="temp_critical" id="temp_critical" placeholder="90"
value="<?= $devicetypes->temp_critical ?>" >
</div>
</div>
</div>

View File

@@ -97,6 +97,12 @@
background-color: #d7d7d7;
opacity: 1;
}
.switch-rack-side {
margin-right: 8px;
margin-top: 2px;
cursor: pointer;
}
</style>
<div class="row">
<div class="col-12">
@@ -339,6 +345,7 @@ if (!empty(trim($pops->vlan_ipv6)))
<div class="col-lg-1"></div>
<label class="col-lg-4 col-form-label" for="module-slot">19 Zoll Position</label>
<div class="col-lg-3">
<input type="hidden" value="front" id="module-side" name="module-side"/>
<select required="required" id="module-slot" name="module-slot"
class="form-control">
<option value="1">1</option>
@@ -394,6 +401,7 @@ if (!empty(trim($pops->vlan_ipv6)))
<div class="col-lg-6">
<select required="required" id="module-ports" name="module-ports"
class="form-control">
<option value="96" data-plugs="1;2">96</option>
<option selected="selected" value="48" data-plugs="1;2">48</option>
<option value="24" data-plugs="2;3">24</option>
<option value="12" data-plugs="2;3">12</option>
@@ -483,13 +491,15 @@ if (!empty(trim($pops->vlan_ipv6)))
data-rackhe="<?= $poprack['rack']['he'] ?>"
data-rackid="<?= $poprack['rack']['id']; ?>"><span
class="rack-name"><i
class="fa-regular fa-arrows-up-down-left-right move-handle float-left"></i><?= $poprack['rack']['name']; ?></span>
class="fa-regular fa-arrows-up-down-left-right move-handle float-left"></i><?= $poprack['rack']['name']; ?>&nbsp;<span class="rack-side-indicator font-weight-normal">-&nbsp;Vorderseite</span></span>
<i class="fas fa-sync-alt float-right switch-rack-side" title="Seite wechseln"></i>
<i class="far fa-edit float-right" title="Bearbeiten"
data-toggle="modal" data-target="#rackModal"></i>
</th>
</tr>
</thead>
<tbody>
<tbody id="rack-body-<?= $poprack['rack']['id'] ?>" data-side="front">
<?php
$cellwidth = 227;
$blocktd = 0;
@@ -499,7 +509,8 @@ if (!empty(trim($pops->vlan_ipv6)))
data-toggle="modal" data-target="#rackModuleModal"
style="cursor: pointer" data-he="<?= $i; ?>">He<?= $i; ?></td>
<?php
foreach ($poprack['modules'] as $module) {
$modules_to_render = $poprack['modules']['front'] ?? [];
foreach ($modules_to_render as $module) {
if ($module['start_he'] == $i) {
$modulestart = 1;
@@ -511,6 +522,7 @@ if (!empty(trim($pops->vlan_ipv6)))
$extText = "";
$extTextspan = "";
foreach ($module['slots'] as $slots) {
var_dump();
$extText = "";
$title = $slots['modulname'];
if ($slots['type'] == '0') {

View File

@@ -24,7 +24,7 @@ $pagination_entity_name = "Vorbestellungen";
}
.preorder-campaign-header-buttons {
max-width: 900px;
max-width: 1100px;
}
.tr-highlight {
@@ -458,6 +458,8 @@ $pagination_entity_name = "Vorbestellungen";
</ul>
</div>
<a id="rimo-types-link" target="_blank" style="display:none" href="#" class="btn btn-outline-success"><i class="fas fa-map-marked-alt"></i>Rimo-Typen Karte anzeigen</a>
</div>
</div>
</form>
@@ -635,6 +637,7 @@ $pagination_entity_name = "Vorbestellungen";
<td style="text-align: left; letter-spacing: 4px; font-size: 1.1em;">
<div class="preorder-campaign-table-actions">
<?php if(!$me->is(["preorderfront"]) && !$me->is("preorderreadonly")): ?>
<a href="#" data-home-id="<?=$preorder->adb_wohneinheit_id?>" data-home-contact title="Kontakte bearbeiten"><i class="fas fa-users-cog text-primary"></i></a>
<a href="<?=self::getUrl("Preorder", "edit", ["id" => $preorder->id])?>"><i class="far fa-edit" title="Vorbestellung Bearbeiten"></i></a>
<a href="<?=self::getUrl("Preorder", "delete", ["id" => $preorder->id, "filter" => $filter])?>" class="text-danger" onclick="if(!confirm('Vorbestellung wirklich löschen?')) return false;" title="Vorbestellung Löschen"><i class="fas fa-trash"></i></a>
<?php endif; ?>
@@ -1085,20 +1088,73 @@ $pagination_entity_name = "Vorbestellungen";
}
async function getFCPs(map) {
var fcp = await $.get("<?=self::getUrl("Preorder", "Api")?>", {
const fcpResponse = await $.get("<?=self::getUrl("Preorder", "Api")?>", {
do: "getFCPsForCampaign",
campaign_id: "<?=$campaign->id?>"
});
if(fcp.status == "OK") {
fcp.result.forEach((fcp) => {
var icon = L.MakiMarkers.icon({icon: "viewpoint", color: "yellow", size: "m"});
var marker = L.marker([fcp.lat, fcp.lng], {icon: icon}).addTo(map);
var google_maps_link = "https://www.google.com/maps/search/?api=1&query=" + fcp.lat + "," + fcp.lng;
var popup_content = "<a href='" + google_maps_link + "' target='_blank'>Google Maps</a><br />" + fcp.text;
marker.bindPopup(popup_content);
});
}
if (fcpResponse.status !== "OK" || !fcpResponse.result?.length) return;
const fcpIds = fcpResponse.result.map(fcp => fcp.real_id);
const statsResponse = await $.ajax({
url: "<?=self::getUrl("Preorder", "Api")?>?do=getRimoFcpStats",
type: 'POST',
contentType: 'application/json', // 1. Set the content type to JSON
data: JSON.stringify({ fcp_ids: fcpIds }) // 2. Stringify the data object
});
const stats = statsResponse.status === "OK" ? statsResponse.result : [];
fcpResponse.result.forEach(fcp => {
const icon = L.MakiMarkers.icon({ icon: "viewpoint", color: "yellow", size: "m" });
const marker = L.marker([fcp.lat, fcp.lng], { icon }).addTo(map);
const fcpStat = stats.find(s => parseInt(s.fcp_id) === parseInt(fcp.real_id));
const googleMapsLink = `https://www.google.com/maps/search/?api=1&query=${fcp.lat},${fcp.lng}`;
const statsHtml = !fcpStat ? `<p>Keine Statistiken gefunden.</p>` : `
<div style="margin-bottom: 15px;">
<strong style="display: block; margin-bottom: 5px; color: #555;">Zusammenfassung:</strong>
<span>Buildings: <b>${fcpStat.total_hausnummer_count}</b></span><br>
<span>Homes: <b>${fcpStat.total_wohneinheit_count}</b></span><br>
<span>Bestellungen: <b>${fcpStat.total_active_preorders}</b></span>
</div>
<strong style="display: block; margin-bottom: 5px; color: #555;">Details nach RIMO-Typ:</strong>
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
<thead>
<tr style="background-color: #f2f2f2; text-align: left;">
<th style="padding: 8px; border: 1px solid #ddd;">Typ</th>
<th style="padding: 8px; border: 1px solid #ddd;">BU</th>
<th style="padding: 8px; border: 1px solid #ddd;">WE</th>
<th style="padding: 8px; border: 1px solid #ddd;">BE</th>
</tr>
</thead>
<tbody>
${Object.entries(fcpStat.counts_by_rimo_type || {}).length ?
Object.entries(fcpStat.counts_by_rimo_type).map(([type, counts], index) => `
<tr style="${index % 2 === 0 ? 'background-color: #ffffff;' : 'background-color: #f9f9f9;'}">
<td style="padding: 8px; border: 1px solid #ddd;">${type}</td>
<td style="padding: 8px; border: 1px solid #ddd;">${counts.hausnummer_count}</td>
<td style="padding: 8px; border: 1px solid #ddd;">${counts.wohneinheit_count}</td>
<td style="padding: 8px; border: 1px solid #ddd;">${counts.preorder_count}</td>
</tr>
`).join('') :
'<tr><td colspan="4" style="padding: 8px; text-align: center; border: 1px solid #ddd;">Keine detaillierten Statistiken verfügbar.</td></tr>'
}
</tbody>
</table>
`;
const popupContent = `
<div style="font-family: Arial, sans-serif; width: 320px; padding: 5px;">
<h3 style="margin-bottom: 10px; color: #333; border-bottom: 1px solid #ddd; padding-bottom: 5px;">
${fcp.text}
</h3>
<a href='${googleMapsLink}' target='_blank' style="color: #007bff; text-decoration: none; margin-bottom: 15px; display: inline-block;">In Google Maps anzeigen</a>
${statsHtml}
</div>
`;
marker.bindPopup(popupContent);
});
}
function centerMap() {
@@ -1967,6 +2023,24 @@ $pagination_entity_name = "Vorbestellungen";
});
});
campaignSelect.trigger("change");
// for the Rimo-Typen Karte <a> only show this <a> button if a preordercampaign is selected and change the display and href dynamically
const rimoTypesLink = $("#rimo-types-link");
function updateRimoTypesLink() {
const campaignId = campaignSelect.val();
if (campaignId) {
rimoTypesLink.show();
rimoTypesLink.attr("href", "<?=self::getUrl("Preorder", "RimoTypeMap")?>?preordercampaign_id=" + campaignId);
} else {
rimoTypesLink.hide();
rimoTypesLink.attr("href", "#");
}
}
campaignSelect.on("change", updateRimoTypesLink);
updateRimoTypesLink();
});
</script>
<script src="<?= self::getResourcePath() ?>js/pages/AddressDB/ADBWohneinheitContactManager.js"></script>
<script src="<?= self::getResourcePath() ?>plugins/axios/axios.min.js"></script>
<script src="<?= self::getResourcePath() ?>plugins/axios/axios.inject.js"></script>
<?php include(realpath(dirname(__FILE__)."/../../$mfLayoutPackage")."/footer.php"); ?>

View File

@@ -16,7 +16,7 @@ foreach(PreorderStatusflagModel::getAll() as $sflag) {
}
?>
<?="\u{FEFF}"?>Kampagne;Netzgebiet ID;Netzgebiet;Extref;Bestellcode;Gutscheincodes;OAID;Bestelldatum;Bestelltyp;Status Code;Status Name;ADB NE;"<?=implode('";"', $status_flags_header)?>";Anschlusstyp;GWR Adresscode;Meridian;RW;HW;Anschluss Strasse;Anschluss Hausnummer;Anschluss PLZ;Anschluss Ort;Anschluss Wohneinheit;GPS Breite;GPS Länge;Anzahl Anschlüsse;Kunde Firma;Kunde UID;Kunde Vorname;Kunde Nachname;Kunde Strasse;Kunde PLZ;Kunde Ort;Kunde Telefon;Kunde Email;Partner;CIF Token;Cif Url;Cif Cable Url;Addon Lehrverrohrung Grundstück;Addon Hausverkabelung;BEP festgelegt;Starterpaket erhalten;Erstellt;Letzte Bearbeitung
<?="\u{FEFF}"?>Kampagne;Netzgebiet ID;Netzgebiet;Extref;Bestellcode;Gutscheincodes;OAID;FCP;Bestelldatum;Bestelltyp;Status Code;Status Name;ADB NE;"<?=implode('";"', $status_flags_header)?>";Anschlusstyp;GWR Adresscode;Meridian;RW;HW;Anschluss Strasse;Anschluss Hausnummer;Anschluss PLZ;Anschluss Ort;Anschluss Wohneinheit;GPS Breite;GPS Länge;Anzahl Anschlüsse;Kunde Firma;Kunde UID;Kunde Vorname;Kunde Nachname;Kunde Strasse;Kunde PLZ;Kunde Ort;Kunde Telefon;Kunde Email;Partner;CIF Token;Cif Url;Cif Cable Url;Addon Lehrverrohrung Grundstück;Addon Hausverkabelung;BEP festgelegt;Starterpaket erhalten;Erstellt;Letzte Bearbeitung
<?php
$line = 0;
@@ -97,12 +97,18 @@ while($data = mysqli_fetch_object($res)):
if($data->uid == "string") $data->uid = "";
$fcp = "";
if ($hausnummer->fcp_id) {
$fcp = ADBRimoFcp::get($hausnummer->fcp_id);
$fcp = $fcp->name;
}
?>
"<?=$campaign->name?>";"<?=$netzgebiet->extref?>";"<?=$netzgebiet->name?>";"<?=$data->extref?>";"<?=$data->ucode?>";"<?=implode(", ",$discounts)?>";"<?=$wohneinheit->oaid?>";"<?=($data->order_date) ? date("d.m.Y",$data->order_date) : ""?>";"<?=__($data->type,"preorder")?>";"<?=$status->code?>";"<?=$status->name?>";"<?=$wohneinheit->num ?>";<?=implode(";", $statusflags)?>;"<?=__($data->connection_type,"preorder")?>";"<?=$adrcd?>";"<?=$hausnummer->meridian?>";"<?=$hausnummer->rw?>";"<?=$hausnummer->hw?>";"<?=$strasse->name?>";"<?=$hausnummer->hausnummer?>";"<?=$plz->plz?>";"<?=$ortschaft->name?>";"<?=$unit_data?>";"<?=$hausnummer->gps_lat?>";"<?=$hausnummer->gps_long?>";<?=$data->connection_count?>;"<?=$data->company?>";"<?=$data->uid?>";"<?=$data->firstname?>";"<?=$data->lastname?>";"<?=$data->street?>";"<?=$data->zip?>";"<?=$data->city?>";"<?=$data->phone?>";"<?=$data->email?>";"<?=$partner->getCompanyOrName()?>";"<?=$data->ciftoken?>";"<?=$data->cifurl?>";"<?=$data->cifcableurl?>";<?=$addon_property?>;<?=$addon_inhouse?>;<?=($bep) ? "1" : "0"?>;<?=($inhouse) ? "1" : "0"?>;"<?=date("Y-m-d H:i:s",$data->create)?>";"<?=date("Y-m-d H:i:s",$data->edit)?>"
"<?=$campaign->name?>";"<?=$netzgebiet->extref?>";"<?=$netzgebiet->name?>";"<?=$data->extref?>";"<?=$data->ucode?>";"<?=implode(", ",$discounts)?>";"<?=$wohneinheit->oaid?>";"<?=$fcp?>";"<?=($data->order_date) ? date("d.m.Y",$data->order_date) : ""?>";"<?=__($data->type,"preorder")?>";"<?=$status->code?>";"<?=$status->name?>";"<?=count($hausnummer->wohneinheiten) ?>";<?=implode(";", $statusflags)?>;"<?=__($data->connection_type,"preorder")?>";"<?=$adrcd?>";"<?=$hausnummer->meridian?>";"<?=$hausnummer->rw?>";"<?=$hausnummer->hw?>";"<?=$strasse->name?>";"<?=$hausnummer->hausnummer?>";"<?=$plz->plz?>";"<?=$ortschaft->name?>";"<?=$unit_data?>";"<?=$hausnummer->gps_lat?>";"<?=$hausnummer->gps_long?>";<?=$data->connection_count?>;"<?=$data->company?>";"<?=$data->uid?>";"<?=$data->firstname?>";"<?=$data->lastname?>";"<?=$data->street?>";"<?=$data->zip?>";"<?=$data->city?>";"<?=$data->phone?>";"<?=$data->email?>";"<?=$partner->getCompanyOrName()?>";"<?=$data->ciftoken?>";"<?=$data->cifurl?>";"<?=$data->cifcableurl?>";<?=$addon_property?>;<?=$addon_inhouse?>;<?=($bep) ? "1" : "0"?>;<?=($inhouse) ? "1" : "0"?>;"<?=date("Y-m-d H:i:s",$data->create)?>";"<?=date("Y-m-d H:i:s",$data->edit)?>"
<?php
$line++;
if($line % 1000 === 0) {
flush();
}
endwhile;
endwhile;

View File

@@ -565,8 +565,12 @@
</table>
<?php endforeach; ?>
<?php else: ?>
<?php elseif($preorder->status->code != "20"): ?>
<button type="button" class="btn btn-outline-primary create-workorder" onclick="createWorkorder(<?=$preorder->id?>)"><i class="fas fa-fw fa-plus"></i> <i class="fas fa-r"></i><i class="fas fa-fw fa-gears"></i> Wokorder erstellen</button>
<?php elseif($preorder->status->code == "20"): ?>
<div class="alert alert-info mt-2" role="alert">
<i class="fas fa-info-circle"></i> Diese Preorder ist auf Hold gesetzt. Es kann keine Workorder erstellt werden.
</div>
<?php endif; ?>
</div>
@@ -586,8 +590,10 @@
<td class="text-monospace"><?=$preorder->adb_wohneinheit->ftu_data["id"]?>
</tr>
</table>
</div>
<h3>FCP</h3>
<div class="col row">
<h3 >FCP</h3>
<?php
if($preorder->fcp): ?>
<table class="table table-sm table-striped">
@@ -610,9 +616,12 @@
</tr>
</table>
<?php else: ?>
<p>Kein FCP zugewiesen</p>
<div class="col-12 p-0">
<div class="alert alert-info mt-2" role="alert">
<i class="fas fa-info-circle"></i> Kein FCP zugewiesen/importert.
</div>
</div>
<?php endif; ?>
</div>

View File

@@ -103,12 +103,12 @@ include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php")
data-ucode="<?= $preorder->ucode ?>"
data-oaid="<?= $preorder->oaid ?>"
data-addr-name="<?= htmlspecialchars($preorder->company ?: "{$preorder->firstname} {$preorder->lastname}", ENT_QUOTES) ?>"
data-addr-street="<?= htmlspecialchars(trim("{$preorder->street} {$preorder->housenumber}"), ENT_QUOTES) ?>"
data-addr-zip="<?= $preorder->zip ?>"
data-addr-city="<?= htmlspecialchars($preorder->city, ENT_QUOTES) ?>"
data-addr-street="<?= htmlspecialchars($preorder->adb_hausnummer_id ? "{$preorder->adb_hausnummer->strasse->name} {$preorder->adb_hausnummer->hausnummer}" : trim("{$preorder->street} {$preorder->housenumber}"), ENT_QUOTES) ?>"
data-addr-zip="<?= htmlspecialchars($preorder->adb_hausnummer_id ? $preorder->adb_hausnummer->plz->plz : $preorder->zip, ENT_QUOTES) ?>"
data-addr-city="<?= htmlspecialchars($preorder->adb_hausnummer_id ? $preorder->adb_hausnummer->ortschaft->name : $preorder->city, ENT_QUOTES) ?>"
data-phone="<?= $preorder->phone ?>"
data-email="<?= $preorder->email ?>">
<td class="text-right align-middle">
<td class="text-right align-middle">
<button type="button" class="btn btn-sm btn-success font-weight-bold" onclick="printShippingSlip(<?= $preorder->id ?>)"><i class="fas fa-fw fa-print"></i> DRUCKEN</button>
</td>
<td class="text-center align-middle">

View File

@@ -35,18 +35,27 @@
</div>
<div style="margin-top: 28pt">
<p>
<?php if($preorder->company): ?>
<?=nl2br($preorder->company)?><br />
<?php else: ?>
<br />
<?php endif; ?>
<?php if($preorder->lastname): ?>
<?=$preorder->firstname?> <?=$preorder->lastname?><br />
<?php endif; ?>
<?=$preorder->street?> <?=$preorder->housenumber?><br />
<?=$preorder->zip?> <?=$preorder->city?>
</p>
<p>
<?php if($preorder->company): ?>
<?=nl2br(htmlspecialchars($preorder->company))?><br />
<?php else: ?>
<br />
<?php endif; ?>
<?php if($preorder->lastname): ?>
<?=htmlspecialchars($preorder->firstname)?> <?=htmlspecialchars($preorder->lastname)?><br />
<?php endif; ?>
<?php if ($preorder->adb_hausnummer_id): ?>
<?= htmlspecialchars($preorder->adb_hausnummer->strasse->name) ?> <?= htmlspecialchars($preorder->adb_hausnummer->hausnummer) ?><br/>
<?php if ($preorder->adb_wohneinheit_id && (string)$preorder->adb_wohneinheit): ?>
<?= htmlspecialchars((string)$preorder->adb_wohneinheit) ?><br />
<?php endif; ?>
<?= htmlspecialchars($preorder->adb_hausnummer->plz->plz) ?> <?= htmlspecialchars($preorder->adb_hausnummer->ortschaft->name) ?>
<?php else: ?>
<?=htmlspecialchars($preorder->street)?> <?=htmlspecialchars($preorder->housenumber)?><br />
<?=htmlspecialchars($preorder->zip)?> <?=htmlspecialchars($preorder->city)?>
<?php endif; ?>
</p>
<p style="text-align: right; padding-top: 4pt;">Liezen, <?=date("d.m.Y")?></p>
<p style="padding-top: 4pt;">Liebe(r) <?=($preorder->firstname) ? $preorder->firstname : ""?> <?=($preorder->lastname) ? $preorder->lastname : ""?>,</p>

View File

@@ -22,12 +22,20 @@ for ($i = 1; $i <= 25; $i++) {
$time = $time - 604800;
}
$time = time();
$monthger = [
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April',
5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August',
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember'
];
$month = [];
$date = new DateTime('first day of this month');
for ($i = 1; $i <= 12; $i++) {
$mon = date('n', $time);
$year = date('Y', $time);
$month[$time] = $monthger[$mon] . " " . $year;
$time = strtotime('-1 month', $time);
$mon = $date->format('n');
$year = $date->format('Y');
$month[$date->getTimestamp()] = $monthger[$mon] . " " . $year;
$date->modify('-1 month');
}
$years[time() + 31536000] = date('Y', time() + 31536000);

View File

@@ -1,620 +0,0 @@
<?php
$siteTitle = "Benutzer";
?>
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/header.php"); ?>
<!-- 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"><a href="<?=self::getUrl("User")?>">Benutzer</a></li>
<li class="breadcrumb-item"><?=($action == "edit") ? "bearbeiten" : "neu"?></li>
</ol>
</div>
<h4 class="page-title">Benutzer</h4>
</div>
</div>
</div>
<!-- end page title -->
<form method="post" action="<?=$this->getUrl("User", "save")?>">
<!-- Main content -->
<div class="row">
<div class="col-lg">
<div class="card bg-light">
<div class="card-body">
<h4 class="header-title mb-3">Benutzer bearbeiten</h4>
<div class="card">
<div class="card-body">
<input type="hidden" name="id" value="<?=$user->id?>"/>
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" class="form-control"
value="<?=$user->username?>"/>
</div>
<div class="form-group">
<label for="name">Name:</label>
<input type="text" id="name" name="name" class="form-control"
value="<?=$user->name?>"/>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="text" id="email" name="email" class="form-control"
value="<?=$user->email?>"/>
</div>
<div class="form-group">
<label for="mobile">Handy Nr.:</label>
<input type="text" id="mobile" placeholder="+436641234xxx" name="mobile"
class="form-control" value="<?=$user->mobile?>"/>
</div>
<div class="form-group">
<label for="address_id">Firma/Person:</label>
<select name="address_id" id="address_id" class="form-control">
<option value=""></option>
<?php foreach($addresses as $address): ?>
<option value="<?=$address->id?>" <?=($address->id == $user->address_id || $address->id == $user->address_id) ? "selected='selected'" : ""?>><?=($address->company) ? $address->company : $address->getFullName()?><?=($address->customer_number) ? " (" . $address->customer_number . ")" : ""?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label for="admin">Admin:</label>
<select name="admin" id="admin"
class="form-control" <?=($user->id == 1) ? "disabled='disabled'" : ""?>>
<option value="false" <?=(isset($user) && !$user->isAdmin()) ? "selected='selected'" : ""?>>
No
</option>
<option value="true" <?=(isset($user) && $user->isAdmin() || $user->id == 1) ? "selected='selected'" : ""?>>
Yes
</option>
</select>
</div>
<div class="form-group">
<label for="active">Aktiv:</label>
<select name="active" id="active" class="form-control">
<option value="false" <?=(isset($user) && !$user->active == 0) ? "selected='selected'" : ""?>>No</option>
<option value="true" <?=(isset($user) && $user->active == 1) ? "selected='selected'" : ""?>>Yes</option>
</select>
</div>
<div class="form-group">
<label for="technician">Techniker:</label>
<select name="technician" id="technician" class="form-control">
<option value="false" <?=(isset($user) && !$user->is("technician")) ? "selected='selected'" : ""?>>
No
</option>
<option value="true" <?=(isset($user) && $user->is("technician")) ? "selected='selected'" : ""?>>
Yes
</option>
</select>
</div>
<div class="form-group">
<label for="employee"><?=TT_SYSOWNER_NAME_HTML?> Mitarbeiter:</label>
<select name="employee" id="employee" class="form-control">
<option value="false" <?=(isset($user) && !$user->is("employee")) ? "selected='selected'" : ""?>>
No
</option>
<option value="true" <?=(isset($user) && $user->is("employee")) ? "selected='selected'" : ""?>>
Yes
</option>
</select>
</div>
<div id="employee-container" <?=(!isset($user) || !$user->is("employee")) ? "hidden" : ""?>>
<div class="form-group">
<label for="employee_number"><?=TT_SYSOWNER_NAME_HTML?> Mitarbeiternummer:</label>
<input type="text" id="employee_number" name="employee_number" class="form-control"
value="<?=(isset($user)) ? (new WorkerFlag($user->id, "employee_number"))->value() : ""?>" />
</div>
<div class="form-group">
<label for="employee_number">Vodia Outbound Identity - Domain:</label>
<input type="text" id="vodia_identity_domain" name="vodia_identity_domain" class="form-control"
value="<?=(isset($user)) ? (new WorkerFlag($user->id, "vodia_identity_domain"))->value() : ""?>" />
</div>
<div class="form-group">
<label for="employee_number">Vodia Outbound Identity - Username (Extension):</label>
<input type="text" id="vodia_identity_username" name="vodia_identity_username" class="form-control"
value="<?=(isset($user)) ? (new WorkerFlag($user->id, "vodia_identity_username"))->value() : ""?>" />
</div>
<div class="form-group">
<label for="employee_number">Vodia Outbound Identity - Standard Identität:</label>
<input type="text" id="vodia_identity_default" name="vodia_identity_default" class="form-control"
value="<?=(isset($user)) ? (new WorkerFlag($user->id, "vodia_identity_default"))->value() : ""?>" />
<small>+43 720 123456</small>
</div>
<div class="form-group">
<label for="project_api_key">OpenProject API Key:</label>
<input type="text" id="project_api_key" name="project_api_key" class="form-control"
value="<?=(isset($user)) ? (new WorkerFlag($user->id, "project_api_key"))->value() : ""?>" />
</div>
</div>
<hr />
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" class="form-control" value=""/>
</div>
<div class="form-group">
<label for="password2">Repeat Password:</label>
<input type="password" id="password2" name="password2" class="form-control"
value=""/>
</div>
<hr/>
<div class="form-group">
<label for="twofactorrequired">2FA erzwingen:</label>
<select name="twofactorrequired" id="twofactorrequired" class="form-control">
<option value="false" <?=(isset($user) && !$user->twofactorrequired) ? "selected='selected'" : ""?>>
No
</option>
<option value="true" <?=((!isset($user) || !$user->id) || (isset($user) && $user->twofactorrequired)) ? "selected='selected'" : ""?>>
Yes
</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h4 class="card-title mb-3">Preorder</h4>
<div class="form-group" id="preorderfront-container">
<label for="preorderfront">Preorder Frontdesk (Semi-Readonly):</label>
<select name="preorderfront" id="preorderfront" class="form-control">
<option value="false" <?=(isset($user) && !$user->is("preorderfront")) ? "selected='selected'" : ""?>>
No
</option>
<option value="true" <?=(isset($user) && $user->is("preorderfront")) ? "selected='selected'" : ""?>>
Yes
</option>
</select>
</div>
<div class="form-group" id="preorder-reporting-container">
<label for="preorderaddressreporting">Preorder Address Reporting API User:</label>
<select name="preorderaddressreporting" id="preorderaddressreporting"
class="form-control">
<option value="false" <?=(isset($user) && !$user->is("preorderaddressreporting")) ? "selected='selected'" : ""?>>
No
</option>
<option value="true" <?=(isset($user) && $user->is("preorderaddressreporting")) ? "selected='selected'" : ""?>>
Yes
</option>
</select>
<small>z.B. Meridiam</small>
</div>
<div class="form-group" id="preorderlogistics-container">
<label for="preorderlogistics">Preorder Logistikpartner:</label>
<select name="preorderlogistics" id="preorderlogistics" class="form-control">
<option value="false" <?=(isset($user) && !$user->is("preorderlogistics")) ? "selected='selected'" : ""?>>
No
</option>
<option value="true" <?=(isset($user) && $user->is("preorderlogistics")) ? "selected='selected'" : ""?>>
Yes
</option>
</select>
</div>
<div class="form-group" id="preorder-network-container">
<label for="preorder_networks">Preorder Netzgebiete:</label>
<?php
$pns = [];
if($user->id) {
$pns = json_decode((new WorkerFlag($user->id, "preorder_networks"))->value());
if(!$pns) {
$pns = [];
}
}
?>
<select name="preorder_networks[]" id="preorder_networks" class="form-control"
multiple="multiple">
<?php foreach(NetworkModel::getAll() as $network): ?>
<option value="<?=$network->id?>" <?=(in_array($network->id, $pns)) ? "selected='selected'" : ""?>><?=$network->name?></option>
<?php endforeach; ?>
</select>
<small>Beschränkt Benutzer auf Netzgebiete. Überschreibt Netzgebiete der Firma. Wenn
leer werden Netzgebiete der Firma angezeigt</small>
</div>
<div class="form-group" id="preorderreadonly-container">
<label for="preorderreadonly">Preorder Readonly:</label>
<select name="preorderreadonly" id="preorderreadonly" class="form-control">
<option value="false" <?=(isset($user) && !$user->is("preorderreadonly")) ? "selected='selected'" : ""?>>
Read/Write
</option>
<option value="true" <?=(isset($user) && $user->is("preorderreadonly")) ? "selected='selected'" : ""?>>
Readonly
</option>
</select>
</div>
<h4 class="mt-2">Preorder Module</h4>
<div class="row mt-3">
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Preorderpricing]"
id="can_preorderpricing"
value="1" <?=($user && $user->can("Preorderpricing")) ? "checked='checked'" : ""?> />
<label for="can_preorderpricing" class="form-check-label">Preorder
Bepreisung</label>
</div>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input"
name="can[PreorderpricingReadonly]" id="can_preorderpricingreadonly"
value="1" <?=$user && $user->can("PreorderpricingReadonly") ? "checked='checked'" : ""?> />
<label for="can_preorderpricingreadonly" class="form-check-label">Preorder
Bepreisung Readonly</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Preorderbilling]"
id="can_preorderbilling"
value="1" <?=($user && $user->can("Preorderbilling")) ? "checked='checked'" : ""?> />
<label for="can_preorderbilling" class="form-check-label">Preorder
Verrechnung</label>
</div>
<div class="form-group form-check">
<input type="checkbox" class="form-check-input"
name="can[PreorderbillingReadonly]" id="can_preorderbillingreadonly"
value="1" <?=$user && $user->can("PreorderbillingReadonly") ? "checked='checked'" : ""?> />
<label for="can_preorderbillingreadonly" class="form-check-label">Preorder
Verrechnung Readonly</label>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h4 class="card-title mb-3">Zustimmungserklärungen</h4>
<div class="form-group" id="constructionconsent-projects-container">
<label for="constructionconsent_projects">Zustimmungserklärungsprojekte:</label>
<?php
$constructionConsent_projects = [];
if($user->id) {
$constructionConsent_projects = json_decode((new WorkerFlag($user->id, "constructionConsent_projects"))->value());
if(!$constructionConsent_projects) {
$constructionConsent_projects = [];
}
}
?>
<select name="constructionconsent_projects[]" id="constructionconsent_projects"
class="form-control" multiple="multiple">
<?php foreach(ConstructionConsentProject::getAll() as $project): ?>
<option value="<?=$project->id?>" <?=(in_array($project->id, $constructionConsent_projects)) ? "selected='selected'" : ""?>><?=$project->name?></option>
<?php endforeach; ?>
</select>
<small>Benutzer kann nur Zustimmungserklärungen in diesen Projekten sehen</small>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<h4 class="card-title mb-3">Modulberechtigungen</h4>
<div class="row">
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Building]"
id="can_building"
value="1" <?=($user && $user->can("Building")) ? "checked='checked'" : ""?> />
<label for="can_building" class="form-check-label">Objekte & Anschlüsse
(Gebäude)</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Pipework]"
id="can_pipework"
value="1" <?=$user && $user->can("Pipework") ? "checked='checked'" : ""?> />
<label for="can_pipework" class="form-check-label">Tiefbau</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Linework]"
id="can_linework"
value="1" <?=$user && $user->can("Linework") ? "checked='checked'" : ""?> />
<label for="can_linework" class="form-check-label">Leitungsbau</label>
</div>
</div>
</div>
<div class="row">
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Patching]"
id="can_patching"
value="1" <?=$user && $user->can("Patching") ? "checked='checked'" : ""?> />
<label for="can_patching" class="form-check-label">Patching</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Filestore]"
id="can_filestore"
value="1" <?=$user && $user->can("Filestore") ? "checked='checked'" : ""?> />
<label for="can_filestore" class="form-check-label">Filestore
(Netzbau)</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Cpeprovisioning]"
id="can_cpeprovisioning"
value="1" <?=$user && $user->can("Cpeprovisioning") ? "checked='checked'" : ""?> />
<label for="can_cpeprovisioning" class="form-check-label">CPE
Provisioning</label>
</div>
</div>
</div>
<div class="row">
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Cpeshipping]"
id="can_cpeshipping"
value="1" <?=$user && $user->can("Cpeshipping") ? "checked='checked'" : ""?> />
<label for="can_cpeshipping" class="form-check-label">CPE Versand</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Voipnumbering]"
id="can_voipnumbering"
value="1" <?=$user && $user->can("Voipnumbering") ? "checked='checked'" : ""?> />
<label for="can_voipnumbering" class="form-check-label">VOIP
Nummernverwaltung</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Preorder]"
id="can_preorder"
value="1" <?=$user && $user->can("Preorder") ? "checked='checked'" : ""?> />
<label for="can_preorder" class="form-check-label">Vorbestellung</label>
</div>
</div>
</div>
<div class="row">
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Order]"
id="can_order"
value="1" <?=$user && $user->can("Order") ? "checked='checked'" : ""?> />
<label for="can_order" class="form-check-label">Bestellung</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Billing]"
id="can_billing"
value="1" <?=$user && $user->can("Billing") ? "checked='checked'" : ""?> />
<label for="can_billing" class="form-check-label">Verrechnung</label>
</div>
</div>
</div>
<h4 class="card-title mb-3 mt-3">Lager</h4>
<div class="row">
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[WarehouseAdmin]"
id="can_warehouse_admin"
value="1" <?=($user && $user->can("WarehouseAdmin")) ? "checked='checked'" : ""?> />
<label for="can_warehouse_admin"
class="form-check-label">Lager-Admin</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[WarehouseUser]"
id="can_warehouse_user"
value="1" <?=($user && $user->can("WarehouseUser")) ? "checked='checked'" : ""?> />
<label for="can_warehouse_user" class="form-check-label">Lager-User</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[WarehouseEShop]"
id="can_warehouse_e_shop"
value="1" <?=($user && $user->can("WarehouseEShop")) ? "checked='checked'" : ""?> />
<label for="can_warehouse_e_shop" class="form-check-label">Energie
Steiermark Shop</label>
</div>
</div>
</div>
<h4 class="card-title mb-3 mt-3">Zusatzberechtigungen</h4>
<div class="row">
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Fibu]"
id="can_fibu"
value="1" <?=($user && $user->can("Fibu")) ? "checked='checked'" : ""?> />
<label for="can_fibu" class="form-check-label">Buchhaltung</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[Statistics]"
id="can_statistics"
value="1" <?=($user && $user->can("Statistics")) ? "checked='checked'" : ""?> />
<label for="can_statistics" class="form-check-label">Statistiken
anzeigen</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[ADBExtended]"
id="can_ADBExtended"
value="1" <?=($user && $user->can("ADBExtended")) ? "checked='checked'" : ""?> />
<label for="can_ADBExtended" class="form-check-label">Address-DB erweitert</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[AssetAdmin]"
id="can_AssetAdmin"
value="1" <?=($user && $user->can("AssetAdmin")) ? "checked='checked'" : ""?> />
<label for="can_AssetAdmin" class="form-check-label">Asset-Admin</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[RMLAdmin]"
id="can_RMLAdmin"
value="1" <?=($user && $user->can("RMLAdmin")) ? "checked='checked'" : ""?> />
<label for="can_RMLAdmin" class="form-check-label">RML-Workorder-Admin</label>
</div>
</div>
<div class="col-4">
<div class="form-group form-check">
<input type="checkbox" class="form-check-input" name="can[RMLCompany]"
id="can_RMLCompany"
value="1" <?=($user && $user->can("RMLCompany")) ? "checked='checked'" : ""?> />
<label for="can_RMLCompany" class="form-check-label">RML-Workorder-Firma</label>
</div>
</div>
</div>
<hr/>
<div class="form-group">
<input type="submit" name="submit" value="Speichern" class="btn btn-primary"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>
<?php if($user->id): ?>
<div class="row">
<div class="col-lg">
<div class="card">
<div class="card-header">
<h3 class="card-title">API Key</h3>
</div>
<div class="card-body">
<div class="form-group">
<input type="text" class="form-control" value="<?=$user->apikey?>" disabled="disabled"/>
</div>
<div class="form-group">
<form method="post" action="<?=self::getUrl("User", "generateApikey")?>">
<input type="hidden" name="id" value="<?=$user->id?>"/>
<?php if($user->apikey): ?>
<button type="submit" class="btn btn-outline-primary"
onclick="if(!confirm('Achtung: Dadurch wird der bisherige API Key ungültig. Wirklich neuen API Key generieren?')) return false;">
Neuen API Key generieren
</button>
<?php else: ?>
<button type="submit" class="btn btn-outline-primary">API Key generieren</button>
<?php endif; ?>
</div>
</div>
</div>
</div>
</div>
</form>
<?php endif; ?>
<script type="text/javascript">
$(document).ready(function () {
$("#address_id").select2({
allowClear: true,
placeholder: ""
});
$("#preorder_networks").select2({
allowClear: true,
placeholder: "",
closeOnSelect: false
});
$("#constructionconsent_projects").select2({
allowClear: true,
placeholder: "",
closeOnSelect: false
});
<?php if(!$user || (!$user->is("preorderfront") && !$user->is("preorderaddressreporting")) ): ?>
//$("#preorder-network-container").hide();
<?php endif; ?>
<?php if($user && ($user->is("preorderfront")) ): ?>
//$("#preorder-reporting-container").hide();
<?php endif; ?>
<?php if($user && ($user->is("preorderaddressreporting")) ): ?>
//$("#preorderfront-container").hide();
<?php endif; ?>
$("select[name=preorderfront]").change(function () {
if ($("select[name=preorderfront]").val() == "true") {
$("#preorder-reporting-container").hide(500);
} else {
$("#preorder-reporting-container").show(500);
}
});
// preorder-reporting-container
$("select[name=preorderaddressreporting]").change(function () {
if ($("select[name=preorderaddressreporting]").val() == "true") {
$("#preorderfront-container").hide(400);
} else {
$("#preorderfront-container").show(400);
}
});
$("#employee").change(function () {
if ($("#employee").val() == "true") {
$("#employee-container").show(400);
} else {
$("#employee-container").hide(400);
}
});
});
</script>
<?php include(realpath(dirname(__FILE__) . "/../../$mfLayoutPackage") . "/footer.php"); ?>

View File

@@ -0,0 +1,873 @@
<?php
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Workorders</title>
<link rel="shortcut icon" href="/assets/images/favicon.ico">
<link rel="manifest" href="/js/pages/WorkorderBase/manifest.json">
<meta name="theme-color" content="#005384">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.4.27/dist/vue.global.prod.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@1.7.2/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.30.1/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.30.1/locale/de.js"></script>
<script>
window.TT_CONFIG = <?= json_encode($JSGlobals ?? []) ?>;
moment.locale('de');
tailwind.config = {
darkMode: 'class', // Enable dark mode based on a class
theme: {
extend: {
colors: {
'primary': '#005384', // Dark Blue
'secondary': '#fac41b', // Yellow/Gold
},
}
}
}
</script>
<style>
html, body {
/* Prevents the rubber-band scroll effect on iOS and pull-to-refresh on Android */
overscroll-behavior: none;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
main {
/* Prevents scrolling within the main container from affecting the body */
overscroll-behavior-y: contain;
}
.slide-enter-active, .slide-leave-active { transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1); }
.slide-enter-from, .slide-leave-to { transform: translateX(100%); }
.list-container.panel-open {
transform: scale(0.95);
filter: blur(4px);
opacity: 0.7;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), filter 0.35s, opacity 0.35s;
}
.list-container {
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1), filter 0.35s, opacity 0.35s;
}
.overlay {
position: fixed;
inset: 0;
background-color: rgba(0,0,0,0.4);
transition: opacity 0.35s ease;
z-index: 15;
}
.overlay-enter-from, .overlay-leave-to { opacity: 0; }
.overlay-enter-to, .overlay-leave-from { opacity: 1; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s ease-in-out; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin { animation: spin 1.5s ease-in-out infinite; }
</style>
</head>
<body class="transition-colors duration-300 overflow-hidden">
<div id="app" class="h-screen w-screen overflow-hidden antialiased"></div>
<script>
const { createApp, ref, reactive, computed, onMounted, watch, nextTick } = Vue;
const app = createApp({
setup() {
// --- STATE ---
const workorders = ref([]);
const selectedWorkorder = ref(null);
const isLoading = ref(true);
const isDetailsLoading = ref(false);
const isDetailsPanelOpen = ref(false);
const searchTerm = ref('');
const documentation = reactive({ docs: [], journals: [] });
const tenantConfig = ref(null);
const tempAdditionalInfo = ref('');
const isEditingInfo = ref(false);
const newJournalEntry = ref('');
const isUploading = ref(false);
const uploadModal = reactive({ show: false, files: null, documentType: '' });
const problemModal = reactive({ show: false, selectedInterventions: [], details: {} });
const fullscreenViewer = reactive({ show: false, item: null });
const missingTasksPopover = reactive({ show: false, tasks: [] });
const installModal = reactive({ show: false });
const isStandalone = ref(false);
const selectedFcp = ref('all');
const isFcpSelectOpen = ref(false);
const fcpSearchTerm = ref('');
const fcpInputRef = ref(null); // For autofocusing FCP search
const isSettingsOpen = ref(false);
const theme = ref('system'); // 'light', 'dark', 'system'
const showThemePicker = ref(false);
const API_BASE_URL = window.TT_CONFIG.BASE_PATH || '/WorkorderCompany';
const api = axios.create({ baseURL: API_BASE_URL });
// --- COMPUTED ---
const fcpOptions = computed(() => {
if (!workorders.value || workorders.value.length === 0) {
return [{ value: 'all', text: 'Alle FCPs' }];
}
const fcps = [...new Set(workorders.value.map(wo => wo.rimo_fcp_name).filter(Boolean))].sort();
const options = fcps.map(fcp => ({ value: fcp, text: fcp }));
return [{ value: 'all', text: 'Alle FCPs' }, ...options];
});
const filteredFcpOptions = computed(() => {
if (!fcpSearchTerm.value) {
return fcpOptions.value;
}
const lowerCaseSearch = fcpSearchTerm.value.toLowerCase();
return fcpOptions.value.filter(option =>
option.text.toLowerCase().includes(lowerCaseSearch)
);
});
const selectedFcpText = computed(() => {
const selectedOption = fcpOptions.value.find(opt => opt.value === selectedFcp.value);
return selectedOption ? selectedOption.text : 'Alle FCPs';
});
const filteredWorkorders = computed(() => {
let filtered = workorders.value;
if (selectedFcp.value !== 'all') {
filtered = filtered.filter(wo => wo.rimo_fcp_name === selectedFcp.value);
}
if (searchTerm.value.length > 2) {
const lowerSearch = searchTerm.value.toLowerCase();
filtered = filtered.filter(wo =>
wo.id.toString().includes(lowerSearch) ||
(wo.customerName && wo.customerName.toLowerCase().includes(lowerSearch)) ||
(wo.street && wo.street.toLowerCase().includes(lowerSearch)) ||
(wo.city && wo.city.toLowerCase().includes(lowerSearch)) ||
(wo.oaid && wo.oaid.toLowerCase().includes(lowerSearch)) ||
(wo.rimo_fcp_name && wo.rimo_fcp_name.toLowerCase().includes(lowerSearch))
);
}
const getStatusRank = (status) => {
switch (status) {
case 'scheduled':
case 'civil_engineering_completed': return 0;
case 'assigned':
case 'new':
case 'problem_solved': return 1;
case 'intervention_required':
case 'correction_requested':
case 'civil_engineering_required': return 2;
case 'documented':
case 'completed': return 3;
case 'cancelled': return 4;
default: return 99;
}
};
return filtered.sort((a, b) => {
const rankA = getStatusRank(a.status);
const rankB = getStatusRank(b.status);
if (rankA !== rankB) return rankA - rankB;
if (rankA === 0) {
const dateA = a.appointmentDate || Infinity;
const dateB = b.appointmentDate || Infinity;
if (dateA === dateB) return (a.deadlineDate || Infinity) - (b.deadlineDate || Infinity);
return dateA - dateB;
}
return (a.deadlineDate || Infinity) - (b.deadlineDate || Infinity);
});
});
const googleMapsLink = computed(() => {
if (!selectedWorkorder.value) return '#';
const { street, hausnummer, plz, city } = selectedWorkorder.value;
const address = encodeURIComponent(`${street} ${hausnummer}, ${plz} ${city}`);
return `https://maps.google.com/maps?q=${address}`;
});
const checklist = computed(() => {
if (!tenantConfig.value?.documentationTypes || !Array.isArray(tenantConfig.value.documentationTypes)) return [];
return tenantConfig.value.documentationTypes.map(reqType => {
const isCompleted = documentation.docs.some(doc => doc.documentType === reqType.value);
return { ...reqType, completed: isCompleted };
});
});
const isChecklistComplete = computed(() => {
if (checklist.value.length === 0) return true;
return checklist.value.every(item => item.completed);
});
const translatedDocs = computed(() => {
if (!documentation.docs.length || !tenantConfig.value?.documentationTypes) {
return documentation.docs;
}
const typeMap = new Map(tenantConfig.value.documentationTypes.map(t => [t.value, t.text]));
return documentation.docs.map(doc => ({
...doc,
translatedName: typeMap.get(doc.documentType) || doc.documentType,
}));
});
const filteredJournals = computed(() => {
return documentation.journals.filter(j => !j.text.toLowerCase().includes('wurde zugewiesen.'));
});
// --- METHODS ---
const applyTheme = () => {
const isDark = localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
const metaThemeColor = document.querySelector('meta[name="theme-color"]');
if (metaThemeColor) {
metaThemeColor.setAttribute('content', isDark ? '#0f172a' : '#005384');
}
};
const setTheme = (newTheme) => {
if (!['light', 'dark', 'system'].includes(newTheme)) return;
theme.value = newTheme;
if (newTheme === 'system') {
localStorage.removeItem('theme');
} else {
localStorage.setItem('theme', newTheme);
}
applyTheme();
isSettingsOpen.value = false;
if (showThemePicker.value) showThemePicker.value = false;
};
const getStatusInfo = (status) => {
const statuses = {
'new': { text: 'Neu', color: 'bg-blue-500' }, 'assigned': { text: 'Zugewiesen', color: 'bg-sky-500' },
'scheduled': { text: 'Geplant', color: 'bg-amber-500' }, 'correction_requested': { text: 'Korrektur', color: 'bg-red-500' },
'intervention_required': { text: 'Eingriff', color: 'bg-red-700' }, 'civil_engineering_required': { text: 'Tiefbau', color: 'bg-orange-500' },
'civil_engineering_completed': { text: 'Tiefbau OK', color: 'bg-green-500' }, 'problem_solved': { text: 'Problem gelöst', color: 'bg-teal-500' },
'documented': { text: 'Dokumentiert', color: 'bg-indigo-500' }, 'completed': { text: 'Abgeschlossen', color: 'bg-slate-500' },
'cancelled': { text: 'Storniert', color: 'bg-gray-600' }, 'default': { text: 'Unbekannt', color: 'bg-gray-400' }
};
return statuses[status] || statuses.default;
};
const formatDate = (timestamp, format = 'DD.MM.YYYY') => {
if (!timestamp) return '';
return moment.unix(timestamp).format(format);
};
const fetchWorkorders = async () => {
isLoading.value = true;
try {
const response = await api.post(`/get`, { pagination: { page: 1, per_page: 500 } });
workorders.value = response.data.rows;
} catch (error) { console.error("Failed to fetch workorders:", error); }
finally { isLoading.value = false; }
};
const fetchDetails = async (workorderId) => {
isDetailsLoading.value = true;
try {
const [docRes, configRes] = await Promise.all([
api.get(`/getDocumentation?workorderId=${workorderId}`),
api.get(`/getTenantConfig?workorderId=${workorderId}`)
]);
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;
}
} catch (e) { console.error("Could not load details", e); }
finally { isDetailsLoading.value = false; }
};
const openDetails = (workorder) => {
selectedWorkorder.value = workorder;
isDetailsPanelOpen.value = true;
fetchDetails(workorder.id);
};
const closeDetails = () => {
isDetailsPanelOpen.value = false;
setTimeout(() => {
selectedWorkorder.value = null;
documentation.docs = []; documentation.journals = [];
tenantConfig.value = null; isEditingInfo.value = false;
}, 350);
};
const startEditInfo = () => {
tempAdditionalInfo.value = selectedWorkorder.value.additionalInfo || '';
isEditingInfo.value = true;
};
const saveAdditionalInfo = async () => {
const newInfo = tempAdditionalInfo.value;
try {
await api.post('/updateAdditionalInfo', { workorderId: selectedWorkorder.value.id, additionalInfo: newInfo });
selectedWorkorder.value.additionalInfo = newInfo;
const woInList = workorders.value.find(w => w.id === selectedWorkorder.value.id);
if(woInList) woInList.additionalInfo = newInfo;
await fetchDetails(selectedWorkorder.value.id); // to refresh journal
} catch(e) { console.error("Failed to save info", e); }
finally { isEditingInfo.value = false; }
};
const addJournalEntry = async () => {
if (!newJournalEntry.value.trim()) return;
try {
const response = await api.post('/addJournal', { workorderId: selectedWorkorder.value.id, text: newJournalEntry.value });
documentation.journals = response.data.journals;
newJournalEntry.value = '';
await nextTick(() => {
const journalContainer = document.querySelector('.journal-container');
if(journalContainer) journalContainer.scrollTop = journalContainer.scrollHeight;
});
} catch(e) { console.error("Failed to add journal entry", e); }
};
const handleFileSelect = (event) => {
if (!event.target.files.length) return;
uploadModal.files = event.target.files;
uploadModal.documentType = tenantConfig.value?.documentationTypes?.[0]?.value || 'general';
uploadModal.show = true;
};
const executeUpload = async () => {
if (!uploadModal.files) return;
isUploading.value = true;
const formData = new FormData();
formData.append('workorderId', selectedWorkorder.value.id);
formData.append('documentType', uploadModal.documentType);
for (let i = 0; i < uploadModal.files.length; i++) {
formData.append('files[]', uploadModal.files[i]);
}
try {
await api.post(`/uploadDocumentation`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
await fetchDetails(selectedWorkorder.value.id);
} catch (error) { console.error('Upload failed:', error); }
finally {
isUploading.value = false;
uploadModal.show = false;
uploadModal.files = null;
}
};
const submitProblem = async () => {
if (problemModal.selectedInterventions.length === 0) return;
let journalParts = [];
const sortedInterventions = [...problemModal.selectedInterventions].sort((a, b) => a.value.localeCompare(b.value));
for (const type of sortedInterventions) {
let text = type.text;
const needsDetail = type.text.includes('X') || type.text.toLowerCase().includes('sonstiges');
if (needsDetail) {
const detail = problemModal.details[type.value] || '';
if (!detail) {
alert(`Bitte geben Sie Details für "${type.text}" an.`);
return;
}
text = text.includes('X') ? text.replace('X', detail) : `${text}: ${detail}`;
}
journalParts.push(text);
}
const combinedText = journalParts.join('\n');
try {
await api.post('/requestIntervention', {
workorderId: selectedWorkorder.value.id,
journalText: combinedText
});
await fetchWorkorders();
closeDetails();
} catch(e) { console.error("Failed to report problem", e); }
finally { problemModal.show = false; problemModal.selectedInterventions = []; problemModal.details = {}; }
};
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);
missingTasksPopover.show = true;
setTimeout(() => missingTasksPopover.show = false, 4000);
}
};
const completeWorkorder = async () => {
try {
await api.post('/completeWorkorder', { workorderId: selectedWorkorder.value.id });
await fetchWorkorders();
closeDetails();
} catch(e) { console.error("Failed to complete workorder", e); }
};
const selectFcp = (fcpValue) => {
selectedFcp.value = fcpValue;
isFcpSelectOpen.value = false;
};
onMounted(() => {
fetchWorkorders();
isStandalone.value = window.matchMedia('(display-mode: standalone)').matches;
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
theme.value = savedTheme;
} else {
showThemePicker.value = true;
}
applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyTheme);
});
watch(isFcpSelectOpen, (isOpen) => {
if (isOpen) {
nextTick(() => {
fcpInputRef.value?.focus();
});
} else {
fcpSearchTerm.value = '';
}
});
return {
isLoading, isDetailsLoading, filteredWorkorders, searchTerm, isDetailsPanelOpen, selectedWorkorder, documentation, tenantConfig,
tempAdditionalInfo, isEditingInfo, newJournalEntry, uploadModal, problemModal, isUploading, isChecklistComplete,
checklist, fullscreenViewer, missingTasksPopover, translatedDocs, filteredJournals, installModal, isStandalone,
selectedFcp, isFcpSelectOpen, fcpOptions, selectedFcpText, fcpSearchTerm, filteredFcpOptions, fcpInputRef,
isSettingsOpen, theme, showThemePicker,
fetchWorkorders, openDetails, closeDetails, getStatusInfo, formatDate, googleMapsLink, startEditInfo, saveAdditionalInfo,
handleFileSelect, executeUpload, addJournalEntry, submitProblem, handleCompleteClick, selectFcp, setTheme
};
},
template: `
<div class="relative h-full w-full">
<transition name="overlay">
<div v-if="isDetailsPanelOpen || installModal.show || isFcpSelectOpen || isSettingsOpen" @click="closeDetails(); isFcpSelectOpen = false; isSettingsOpen = false;" class="overlay"></div>
</transition>
<div :class="{'panel-open': isDetailsPanelOpen}" class="list-container flex flex-col h-full bg-slate-100 dark:bg-slate-900 overflow-hidden transition-colors duration-300">
<header class="bg-white dark:bg-slate-800 shadow dark:shadow-md p-4 flex-shrink-0 z-10">
<div class="grid grid-cols-3 items-center">
<div class="justify-self-start">
<button @click="fetchWorkorders" class="p-2 rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 active:bg-slate-200 dark:active:bg-slate-600 focus:outline-none focus:ring-0">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M480-160q-134 0-227-93t-93-227q0-134 93-227t227-93q69 0 132 28.5T720-690v-110h80v280H520v-80h168q-32-56-87.5-88T480-720q-100 0-170 70t-70 170q0 100 70 170t170 70q77 0 139-44t87-116h84q-28 106-114 173t-196 67Z"/></svg>
</button>
</div>
<div class="justify-self-center">
<img src="/assets/images/xinon-full-transparent.png" alt="Logo" class="h-8 w-auto block dark:hidden">
<img src="/assets/images/xinon-full-transparent-white.png" alt="Logo" class="h-8 w-auto hidden dark:block">
</div>
<div class="justify-self-end">
<button @click="isSettingsOpen = true" class="p-2 rounded-full text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-700 active:bg-slate-200 dark:active:bg-slate-600 focus:outline-none focus:ring-0">
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5-38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>
</button>
</div>
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
<input type="text" v-model="searchTerm" placeholder="Suche..." inputmode="search" class="w-full p-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:placeholder-slate-300">
<button @click="isFcpSelectOpen = true" class="w-full p-3 border border-slate-300 rounded-lg bg-white dark:bg-slate-700 dark:border-slate-600 text-left flex justify-between items-center text-sm">
<span class="truncate pr-2 text-slate-800 dark:text-slate-100">{{ selectedFcpText }}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-slate-400 dark:text-slate-400 flex-shrink-0" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</header>
<main class="flex-grow overflow-y-auto p-2 pb-16">
<div v-if="isLoading" class="space-y-3 p-2 animate-pulse">
<div v-for="i in 4" :key="i" class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-md">
<div class="flex justify-between items-start">
<div class="flex-grow pr-2 min-w-0">
<div class="h-5 bg-slate-200 dark:bg-slate-700 rounded w-3/4 mb-1.5"></div>
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded w-full mb-2"></div>
<div class="space-y-1.5">
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-1/2"></div>
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded w-2/3"></div>
</div>
</div>
<div class="flex-shrink-0 ml-2 text-right space-y-1">
<div class="h-5 w-24 bg-slate-200 dark:bg-slate-700 rounded-full ml-auto"></div>
<div class="h-4 w-28 bg-slate-200 dark:bg-slate-700 rounded ml-auto"></div>
<div class="h-3 w-20 bg-slate-200 dark:bg-slate-700 rounded ml-auto"></div>
</div>
</div>
</div>
</div>
<div v-else-if="filteredWorkorders.length === 0" class="text-center p-10"><p class="text-slate-500 dark:text-slate-300">Keine Aufträge gefunden.</p></div>
<div v-else class="space-y-3">
<div v-for="wo in filteredWorkorders" :key="wo.id" @click="openDetails(wo)" class="bg-white dark:bg-slate-800 p-4 rounded-lg shadow-md dark:shadow-lg cursor-pointer transition active:scale-[0.98]">
<div class="flex justify-between items-start">
<div class="flex-grow pr-2 min-w-0">
<p class="font-bold text-slate-800 dark:text-slate-50 break-words"><span class="dark:text-secondary">#{{ wo.id }}</span> | {{ wo.customerName || 'N/A' }}</p>
<p class="text-sm text-slate-500 dark:text-slate-300 break-words">{{ wo.street }} {{ wo.hausnummer }}, {{ wo.plz }} {{ wo.city }}</p>
<div class="items-center text-xs text-slate-400 dark:text-slate-400 mt-1">
<span class="mr-2">OAID: {{ wo.oaid || 'N/A' }}</span><br>
<span class="truncate">FCP: {{ wo.rimo_fcp_name || 'N/A' }}</span>
</div>
</div>
<div class="flex-shrink-0 ml-2 text-right space-y-1">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white" :class="getStatusInfo(wo.status).color">{{ getStatusInfo(wo.status).text }}</span>
<p class="text-sm font-semibold text-slate-600 dark:text-slate-200">{{ formatDate(wo.appointmentDate, 'DD.MM HH:mm') }}</p>
<p class="text-xs text-red-500">Frist: {{ formatDate(wo.deadlineDate) }}</p>
</div>
</div>
</div>
</div>
</main>
</div>
<transition name="slide">
<div v-if="isDetailsPanelOpen && selectedWorkorder" class="fixed inset-0 bg-slate-50 dark:bg-slate-950 z-20 flex flex-col shadow-2xl">
<header class="bg-white dark:bg-slate-900 p-4 flex justify-between items-center border-b border-slate-200 dark:border-slate-800 flex-shrink-0">
<div class="flex items-center min-w-0">
<div class="h-6 w-auto mr-4">
<img src="/assets/images/xinon-full-transparent.png" alt="Logo" class="h-6 w-auto block dark:hidden">
<img src="/assets/images/xinon-full-transparent-white.png" alt="Logo" class="h-6 w-auto hidden dark:block">
</div>
<h2 class="text-xl font-bold text-primary dark:text-secondary truncate">Auftrag #{{ selectedWorkorder.id }}</h2>
</div>
<button @click="closeDetails" class="p-2 rounded-full hover:bg-slate-200 dark:hover:bg-slate-700 flex-shrink-0"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-slate-600 dark:text-slate-200" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
</header>
<div class="overflow-y-auto p-4 flex-grow space-y-4">
<div class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800 space-y-3 text-sm">
<div class="flex items-center text-base font-bold text-slate-800 dark:text-slate-50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-slate-500 dark:text-slate-300" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd" /></svg>
<span>{{ selectedWorkorder.customerCompany || selectedWorkorder.customerName }}</span>
</div>
<a :href="googleMapsLink" target="_blank" class="flex items-center text-primary dark:text-secondary hover:underline">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clip-rule="evenodd" /></svg>
<span>{{ selectedWorkorder.street }} {{ selectedWorkorder.hausnummer }}, {{ selectedWorkorder.plz }} {{ selectedWorkorder.city }}</span>
</a>
<div class="border-t border-slate-200 dark:border-slate-800 pt-3 mt-3 grid grid-cols-2 gap-2 text-sm">
<div>
<p class="text-xs text-slate-500 dark:text-slate-300 font-semibold">OAID</p>
<p class="text-slate-800 dark:text-slate-100">{{ selectedWorkorder.oaid || 'N/A' }}</p>
</div>
<div>
<p class="text-xs text-slate-500 dark:text-slate-300 font-semibold">FCP</p>
<p class="text-slate-800 dark:text-slate-100">{{ selectedWorkorder.rimo_fcp_name || 'N/A' }}</p>
</div>
</div>
<div class="border-t border-slate-200 dark:border-slate-800 pt-3 space-y-2">
<a :href="'mailto:' + selectedWorkorder.email" class="flex items-center text-primary dark:text-secondary hover:underline"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" /><path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" /></svg><span>{{ selectedWorkorder.email }}</span></a>
<a :href="'tel:' + selectedWorkorder.phone" class="flex items-center text-primary dark:text-secondary hover:underline"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path d="M2 3a1 1 0 011-1h2.153a1 1 0 01.986.836l.74 4.435a1 1 0 01-.54 1.06l-1.548.773a11.037 11.037 0 006.105 6.105l.774-1.548a1 1 0 011.059-.54l4.435.74a1 1 0 01.836.986V17a1 1 0 01-1 1h-2C7.82 18 2 12.18 2 5V3z" /></svg><span>{{ selectedWorkorder.phone }}</span></a>
</div>
</div>
<div class="bg-white dark:bg-slate-900 p-4 rounded-lg border border-slate-200 dark:border-slate-800">
<div class="flex justify-between items-center mb-2">
<h3 class="font-bold text-slate-700 dark:text-secondary">Notiz</h3>
<button v-if="!isEditingInfo" @click="startEditInfo" class="flex items-center text-sm font-medium text-primary dark:text-primary bg-slate-100 hover:bg-slate-200 dark:bg-secondary dark:hover:bg-yellow-400 px-3 py-1.5 rounded-md">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-1" viewBox="0 0 20 20" fill="currentColor"><path d="M17.414 2.586a2 2 0 00-2.828 0L7 10.172V13h2.828l7.586-7.586a2 2 0 000-2.828z" /><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4a1 1 0 010 2H4v10h10v-4a1 1 0 112 0v4a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" clip-rule="evenodd" /></svg> Bearbeiten
</button>
</div>
<div v-if="isEditingInfo">
<textarea v-model="tempAdditionalInfo" class="w-full p-2 border rounded-md dark:bg-slate-800 dark:border-slate-700 dark:text-white" rows="4"></textarea>
<div class="flex justify-end space-x-2 mt-2">
<button @click="isEditingInfo = false" class="px-3 py-1.5 bg-slate-200 dark:bg-slate-600 dark:text-slate-100 rounded-md text-sm font-medium">Abbrechen</button>
<button @click="saveAdditionalInfo" class="px-3 py-1.5 bg-secondary text-primary font-bold rounded-md text-sm">Speichern</button>
</div>
</div>
<p v-else class="text-sm whitespace-pre-wrap text-slate-800 dark:text-slate-200">{{ selectedWorkorder.additionalInfo || 'Keine Notiz.' }}</p>
</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">
<div v-for="i in 4" :key="i" class="flex items-center">
<div class="h-5 w-5 rounded-full bg-slate-200 dark:bg-slate-700 mr-2"></div>
<div class="h-4 w-3/4 rounded bg-slate-200 dark:bg-slate-700"></div>
</div>
</div>
<div v-else>
<ul v-if="checklist.length > 0" class="space-y-2">
<li v-for="item in checklist" :key="item.value" class="flex items-center text-sm">
<svg v-if="item.completed" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-green-500" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" /></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2 text-slate-400 dark:text-slate-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" /></svg>
<span :class="{'text-slate-500 dark:text-slate-300 line-through': item.completed, 'text-slate-800 dark:text-slate-100': !item.completed}">{{ item.text }}</span>
</li>
</ul>
<p v-else class="text-sm text-slate-500 dark:text-slate-300">Keine Checklisten-Einträge vorhanden.</p>
</div>
</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-2">Dokumentation</h3>
<label for="file-upload" class="w-full inline-flex items-center justify-center px-4 py-2 border border-dashed border-slate-300 dark:border-slate-700 text-sm font-medium rounded-md text-slate-700 dark:text-slate-200 bg-slate-50 dark:bg-slate-800 hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
<span>Foto/Dokument hinzufügen</span>
</label>
<input id="file-upload" type="file" class="hidden" @change="handleFileSelect" multiple accept="image/*,application/pdf">
<div v-if="translatedDocs.length > 0" class="grid grid-cols-3 sm:grid-cols-4 gap-2 mt-4">
<div v-for="doc in translatedDocs" :key="doc.id" @click="fullscreenViewer.show = true; fullscreenViewer.item = doc" class="relative aspect-square bg-slate-100 dark:bg-slate-800 rounded-md overflow-hidden cursor-pointer group">
<template v-if="doc.isPdf">
<div class="h-full w-full flex items-center justify-center bg-red-50 dark:bg-red-900/20 p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-500 dark:text-red-400" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4 2a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2H4zm3 4a1 1 0 000 2h6a1 1 0 100-2H7zm0 4a1 1 0 100 2h6a1 1 0 100-2H7zm0 4a1 1 0 100 2h4a1 1 0 100-2H7z" clip-rule="evenodd" /></svg>
</div>
</template>
<template v-else>
<img :src="'/File/show?id=' + doc.fileId + '&size=small'" class="h-full w-full object-cover">
</template>
<div class="absolute inset-x-0 bottom-0 p-1 bg-black bg-opacity-50">
<p class="text-white text-xs truncate">{{ doc.translatedName }}</p>
</div>
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-40 transition flex items-center justify-center"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-white opacity-0 group-hover:opacity-100 transition" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /></svg></div>
</div>
</div>
</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-4">Journal</h3>
<div v-if="isDetailsLoading" class="animate-pulse">
<div class="flex items-start">
<div class="flex-shrink-0 bg-slate-200 dark:bg-slate-700 h-8 w-8 rounded-full mr-3"></div>
<div class="flex-grow space-y-2">
<div class="h-4 w-full rounded bg-slate-200 dark:bg-slate-700"></div>
<div class="h-3 w-1/2 rounded bg-slate-200 dark:bg-slate-700"></div>
</div>
</div>
</div>
<div v-else class="space-y-4 max-h-60 overflow-y-auto pr-2 journal-container">
<div v-if="filteredJournals.length === 0"><p class="text-sm text-slate-500 dark:text-slate-300">Keine Einträge.</p></div>
<div v-for="entry in filteredJournals" :key="entry.id" class="flex items-start">
<div class="flex-shrink-0 bg-secondary h-8 w-8 rounded-full flex items-center justify-center mr-3"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" /></svg></div>
<div class="flex-grow">
<p class="text-sm whitespace-pre-wrap text-slate-800 dark:text-slate-100">{{ entry.text }}</p>
<p class="text-xs text-slate-400 dark:text-slate-400 mt-1">{{ entry.createByName }} - {{ formatDate(entry.create, 'DD.MM.YY HH:mm') }}</p>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-slate-200 dark:border-slate-800">
<textarea v-model="newJournalEntry" placeholder="Neuer Eintrag..." class="w-full p-2 border rounded-md dark:bg-slate-800 dark:border-slate-700 dark:text-white" rows="3"></textarea>
<button @click="addJournalEntry" class="mt-2 w-full px-4 py-2 bg-secondary text-primary font-bold rounded-md text-sm">Senden</button>
</div>
</div>
</div>
<footer class="bg-white dark:bg-slate-900 p-2 border-t border-slate-200 dark:border-slate-800 flex-shrink-0 grid grid-cols-2 gap-2 pt-2 px-2 pb-[calc(0.5rem+env(safe-area-inset-bottom))]">
<button @click="problemModal.show = true" class="w-full px-4 py-3 bg-red-600 text-white font-bold rounded-md text-center">Problem melden</button>
<div class="relative">
<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>
<ul class="list-disc list-inside space-y-1">
<li v-for="task in missingTasksPopover.tasks" :key="task">{{ task }}</li>
</ul>
<div class="absolute bottom-[-5px] right-[calc(6rem-8px)] w-0 h-0 border-x-8 border-x-transparent border-t-8 border-t-red-700"></div>
</div>
</transition>
</div>
</footer>
</div>
</transition>
<transition name="fade">
<div v-if="isFcpSelectOpen" class="fixed inset-0 z-30 flex items-start justify-center p-4 pt-20" @click.self="isFcpSelectOpen = false">
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 w-full max-w-sm flex flex-col max-h-[80vh] text-slate-800 dark:text-slate-100">
<div class="flex justify-between items-center mb-2 flex-shrink-0">
<h3 class="font-bold text-lg">FCP auswählen</h3>
<button @click="isFcpSelectOpen = false" class="flex items-center justify-center h-7 w-7 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-xl">×</button>
</div>
<div class="relative mb-2 flex-shrink-0">
<input type="text" v-model="fcpSearchTerm" ref="fcpInputRef" inputmode="search" placeholder="FCP suchen..." class="w-full p-2 pl-8 border border-slate-300 rounded-md dark:bg-slate-700 dark:border-slate-600">
<svg class="absolute left-2 top-1/2 -translate-y-1/2 h-5 w-5 text-slate-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z" clip-rule="evenodd" />
</svg>
</div>
<ul class="flex-grow overflow-y-auto -mr-2 pr-2">
<li v-for="option in filteredFcpOptions" :key="option.value" @click="selectFcp(option.value)"
class="flex justify-between items-center p-3 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer text-sm font-medium"
:class="{'bg-primary/10 text-primary dark:bg-secondary/20 dark:text-secondary': selectedFcp === option.value}">
<span>{{ option.text }}</span>
<svg v-if="selectedFcp === option.value" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary dark:text-secondary" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</li>
<li v-if="filteredFcpOptions.length === 0" class="text-sm text-slate-500 dark:text-slate-300 p-3">Kein FCP gefunden.</li>
</ul>
</div>
</div>
</transition>
<div v-if="uploadModal.show" class="fixed inset-0 bg-black bg-opacity-50 z-30 flex items-start justify-center p-4 pt-20" @click.self="uploadModal.show = false">
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 w-full max-w-sm flex flex-col max-h-[80vh] text-slate-800 dark:text-slate-100" @click.stop>
<div class="flex justify-between items-center mb-4 flex-shrink-0">
<h3 class="font-bold text-lg">Dokumenttyp wählen</h3>
<button @click="uploadModal.show = false" class="flex items-center justify-center h-7 w-7 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-xl">×</button>
</div>
<ul class="flex-grow overflow-y-auto -mr-2 pr-2 space-y-1 mb-4">
<li v-for="type in tenantConfig.documentationTypes" :key="type.value" @click="uploadModal.documentType = type.value"
class="flex justify-between items-center p-3 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 cursor-pointer text-sm font-medium"
:class="{'bg-secondary/20 text-secondary': uploadModal.documentType === type.value}">
<span>{{ type.text }}</span>
<svg v-if="uploadModal.documentType === type.value" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-secondary" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</li>
<li v-if="!tenantConfig.documentationTypes || tenantConfig.documentationTypes.length === 0">
<p class="text-sm text-slate-500 dark:text-slate-300 p-3">Keine Dokumenttypen konfiguriert.</p>
</li>
</ul>
<div class="flex justify-end space-x-2 mt-auto flex-shrink-0 border-t border-slate-200 dark:border-slate-700 pt-3">
<button @click="uploadModal.show = false" class="px-4 py-2 bg-slate-200 dark:bg-slate-600 dark:text-slate-100 rounded-md text-sm font-medium">Abbrechen</button>
<button @click="executeUpload" :disabled="isUploading" class="px-4 py-2 bg-primary text-white rounded-md disabled:bg-slate-400 text-sm font-medium">{{ isUploading ? 'Lade...' : 'Hochladen' }}</button>
</div>
</div>
</div>
<div v-if="problemModal.show" class="fixed inset-0 bg-black bg-opacity-50 z-30 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-sm flex flex-col max-h-[80vh] text-slate-800 dark:text-slate-100">
<h3 class="font-bold text-lg mb-4 flex-shrink-0">Problem melden</h3>
<div class="flex-grow overflow-y-auto pr-2 space-y-2 mb-4">
<div v-for="type in tenantConfig.interventionTypes" :key="type.value">
<label class="flex items-center p-3 border border-slate-200 dark:border-slate-700 rounded-lg hover:bg-slate-50 dark:hover:bg-slate-700/50 transition cursor-pointer">
<input type="checkbox" :value="type" v-model="problemModal.selectedInterventions" class="h-5 w-5 rounded text-primary focus:ring-primary focus:ring-2 focus:ring-offset-1">
<span class="ml-3 text-sm font-medium">{{ type.text.replace('X', '...') }}</span>
</label>
<input v-if="problemModal.selectedInterventions.some(i => i.value === type.value) && (type.text.includes('X') || type.text.toLowerCase().includes('sonstiges'))"
v-model="problemModal.details[type.value]"
type="text" class="w-full p-2 border rounded-md mt-1 text-sm focus:ring-primary focus:border-primary dark:bg-slate-700 dark:border-slate-600" placeholder="Details hier eingeben...">
</div>
</div>
<div class="flex justify-end space-x-2 mt-auto flex-shrink-0">
<button @click="problemModal.show = false; problemModal.selectedInterventions = []; problemModal.details = {}" class="px-4 py-2 bg-slate-200 dark:bg-slate-600 dark:text-slate-100 rounded-md">Abbrechen</button>
<button @click="submitProblem" class="px-4 py-2 bg-red-600 text-white rounded-md">Senden</button>
</div>
</div>
</div>
<transition name="fade">
<div v-if="isSettingsOpen" class="fixed inset-0 z-30 flex items-start justify-center p-4 pt-20" @click.self="isSettingsOpen = false">
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-xl w-full max-w-sm flex flex-col text-slate-800 dark:text-slate-100">
<div class="p-4 border-b border-slate-200 dark:border-slate-700 flex justify-between items-center">
<h3 class="font-bold text-lg">Einstellungen</h3>
<button @click="isSettingsOpen = false" class="flex items-center justify-center h-7 w-7 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700 text-xl">×</button>
</div>
<div class="p-4 space-y-4">
<div>
<h4 class="text-sm font-semibold mb-2 text-slate-600 dark:text-slate-300">Farbschema</h4>
<div class="grid grid-cols-3 gap-2">
<button @click="setTheme('light')" :class="{'bg-primary text-white': theme === 'light'}" class="p-2 text-sm font-medium rounded-md border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700">Hell</button>
<button @click="setTheme('dark')" :class="{'bg-primary text-white': theme === 'dark'}" class="p-2 text-sm font-medium rounded-md border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700">Dunkel</button>
<button @click="setTheme('system')" :class="{'bg-primary text-white': theme === 'system'}" class="p-2 text-sm font-medium rounded-md border border-slate-300 dark:border-slate-600 hover:bg-slate-100 dark:hover:bg-slate-700">System</button>
</div>
</div>
<div v-if="!isStandalone">
<h4 class="text-sm font-semibold mb-2 text-slate-600 dark:text-slate-300">App</h4>
<button @click="installModal.show = true; isSettingsOpen = false" class="w-full text-left p-3 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 text-sm font-medium">App installieren</button>
</div>
</div>
<div class="p-4 border-t border-slate-200 dark:border-slate-700">
<a href="https://thetool.xinon.at/WorkorderCompany/logout" class="w-full text-left p-3 rounded-md hover:bg-slate-100 dark:hover:bg-slate-700 text-sm font-medium flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clip-rule="evenodd" /></svg>
Logout
</a>
</div>
<footer class="p-4 mt-2 text-center text-xs text-slate-500 dark:text-slate-300 space-y-2">
<img src="/assets/images/xinon-sm.png" class="h-10 mx-auto" alt="XINON Logo">
<p>
powered by XINON GmbH<br>
<a href="https://xinon.at/impressum/" target="_blank" class="hover:underline">Impressum</a>
</p>
</footer>
</div>
</div>
</transition>
<transition name="fade">
<div v-if="showThemePicker" class="fixed inset-0 bg-black bg-opacity-60 z-40 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-xs text-center shadow-2xl">
<h3 class="font-bold text-lg mb-2 dark:text-white">Willkommen!</h3>
<p class="text-sm text-slate-600 dark:text-slate-200 mb-6">Wähle dein bevorzugtes Farbschema.</p>
<div class="flex flex-col space-y-3">
<button @click="setTheme('light')" class="w-full px-4 py-3 bg-slate-200 text-slate-800 font-bold rounded-md">Hell</button>
<button @click="setTheme('dark')" class="w-full px-4 py-3 bg-slate-700 text-white font-bold rounded-md">Dunkel</button>
<button @click="setTheme('system')" class="w-full mt-2 text-sm text-slate-500 dark:text-slate-300 hover:underline">Systemstandard</button>
</div>
</div>
</div>
</transition>
<transition name="fade">
<div v-if="installModal.show" class="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 w-full max-w-md max-h-[80vh] flex flex-col">
<div class="flex justify-between items-center mb-4 flex-shrink-0 dark:text-white">
<h3 class="font-bold text-lg">App installieren</h3>
<button @click="installModal.show = false" class="p-1 rounded-full hover:bg-slate-100 dark:hover:bg-slate-700">×</button>
</div>
<div class="overflow-y-auto text-sm text-slate-700 dark:text-slate-200 space-y-6">
<div>
<h4 class="font-bold text-base mb-2 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>
iPhone & iPad (mit Safari)
</h4>
<ol class="list-decimal list-inside space-y-1 pl-2">
<li>Öffnen Sie diese Webseite im <strong>Safari</strong>-Browser.</li>
<li>Tippen Sie auf das "Teilen"-Symbol (das Quadrat mit dem Pfeil nach oben).</li>
<li>Scrollen Sie nach unten und wählen Sie <strong>"Zum Home-Bildschirm"</strong>.</li>
<li>Bestätigen Sie mit "Hinzufügen". Die App erscheint nun auf Ihrem Startbildschirm.</li>
</ol>
</div>
<div>
<h4 class="font-bold text-base mb-2 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2h14a2 2 0 002-2V6zM3.5 9h17M3.5 15h17"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.5v15"></path></svg>
Android (mit Chrome)
</h4>
<ol class="list-decimal list-inside space-y-1 pl-2">
<li>Öffnen Sie diese Webseite im <strong>Chrome</strong>-Browser.</li>
<li>Tippen Sie auf die drei Punkte oben rechts, um das Menü zu öffnen.</li>
<li>Wählen Sie <strong>"App installieren"</strong> oder <strong>"Zum Startbildschirm hinzufügen"</strong>.</li>
<li>Bestätigen Sie die Installation. Die App erscheint nun auf Ihrem Startbildschirm.</li>
</ol>
</div>
</div>
<div class="mt-6 text-right flex-shrink-0">
<button @click="installModal.show = false" class="px-4 py-2 bg-primary text-white rounded-md">Verstanden</button>
</div>
</div>
</div>
</transition>
<div v-if="fullscreenViewer.show" @click="fullscreenViewer.show = false" class="fixed inset-0 bg-black bg-opacity-90 z-50 flex items-center justify-center p-2">
<button @click="fullscreenViewer.show = false" class="absolute top-2 right-2 p-2 bg-white/20 rounded-full text-white"><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
<template v-if="fullscreenViewer.item.isPdf">
<iframe :src="'/File/show?id=' + fullscreenViewer.item.fileId" class="w-full h-full border-0"></iframe>
</template>
<template v-else>
<img :src="'/File/show?id=' + fullscreenViewer.item.fileId" class="max-w-full max-h-full object-contain">
</template>
</div>
</div>
`
});
app.mount('#app');
</script>
<script src="/js/pages/WorkorderBase/WorkorderServiceWorker.js"></script>
</body>
</html>

View File

@@ -23,6 +23,7 @@ $texts = [
'amount' => 'Menge',
'unit' => 'Einheit',
'unitPrice' => 'Einzelpreis',
'discount' => 'Rabatt',
'totalPrice' => 'Gesamtpreis'
],
'summary' => [
@@ -32,6 +33,7 @@ $texts = [
'total' => 'Gesamtbetrag',
'alternativeTotal' => 'Summe Alternativpositionen'
],
'purpose' => 'Zweck / Projekt',
'alternativeHeader' => 'Alternativpositionen',
'notes' => 'Anmerkungen & Konditionen',
'defaultOfferText' => 'Vielen Dank für Ihre Anfrage. Es gelten unsere Allgemeinen Geschäftsbedingungen.',
@@ -70,11 +72,11 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
<style>
body { font-family: "Open Sans", sans-serif, Verdana; font-size: 10px; color: #333; }
h1 { text-align: center; color: #005384; font-size: 18px; margin-bottom: 20px; }
.header-info table { width: 100%; border-collapse: collapse; font-size: 11px; margin-bottom: 20px; }
.header-info table { width: 100%; border-collapse: collapse; font-size: 11px; margin-bottom: 12px; }
.header-info td { padding: 2px 5px; }
.header-info .label { font-weight: bold; text-align: right; padding-right: 10px; width: 120px; }
#positionsTable { width: 100%; border-collapse: collapse; margin-top: 15px; margin-bottom: 15px; }
#positionsTable { width: 100%; border-collapse: collapse; margin-top: 8px; margin-bottom: 15px; }
#positionsTable th { border-bottom: 2px solid #005384; padding: 8px 4px; text-align: left; background-color: #f2f2f2; font-size: 10px; }
#positionsTable td { border-bottom: 1px solid #e1e1e1; padding: 6px 4px; vertical-align: top; }
@@ -82,6 +84,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
#positionsTable th.amount, #positionsTable td.amount { text-align: right; width: 50px;}
#positionsTable th.unit, #positionsTable td.unit { text-align: center; width: 40px;}
#positionsTable th.price, #positionsTable td.price { text-align: right; width: 80px;}
#positionsTable th.discount, #positionsTable td.discount { text-align: right; width: 50px;}
#positionsTable th.total, #positionsTable td.total { text-align: right; width: 90px;}
.position-group-header td { background-color: #e8f0f8; font-weight: bold; border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; padding: 4px; }
@@ -123,6 +126,12 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
<td class="label"><?= $text['validUntilLabel'] ?></td>
<td><?= $formattedValidUntil ?></td>
</tr>
<?php if (!empty($offer->purpose)): ?>
<tr>
<td class="label" style="text-align: left; vertical-align: top; padding-top: 12px;"><?= $text['purpose'] ?></td>
<td colspan="3" style="padding-top: 12px;"><?= nl2br(htmlspecialchars($offer->purpose)) ?></td>
</tr>
<?php endif; ?>
</table>
</div>
@@ -134,6 +143,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
<th class="amount"><?= $text['table']['amount'] ?></th>
<th class="unit"><?= $text['table']['unit'] ?></th>
<th class="price"><?= $text['table']['unitPrice'] ?></th>
<th class="discount"><?= $text['table']['discount'] ?></th>
<th class="total"><?= $text['table']['totalPrice'] ?></th>
</tr>
</thead>
@@ -144,7 +154,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
$isAlternativeGroup = ($groupName === 'Alternativpositionen');
if (!empty($groupName)): ?>
<tr class="position-group-header <?= $isAlternativeGroup ? 'alternative-group-header' : '' ?>">
<td colspan="6"><?= htmlspecialchars($isAlternativeGroup ? $text['alternativeHeader'] : $groupName) ?></td>
<td colspan="7"><?= htmlspecialchars($isAlternativeGroup ? $text['alternativeHeader'] : $groupName) ?></td>
</tr>
<?php endif;
@@ -163,6 +173,7 @@ $formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
<td class="amount"><?= number_format($p['amount'], 2, ',', '.') ?></td>
<td class="unit"><?= htmlspecialchars($p['articleUnit']) ?></td>
<td class="price"><?= formatPrice($p['price'], '€') ?></td>
<td class="price"><?= htmlspecialchars($p['discount'] . '%') ?></td>
<td class="total"><?= formatPrice($p['totalPrice'], '€') ?></td>
</tr>
<?php endforeach; ?>

View File

@@ -141,8 +141,8 @@
<?php if($me->is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanPipe")?>"><i class="fas fa-pipe text-info pl-1"></i> Rohrverzeichnis</a></li><?php endif; ?>
<?php if($me->is(["Admin","netowner","lineplanner","lineworker"]) && $me->is("employee")): ?><li><a href="<?=self::getUrl("FiberPlanCable")?>"><i class="fa-solid fa-timeline text-info "></i> Kabelverzeichnis</a></li><?php endif; ?>
<!-- add a new line Arbeitsaufträge for RMLCompany, add a new line Arbeitsaufträge-Management for RMLAdmin -->
<?php if($me->can("RMLCompany")): ?><li><a href="<?=self::getUrl("RMLWorkorderCompany")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge</a></li><?php endif; ?>
<?php if($me->can("RMLAdmin")): ?><li><a href="<?=self::getUrl("RMLWorkorderAdmin")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge-Management</a></li><?php endif; ?>
<?php if($me->can("RMLCompany")): ?><li><a href="<?=self::getUrl("WorkorderCompany")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge</a></li><?php endif; ?>
<?php if($me->can("RMLAdmin")): ?><li><a href="<?=self::getUrl("WorkorderAdmin")?>"><i class="far fa-fw fa-clipboard-question text-info"></i> Arbeitsaufträge-Management</a></li><?php endif; ?>
</ul>
</li>
<?php endif; ?>

View File

@@ -533,6 +533,35 @@ class ADBHausnummerModel {
$where .= ")";
}
if (array_key_exists("rimo_fcp_name", $filter)) {
if (is_array($filter['rimo_fcp_name'])) {
$escapedNames = array_map(function($name) {
return "'" . FronkDB::singleton()->escape($name) . "'";
}, $filter['rimo_fcp_name']);
$where .= " AND Hausnummer.rimo_fcp_name IN (" . implode(", ", $escapedNames) . ")";
} else {
$rimo_fcp_name = FronkDB::singleton()->escape($filter['rimo_fcp_name']);
if ($rimo_fcp_name) {
$where .= " AND Hausnummer.rimo_fcp_name = '$rimo_fcp_name'";
}
}
}
if (array_key_exists("fcp_id", $filter)) {
if (is_array($filter['fcp_id'])) {
$escapedIds = array_map(function($id) {
return "'" . FronkDB::singleton()->escape($id) . "'";
}, $filter['fcp_id']);
$where .= " AND Hausnummer.fcp_id IN (" . implode(", ", $escapedIds) . ")";
} else {
$fcp_id = FronkDB::singleton()->escape($filter['fcp_id']);
if ($fcp_id) {
$where .= " AND Hausnummer.fcp_id = '$fcp_id'";
}
}
}
return $where;
}

View File

@@ -1,6 +1,7 @@
<?php
class ADBRimoFcp extends TTCrudBaseModel {
class ADBRimoFcp extends TTCrudBaseModel
{
public int $id;
public int $netzgebiet_id;
public ?string $name;
@@ -13,4 +14,67 @@ class ADBRimoFcp extends TTCrudBaseModel {
public ?float $gps_long;
public int $create;
public int $edit;
public static function getRimoFcpStatistics(): array {
$db = self::getDB();
$fronkDbName = defined('FRONKDB_DBNAME') ? FRONKDB_DBNAME : 'thetool';
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$sql = "
-- Use a Common Table Expression (CTE) to pre-calculate counts for each combination of FCP and rimo_type.
WITH RimoTypeCounts AS (
SELECT
hn.fcp_id,
-- Group NULL rimo_types into an 'UNKNOWN' category for clarity.
COALESCE(hn.rimo_type, 'UNKNOWN') AS rimo_type,
COUNT(DISTINCT hn.id) AS hausnummer_count,
COUNT(DISTINCT we.id) AS wohneinheit_count,
COUNT(DISTINCT CASE WHEN ps.code < 899 THEN p.id ELSE NULL END) AS preorder_count
FROM
`{$addressDbName}`.`Hausnummer` AS hn
LEFT JOIN
`{$addressDbName}`.`Wohneinheit` AS we ON hn.id = we.hausnummer_id
LEFT JOIN
`{$fronkDbName}`.`Preorder` AS p ON hn.id = p.adb_hausnummer_id
LEFT JOIN
`{$fronkDbName}`.`Preorderstatus` AS ps ON p.status_id = ps.id
WHERE
hn.fcp_id IS NOT NULL
GROUP BY
hn.fcp_id,
COALESCE(hn.rimo_type, 'UNKNOWN')
)
-- Final SELECT statement to assemble the data for each FCP.
SELECT
fcp.id AS fcp_id,
fcp.name AS fcp_name,
fcp.rimo_id AS fcp_rimo_id,
-- Aggregate total counts for the entire FCP.
SUM(rtc.hausnummer_count) AS total_hausnummer_count,
SUM(rtc.wohneinheit_count) AS total_wohneinheit_count,
SUM(rtc.preorder_count) AS total_active_preorders,
-- Create a single JSON object from all the rimo_type groups for the current FCP.
JSON_OBJECTAGG(
rtc.rimo_type,
JSON_OBJECT(
'hausnummer_count', rtc.hausnummer_count,
'wohneinheit_count', rtc.wohneinheit_count,
'preorder_count', rtc.preorder_count
)
) AS counts_by_rimo_type
FROM
`{$addressDbName}`.`RimoFcp` AS fcp
LEFT JOIN
RimoTypeCounts AS rtc ON fcp.id = rtc.fcp_id
WHERE
rtc.fcp_id IS NOT NULL
GROUP BY
fcp.id, fcp.name, fcp.rimo_id
ORDER BY
fcp.name;
";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
}

View File

@@ -136,14 +136,10 @@ class ADBRimoFcpController extends TTCrud {
public function getAllFCPsAction() {
$input = json_decode(file_get_contents('php://input'), true);
$fcpList = ADBRimoFcp::getAll();
$fcpData = array_map(function ($fcp) {
return [
'id' => $fcp->id,
// 'rimo_ex_state' => $fcp->rimo_ex_state,
// 'rimo_op_state' => $fcp->rimo_op_state,
'gps_lat' => $fcp->gps_lat,
'gps_long' => $fcp->gps_long
];
@@ -151,4 +147,20 @@ class ADBRimoFcpController extends TTCrud {
self::returnJson(['success' => true, 'data' => $fcpData]);
}
public function getRimoFcpStatsAction() {
$stats = ADBRimoFcp::getRimoFcpStatistics();
if (!empty($this->postData->fcp_ids)) {
$fcpIds = (array) $this->postData->fcp_ids;
$stats = array_filter($stats, fn($item) => in_array($item['fcp_id'], $fcpIds));
}
foreach ($stats as &$item)
if (isset($item['counts_by_rimo_type']) && is_string($item['counts_by_rimo_type']))
$item['counts_by_rimo_type'] = json_decode($item['counts_by_rimo_type']);
unset($item);
self::returnJson(array_values($stats));
}
}

View File

@@ -185,20 +185,29 @@ class ADBWohneinheitController extends mfBaseController {
}
protected function duplicateAction() {
protected function duplicateAction()
{
if (!$this->me->is("Admin") && !$this->me->can("ADBExtended")) {
$this->redirect("Dashboard");
}
$address_id = $this->me->is("Admin") ? null : $this->me->address->id;
$isAdmin = $this->me->is("Admin");
$duplicateHomes = array_merge(
ADBWohneinheitModel::searchDuplicateExtref([], $address_id),
ADBWohneinheitModel::searchDuplicateOAID([], $address_id),
ADBWohneinheitModel::getRimoDeletedHomes([], $address_id),
($this->me->is("Admin") || $address_id === "4807") ? ADBWohneinheitModel::getUnscheduledOrderHomes([], 4807) : []
ADBWohneinheitModel::getRimoDeletedHomes([], $address_id)
);
if ($isAdmin || $address_id === "4807") {
$duplicateHomes = array_merge($duplicateHomes, ADBWohneinheitModel::getUnscheduledOrderHomes([], 4807));
}
if ($isAdmin) {
$duplicateHomes = array_merge($duplicateHomes, ADBWohneinheitModel::getGreenfieldWithActivePreorders([], $address_id));
}
$getUniqueValues = fn($key) => array_values(array_unique(array_filter(array_column($duplicateHomes, $key))));
$networkOwners = array_map(
@@ -217,10 +226,10 @@ class ADBWohneinheitController extends mfBaseController {
"BASE_URL" => self::getUrl(""),
"DASHBOARD_URL" => self::getUrl("Dashboard"),
"MFAPPNAME" => MFAPPNAME_SLUG,
"PAGE_TITLE" => "Doppelte Homes",
"PAGE_TITLE" => "Datenqualitäts-Checks",
"PATH" => [
["text" => MFAPPNAME_SLUG, "href" => self::getUrl("Dashboard")],
["text" => "Doppelte Homes", "href" => self::getUrl("ADBWohneinheit", "duplicate")],
["text" => "Datenqualitäts-Checks", "href" => self::getUrl("ADBWohneinheit", "duplicate")],
],
"DUPLICATE_HOMES" => $duplicateHomes,
"ADB_NETZGEBIETE" => $networks,
@@ -228,4 +237,50 @@ class ADBWohneinheitController extends mfBaseController {
"IS_ADMIN" => $isAdmin,
]);
}
}
protected function getContactsAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['id'])) {
self::returnJson(['success' => false, 'message' => 'Wohneinheit ID fehlt.']);
return;
}
$unit = new ADBWohneinheit($post['id']);
if (!$unit->id) {
self::returnJson(['success' => false, 'message' => 'Wohneinheit nicht gefunden.']);
return;
}
$contact = $unit->contact;
$contacts = !empty($contact) ? json_decode($contact, true) : [];
self::returnJson(['success' => true, 'contacts' => $contacts, 'header' =>
($unit->hausnummer->strasse ? $unit->hausnummer->strasse->name : '') . ' ' .
($unit->hausnummer ? $unit->hausnummer->hausnummer : '') . ', ' .
($unit->hausnummer->plz ? $unit->hausnummer->plz->plz : '') . ' ' .
($unit->hausnummer->ortschaft ? $unit->hausnummer->ortschaft->name : '')
]);
}
protected function saveContactsAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['id']) || !isset($post['data'])) {
self::returnJson(['success' => false, 'message' => 'ID oder Daten fehlen.']);
return;
}
$unit = new ADBWohneinheit($post['id']);
if (!$unit->id) {
self::returnJson(['success' => false, 'message' => 'Wohneinheit nicht gefunden.']);
return;
}
$unit->contact = json_encode($post['data']);
if ($unit->save()) {
self::returnJson(['success' => true, 'message' => 'Kontakt erfolgreich gespeichert.']);
} else {
self::returnJson(['success' => false, 'message' => 'Fehler beim Speichern der Kontakt.']);
}
}
}

View File

@@ -22,7 +22,7 @@ class ADBWohneinheitModel {
public $patch_module;
public $patch_port;
public $external_data;
public $note;
public $create_by = null;
public $edit_by = null;
@@ -31,13 +31,13 @@ class ADBWohneinheitModel {
public static function create(Array $data) {
$model = new ADBWohneinheit();
foreach($data as $field => $value) {
if(property_exists(get_called_class(), $field)) {
$model ->$field = $value;
}
}
$me = mfValuecache::singleton()->get("me");
if(!$me) {
$me = new User();
@@ -51,13 +51,13 @@ class ADBWohneinheitModel {
if($model->edit_by === null) {
$model->edit_by = $me->id;
}*/
return $model;
}
public static function getFirst($filter) {
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$where = self::getSqlFilter($filter);
$sql = "SELECT Wohneinheit.* FROM Wohneinheit
LEFT JOIN Hausnummer ON (Hausnummer.id = Wohneinheit.hausnummer_id)
@@ -65,7 +65,7 @@ class ADBWohneinheitModel {
GROUP BY Wohneinheit.id
ORDER BY hausnummer_id,block,stiege,LENGTH(stock),stock,LENGTH(tuer),tuer,num
LIMIT 1";
//mfLoghandler::singleton()->debug($sql);
$res = $db->query($sql);
if($db->num_rows($res)) {
@@ -79,12 +79,12 @@ class ADBWohneinheitModel {
}
return null;
}
public static function getAll() {
$items = [];
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$res = $db->select("Wohneinheit", "*", "1=1 ORDER BY hausnummer_id,block,stiege,LENGTH(stock),stock,LENGTH(tuer),tuer,num");
if($db->num_rows($res)) {
while($data = $db->fetch_object($res)) {
@@ -92,22 +92,22 @@ class ADBWohneinheitModel {
}
}
return $items;
}
public static function count($filter) {
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$where = self::getSqlFilter($filter);
$sql = "SELECT COUNT(*) as cnt FROM
$sql = "SELECT COUNT(*) as cnt FROM
(SELECT Wohneinheit.* FROM Wohneinheit
LEFT JOIN Hausnummer ON (Hausnummer.id = Wohneinheit.hausnummer_id)
WHERE $where
GROUP BY Wohneinheit.id
) as tbl
";
//mfLoghandler::singleton()->debug($sql);
$res = $db->query($sql);
if($db->num_rows($res)) {
@@ -116,18 +116,18 @@ class ADBWohneinheitModel {
}
return 0;
}
public static function search($filter, $limit = false) {
$items = [];
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$where = self::getSqlFilter($filter);
$sql = "SELECT Wohneinheit.* FROM Wohneinheit
LEFT JOIN Hausnummer ON (Hausnummer.id = Wohneinheit.hausnummer_id)
WHERE $where
GROUP BY Wohneinheit.id
ORDER BY hausnummer_id,block,stiege,LENGTH(stock),stock,LENGTH(tuer),tuer,num";
//mfLoghandler::singleton()->debug($sql);
if(is_array($limit) && count($limit)) {
if(is_numeric($limit['start']) && is_numeric($limit['count'])) {
@@ -136,7 +136,7 @@ class ADBWohneinheitModel {
$sql .= " LIMIT ".$limit['count'];
}
}
$res = $db->query($sql);
if($db->num_rows($res)) {
while($data = $db->fetch_object($res)) {
@@ -145,10 +145,10 @@ class ADBWohneinheitModel {
}
return $items;
}
private static function getSqlFilter($filter) {
$where = "1=1 ";
if(array_key_exists("extref", $filter)) {
if($filter['extref'] === null || $filter['extref'] === false) {
$where .= " AND (Wohneinheit.`extref` IS NULL OR Wohneinheit.`extref` = '')";
@@ -168,7 +168,7 @@ class ADBWohneinheitModel {
$where .= " AND Wohneinheit.`rimo_deleted` = 0";
}
}
if(array_key_exists("netzgebiet_id", $filter)) {
$netzgebiet_id = $filter['netzgebiet_id'];
if(is_numeric($netzgebiet_id)) {
@@ -177,7 +177,7 @@ class ADBWohneinheitModel {
$where .= " AND Hausnummer.netzgebiet_id IN (". implode(",", $netzgebiet_id).")";
}
}
if(array_key_exists("hausnummer_id", $filter)) {
$hausnummer_id = $filter['hausnummer_id'];
if(is_numeric($hausnummer_id)) {
@@ -186,7 +186,7 @@ class ADBWohneinheitModel {
$where .= " AND Wohneinheit.hausnummer_id IN (". implode(",", $hausnummer_id).")";
}
}
if(array_key_exists("oaid", $filter)) {
$oaid = FronkDB::singleton()->escape($filter['oaid']);
if(strlen($oaid)) {
@@ -195,7 +195,7 @@ class ADBWohneinheitModel {
$where .= " AND (Wohneinheit.`oaid` IS NULL OR Wohneinheit.`oaid` = '')";
}
}
if(array_key_exists("num", $filter)) {
$num = $filter['num'];
if($num === false || $num === null) {
@@ -206,7 +206,7 @@ class ADBWohneinheitModel {
$where .= " AND Wohneinheit.num IN (". implode(",", $num).")";
}
}
if(array_key_exists("block", $filter)) {
$block = FronkDB::singleton()->escape($filter['block']);
if(strlen($block)) {
@@ -256,7 +256,7 @@ class ADBWohneinheitModel {
$where .= " AND (Wohneinheit.`nutzung` IS NULL OR Wohneinheit.`nutzung` = '')";
}
}
if(array_key_exists("block%", $filter)) {
$block = FronkDB::singleton()->escape($filter['block']);
if($block) {
@@ -287,7 +287,7 @@ class ADBWohneinheitModel {
$where .= " AND Wohneinheit.`zusatz` like '%$zusatz%'";
}
}
//var_dump($filter, $where);exit;
return $where;
}
@@ -485,4 +485,69 @@ class ADBWohneinheitModel {
return array_values($deletedHomes);
}
public static function getGreenfieldWithActivePreorders($filter = [], $network_owner = null) {
$homes = [];
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME)->link;
$where = self::getSqlFilter($filter);
if ($network_owner) {
$where .= " AND Network.owner_id = '" . $db->real_escape_string($network_owner) . "'";
}
$detailSql = "
SELECT
W.*,
Owner.company AS company,
H.rimo_id AS hausnummer_extref,
NG.id AS netzgebiet_id,
Owner.company as netzgebiet_owner
FROM ". ADDRESSDB_DBNAME .".Wohneinheit W
JOIN ". ADDRESSDB_DBNAME .".Hausnummer H ON H.id = W.hausnummer_id
JOIN ". FRONKDB_DBNAME .".Preorder P ON P.adb_wohneinheit_id = W.id
JOIN ". FRONKDB_DBNAME .".Preorderstatus PS ON PS.id = P.status_id
LEFT JOIN ". ADDRESSDB_DBNAME .".Netzgebiet NG ON NG.id = H.netzgebiet_id
LEFT JOIN ". FRONKDB_DBNAME .".Network Network ON Network.adb_netzgebiet_id = NG.id
LEFT JOIN ". FRONKDB_DBNAME .".Address Owner ON Network.owner_id = Owner.id
WHERE $where
AND H.rimo_type = 'greenfield'
AND PS.code < 899
AND P.deleted = 0
GROUP BY W.id, Owner.company, H.rimo_id, NG.id
ORDER BY W.oaid";
$detailRes = $db->query($detailSql);
if (!$detailRes) {
return [];
}
while($homeData = $detailRes->fetch_assoc()) {
if (empty($homeData['netzgebiet_owner'])) continue;
$homes[] = [
"duplicateType" => "greenfield_with_order",
'oaid' => $homeData['oaid'] ?? 'Keine OAID',
'extref' => $homeData['extref'],
'netzgebiet_id' => $homeData['netzgebiet_id'],
'netzgebiet_owner' => $homeData['netzgebiet_owner'],
'count' => 1,
'homeData' => [[
"id" => $homeData['id'],
'oaid' => $homeData['oaid'] ?? 'Keine OAID',
"extref" => $homeData['extref'],
"network_company" => $homeData['company'],
"hausnummer_id" => $homeData['hausnummer_id'],
"hausnummer_extref" => $homeData['hausnummer_extref'],
"num" => $homeData['num'],
"nutzung" => $homeData['nutzung'],
"rimo_ex_state" => $homeData['rimo_ex_state'],
"rimo_op_state" => $homeData['rimo_op_state'],
"create" => $homeData['create'],
"edit" => $homeData['edit'],
]]
];
}
return $homes;
}
}

View File

@@ -196,7 +196,8 @@ class AddressDBController extends mfBaseController {
if(is_array($filter) && count($filter)) {
foreach($filter as $name => $value) {
if(strlen($value) > 0) $new_filter[$name] = $value;
if (is_array($value) && count($value)) $new_filter[$name] = $value;
else if(strlen($value) > 0) $new_filter[$name] = $value;
}
}
@@ -821,6 +822,9 @@ class AddressDBController extends mfBaseController {
case 'getUnit':
$return = $this->getUnitApi();
break;
case 'getFCPsForNetwork':
$return = $this->getFCPsForNetworkApi();
break;
case "findUnit":
break;
default:
@@ -1364,4 +1368,14 @@ class AddressDBController extends mfBaseController {
return ["count" => count($buildings), "buildings" => $results];
}
protected function getFCPsForNetworkApi(): array {
if (!$this->request->network_id) return [];
return array_map(
fn($fcp) => ["id" => $fcp->name ?? null, "text" => $fcp->name ?? null, 'lat' => $fcp->gps_lat ?? null, 'lng' => $fcp->gps_long ?? null],
ADBRimoFcp::getAll(["netzgebiet_id" => intval($this->request->network_id)]) ?? []
);
}
}

View File

@@ -246,7 +246,8 @@ class CalendarModel
$attachment = 0;
$attachmentLinks = "";
}
if (in_array("Abwesenheit", $categories)) {
if (!empty($categories) && in_array("Abwesenheit", $categories)) {
continue;
}
if ($data['all_day_event'] == 1) {
@@ -269,14 +270,20 @@ class CalendarModel
$recurrence = json_decode($data['recurrence'], true);
$rrule_events = json_decode($data['rrule_events'], true);
foreach ($rrule_events as $key => $value) {
if ($r->visibleCancellation === "0" && (str_starts_with(trim($value['subject']), "Abgesagt:") || str_starts_with(trim($value['subject']), "Abgesage:") || str_starts_with(trim($value['subject']), "Canceled:"))) {
unset($rrule_events[$key]);
continue;
}
$rrule_events[$key]['start'] = self::convertToSummertime(strtotime($value['start']));
$rrule_events[$key]['end'] = self::convertToSummertime(strtotime($value['end']));
}
if ($rrulefreq[$recurrence['pattern']['type']]) {
unset ($byweekday);
$freq = $rrulefreq[$recurrence['pattern']['type']];
foreach ($recurrence['pattern']['daysOfWeek'] as $value) {
$byweekday[] = strtolower(substr($value, 0, 2));
if (isset($recurrence['pattern']['daysOfWeek'])) {
foreach ($recurrence['pattern']['daysOfWeek'] as $value) {
$byweekday[] = strtolower(substr($value, 0, 2));
}
}
$duration = ($data['end_time'] - $data['start_time']) * 1000;
$until = $recurrence['range']['endDate'];
@@ -541,24 +548,26 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda
}
$attendees = json_decode($data['attendees'], true);
$organizer = json_decode($data['organizer'], true);
foreach ($attendees as $key => $value) {
if ($key == "required" || $key == "optional") {
foreach ($value as $attendeekey => $attendee) {
$attendee['name'] = self::replace_unicode_sequences($attendee['name']);
if ($attendee['email'] == $organizer['email']) {
if ($attendees) {
foreach ($attendees as $key => $value) {
if ($key == "required" || $key == "optional") {
} elseif ($Allcalendar[$attendee['name']] && $Allcalendar[$attendee['name']] == $data['calendar_id']) {
foreach ($value as $attendeekey => $attendee) {
$attendee['name'] = self::replace_unicode_sequences($attendee['name']);
if ($attendee['email'] == $organizer['email']) {
} else if ($Allcalendar[$attendee['name']]) {
$AttendeeArray[] = $Allcalendar[$attendee['name']];
} else {
$AttendeeArray[] = $attendee['email'];
} elseif ($Allcalendar[$attendee['name']] && $Allcalendar[$attendee['name']] == $data['calendar_id']) {
} else if ($Allcalendar[$attendee['name']]) {
$AttendeeArray[] = $Allcalendar[$attendee['name']];
} else {
$AttendeeArray[] = $attendee['email'];
}
}
}
}
}
if (!$data['accepted'] && $data['busy'] == 1) {
$data['accepted']['ok'] = 1;
$data['accepted'] = json_encode($data['accepted']);
@@ -644,11 +653,22 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda
$title = ($r->title);
$start = ((($r->start - 7200000) / 1000));
$end = ((($r->end - 7200000) / 1000));
if ($title) {
$start = strtotime($r->start);
$end = strtotime($r->end);
}
$originalend = $end;
$allday = ($r->allday);
if ($allday) {
$start = $start + 7200;
$originalend = $end + 7200;
$end = $end + 7200 + 86400;
}
$reminder = ($r->reminder);
$newkey = ($r->newkey);
$id = ($r->id);
@@ -706,7 +726,11 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda
$updateArray['event_type'] = $type;
$calEventCategories = self::$eventCategories;
foreach ($calEventCategories as $key => $value) {
$arraykey = array_search($value, $categories);
if ($categories) {
$arraykey = array_search($value, $categories);
} else {
$arraykey = false;
}
if (!empty($arraykey || $arraykey === 0)) {
unset($categories[$arraykey]);
}
@@ -766,8 +790,10 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda
$toolattachments[$dataAttachment['id']] = 1;
}
}
foreach ($attachments as $key => $value) {
unset($toolattachments[$value]);
if ($attachments) {
foreach ($attachments as $key => $value) {
unset($toolattachments[$value]);
}
}
if (!empty($toolattachments)) {
foreach ($toolattachments as $key => $value) {
@@ -785,9 +811,8 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda
$db->delete("tmp_cal_events_attachments", "id = '" . $tmpid . "'");
}
}
$updateArray['attachments'] = $attachments;
$updateArray['end_time'] = $originalend;
}
if ($attendees)
$updateArray['attendees'] = $attendees;
@@ -866,6 +891,7 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda
$title = ($r->title);
$start = strtotime($r->start);
$end = strtotime($r->end);
$originalend = $end;
$allday = ($r->allday);
$reminder = ($r->reminder);
$newkey = ($r->newkey);
@@ -890,6 +916,10 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda
}
if (!$allday) {
$allday = 0;
} else {
$start = $start + 7200;
$originalend = $end + 7200;
$end = $end + 7200 + 86400;
}
if ($reminder == 'NULL') {
$reminder = NULL;
@@ -947,6 +977,7 @@ WHERE `TimerecordingCategory`.`hourday`!='1' AND `TimerecordingCategory`.`hourda
if ($rrules) {
$dataarray['rrule'] = $rrules;
}
$dataarray['end_time'] = $originalend;
$json_data = json_encode($dataarray);
$data = [];

View File

@@ -1464,4 +1464,197 @@ class ConstructionConsentController extends mfBaseController {
$this->layout()->setTemplate("VueViews/Vue");
}
private function createFileFromFile(string $sourcePath, string $filename, string $subfolder, string $name = null): ?File {
if (!file_exists($sourcePath)) {
$this->log->error(__METHOD__ . ": Source file does not exist at path: " . $sourcePath);
return null;
}
$savePath = rtrim(MFUPLOAD_FILE_SAVE_PATH, '/') . '/' . trim($subfolder, '/');
if (!is_dir($savePath)) {
if (!mkdir($savePath, 0777, true)) {
$this->log->error(__METHOD__ . ": Could not create save directory: " . $savePath);
return null;
}
}
$store_filename = uniqid() . '-' . basename($filename);
$destinationPath = $savePath . '/' . $store_filename;
if (!copy($sourcePath, $destinationPath)) {
$this->log->error(__METHOD__ . ": Could not copy file from '$sourcePath' to '$destinationPath'");
return null;
}
$fileData = [
"name" => $name ?? $filename,
"description" => "Generated signed consent form",
"filename" => $filename,
"orig_filename" => $filename,
"store_filename" => $store_filename,
"subfolder" => $subfolder,
];
$file = FileModel::create($fileData);
if (!$file->save()) {
$this->log->error(__METHOD__ . ": Error saving File Object");
unlink($destinationPath); // Clean up copied file
return null;
}
$file->mimetype = $file->getMimetype();
$file->save();
return $file;
}
protected function generateSignedPdfsAction() {
$this->layout()->setTemplate(null); // No view needed
$signedOwners = ConstructionConsentOwner::search([
'add-where' => "AND signature IS NOT NULL AND signature != '' AND signature_name IS NOT NULL AND signature_name != '' AND signature_date IS NOT NULL"
]);
if (empty($signedOwners)) {
$this->layout()->setFlash("Keine unterschriebenen Erklärungen zum Generieren gefunden.", "info");
$this->redirect("ConstructionConsent");
}
$successCount = 0;
$errorCount = 0;
foreach ($signedOwners as $owner) {
$desiredFilename = "Zustimmungserklaerung-unterschrieben-{$owner->id}.pdf";
// Check if a signed PDF already exists to avoid re-generation
$existingFile = ConstructionConsentOwnerFile::getFirst([
"constructionconsentowner_id" => $owner->id,
"filename" => $desiredFilename
]);
if ($existingFile) {
continue;
}
$pdfPath = $owner->consent->createConsentFormPdf([$owner]);
if (!$pdfPath || !file_exists($pdfPath)) {
$this->log->error("PDF generation failed for owner ID: {$owner->id}");
$errorCount++;
continue;
}
$file = $this->createFileFromFile(
$pdfPath,
$desiredFilename,
TT_CONSTRUCTIONCONSENT_FILE_UPLOAD_SUBFOLDER,
$desiredFilename
);
unlink($pdfPath);
if (!$file) {
$this->log->error("File record creation failed for owner ID: {$owner->id}");
$errorCount++;
continue;
}
$ccof = ConstructionConsentOwnerFile::create([
'constructionconsentowner_id' => $owner->id,
'file_id' => $file->id,
'filename' => $desiredFilename,
]);
if (!$ccof->save()) {
$this->log->error("ConstructionConsentOwnerFile record creation failed for owner ID: {$owner->id}. Deleting created file record.");
$file->file->delete();
$file->delete();
$errorCount++;
} else {
$successCount++;
}
}
$message = "PDF-Generierung abgeschlossen. $successCount Dokument(e) erfolgreich erstellt.";
if ($errorCount > 0) {
$message .= " $errorCount Fehler sind aufgetreten.";
$this->layout()->setFlash($message, "warning");
} else {
$this->layout()->setFlash($message, "success");
}
$this->redirect("ConstructionConsent");
}
protected function downloadProjectPdfsAction() {
$projectId = $this->request->project_id;
if (!is_numeric($projectId) || $projectId < 1) {
$this->layout()->setFlash("Projekt nicht gefunden", "error");
$this->redirect("ConstructionConsent");
}
$project = new ConstructionConsentProject($projectId);
if (!$project->id) {
$this->layout()->setFlash("Projekt nicht gefunden", "error");
$this->redirect("ConstructionConsent");
}
$consents = ConstructionConsent::search(['project_id' => $projectId]);
if (empty($consents)) {
$this->layout()->setFlash("Keine Zustimmungserklärungen für dieses Projekt gefunden.", "info");
$this->redirect("ConstructionConsent");
}
$pdf_files = [];
foreach ($consents as $consent) {
$owners = $consent->owners;
if (empty($owners)) {
continue;
}
foreach ($owners as $owner) {
$ownerFiles = $owner->files;
if (empty($ownerFiles)) {
continue;
}
foreach ($ownerFiles as $ownerFile) {
$file = $ownerFile->file;
if ($file && strtolower(pathinfo($file->store_filename, PATHINFO_EXTENSION)) === 'pdf') {
if ($file->fileExists()) {
$filePath = rtrim(MFUPLOAD_FILE_SAVE_PATH, '/') . '/' . trim($file->subfolder, '/') . '/' . $file->store_filename;
$pdf_files[] = $filePath;
}
}
}
}
}
$pdf_files = array_unique($pdf_files);
if (empty($pdf_files)) {
$this->layout()->setFlash("Keine hochgeladenen PDFs für dieses Projekt gefunden.", "error");
$this->redirect("ConstructionConsent");
}
$tempDir = rtrim(BASEDIR, '/') . "/var/temp/";
$finalPdfPath = $tempDir . "Zustimmungserklaerungen-Projekt-{$projectId}-" . date('Ymd_His') . ".pdf";
$escaped_files = array_map('escapeshellarg', $pdf_files);
$pdf_unite_cmd = PDFUNITE_BIN_PATH . " " . implode(' ', $escaped_files) . " " . escapeshellarg($finalPdfPath);
shell_exec($pdf_unite_cmd);
if (!file_exists($finalPdfPath) || filesize($finalPdfPath) === 0) {
$this->log->error("pdfunite command failed or produced an empty file. Command: " . $pdf_unite_cmd);
$this->layout()->setFlash("PDF-Zusammenführung fehlgeschlagen", "error");
$this->redirect("ConstructionConsent");
}
$downloadName = "Zustimmungserklärungen-{$project->name}-" . date('Y-m-d') . ".pdf";
$this->sendPdfResponse($finalPdfPath, $downloadName);
unlink($finalPdfPath);
exit;
}
}

View File

@@ -60,18 +60,18 @@ class CpeprovisioningController extends mfBaseController
continue;
}
}
if(array_key_exists($order->id, $orderproductsprefetch)) {
foreach ($orderproductsprefetch[$order->id] as $orderproduct) {
if(!$orderproduct) continue;
if(!is_array($orderproduct)) continue;
if ($orderproduct['routerconfig_finished'] == 1) {
if (!$filter['routerconfig_finished']) continue;
} else {
if ($filter['routerconfig_finished']) continue;
}
$productattributes = $orderproduct['attributes'];
@@ -570,7 +570,7 @@ class CpeprovisioningController extends mfBaseController
'firstline' => $order->owner->getCompanyOrName(),
'secondline' => $order->owner->street,
'thirdline' => $order->owner->zip . " " . $order->owner->city,
'fourthline' => $order->owner->customer_number
'fourthline' => $order->owner->customer_number ?? $order->partner_number
];
$pdf = new PdfForm("Cpeprovisioning/PDF_MAIN", $pdf_vars);
@@ -584,4 +584,4 @@ class CpeprovisioningController extends mfBaseController
}
}
}

View File

@@ -44,6 +44,8 @@ class DeviceController extends mfBaseController
"manufacturer" => $deviceType->devicemanufactor->name,
"price" => $deviceType->price,
"power" => $deviceType->power,
"temp_warning" => $deviceType->temp_warning,
"temp_critical" => $deviceType->temp_critical,
"creator" => $deviceType->creator->name,
"created" => $deviceType->create,
];

View File

@@ -30,9 +30,6 @@ class DeviceMonitoringController extends mfBaseController
$this->postData = json_decode(file_get_contents('php://input'), true) ?? [];
}
/**
* Gets a list of all available interfaces, grouping Sent/Received items.
*/
protected function listInterfacesAction()
{
$hostId = $this->request->hostId;
@@ -54,9 +51,6 @@ class DeviceMonitoringController extends mfBaseController
self::returnJson($sortedInterfaces);
}
/**
* Gets historical data for a specific list of item IDs.
*/
protected function interfaceDataAction()
{
$itemIds = $this->postData['itemIds'] ?? [];
@@ -71,7 +65,7 @@ class DeviceMonitoringController extends mfBaseController
$params = [
'itemids' => $itemIds,
'output' => 'extend',
'history' => 3, // Numeric (unsigned)
'history' => 3, // Type of history: float
'sortfield' => 'clock',
'sortorder' => 'ASC',
'time_from' => $time_from,
@@ -82,66 +76,246 @@ class DeviceMonitoringController extends mfBaseController
foreach ($history as $point) {
$historyByItemId[$point['itemid']][] = [
'x' => intval($point['clock']) * 1000,
'y' => round(floatval($point['value']) / 1000000, 2)
'y' => round(floatval($point['value']) / 1000000, 2) // Mbps
];
}
self::returnJson($historyByItemId);
}
/**
* Gets general monitoring data (Uptime, Ping, Temp).
*/
protected function generalDataAction() {
$hostId = $this->request->hostId;
$itemsToFetch = [
'ping' => $this->zabbix->getICMPItems($hostId),
'uptime' => $this->zabbix->getUptimeItems($hostId),
];
$itemIds = [];
$itemMap = [];
foreach ($itemsToFetch as $type => $items) {
if (!empty($items)) {
foreach($items as $item) {
$itemIds[] = $item['itemid'];
$itemMap[$item['itemid']] = ['type' => $type, 'name' => $item['name'], 'units' => $item['units']];
}
}
}
$values = [];
if(!empty($itemIds)) {
$history = $this->zabbix->getItemValues($itemIds, 1);
foreach($history as $h) {
$info = $itemMap[$h['itemid']];
$values[$info['type']][] = ['name' => $info['name'], 'value' => $h['value'], 'clock' => $h['clock'], 'units' => $info['units']];
}
}
self::returnJson($values);
$data = $this->zabbix->getOverviewData($hostId);
self::returnJson($data);
}
/**
* Gets Zabbix problems (triggers) for the host.
*/
protected function getProblemsAction() {
$hostId = $this->request->hostId;
$problems = $this->zabbix->zabbixRequest('problem.get', [
$currentProblems = $this->zabbix->zabbixRequest('problem.get', [
'hostids' => $hostId,
'output' => 'extend',
'recent' => true, // Use boolean true
'recent' => true,
'sortfield' => ['eventid'],
'sortorder' => 'DESC'
])['result'] ?? [];
self::returnJson($problems);
$resolvedProblems = $this->zabbix->getResolvedProblems($hostId, strtotime('-7 days'));
self::returnJson([
'current' => $currentProblems,
'resolved' => $resolvedProblems
]);
}
protected function getConfigurationDataAction() {
$hostId = $this->request->hostId;
$host = $this->zabbix->getHostWithInterfaces($hostId);
if (!$host) {
self::returnJson(['error' => 'Host not found.']);
return;
}
$snmpInterface = null;
foreach ($host['interfaces'] as $iface) {
if ($iface['type'] == '2') { // SNMP type
$snmpInterface = $iface;
break;
}
}
$opStatusItems = $this->zabbix->getInterfaceOperationalStatusItems($hostId);
$allTriggers = $this->zabbix->getTriggersForHostByDescription($hostId, "Interface ");
$triggerMap = [];
foreach ($allTriggers as $trigger) {
$triggerMap[$trigger['description']] = $trigger;
}
$interfaceAlarms = [];
foreach ($opStatusItems as $item) {
$expectedDescription = "Interface " . $item['name'] . " is down on " . $host['name'];
$trigger = $triggerMap[$expectedDescription] ?? null;
$interfaceAlarms[] = [
'itemid' => $item['itemid'],
'name' => $item['name'],
'key' => $item['key_'],
'isAlarmed' => !is_null($trigger),
'triggerId' => $trigger['triggerid'] ?? null
];
}
self::returnJson([
'snmp' => $snmpInterface,
'interfaces' => $interfaceAlarms
]);
}
protected function updateSnmpAction() {
$interfaceId = $this->postData['interfaceId'] ?? null;
$details = $this->postData['details'] ?? null;
if (!$interfaceId || !$details) {
http_response_code(400);
self::returnJson(['error' => 'Missing required parameters.']);
return;
}
$result = $this->zabbix->updateHostInterface($interfaceId, $details);
self::returnJson($result);
}
protected function updateInterfaceAlarmAction() {
$hostId = $this->postData['hostId'];
$item = $this->postData['item'];
$enabled = $this->postData['enabled'];
$host = $this->zabbix->getHostById($hostId)[0] ?? null;
if (!$host) {
self::returnJson(['error' => 'Host not found.']);
return;
}
$description = "Interface " . $item['name'] . " is down on " . $host['name'];
if ($enabled) {
$expression = "last(/".$host['host']."/".$item['key'].")=2";
$result = $this->zabbix->createInterfaceLinkDownTrigger($expression, $description);
} else {
$triggers = $this->zabbix->getTriggersForHostByDescription($hostId, $description);
$triggerIds = array_column($triggers, 'triggerid');
$result = $this->zabbix->deleteTriggers($triggerIds);
}
self::returnJson($result);
}
protected function getReportDataAction() {
$hostId = $this->request->hostId;
$timeRange = $this->request->timeRange ?? '7d';
$time_from = strtotime('-' . str_replace(['d'], [' days'], $timeRange));
// Step 1: Fetch all interface-related items (traffic and speed) in a single API call.
// We include 'value_type' to handle different history types correctly.
$items = $this->zabbix->zabbixRequest('item.get', [
'hostids' => $hostId,
'output' => ['itemid', 'name', 'key_', 'value_type'],
'search' => ['key_' => ['net.if.in', 'net.if.out', 'net.if.speed']],
'searchByAny' => true,
'sortfield' => 'name'
])['result'] ?? [];
// Step 2: Organize items and group them for efficient processing.
$interfaces = [];
$trafficItems = []; // Will hold item info for both rx and tx.
$speedItemsByType = [];
$speedItemMap = [];
foreach ($items as $item) {
$key = $item['key_'];
if (str_contains($key, 'net.if.in') || str_contains($key, 'net.if.out')) {
$baseName = preg_replace('/:\s*Bits\s*(sent|received)$/i', '', $item['name']);
$direction = str_contains($key, 'net.if.in') ? 'rx' : 'tx';
if (!isset($interfaces[$baseName])) {
$interfaces[$baseName] = ['name' => $baseName, 'rx_item' => null, 'tx_item' => null, 'speed' => null];
}
$interfaces[$baseName][$direction . '_item'] = $item;
$trafficItems[$item['itemid']] = $item;
} elseif (str_contains($key, 'net.if.speed')) {
$baseName = preg_replace('/:\s*Interface\s*|\s*speed$/i', '', $item['name']);
$value_type = (int)$item['value_type'];
$speedItemsByType[$value_type][] = $item['itemid'];
$speedItemMap[$item['itemid']] = $baseName;
}
}
// Step 3: Aggressively fetch the last known speed for all interfaces.
// We query history with a larger limit to find the value even if it's not recent.
foreach ($speedItemsByType as $type => $itemIds) {
$historyResult = $this->zabbix->zabbixRequest('history.get', [
'itemids' => $itemIds,
'history' => $type,
'output' => ['itemid', 'value'],
'sortfield' => 'clock',
'sortorder' => 'DESC',
'limit' => count($itemIds) * 5 // Increase limit to better ensure finding a value for each item
])['result'] ?? [];
$latestForType = [];
foreach ($historyResult as $point) {
if (!isset($latestForType[$point['itemid']])) {
$latestForType[$point['itemid']] = $point;
$baseName = $speedItemMap[$point['itemid']] ?? null;
if ($baseName && isset($interfaces[$baseName])) {
$interfaces[$baseName]['speed'] = (float)$point['value'];
}
}
}
}
// Step 4: Attempt to fetch trend data for all traffic items at once.
$trafficItemIds = array_keys($trafficItems);
$trends = $this->zabbix->getTrends($trafficItemIds, $time_from);
$trendsByItemId = [];
foreach ($trends as $trend) {
$trendsByItemId[$trend['itemid']][] = $trend;
}
// Step 5: Build the report, using trends first and falling back to raw history if trends are unavailable.
$report = [];
foreach ($interfaces as $iface) {
$rx_item = $iface['rx_item'];
$tx_item = $iface['tx_item'];
$speed = $iface['speed'];
// This function calculates statistics from either trend data or raw history data.
$calcStats = function($item, $speed, $trendData) use ($time_from) {
if (!$item) return ['avg' => 0, 'max' => 0, 'usage' => 0];
$values = [];
$avg = 0;
$max = 0;
if (!empty($trendData)) {
// Method 1: Use efficient trend data if available.
$avg = array_sum(array_column($trendData, 'value_avg')) / count($trendData);
$max = max(array_column($trendData, 'value_max'));
} else {
// Method 2 (Fallback): Fetch raw history if trends are missing.
$history = $this->zabbix->zabbixRequest('history.get', [
'itemids' => [$item['itemid']],
'history' => (int)$item['value_type'],
'time_from' => $time_from,
'output' => ['value']
])['result'] ?? [];
if (!empty($history)) {
$values = array_column($history, 'value');
$avg = array_sum($values) / count($values);
$max = max($values);
}
}
$usage = ($speed > 0) ? ($avg / $speed) * 100 : 0;
return [
'avg' => round($avg / 1000000, 2), // bps to Mbps
'max' => round($max / 1000000, 2), // bps to Mbps
'usage' => round($usage, 2)
];
};
$report[] = [
'name' => $iface['name'],
'speed' => $speed !== null ? round($speed / 1000000) : 'N/A', // bps to Mbps
'rx' => $calcStats($rx_item, $speed, $trendsByItemId[$rx_item['itemid']] ?? []),
'tx' => $calcStats($tx_item, $speed, $trendsByItemId[$tx_item['itemid']] ?? [])
];
}
usort($report, fn($a, $b) => strnatcmp($a['name'], $b['name']));
self::returnJson($report);
}
/**
* Forces a Zabbix item check and returns the latest value for live graphs.
*/
protected function liveDataAction() {
$itemId = $this->request->itemId;
if(empty($itemId)) {
@@ -168,9 +342,6 @@ class DeviceMonitoringController extends mfBaseController
self::returnJson($formattedPoint);
}
/**
* Renders a dedicated HTML page for the live graph popup.
*/
public function liveGraphPageAction() {
$this->layout(false);
$this->layout()->set('API_BASE_URL', self::getUrl("DeviceMonitoring"));

View File

@@ -76,6 +76,17 @@ class DevicetypeController extends mfBaseController
} else {
$power = $r->power;
}
if (!$r->temp_warning) {
$temp_warning = "80";
} else {
$temp_warning = $r->temp_warning;
}
if (!$r->temp_critical) {
$temp_critical = "90";
} else {
$temp_critical = $r->temp_critical;
}
if ($r->olt) {
@@ -88,6 +99,8 @@ class DevicetypeController extends mfBaseController
$data['price'] = $price;
$data['power'] = $power;
$data['olt'] = $olt;
$data['temp_warning'] = $temp_warning;
$data['temp_critical'] = $temp_critical;
if (!$data['name']) {
$this->layout()->setFlash("Name darf nicht leer sein", "error");

View File

@@ -7,6 +7,8 @@ class DevicetypeModel
public $price = null;
public $olt = null;
public $devicemanufactor_id = null;
public $temp_warning = 80;
public $temp_critical = 90;
public $create_by = null;

View File

@@ -99,7 +99,9 @@ class FileController extends mfBaseController {
$originalPath = MFUPLOAD_FILE_SAVE_PATH . ($file->subfolder ? "/{$file->subfolder}" : "") . "/{$file->store_filename}";
if (!is_readable($originalPath)) self::sendError("Physical file not found");
header("Cache-Control: public, max-age=604800");
header("Expires: " . gmdate("D, d M Y H:i:s", time() + 604800) . " GMT");
header("Last-Modified: " . gmdate("D, d M Y H:i:s", filemtime($originalPath)) . " GMT");
$imageInfo = @getimagesize($originalPath);
@@ -140,4 +142,4 @@ class FileController extends mfBaseController {
readfile($cachedPath);
exit;
}
}
}

View File

@@ -0,0 +1,22 @@
<?php
class ManualInvoiceController extends TTCrud
{
protected string $headerTitle = 'Manuelle Rechnungen';
protected bool $createText = false;
//@formatter:off
protected array $columns = [
['key' => 'invoiceNumber', 'text' => 'Rechnungsnr.', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'customerName', 'text' => 'Kunde', 'table' => ['sortable' => true, 'filter' => 'search']],
['key' => 'invoiceDate', 'text' => 'Datum', 'table' => ['sortable' => true, 'filter' => 'date']],
['key' => 'totalAmount', 'text' => 'Betrag', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [
['value' => 'draft', 'text' => 'Entwurf'],
['value' => 'sent', 'text' => 'Gesendet'],
['value' => 'paid', 'text' => 'Bezahlt'],
]]],
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
];
//@formatter:on
}

View File

@@ -0,0 +1,186 @@
<?php
function getMockData() {
$mockData = [
[
'id' => 1, 'invoiceNumber' => 'RE-2025-001', 'customerName' => 'Musterfirma GmbH', 'billingAddressId' => 1,
'invoiceDate' => strtotime('2025-09-11'), 'dueDate' => strtotime('2025-09-25'), 'totalAmount' => 948.00, 'status' => 'paid',
'positions' => json_encode([
['product_name' => 'IT-Support-Stunden', 'product_info' => 'Remote-Hilfe für Mitarbeiter', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 2, 'price' => 120.00, 'vatrate' => 20],
['product_name' => 'Netzwerk-Switch 24-Port', 'product_info' => 'Modell: XYZ-24G', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 1, 'price' => 550.00, 'vatrate' => 20],
]),
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
],
[
'id' => 2, 'invoiceNumber' => 'RE-2025-002', 'customerName' => 'Beispiel AG', 'billingAddressId' => 2,
'invoiceDate' => strtotime('2025-09-14'), 'dueDate' => strtotime('2025-09-28'), 'totalAmount' => 720.00, 'status' => 'sent',
'positions' => json_encode([
['product_name' => 'Beratung Digitalisierungsstrategie', 'product_info' => 'Workshop am 05.09.2025', 'start_date' => '2025-09-05', 'end_date' => '2025-09-05', 'amount' => 4, 'price' => 150.00, 'vatrate' => 20],
]),
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
],
[
'id' => 3, 'invoiceNumber' => 'RE-2025-003', 'customerName' => 'John Doe Services', 'billingAddressId' => 3,
'invoiceDate' => strtotime('2025-09-16'), 'dueDate' => strtotime('2025-09-30'), 'totalAmount' => 912.00, 'status' => 'draft',
'positions' => json_encode([
['product_name' => 'Kabelverlegung LWL', 'product_info' => 'Inhouse-Verkabelung Bürogebäude', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 8, 'price' => 85.00, 'vatrate' => 20],
['product_name' => 'LWL-Kabel 8 Fasern', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 100, 'price' => 0.80, 'vatrate' => 20],
]),
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
],
[
'id' => 4, 'invoiceNumber' => 'RE-2025-004', 'customerName' => 'Bau & Co KG', 'billingAddressId' => 4,
'invoiceDate' => strtotime('2025-09-06'), 'dueDate' => strtotime('2025-09-20'), 'totalAmount' => 1890.00, 'status' => 'paid',
'positions' => json_encode([
['product_name' => 'Netzwerk-Grundinstallation Baustelle', 'product_info' => 'Containerdorf Einrichtung', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 1, 'price' => 1200.00, 'vatrate' => 20],
['product_name' => 'Stunden Elektriker', 'product_info' => 'Anpassungen Verteilerkasten', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 5, 'price' => 75.00, 'vatrate' => 20],
]),
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
],
[
'id' => 5, 'invoiceNumber' => 'RE-2025-005', 'customerName' => 'Creative Solutions', 'billingAddressId' => 5,
'invoiceDate' => strtotime('2025-09-15'), 'dueDate' => strtotime('2025-09-29'), 'totalAmount' => 1920.00, 'status' => 'sent',
'positions' => json_encode([
['product_name' => 'Web-Entwicklung', 'product_info' => 'Umsetzung Landingpage "Herbst-Aktion"', 'start_date' => '2025-09-01', 'end_date' => '2025-09-12', 'amount' => 10, 'price' => 110.00, 'vatrate' => 20],
['product_name' => 'Domain-Registrierung (.at)', 'product_info' => 'herbst-aktion.at', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 500.00, 'vatrate' => 20],
]),
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
],
[
'id' => 6, 'invoiceNumber' => 'RE-2025-006', 'customerName' => 'Logistik Express', 'billingAddressId' => 6,
'invoiceDate' => strtotime('2025-08-28'), 'dueDate' => strtotime('2025-09-11'), 'totalAmount' => 3432.00, 'status' => 'paid',
'positions' => json_encode([
['product_name' => 'Software-Lizenz WMS Pro', 'product_info' => 'Jahreslizenz für 10 User', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 2500.00, 'vatrate' => 20],
['product_name' => 'Mitarbeiterschulung WMS', 'product_info' => 'Vor Ort am 27.08.2025', 'start_date' => '2025-08-27', 'end_date' => '2025-08-27', 'amount' => 4, 'price' => 90.00, 'vatrate' => 20],
]),
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
],
[
'id' => 7, 'invoiceNumber' => 'RE-2025-007', 'customerName' => 'Gastro Profi', 'billingAddressId' => 7,
'invoiceDate' => strtotime('2025-09-10'), 'dueDate' => strtotime('2025-09-24'), 'totalAmount' => 2577.60, 'status' => 'draft',
'positions' => json_encode([
['product_name' => 'Kassensystem "GastroTouch"', 'product_info' => '2x Terminal, 1x Bondrucker', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 2, 'price' => 899.00, 'vatrate' => 20],
['product_name' => 'Installationspauschale', 'product_info' => 'Inkl. Einschulung', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 1, 'price' => 350.00, 'vatrate' => 20],
]),
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
],
[
'id' => 8, 'invoiceNumber' => 'RE-2025-008', 'customerName' => 'Sicherheitsdienst Huber', 'billingAddressId' => 8,
'invoiceDate' => strtotime('2025-09-01'), 'dueDate' => strtotime('2025-09-15'), 'totalAmount' => 1782.00, 'status' => 'sent',
'positions' => json_encode([
['product_name' => 'IP Kamera 4K Dome', 'product_info' => 'Modell SEC-4K-D', 'start_date' => '2025-08-29', 'end_date' => '2025-08-29', 'amount' => 8, 'price' => 180.00, 'vatrate' => 20],
['product_name' => 'Monatliche Wartungspauschale', 'product_info' => 'September 2025', 'start_date' => '2025-09-01', 'end_date' => '2025-09-30', 'amount' => 1, 'price' => 45.00, 'vatrate' => 20],
]),
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
],
[
'id' => 9, 'invoiceNumber' => 'RE-2025-009', 'customerName' => 'Praxis Dr. Eder', 'billingAddressId' => 9,
'invoiceDate' => strtotime('2025-09-12'), 'dueDate' => strtotime('2025-09-26'), 'totalAmount' => 3090.00, 'status' => 'draft',
'positions' => json_encode([
['product_name' => 'Arbeitsstunden IT-Migration', 'product_info' => 'Serverumzug und Client-Setup', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 5, 'price' => 95.00, 'vatrate' => 20],
['product_name' => 'Server-Hardware "MedServ"', 'product_info' => 'Spez. für Arztpraxen', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 1800.00, 'vatrate' => 20],
['product_name' => 'Datensicherungslösung "CloudSafe"', 'product_info' => 'Einrichtungspauschale', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 300.00, 'vatrate' => 20],
]),
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
],
[
'id' => 10, 'invoiceNumber' => 'RE-2025-010', 'customerName' => 'Architekturbüro Planweit', 'billingAddressId' => 10,
'invoiceDate' => strtotime('2025-09-08'), 'dueDate' => strtotime('2025-09-22'), 'totalAmount' => 357.60, 'status' => 'paid',
'positions' => json_encode([
['product_name' => 'Plotter Service', 'product_info' => 'Wartung und Reinigung', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 1, 'price' => 250.00, 'vatrate' => 20],
['product_name' => 'Netzwerkkabel Cat7', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 40, 'price' => 1.20, 'vatrate' => 20],
]),
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
],
];
return $mockData;
}
class ManualInvoiceModel extends TTCrudBaseModel {
public int $id;
public ?string $invoiceNumber;
public ?int $invoiceDate;
public ?int $dueDate;
public int $billingAddressId;
public ?string $customerName;
public ?float $totalAmount;
public string $status;
public string $positions;
public string $closingText;
public string $taxText;
private static function applyFilter(array $data, array $filter): array {
if (empty($filter)) {
return $data;
}
return array_filter($data, function ($row) use ($filter) {
foreach ($filter as $key => $value) {
if (!isset($row[$key]) || empty($value)) {
continue;
}
if (is_array($value)) { // Handle date ranges
if (isset($value['from']) && $row[$key] < $value['from']) return false;
if (isset($value['to']) && $row[$key] > $value['to']) return false;
} else if (is_array($row[$key])) {
if (!in_array($value, $row[$key])) return false;
} else if (stripos($row[$key], $value) === false) {
return false;
}
}
return true;
});
}
public static function getAll($filter = [], $limit = null, $offset = 0, $order = ["key" => null]): array
{
$mockData = getMockData();
$filteredData = self::applyFilter($mockData, $filter);
if ($order['key'] !== null) {
usort($filteredData, function ($a, $b) use ($order) {
if ($a[$order['key']] == $b[$order['key']]) return 0;
if ($order['order'] === 'ASC') {
return $a[$order['key']] < $b[$order['key']] ? -1 : 1;
} else {
return $a[$order['key']] > $b[$order['key']] ? -1 : 1;
}
});
}
if ($limit !== null) {
return array_slice($filteredData, $offset, $limit);
}
return $filteredData;
}
public static function count($filter = []): int {
$mockData = getMockData();
return count(self::applyFilter($mockData, $filter));
}
public static function get($id) {
$mockData = getMockData();
foreach ($mockData as $row)
if ($row['id'] == $id)
return new self($row);
return null;
}
public static function create($data) {
error_log("ManualInvoiceModel::create called with: " . json_encode($data));
return time();
}
public static function update($data) {
error_log("ManualInvoiceModel::update called with: " . json_encode($data));
return 1;
}
public static function delete($id) {
error_log("ManualInvoiceModel::delete called with ID: " . $id);
return 1;
}
}

View File

@@ -119,6 +119,7 @@ class PoprackController extends mfBaseController
private function generateRack()
{
$id = $this->request->id;
$side=$this->request->side;
$cellwidth = 227;
$blocktd = 0;
$poprack = PoprackModel::getAllbyRack($id);
@@ -130,7 +131,7 @@ class PoprackController extends mfBaseController
<td class="border-right w-15 p-0 pl-1 pr-1 border-bottom font-13 rack-he user-select-none"
data-toggle="modal" data-target="#rackModuleModal"
style="cursor: pointer" data-he="' . $i . '">He' . $i . '</td>';
foreach ($poprack[0]['modules'] as $module) {
foreach ($poprack[0][$side]['modules'] as $module) {
if ($module['start_he'] == $i) {
$modulestart = 1;

View File

@@ -48,11 +48,11 @@ class PoprackModel
$items = [];
$db = FronkDB::singleton();
$sql = "(SELECT `Poprackmodule`.`type`,`Poprack`.`id`,`Poprack`.`sort`, `Poprack`.`name`, `Poprack`.`he`,`Poprackmodule`.start_he,`Poprackmodule`.end_he,`Poprackmodule`.`name` as `modulname`,device_id ,`Poprackmodule`.`id` as moduleid,`Poprackmodule`.`width` as modulewidth,`Poprackmodule`.`position` as moduleposition,`Poprackmodule`.`ports` as moduleports,`Poprackmodule`.`plug` as moduleplug FROM `Poprack`
$sql = "(SELECT `Poprackmodule`.`type`,`Poprack`.`id`,`Poprack`.`sort`, `Poprack`.`name`, `Poprack`.`he`,`Poprackmodule`.start_he,`Poprackmodule`.end_he,`Poprackmodule`.`name` as `modulname`,device_id ,`Poprackmodule`.`id` as moduleid,`Poprackmodule`.`width` as modulewidth,`Poprackmodule`.`position` as moduleposition,`Poprackmodule`.`side` as moduleside,`Poprackmodule`.`ports` as moduleports,`Poprackmodule`.`plug` as moduleplug FROM `Poprack`
LEFT JOIN `Poprackmodule` ON (`Poprackmodule`.`poprack_id`=`Poprack`.`id` AND `Poprackmodule`.`type`!=1)
WHERE `Poprack`.`pop_id`='" . $pop_id . "' ORDER by `Poprack`.`sort`,`Poprack`.`name`,`Poprackmodule`.start_he,`Poprackmodule`.position)
UNION
(SELECT `Poprackmodule`.`type`,`Poprack`.`id`,`Poprack`.`sort`, `Poprack`.`name`, `Poprack`.`he`,`Poprackmodule`.start_he,`Poprackmodule`.end_he,`Device`.`name` as `modulname`,device_id,`Poprackmodule`.`id` as moduleid,`Poprackmodule`.`width` as modulewidth,`Poprackmodule`.`position` as moduleposition,`Poprackmodule`.`ports` as moduleports,`Poprackmodule`.`plug` as moduleplug FROM `Poprack`
(SELECT `Poprackmodule`.`type`,`Poprack`.`id`,`Poprack`.`sort`, `Poprack`.`name`, `Poprack`.`he`,`Poprackmodule`.start_he,`Poprackmodule`.end_he,`Device`.`name` as `modulname`,device_id,`Poprackmodule`.`id` as moduleid,`Poprackmodule`.`width` as modulewidth,`Poprackmodule`.`position` as moduleposition,`Poprackmodule`.`side` as moduleside,`Poprackmodule`.`ports` as moduleports,`Poprackmodule`.`plug` as moduleplug FROM `Poprack`
LEFT JOIN `Poprackmodule` ON (`Poprackmodule`.`poprack_id`=`Poprack`.`id` AND `Poprackmodule`.`type`=1)
LEFT JOIN `Device` ON (`Device`.`id`=`Poprackmodule`.`device_id`)
WHERE `Poprack`.`pop_id`='" . $pop_id . "' ORDER by `Poprack`.`sort`,`Poprack`.`name`,`Poprackmodule`.start_he,`Poprackmodule`.position)
@@ -68,7 +68,6 @@ class PoprackModel
$oldrackid = "";
$counter = -1;
while ($data = $db->fetch_array($res)) {
if ($oldrackid != $data['id']) {
$counter++;
$items[$counter]['rack']['id'] = $data['id'];
@@ -78,17 +77,17 @@ class PoprackModel
}
if ($data['modulname']) {
if ($data['device_id']) {
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['device_id'] = $data['device_id'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['slots'][$data['moduleid']]['device_id'] = $data['device_id'];
}
$items[$counter]['modules'][$data['start_he']]['start_he'] = $data['start_he'];
$items[$counter]['modules'][$data['start_he']]['end_he'] = $data['end_he'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['modulname'] = $data['modulname'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['moduleid'] = $data['moduleid'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['type'] = $data['type'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['width'] = $data['modulewidth'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['position'] = $data['moduleposition'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['ports'] = $data['moduleports'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['plug'] = $data['moduleplug'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['start_he'] = $data['start_he'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['end_he'] = $data['end_he'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['slots'][$data['moduleid']]['modulname'] = $data['modulname'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['slots'][$data['moduleid']]['moduleid'] = $data['moduleid'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['slots'][$data['moduleid']]['type'] = $data['type'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['slots'][$data['moduleid']]['width'] = $data['modulewidth'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['slots'][$data['moduleid']]['position'] = $data['moduleposition'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['slots'][$data['moduleid']]['ports'] = $data['moduleports'];
$items[$counter]['modules'][$data['moduleside']][$data['start_he']]['slots'][$data['moduleid']]['plug'] = $data['moduleplug'];
$modulecounter++;
}
$oldrackid = $data['id'];
@@ -103,13 +102,12 @@ class PoprackModel
public static function getAllbyRack($rack_id)
{
$items = [];
$db = FronkDB::singleton();
$sql = "(SELECT `Poprackmodule`.`type`,`Poprack`.`id`,`Poprack`.`sort`, `Poprack`.`name`, `Poprack`.`he`,`Poprackmodule`.start_he,`Poprackmodule`.end_he,`Poprackmodule`.`name` as `modulname`,device_id ,`Poprackmodule`.`id` as moduleid,`Poprackmodule`.`width` as modulewidth,`Poprackmodule`.`position` as moduleposition,`Poprackmodule`.`ports` as moduleports,`Poprackmodule`.`plug` as moduleplug FROM `Poprack`
$sql = "(SELECT `Poprackmodule`.`type`,`Poprack`.`id`,`Poprack`.`sort`, `Poprack`.`name`, `Poprack`.`he`,`Poprackmodule`.start_he,`Poprackmodule`.end_he,`Poprackmodule`.`name` as `modulname`,device_id ,`Poprackmodule`.`id` as moduleid,`Poprackmodule`.`width` as modulewidth,`Poprackmodule`.`position` as moduleposition,`Poprackmodule`.`side` as moduleside,`Poprackmodule`.`ports` as moduleports,`Poprackmodule`.`plug` as moduleplug FROM `Poprack`
LEFT JOIN `Poprackmodule` ON (`Poprackmodule`.`poprack_id`=`Poprack`.`id` AND `Poprackmodule`.`type`!=1)
WHERE `Poprack`.`id`='" . $rack_id . "' ORDER by `Poprack`.`sort`,`Poprack`.`name`,`Poprackmodule`.start_he,`Poprackmodule`.position)
UNION
(SELECT `Poprackmodule`.`type`,`Poprack`.`id`,`Poprack`.`sort`, `Poprack`.`name`, `Poprack`.`he`,`Poprackmodule`.start_he,`Poprackmodule`.end_he,`Device`.`name` as `modulname`,device_id,`Poprackmodule`.`id` as moduleid,`Poprackmodule`.`width` as modulewidth,`Poprackmodule`.`position` as moduleposition,`Poprackmodule`.`ports` as moduleports,`Poprackmodule`.`plug` as moduleplug FROM `Poprack`
(SELECT `Poprackmodule`.`type`,`Poprack`.`id`,`Poprack`.`sort`, `Poprack`.`name`, `Poprack`.`he`,`Poprackmodule`.start_he,`Poprackmodule`.end_he,`Device`.`name` as `modulname`,device_id,`Poprackmodule`.`id` as moduleid,`Poprackmodule`.`width` as modulewidth,`Poprackmodule`.`position` as moduleposition,`Poprackmodule`.`side` as moduleside,`Poprackmodule`.`ports` as moduleports,`Poprackmodule`.`plug` as moduleplug FROM `Poprack`
LEFT JOIN `Poprackmodule` ON (`Poprackmodule`.`poprack_id`=`Poprack`.`id` AND `Poprackmodule`.`type`=1)
LEFT JOIN `Device` ON (`Device`.`id`=`Poprackmodule`.`device_id`)
WHERE `Poprack`.`id`='" . $rack_id . "' ORDER by `Poprack`.`sort`,`Poprack`.`name`,`Poprackmodule`.start_he,`Poprackmodule`.position)
@@ -137,15 +135,15 @@ class PoprackModel
if ($data['device_id']) {
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['device_id'] = $data['device_id'];
}
$items[$counter]['modules'][$data['start_he']]['start_he'] = $data['start_he'];
$items[$counter]['modules'][$data['start_he']]['end_he'] = $data['end_he'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['modulname'] = $data['modulname'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['moduleid'] = $data['moduleid'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['type'] = $data['type'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['width'] = $data['modulewidth'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['position'] = $data['moduleposition'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['ports'] = $data['moduleports'];
$items[$counter]['modules'][$data['start_he']]['slots'][$data['moduleid']]['plug'] = $data['moduleplug'];
$items[$counter][$data['moduleside']]['modules'][$data['start_he']]['start_he'] = $data['start_he'];
$items[$counter][$data['moduleside']]['modules'][$data['start_he']]['end_he'] = $data['end_he'];
$items[$counter][$data['moduleside']]['modules'][$data['start_he']]['slots'][$data['moduleid']]['modulname'] = $data['modulname'];
$items[$counter][$data['moduleside']]['modules'][$data['start_he']]['slots'][$data['moduleid']]['moduleid'] = $data['moduleid'];
$items[$counter][$data['moduleside']]['modules'][$data['start_he']]['slots'][$data['moduleid']]['type'] = $data['type'];
$items[$counter][$data['moduleside']]['modules'][$data['start_he']]['slots'][$data['moduleid']]['width'] = $data['modulewidth'];
$items[$counter][$data['moduleside']]['modules'][$data['start_he']]['slots'][$data['moduleid']]['position'] = $data['moduleposition'];
$items[$counter][$data['moduleside']]['modules'][$data['start_he']]['slots'][$data['moduleid']]['ports'] = $data['moduleports'];
$items[$counter][$data['moduleside']]['modules'][$data['start_he']]['slots'][$data['moduleid']]['plug'] = $data['moduleplug'];
$modulecounter++;
}
$oldrackid = $data['id'];

View File

@@ -53,6 +53,7 @@ class PoprackmoduleController extends mfBaseController
$data['end_he'] = $r->end_he;
$data['width'] = $r->width;
$data['position'] = ($r->position) ? $r->position : null;
$data['side'] = ($r->side) ? $r->side : 'front';
$poprackmodule = PoprackmoduleModel::create($data);

View File

@@ -13,6 +13,7 @@ class PoprackmoduleModel
public $ports = null;
public $plug = null;
public $position = null;
public $side = null;
public $create_by = null;
public $edit_by = null;
public $create = null;

View File

@@ -1576,7 +1576,7 @@ class Preorder extends mfBaseModel {
}
if($name === 'fcp') {
if(!$this->adb_hausnummer->fcp_id) return null;
if(!$this->adb_hausnummer || !$this->adb_hausnummer->fcp_id) return null;
return ADBRimoFcp::get($this->adb_hausnummer->fcp_id);
}

View File

@@ -1095,6 +1095,8 @@ class PreorderController extends mfBaseController {
break;
case "denyCancelRequest":
$return = $this->denyCancelRequest();
case "getRimoFcpStats":
$return = $this->getRimoFcpStatsApi();
break;
default:
$return = false;
@@ -1270,11 +1272,28 @@ class PreorderController extends mfBaseController {
if (!$campaign->id) return [];
return array_map(
fn($fcp) => ["id" => $fcp->name ?? null, "text" => $fcp->name ?? null, 'lat' => $fcp->gps_lat ?? null, 'lng' => $fcp->gps_long ?? null],
fn($fcp) => ["real_id" => $fcp->id, "id" => $fcp->name ?? null, "text" => $fcp->name ?? null, 'lat' => $fcp->gps_lat ?? null, 'lng' => $fcp->gps_long ?? null],
ADBRimoFcp::getAll(["netzgebiet_id" => intval($campaign->network->adb_netzgebiet_id)]) ?? []
);
}
public function getRimoFcpStatsApi() {
$this->postData = json_decode(file_get_contents("php://input"));
$stats = ADBRimoFcp::getRimoFcpStatistics();
if (!empty($this->postData->fcp_ids)) {
$fcpIds = (array) $this->postData->fcp_ids;
$stats = array_filter($stats, fn($item) => in_array($item['fcp_id'], $fcpIds));
}
foreach ($stats as &$item)
if (isset($item['counts_by_rimo_type']) && is_string($item['counts_by_rimo_type']))
$item['counts_by_rimo_type'] = json_decode($item['counts_by_rimo_type']);
unset($item);
return array_values($stats);
}
private function setBilledApi() {
$preorder_id = $this->request->id;
@@ -1817,4 +1836,26 @@ class PreorderController extends mfBaseController {
$this->layout()->set("JSGlobals", $JSGlobals);
$this->layout()->setTemplate("VueViews/Vue"); // Assuming a generic Vue template
}
public function RimoTypeMapAction() {
$allowedCampaigns = Helper::getPreorderCampaignFromUser($this->me);
$campaignId = $this->request->preordercampaign_id ?? null;
if (!$campaignId || !in_array($campaignId, $allowedCampaigns)) {
$this->layout()->setFlash("Ungültige oder keine Kampagne ausgewählt.", "warning");
$this->redirect("Preorder", "Index");
}
Helper::renderVue($this, "PreorderRimoTypeMap", "PreorderRimoTypeMap", ["MAPBOX_KEY" => TT_MAPBOX_TILE_API_TOKEN]);
}
public function RimoTypeMapDataAction() {
$input = json_decode(file_get_contents('php://input'), true);
$campaignId = $input['campaignId'] ?? null;
$allowedCampaigns = Helper::getPreorderCampaignFromUser($this->me);
if (!$campaignId || !in_array($campaignId, $allowedCampaigns)) self::sendError('Ungültige oder keine Kampagne ausgewählt.');
$data = PreorderModel::getPreorderRimoTypeData($campaignId);
self::returnJson(['success' => true, 'data' => $data]);
}
}

View File

@@ -469,6 +469,7 @@ class PreorderModel
if (!array_key_exists("<status_code", $filter) && !array_key_exists(">status_code", $filter) && !array_key_exists("status_code", $filter)
&& !array_key_exists("<status_id", $filter) && !array_key_exists(">status_id", $filter) && !array_key_exists("status_id", $filter)) {
$filter["<status_code"] = 899;
$filter["!status_code"] = 20;
}
return self::count($filter);
}
@@ -484,6 +485,7 @@ class PreorderModel
if (!array_key_exists("<status_code", $filter) && !array_key_exists(">status_code", $filter) && !array_key_exists("status_code", $filter)
&& !array_key_exists("<status_id", $filter) && !array_key_exists(">status_id", $filter) && !array_key_exists("status_id", $filter)) {
$filter["<status_code"] = 899;
$filter["!status_code"] = 20;
}
return self::search($filter, $limit, $returnDBRessource, $returnArray);
@@ -708,6 +710,15 @@ class PreorderModel
}
}
if (array_key_exists("!status_code", $filter)) {
$status_code = $filter['!status_code'];
if (is_numeric($status_code)) {
$where .= " AND tt_preorderstatus.code != $status_code";
} elseif (is_array($status_code)) {
$where .= " AND tt_preorderstatus.code NOT IN (" . implode(",", $status_code) . ")";
}
}
if (array_key_exists("<=status_code", $filter)) {
$status_code = $filter['<=status_code'];
if (is_numeric($status_code)) {
@@ -729,7 +740,7 @@ class PreorderModel
$where .= " AND tt_preorder.borderpoint_status='$borderpoint_status'";
}
}
if (array_key_exists("preordercampaign_id", $filter)) {
$preordercampaign_id = $filter['preordercampaign_id'];
@@ -1194,7 +1205,7 @@ class PreorderModel
`".FRONKDB_DBNAME."`.Preorder p
LEFT JOIN `".ADDRESSDB_DBNAME."`.Hausnummer h ON p.adb_hausnummer_id = h.id
LEFT JOIN `".FRONKDB_DBNAME."`.Preorderstatus tt_preorderstatus ON p.status_id = tt_preorderstatus.id
WHERE p.deleted = 0 AND tt_preorderstatus.code < 899";
WHERE p.deleted = 0 AND tt_preorderstatus.code NOT IN (20) AND tt_preorderstatus.code < 899" . $where;
$queryStart = microtime(true);
$res = $db->query($sql . $where);
@@ -1219,7 +1230,7 @@ class PreorderModel
public static function countTotalUnits($preorderCampaignId = null) {
$db = FronkDB::singleton();
$where = "1=1";
$where = " h.rimo_type != 'greenfield' ";
if ($preorderCampaignId) {
$where .= " AND pc.id = $preorderCampaignId";
}
@@ -1264,9 +1275,9 @@ class PreorderModel
}
public static function countHistoryStatus($filter = [], $status_code = null) {
if ($status_code === null) {
die("Please select a status code");
}
if ($status_code === null) {
die("Please select a status code");
}
if (!is_array($filter)) return false;
@@ -1337,5 +1348,35 @@ ORDER BY
return $items;
}
public static function getPreorderRimoTypeData(int $campaignId): array {
$db = FronkDB::singleton(ADDRESSDB_DBHOST, ADDRESSDB_DBUSER, ADDRESSDB_DBPASS, ADDRESSDB_DBNAME);
$fronkDbName = defined('FRONKDB_DBNAME') ? FRONKDB_DBNAME : 'thetool';
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$safeCampaignId = (int)$campaignId;
$sql = "
SELECT
h.id AS hausnummer_id, h.gps_lat, h.gps_long, h.rimo_type, h.rimo_op_state, h.rimo_ex_state, h.hausnummer,
s.name AS strasse_name, plz.plz AS plz_name, o.name AS ortschaft_name,
COUNT(DISTINCT we.id) AS wohneinheit_count,
COUNT(DISTINCT ps.id) AS preorder_count
FROM `{$addressDbName}`.`Hausnummer` AS h
LEFT JOIN `{$addressDbName}`.`Wohneinheit` AS we ON h.id = we.hausnummer_id
LEFT JOIN `{$fronkDbName}`.`Preorder` AS pr ON we.id = pr.adb_wohneinheit_id AND pr.preordercampaign_id = {$safeCampaignId} AND pr.deleted = 0
LEFT JOIN `{$fronkDbName}`.`Preorderstatus` AS ps ON pr.status_id = ps.id AND ps.code < 899
LEFT JOIN `{$addressDbName}`.`Strasse` AS s ON h.strasse_id = s.id
LEFT JOIN `{$addressDbName}`.`Plz` AS plz ON h.plz_id = plz.id
LEFT JOIN `{$addressDbName}`.`Ortschaft` AS o ON h.ortschaft_id = o.id
WHERE h.netzgebiet_id = (
SELECT n.adb_netzgebiet_id FROM `{$fronkDbName}`.`Preordercampaign` pc
JOIN `{$fronkDbName}`.`Network` n ON pc.network_id = n.id
WHERE pc.id = {$safeCampaignId}
) AND h.gps_lat IS NOT NULL AND h.gps_long IS NOT NULL
GROUP BY h.id
ORDER BY h.id
";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
}

View File

@@ -87,9 +87,8 @@ class PreorderlogisticsController extends mfBaseController {
$installation_kit_status_flag = PreorderStatusflagModel::getFirst(["code" => "145"]);
if ($filter['sent'] !== true) {
$filter["preorder_status_flags_disabled"] = [$installation_kit_status_flag->id];
} else {
$filter["preorder_status_flags"] = [$installation_kit_status_flag->id];
}
$pagination['maxItems'] = PreorderModel::countWithLogistics($filter);
$preorders = PreorderModel::searchWithLogistics($filter, $pagination);

View File

@@ -1,248 +0,0 @@
<?php
// RMLWorkorderModel.php
class RMLWorkorderModel extends TTCrudBaseModel {
public int $id;
public int $preorderId;
public ?int $companyId;
public ?int $clusterId;
public string $status;
public ?int $assignmentDate;
public ?int $deadlineDate;
public ?int $appointmentDate;
public int $create;
public int $createBy;
// This method remains unchanged as requested.
public static function getWorkordersByUrgency(string $urgency, ?int $companyId = null): array {
$db = self::getDB();
$table = self::getFullyQualifiedTable();
$whereClause = "WHERE status IN ('assigned', 'scheduled', 'correction_requested')";
if ($companyId) {
$whereClause .= " AND companyId = " . intval($companyId);
}
switch ($urgency) {
case 'red': // Less than 1 week left or overdue
$redDate = strtotime('+1 week');
$whereClause .= " AND deadlineDate IS NOT NULL AND deadlineDate < $redDate";
break;
case 'yellow': // Between 1 and 3 weeks left
$yellowDateStart = strtotime('+1 week');
$yellowDateEnd = strtotime('+3 weeks');
$whereClause .= " AND deadlineDate BETWEEN $yellowDateStart AND $yellowDateEnd";
break;
case 'green': // More than 3 weeks left
$greenDate = strtotime('+3 weeks');
$whereClause .= " AND deadlineDate > $greenDate";
break;
default:
return [];
}
$sql = "SELECT * FROM $table $whereClause ORDER BY deadlineDate ASC";
$result = $db->query($sql);
$orders = [];
while ($row = $result->fetch_assoc()) {
$orders[] = new self($row);
}
return $orders;
}
// --- REFACTORED METHODS ---
private static function buildWhereClause(array $filters, array $allowedCampaignIds): string {
if (empty($allowedCampaignIds)) {
return " WHERE 1=0";
}
$sql = Helper::generateFilterCondition(array_map('intval', $allowedCampaignIds), 'p.preordercampaign_id');
if (!empty($filters['id'])) {
$sql .= Helper::generateFilterCondition($filters['id'], 'w.id', true);
}
if (!empty($filters['status'])) {
$sql .= Helper::generateFilterCondition($filters['status'], 'w.status', true);
}
if (!empty($filters['preordercampaign_id'])) {
$sql .= Helper::generateFilterCondition($filters['preordercampaign_id'], 'p.preordercampaign_id');
}
if (!empty($filters['companyName'])) {
$sql .= Helper::generateFilterCondition($filters['companyName'], 'c.name');
}
if (!empty($filters['deadlineDate'])) {
$sql .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate');
}
if (!empty($filters['preorderInfo'])) {
$searchColumns = "p.firstname|p.lastname|p.company|p.oaid|p.street|p.housenumber|p.zip|p.city|str.name|ort.name";
$sql .= Helper::generateFilterCondition($filters['preorderInfo'], $searchColumns);
} if (!empty($filters['rimo_fcp_name'])) {
$searchColumns = "hn.rimo_fcp_name";
$sql .= Helper::generateFilterCondition($filters['rimo_fcp_name'], $searchColumns);
}
return "WHERE " . ltrim(trim($sql), 'AND');
}
public static function getAdminWorkorders(array $filters, ?int $limit, int $offset, array $order, array $allowedCampaignIds): array {
$db = self::getDB();
$fronkDbName = FRONKDB_DBNAME;
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$sql = "
SELECT
w.id, w.status, w.deadlineDate, w.companyId, p.preordercampaign_id, hn.rimo_fcp_name,
CONCAT_WS(' ', p.firstname, p.lastname) as customerName, p.ucode,
p.company as customerCompany, p.oaid, c.name as companyName,
str.name as street, hn.hausnummer, hn.stiege, we.bezeichner as apartment, plz.plz, ort.name as city
FROM `$fronkDbName`.`RMLWorkorder` w
JOIN `$fronkDbName`.`Preorder` p ON w.preorderId = p.id
LEFT JOIN `$fronkDbName`.`Preordercampaign` pc ON p.preordercampaign_id = pc.id
LEFT JOIN `$fronkDbName`.`RMLWorkorderCompany` c ON w.companyId = c.id
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
LEFT JOIN `$addressDbName`.`Wohneinheit` we ON p.adb_wohneinheit_id = we.id
";
$sql .= self::buildWhereClause($filters, $allowedCampaignIds);
$sql .= " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC";
if (!empty($order['key'])) {
$sortableColumns = ['id', 'status', 'deadlineDate', 'companyName', 'clusterName'];
if (in_array($order['key'], $sortableColumns)) {
$sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC';
$sql .= ", " . $db->real_escape_string($order['key']) . " " . $sortOrder;
}
}
if ($limit !== null) {
$sql .= " LIMIT " . intval($limit) . " OFFSET " . intval($offset);
}
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
public static function countAdminWorkorders(array $filters, array $allowedCampaignIds): int {
$db = self::getDB();
$fronkDbName = FRONKDB_DBNAME;
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$sql = "
SELECT COUNT(w.id) as count
FROM `$fronkDbName`.`RMLWorkorder` w
JOIN `$fronkDbName`.`Preorder` p ON w.preorderId = p.id
LEFT JOIN `$fronkDbName`.`RMLWorkorderCompany` c ON w.companyId = c.id
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
";
$sql .= self::buildWhereClause($filters, $allowedCampaignIds);
$result = $db->query($sql);
if ($result === false) return 0;
$row = $result->fetch_assoc();
return $row['count'] ?? 0;
}
private static function buildCompanyWhereClause(array $filters, int $companyId): string
{
$sql = "c.addressId = " . $companyId;
if (!empty($filters['id'])) {
$sql .= Helper::generateFilterCondition($filters['id'], 'w.id', true);
}
if (!empty($filters['status'])) {
$sql .= Helper::generateFilterCondition($filters['status'], 'w.status');
}
if (!empty($filters['deadlineDate'])) {
$sql .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate');
}
if (!empty($filters['appointmentDate'])) {
$sql .= Helper::generateFilterCondition($filters['appointmentDate'], 'w.appointmentDate');
}
if (!empty($filters['preorderInfo'])) {
$searchColumns = "p.firstname|p.lastname|p.company|p.oaid|p.street|p.housenumber|p.zip|p.city|str.name|ort.name|p.phone|p.email";
$sql .= Helper::generateFilterCondition($filters['preorderInfo'], $searchColumns);
} if (!empty($filters['rimo_fcp_name'])) {
$searchColumns = "hn.rimo_fcp_name";
$sql .= Helper::generateFilterCondition($filters['rimo_fcp_name'], $searchColumns);
}
return "WHERE " . $sql;
}
public static function getCompanyWorkorders(array $filters, ?int $limit, int $offset, array $order, int $companyId): array
{
$db = self::getDB();
$fronkDbName = FRONKDB_DBNAME;
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$sql = "
SELECT
w.id, w.status, w.deadlineDate, w.appointmentDate, hn.rimo_fcp_name,
CONCAT_WS(' ', p.firstname, p.lastname) as customerName,
p.company as customerCompany, p.oaid, p.phone, p.email,
str.name as street, hn.hausnummer, hn.stiege, we.bezeichner as apartment, plz.plz, ort.name as city
FROM `$fronkDbName`.`RMLWorkorder` w
JOIN `$fronkDbName`.`Preorder` p ON w.preorderId = p.id
LEFT JOIN `$fronkDbName`.`RMLWorkorderCompany` c ON w.companyId = c.id
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
LEFT JOIN `$addressDbName`.`Wohneinheit` we ON p.adb_wohneinheit_id = we.id
";
$sql .= self::buildCompanyWhereClause($filters, $companyId);
$orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC";
if (!empty($order['key'])) {
$sortableColumns = ['id', 'status', 'deadlineDate', 'appointmentDate'];
if (in_array($order['key'], $sortableColumns)) {
$sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC';
$orderBy = " ORDER BY " . $db->real_escape_string($order['key']) . " " . $sortOrder;
}
}
$sql .= $orderBy;
if ($limit !== null) {
$sql .= " LIMIT " . intval($limit) . " OFFSET " . intval($offset);
}
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
public static function countCompanyWorkorders(array $filters, int $companyId): int
{
$db = self::getDB();
$fronkDbName = FRONKDB_DBNAME;
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$sql = "
SELECT COUNT(w.id) as count
FROM `$fronkDbName`.`RMLWorkorder` w
JOIN `$fronkDbName`.`Preorder` p ON w.preorderId = p.id
LEFT JOIN `$fronkDbName`.`RMLWorkorderCompany` c ON w.companyId = c.id
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
";
$sql .= self::buildCompanyWhereClause($filters, $companyId);
$result = $db->query($sql);
if ($result === false) {
return 0;
}
$row = $result->fetch_assoc();
return $row['count'] ?? 0;
}
}

View File

@@ -1,333 +1,8 @@
<?php
// RMLWorkorderAdminController.php
class RMLWorkorderAdminController extends TTCrud
{
protected string $headerTitle = 'RML Arbeitsaufträge (Admin)';
protected bool $createText = false;
protected array $permissionCheck = ['RMLAdmin'];
protected array $columns = [
// ['key' => 'id', 'text' => 'Auftrag-Nr.', 'table' => ['sortable' => true]],
['key' => 'preordercampaign_id', 'text' => 'Cluster', 'modal' => false, 'table' => ['filter' => 'select']],
['key' => 'preorderInfo', 'text' => 'Kunde / Projekt', 'modal' => false, 'table' => ['sortable' => false]],
['key' => 'rimo_fcp_name', 'text' => 'FCP', 'modal' => false, 'table' => ['sortable' => false]],
['key' => 'companyName', 'text' => 'Zuständige Firma', 'modal' => false],
['key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'filterOptions' => [
['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'],
['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'],
['value' => 'scheduled', 'text' => 'Terminiert', 'icon' => 'fas fa-calendar-check text-warning'],
['value' => 'correction_requested', 'text' => 'Korrektur angefordert', 'icon' => 'fas fa-exclamation-triangle text-danger'],
['value' => 'intervention_required', 'text' => 'Eingriff benötigt', 'icon' => 'fas fa-times-circle text-danger'],
['value' => 'problem_solved', 'text' => 'Problem behoben', 'icon' => 'fas fa-check-circle text-success'],
['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'],
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'],
]]],
['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date']],
];
protected function indexAction()
{
$campaigns = Helper::getPreorderCampaignFromUser($this->user, true);
$this->columns[array_search('preordercampaign_id', array_column($this->columns, 'key'))]['table']['filterOptions'] = array_map(
fn($c) => ['value' => $c->id, 'text' => $c->name],
$campaigns
);
$this->createWorkordersFromPreorders();
Helper::renderVue($this, 'RMLWorkorderAdmin', $this->headerTitle, [
"CRUD_CONFIG" => $this->getCrudConfig(),
"TABLE_URL" => $this::getUrl("RMLWorkorderAdmin/get"),
]);
}
protected function getAction() {
$json = json_decode(file_get_contents('php://input'), true);
$pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10];
$filters = $json['filters'] ?? [];
$order = $json['order'] ?? [];
$allowedCampaignIds = Helper::getPreorderCampaignFromUser($this->user);
if (empty($allowedCampaignIds)) {
self::returnJson([
'rows' => [],
'pagination' => array_merge($pagination, ['total_rows' => 0, 'total_pages' => 0, 'filtered_available' => 0])
]);
return;
}
$limit = $pagination['per_page'];
$offset = ($pagination['page'] - 1) * $limit;
$workorders = RMLWorkorderModel::getAdminWorkorders($filters, $limit, $offset, $order, $allowedCampaignIds);
$totalCount = RMLWorkorderModel::countAdminWorkorders($filters, $allowedCampaignIds);
$rows = array_map(function($workorder) {
$row = (array)$workorder;
$row['companyName'] ??= 'Nicht zugewiesen';
$row['deadlineDateFormatted'] = $row['deadlineDate'] ? date('d.m.Y', $row['deadlineDate']) : 'Keine Deadline';
$row['daysUntilDeadline'] = $row['deadlineDate'] ? ceil(($row['deadlineDate'] - time()) / (60 * 60 * 24)) : null;
return $row;
}, $workorders);
self::returnJson([
'rows' => $rows,
'pagination' => [
'page' => $pagination['page'],
'per_page' => $pagination['per_page'],
'total_rows' => $totalCount,
'total_pages' => ceil($totalCount / $limit),
'filtered_available' => $totalCount
]
]);
}
private function createWorkordersFromPreorders() {
$newPreorders = PreorderModel::searchActive(['status_code' => 220, 'preorder_status_flags_all' => [3,5]]);
if (empty($newPreorders)) return;
foreach ($newPreorders as $preorder) {
if (!RMLWorkorderModel::getFirst(['preorderId' => $preorder->id])) {
RMLWorkorderModel::create([
'preorderId' => $preorder->id,
'clusterId' => $preorder->preordercampaign_id,
'status' => 'new',
'create' => time(),
'createBy' => $this->user->id
]);
}
}
}
protected function getDocumentationAction() {
if(empty($this->request->workorderId)) self::sendError("Workorder ID missing.");
$docs = RMLWorkorderDocumentationModel::getAll(['workorderId' => $this->request->workorderId], null, 0, ['key' => 'create', 'order' => 'DESC']);
$journals = RMLWorkorderJournalModel::getAll(['workorderId' => $this->request->workorderId], null, 0, ['key' => 'create', 'order' => 'DESC']);
$users = UserModel::search();
foreach($docs as $doc) {
$file = new File($doc->fileId);
$doc->fileName = $file->orig_filename ?? $file->filename;
$doc->userName = UserModel::getOne($doc->createBy)->name ?? 'Unbekannt';
$doc->mimetype = $file->mimetype ?? 'application/octet-stream';
}
foreach($journals as $journal) {
$journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt';
}
self::returnJson(['docs' => $docs, 'journals' => $journals]);
}
private function assignSingleWorkorder($workorderId, $companyId, $deadline, $userId) {
$workorder = RMLWorkorderModel::get($workorderId);
if (!$workorder) {
return false;
}
$company = RMLWorkorderCompanyModel::get($companyId);
if (!$company) {
return false;
}
$workorder->companyId = $companyId;
$workorder->status = 'assigned';
$workorder->assignmentDate = time();
$workorder->deadlineDate = $deadline;
RMLWorkorderModel::update((array)$workorder);
RMLWorkorderJournalModel::create([
'workorderId' => $workorder->id,
'text' => "Firma '{$company->name}' wurde zugewiesen.",
'create' => time(),
'createBy' => $userId,
]);
$preorder = new Preorder($workorder->preorderId);
if ($preorder) {
$preorder->status_id = 10; // Assuming 10 is the status for "assigned"
$preorder->edit_by = $this->user->id;
$preorder->save();
}
return true;
}
protected function assignWorkorderAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty($post['companyId'])) {
self::sendError("Erforderliche Felder fehlen.");
}
$deadline = !empty($post['deadlineDate']) ? $post['deadlineDate'] : strtotime('+6 weeks');
$success = $this->assignSingleWorkorder($post['workorderId'], $post['companyId'], $deadline, $this->user->id);
if ($success) {
self::returnJson(['success' => true, 'message' => 'Auftrag erfolgreich zugewiesen.']);
} else {
self::sendError("Auftrag konnte nicht zugewiesen werden. Möglicherweise wurde er bereits bearbeitet oder existiert nicht.");
}
}
protected function massAssignWorkordersAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderIds']) || empty($post['companyId'])) {
self::sendError("Erforderliche Felder fehlen.");
}
$deadline = strtotime($post['deadlineDate'] ?? '+6 weeks');
$count = 0;
foreach ($post['workorderIds'] as $workorderId) {
if ($this->assignSingleWorkorder($workorderId, $post['companyId'], $deadline, $this->user->id)) {
$count++;
}
}
self::returnJson(['success' => true, 'message' => "$count Aufträge erfolgreich zugewiesen."]);
}
protected function requestCorrectionAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty($post['text'])) self::sendError("Required fields are missing.");
$workorder = RMLWorkorderModel::get($post['workorderId']);
if (!$workorder) self::sendError("Workorder not found.");
$oldStatus = $workorder->status;
$workorder->status = 'correction_requested';
RMLWorkorderModel::update((array)$workorder);
RMLWorkorderJournalModel::create([
'workorderId' => $workorder->id,
'text' => $post['text'],
'fileIds' => !empty($post['fileIds']) ? json_encode($post['fileIds']) : null,
'statusChange' => "$oldStatus -> correction_requested",
'create' => time(),
'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Korrektur wurde angefordert.']);
}
protected function getCompaniesAction() {
$companies = RMLWorkorderCompanyModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
$items = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies);
self::returnJson($items);
}
protected function addJournalAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty(trim($post['text']))) {
self::sendError("Workorder ID and text are required.");
}
RMLWorkorderJournalModel::create([
'workorderId' => $post['workorderId'],
'text' => $post['text'],
'createBy' => $this->user->id,
'create' => time(),
]);
$journals = array_map(
function ($j) {
$j->createByName = UserModel::getOne($j->createBy)->name ?? 'Unbekannt';
return (array)$j;
},
RMLWorkorderJournalModel::getAll(['workorderId' => $post['workorderId']], null, 0, ['key' => 'create', 'order' => 'DESC'])
);
self::returnJson([
'success' => true,
'message' => 'Journal-Eintrag hinzugefügt.',
'journals' => $journals
]);
}
protected function updateDeadlineAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty($post['deadlineDate'])) self::sendError("Required fields are missing.");
$workorder = RMLWorkorderModel::get($post['workorderId']);
if (!$workorder) self::sendError("Workorder not found.");
$workorder->deadlineDate = $post['deadlineDate'];
RMLWorkorderModel::update((array)$workorder);
RMLWorkorderJournalModel::create([
'workorderId' => $workorder->id,
'text' => 'Deadline wurde auf ' . date('d.m.Y', $post['deadlineDate']) . ' geändert.',
'create' => time(),
'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Deadline erfolgreich aktualisiert.']);
}
protected function acceptDocumentationAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId'])) self::sendError("Workorder ID is missing.");
$workorder = RMLWorkorderModel::get($post['workorderId']);
if (!$workorder) self::sendError("Workorder not found.");
if ($workorder->status !== 'documented') {
self::sendError("Die Dokumentation muss zuerst von der Firma als fertig markiert werden.");
}
$oldStatus = $workorder->status;
$workorder->status = 'completed';
RMLWorkorderModel::update((array)$workorder);
RMLWorkorderJournalModel::create([
'workorderId' => $workorder->id,
'text' => 'Dokumentation wurde akzeptiert und der Auftrag abgeschlossen.',
'statusChange' => "$oldStatus -> completed",
'create' => time(),
'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Dokumentation akzeptiert und Auftrag abgeschlossen.']);
}
protected function setToProblemSolvedAction() {
// const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/setToProblemSolved`, {
// workorderId: row.id,
// text: text
// });
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty($post['text'])) {
self::sendError("Workorder ID und Text sind erforderlich.");
}
$workorder = RMLWorkorderModel::get($post['workorderId']);
if (!$workorder) {
self::sendError("Workorder nicht gefunden.");
}
if ($workorder->status !== 'intervention_required') {
self::sendError("Der Auftrag muss den Status 'Eingriff benötigt' haben, um als Problem gelöst markiert zu werden.");
}
$oldStatus = $workorder->status;
$workorder->status = 'problem_solved';
RMLWorkorderModel::update((array)$workorder);
$oldStatusText = $oldStatus === 'intervention_required' ? 'Eingriff benötigt' : $oldStatus;
$problem_solved = 'Problem gelöst';
RMLWorkorderJournalModel::create([
'workorderId' => $workorder->id,
'text' => $post['text'],
'statusChange' => "$oldStatusText -> $problem_solved",
'create' => time(),
'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Auftrag als Problem gelöst markiert.']);
class RMLWorkorderAdminController extends mfBaseController {
protected function init() {
$this->needlogin = true;
$this->redirect("WorkorderAdmin");
}
}

View File

@@ -1,403 +1,8 @@
<?php
// RMLWorkorderCompanyController.php
class RMLWorkorderCompanyController extends TTCrud
{
protected string $headerTitle = 'Meine Arbeitsaufträge';
protected bool $createText = false;
protected array $permissionCheck = ['RMLCompany'];
protected array $columns = [
['key' => 'id', 'text' => 'Auftrag-Nr.', 'table' => ['sortable' => true]],
['key' => 'preorderInfo', 'text' => 'Kunde / Projekt', 'modal' => false, 'table' => ['sortable' => false]],
['key' => 'rimo_fcp_name', 'text' => 'FCP', 'modal' => false, 'table' => ['sortable' => false]],
['key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'filterOptions' => [
['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'],
['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'],
['value' => 'scheduled', 'text' => 'Terminiert', 'icon' => 'fas fa-calendar-check text-warning'],
['value' => 'correction_requested', 'text' => 'Korrektur angefordert', 'icon' => 'fas fa-exclamation-triangle text-danger'],
['value' => 'intervention_required', 'text' => 'Eingriff benötigt', 'icon' => 'fas fa-times-circle text-danger'],
['value' => 'problem_solved', 'text' => 'Problem behoben', 'icon' => 'fas fa-check-circle text-success'],
['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'],
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'],
]]],
['key' => 'deadlineDate', 'text' => 'Deadline', 'modal' => false, 'table' => ['filter' => 'date']],
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date']],
];
protected array $additionalJSVariables = ['COMPANY_ID' => '0'];
protected function prepareCrudConfig() {
$company = RMLWorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
if ($company) {
$this->additionalJSVariables['COMPANY_ID'] = $company->id;
} else {
// Allow access but show no data if not associated
$this->additionalJSVariables['COMPANY_ID'] = 0;
}
}
protected function indexAction()
{
Helper::renderVue($this, 'RMLWorkorderCompany', $this->headerTitle, [
"CRUD_CONFIG" => $this->getCrudConfig(),
"TABLE_URL" => $this::getUrl("RMLWorkorderCompany/get"),
"COMPANY_ID" => $this->additionalJSVariables['COMPANY_ID'],
]);
}
protected function getAction()
{
$json = json_decode(file_get_contents('php://input'), true);
$pagination = $json['pagination'] ?? ['page' => 1, 'per_page' => 10];
$filters = $json['filters'] ?? [];
$order = $json['order'] ?? ['key' => 'deadlineDate', 'order' => 'ASC'];
$companyId = $this->user->address_id;
if ($companyId === 0) {
self::returnJson(['rows' => [], 'pagination' => array_merge($pagination, ['total_rows' => 0, 'total_pages' => 0, 'filtered_available' => 0])]);
return;
}
$workorders = RMLWorkorderModel::getCompanyWorkorders($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order, $companyId);
$totalCount = RMLWorkorderModel::countCompanyWorkorders($filters, $companyId);
$rows = array_map(function($workorder) {
$row = (array)$workorder;
$row['preorderInfo'] = $this->getPreorderInfoTextByData($row);
unset($row['customerName'], $row['customerCompany'], $row['street'], $row['hausnummer'], $row['stiege'], $row['oaid'], $row['apartment'], $row['plz'], $row['city'], $row['phone'], $row['email']);
return $row;
}, $workorders);
self::returnJson([
'rows' => $rows,
'pagination' => [
'page' => $pagination['page'],
'per_page' => $pagination['per_page'],
'total_rows' => $totalCount,
'total_pages' => ceil($totalCount / $pagination['per_page']),
'filtered_available' => $totalCount
]
]);
}
private function getPreorderInfoTextByData($data) {
$anschlussadresse = "{$data['street']} {$data['hausnummer']}";
if ($data['stiege']) $anschlussadresse .= "/{$data['stiege']}";
if ($data['apartment']) $anschlussadresse .= " / WE: {$data['apartment']}";
$anschlussadresse .= ", {$data['plz']} {$data['city']}";
$kunde = $data['customerCompany'] ?: $data['customerName'];
return "<strong>Kunde:</strong> {$kunde}<br>" .
"<strong>Anschluss:</strong> {$anschlussadresse}<br>" .
"<strong>Kontakt:</strong> {$data['phone']} / {$data['email']}<br>" .
"<strong>OAID:</strong> <span class='text-pink'>{$data['oaid']}</span>";
}
public function getWorkorderByIdAction() {
$id = $this->request->id;
if(!$id) self::sendError("ID missing");
$workorder = RMLWorkorderModel::get($id);
if(!$workorder) self::sendError("Workorder not found");
$workorder->preorderInfo = $this->getPreorderInfoText($workorder->preorderId);
self::returnJson((array) $workorder);
}
private function getPreorderInfoText($preorderId) {
$preorder = new Preorder($preorderId);
$anschlussadresse = 'N/A';
if ($preorder->adb_hausnummer_id) {
$hn = $preorder->adb_hausnummer;
$anschlussadresse = "{$hn->strasse->name} {$hn->hausnummer}";
if ($hn->stiege) $anschlussadresse .= "/{$hn->stiege}";
if ($preorder->adb_wohneinheit_id) $anschlussadresse .= " / WE: {$preorder->adb_wohneinheit->bezeichner}";
$anschlussadresse .= ", {$hn->plz->plz} {$hn->ortschaft->name}";
}
$kunde = ($preorder->company) ?: "{$preorder->firstname} {$preorder->lastname}";
return "<strong>Kunde:</strong> {$kunde}<br>" .
"<strong>Anschluss:</strong> {$anschlussadresse}<br>" .
"<strong>Kontakt:</strong> {$preorder->phone} / {$preorder->email}<br>" .
"<strong>OAID:</strong> <span class='text-pink'>{$preorder->oaid}</span>";
}
protected function scheduleAppointmentAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty($post['appointmentDate'])) self::sendError("Required fields are missing.");
$workorder = RMLWorkorderModel::get($post['workorderId']);
if(!$workorder) self::sendError("Workorder not found");
$hour = (int)date('H', $post['appointmentDate']);
if ($hour >= 23 || $hour < 1) {
self::sendError("Bitte Uhrzeit angeben!");
}
$workorder->appointmentDate = $post['appointmentDate'];
$workorder->status = 'scheduled';
RMLWorkorderModel::update((array)$workorder);
RMLWorkorderJournalModel::create([
'workorderId' => $workorder->id,
'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $post['appointmentDate']),
'create' => time(),
'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']);
}
protected function rescheduleAppointmentAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty($post['appointmentDate']) || empty($post['reason'])) {
self::sendError("Required fields are missing.");
}
$workorder = RMLWorkorderModel::get($post['workorderId']);
if(!$workorder) self::sendError("Workorder not found.");
$hour = (int)date('H', $post['appointmentDate']);
if ($hour >= 23 || $hour < 1) {
self::sendError("Bitte Uhrzeit angeben!");
}
$oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A';
$newDateFormatted = date('d.m.Y H:i', $post['appointmentDate']);
$workorder->appointmentDate = $post['appointmentDate'];
RMLWorkorderModel::update((array)$workorder);
RMLWorkorderJournalModel::create([
'workorderId' => $workorder->id,
'text' => "Termin verschoben von {$oldDateFormatted} auf {$newDateFormatted}. Grund: " . $post['reason'],
'create' => time(),
'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']);
}
protected function requestInterventionAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty($post['journalText'])) {
self::sendError("Required fields are missing.");
}
$workorder = RMLWorkorderModel::get($post['workorderId']);
if(!$workorder) self::sendError("Workorder not found.");
$oldStatus = $workorder->status;
$workorder->status = 'intervention_required';
RMLWorkorderModel::update((array)$workorder);
RMLWorkorderJournalModel::create([
'workorderId' => $workorder->id,
'text' => "Eingriff benötigt: " . $post['journalText'],
'statusChange' => "$oldStatus -> intervention_required",
'create' => time(),
'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Eingriff wurde angefordert.']);
}
protected function uploadDocumentationAction()
{
if (empty($_FILES['files']) || empty($_POST['workorderId'])) {
self::returnJson(['error' => 'Required data is missing.']);
return;
}
$workorderId = $_POST['workorderId'];
$description = $_POST['description'] ?? '';
$documentType = $_POST['documentType'] ?? 'general';
$files = $_FILES['files'];
$uploadCount = 0;
foreach ($files['name'] as $index => $name) {
if ($files['error'][$index] === UPLOAD_ERR_OK) {
$_FILES['file'] = [
'name' => $files['name'][$index],
'type' => $files['type'][$index],
'tmp_name' => $files['tmp_name'][$index],
'error' => $files['error'][$index],
'size' => $files['size'][$index]
];
try {
$uploaded = mfUpload::handleFormUpload("file", false, "/RMLWorkorder");
RMLWorkorderDocumentationModel::create([
'workorderId' => $workorderId,
'fileId' => $uploaded->id,
'description' => $description,
'documentType' => $documentType,
'create' => time(),
'createBy' => $this->user->id
]);
$uploadCount++;
} catch (Exception $e) {
error_log("File upload failed for $name: " . $e->getMessage());
}
}
}
$workorder = RMLWorkorderModel::get($workorderId);
if ($workorder->status === 'correction_requested') {
$workorder->status = 'assigned';
RMLWorkorderModel::update((array)$workorder);
$workorder = RMLWorkorderModel::get($workorderId);
}
$formattedDocs = $this->getFormattedDocs($workorderId);
self::returnJson([
'success' => true,
'message' => "$uploadCount Datei(en) erfolgreich hochgeladen.",
'docs' => $formattedDocs,
'workorder' => (array)$workorder
]);
}
private function getFormattedDocs($workorderId) {
$docs = RMLWorkorderDocumentationModel::getAll(['workorderId' => $workorderId], null, 0, ['key' => 'create', 'order' => 'ASC']);
$responseDocs = [];
$typeCounts = [];
$translationMap = [
'photo_hup_mounted' => 'Foto_montierter_HÜP',
'photo_hup_open' => 'Foto_offener_HÜP',
'photo_splice_cassette' => 'Foto_Spleißkassette',
'photo_hup_closed_stickers' => 'Foto_geschlossener_HÜP_mit_Aufklebern',
'photo_fcp_labeled' => 'Foto_FCP_beschriftet',
'measurement_protocol_otdr' => 'ODTR_Messung',
];
foreach($docs as $doc) {
$file = new File($doc->fileId);
$documentTypeKey = $doc->documentType;
if (!isset($typeCounts[$documentTypeKey])) {
$typeCounts[$documentTypeKey] = 1;
} else {
$typeCounts[$documentTypeKey]++;
}
$originalFilename = $file->orig_filename ?? $file->filename;
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
$translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey;
$newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension);
$responseDocs[] = [
'id' => $doc->id,
'fileId' => $doc->fileId,
'fileName' => $newFilename,
'documentType' => $documentTypeKey,
'mimetype' => $file->mimetype,
];
}
return $responseDocs;
}
protected function getDocumentationAction() {
if(empty($this->request->workorderId)) self::sendError("Workorder ID missing.");
$docs = $this->getFormattedDocs($this->request->workorderId);
$journals = array_map(
function ($j) {
$j->createByName = UserModel::getOne($j->createBy)->getAbbrName();
return (array)$j;
},
RMLWorkorderJournalModel::getAll(['workorderId' => $this->request->workorderId], null, 0, ['key' => 'create', 'order' => 'DESC'])
);
self::returnJson(['docs' => $docs, 'journals' => $journals]);
}
protected function completeWorkorderAction() {
$post = json_decode(file_get_contents('php://input'), true);
if(empty($post['workorderId'])) self::sendError("Workorder ID missing.");
$workorder = RMLWorkorderModel::get($post['workorderId']);
if(!$workorder) self::sendError("Workorder not found.");
$workorder->status = 'documented';
RMLWorkorderModel::update((array)$workorder);
self::returnJson(['success' => true, 'message' => 'Auftrag abgeschlossen.']);
}
protected function deleteDocumentationAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['id'])) self::sendError("Document ID missing.");
$doc = RMLWorkorderDocumentationModel::get($post['id']);
if (!$doc) self::sendError("Document not found.");
$workorderId = $doc->workorderId;
RMLWorkorderDocumentationModel::delete($post['id']);
$formattedDocs = $this->getFormattedDocs($workorderId);
self::returnJson([
'success' => true,
'message' => 'Dokument gelöscht.',
'docs' => $formattedDocs
]);
}
protected function updateDocumentationAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['id'])) self::sendError("Document ID missing.");
$doc = RMLWorkorderDocumentationModel::get($post['id']);
if (!$doc) self::sendError("Dokument nicht gefunden.");
if (isset($post['documentType'])) {
$doc->documentType = $post['documentType'];
}
RMLWorkorderDocumentationModel::update((array)$doc);
$formattedDocs = $this->getFormattedDocs($doc->workorderId);
self::returnJson([
'success' => true,
'message' => 'Dokument aktualisiert.',
'docs' => $formattedDocs
]);
}
protected function addJournalAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty(trim($post['text']))) {
self::sendError("Workorder ID and text are required.");
}
RMLWorkorderJournalModel::create([
'workorderId' => $post['workorderId'],
'text' => $post['text'],
'createBy' => $this->user->id,
'create' => time(),
]);
$journals = array_map(
function ($j) {
$j->createByName = UserModel::getOne($j->createBy)->getAbbrName();
return (array)$j;
},
RMLWorkorderJournalModel::getAll(['workorderId' => $post['workorderId']], null, 0, ['key' => 'create', 'order' => 'DESC'])
);
self::returnJson([
'success' => true,
'message' => 'Journal-Eintrag hinzugefügt.',
'journals' => $journals
]);
class RMLWorkorderCompanyController extends mfBaseController {
protected function init() {
$this->needlogin = true;
$this->redirect("WorkorderCompany");
}
}

View File

@@ -1,10 +0,0 @@
<?php
// RMLWorkorderCompanyModel.php
class RMLWorkorderCompanyModel extends TTCrudBaseModel {
public int $id;
public int $addressId;
public string $name;
public int $create;
public int $createBy;
}

View File

@@ -17,12 +17,8 @@ class UserController extends mfBaseController
$this->me = $me;
$this->layout()->set("me", $me);
if (!$me->isAdmin()) {
// all users can call non-action methods
if ($this->action != "" || $request != null) {
$this->redirect("Dashboard");
}
}
if (!$me->isAdmin() && ($this->action != "" || $request != null)) $this->redirect("Dashboard");
if ($_SERVER['REQUEST_METHOD'] === 'POST') $this->postData = json_decode(file_get_contents('php://input'), true);
}
protected function indexAction($request)
@@ -45,53 +41,140 @@ class UserController extends mfBaseController
"isActive" => $user->active,
"id" => $user->id
], UserModel::getAll()),
"ADD_URL" => self::getUrl("User", "add"),
"EDIT_URL" => self::getUrl("User", "edit"),
"ADD_URL" => self::getUrl("User", "Form"),
"EDIT_URL" => self::getUrl("User", "Form"),
"IMPERSONATE_URL" => self::getUrl("User", "impersonate"),
]);
}
protected function addAction($request)
{
if (!$this->isAdmin()) {
throw new Exception("Forbidden", 403);
}
$this->layout()->setTemplate('User/Form');
protected function formAction() {
if (!$this->isAdmin()) $this->redirect("Dashboard");
$roles = TT_NETWORK_ROLES_WITH_OWNER;
$roles[] = "systemowner";
$addresses = AddressModel::search(["addresstype" => $roles]);
$this->layout()->set("addresses", $addresses);
if ($this->request->address_id) {
$this->layout()->set("address_id", $this->request->address_id);
$id = $this->request->id;
$user = ($id && is_numeric($id) && $id > 0) ? new User($id) : new User();
if ($user->id) {
$pageTitle = "Benutzer bearbeiten: " . $user->name;
} else {
$user->id = null;
$user->permissions = (object)['data' => []];
$pageTitle = "Benutzer erstellen";
}
if ($user->id && !$user->id) throw new Exception("User not found.", 404);
$flags = $user->id ? $this->getFlags($user) : [];
$userData = array_merge(
$user->toArray(),
$flags,
['permissions' => (array)$user->permissions->data]
);
$lookups = [
"addresses" => array_map(fn($addr) => ['value' => $addr->id, 'text' => $addr->company ?: $addr->getFullName()], AddressModel::getAll()),
"networks" => array_map(fn($net) => ['value' => $net->id, 'text' => $net->name], NetworkModel::getAll()),
"consentProjects" => array_map(fn($proj) => ['value' => $proj->id, 'text' => $proj->name], ConstructionConsentProject::getAll()),
"permissionTemplates" => UserPermissionTemplateModel::getAll([], null, 0, ['key' => 'name', 'order' => 'asc']),
"users" => array_map(fn($u) => ['value' => $u->id, 'text' => $u->name], UserModel::search(['active' => 1])),
];
Helper::renderVue($this, "UserEdit", $pageTitle, [
"USER_DATA" => $userData,
"LOOKUPS" => $lookups,
"PERMISSIONS_CONFIG" => TT_USER_PERMISSION,
"SAVE_URL" => self::getUrl("User", "save"),
"API_KEY_URL" => self::getUrl("User", "generateApikey"),
]);
}
protected function editAction($request)
{
if (!$this->isAdmin()) {
throw new Exception("Forbidden", 403);
}
$this->layout()->setTemplate('User/Form');
private function getFlags(User $user): array {
$flags = [
'preorder_networks' => $user->getFlag("preorder_networks")->value(),
'constructionconsent_projects' => $user->getFlag("constructionConsent_projects")->value(),
'employee_number' => $user->getFlag("employee_number")->value(),
'project_api_key' => $user->getFlag("project_api_key")->value(),
'vodia_identity_domain' => $user->getFlag("vodia_identity_domain")->value(),
'vodia_identity_username' => $user->getFlag("vodia_identity_username")->value(),
'vodia_identity_default' => $user->getFlag("vodia_identity_default")->value(),
];
$id = $request['id'];
if (!is_numeric($id) || $id <= 0) {
throw new Exception("User $id not found", 604);
}
$jsonKeys = ['preorder_networks', 'constructionconsent_projects'];
foreach ($flags as $key => &$value)
if (in_array($key, $jsonKeys) && $value) $value = json_decode($value, true);
return $flags;
}
protected function getUserDataForTemplateAction() {
$id = $this->request->id;
if (!$id) self::sendError("User ID is required.");
$user = new User($id);
$this->layout()->set('user', $user);
if (!$user->id) self::sendError("User not found.");
$addresses = AddressModel::getAll();
$this->layout()->set("addresses", $addresses);
$preorderNetworks = $user->getFlag("preorder_networks")->value();
$consentProjects = $user->getFlag("constructionConsent_projects")->value();
self::returnJson([
'permissions' => (array)$user->permissions->data,
'preorder_networks' => $preorderNetworks ? json_decode($preorderNetworks, true) : [],
'constructionconsent_projects' => $consentProjects ? json_decode($consentProjects, true) : [],
'vodia_identity_domain' => $user->getFlag("vodia_identity_domain")->value(),
'vodia_identity_default' => $user->getFlag("vodia_identity_default")->value(),
]);
}
protected function generateApikeyAction($request)
{
if (!$this->isAdmin()) {
$this->redirect("Dashboard");
protected function managePermissionTemplatesAction() {
Helper::renderVue($this, "UserPermissionTemplate", "Berechtigungsvorlagen", ["PERMISSIONS_CONFIG" => TT_USER_PERMISSION]);
}
protected function getPermissionTemplatesAction() {
self::returnJson(array_map(
function ($perm) {
$perm = (array)$perm;
$perm['permissions'] = json_decode($perm['permissions'], true) ?: [];
return $perm;
}, UserPermissionTemplateModel::getAll([], null, 0, ['key' => 'name', 'order' => 'asc'])
));
}
protected function savePermissionTemplateAction() {
if (empty($this->postData['name'])) self::sendError("Template name is required.");
$data = [
'name' => $this->postData['name'],
'permissions' => json_encode($this->postData['permissions'] ?? []),
];
if (empty($this->postData['id'])) {
$data += ['createBy' => $this->user->id, 'create' => time()];
$id = UserPermissionTemplateModel::create($data);
self::returnJson(['success' => true, 'message' => 'Vorlage erstellt.', 'id' => $id]);
}
$template = UserPermissionTemplateModel::get($this->postData['id']);
$data += [
'id' => $this->postData['id'],
'create' => $template->create,
'createBy' => $template->createBy,
];
UserPermissionTemplateModel::update($data);
self::returnJson(['success' => true, 'message' => 'Vorlage gespeichert.']);
}
protected function deletePermissionTemplateAction() {
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['id'])) self::sendError("Template ID is required.");
UserPermissionTemplateModel::delete($post['id']);
self::returnJson(['success' => true, 'message' => 'Vorlage gelöscht.']);
}
protected function generateApikeyAction($request) {
if (!$this->isAdmin()) $this->redirect("Dashboard");
$id = $request['id'];
if (!is_numeric($id) || $id < 1) {
$this->layout()->setFlash("User nicht gefunden.", "error");
@@ -112,28 +195,19 @@ class UserController extends mfBaseController
}
protected function profileAction($request)
{
}
protected function saveAction()
{
protected function saveAction() {
$r = $this->request;
$id = $r->id;
if (!$this->isAdmin()) {
$id = $this->me->id;
$request['username'] = $this->me->username;
unset($r->address_id);
}
if (!$id && !$r->username) {
self::redirect('User');
}
if (!$id && !$r->username) self::redirect('User');
$user = new User($id);
// check if new user already exits
if ($this->isAdmin() && !$r->id) {
$tu = new User();
$tu->loadByUsername($r->username);
@@ -145,28 +219,16 @@ class UserController extends mfBaseController
$user->active = $r->active === "true" ? 1 : 0;
if (!$user->permissions) {
$user->permissions = new WorkerPermission();
}
if ($r->username) {
$user->username = $r->username;
}
if ($r->name) {
$user->name = $r->name;
}
if ($r->email) {
$user->email = $r->email;
}
if ($r->mobile) {
$user->mobile = $r->mobile;
} else {
$user->mobile = NULL;
}
if (!$user->permissions) $user->permissions = new WorkerPermission();
if ($r->username) $user->username = $r->username;
if ($r->name) $user->name = $r->name;
if ($r->email) $user->email = $r->email;
if ($r->mobile) $user->mobile = $r->mobile;
else $user->mobile = NULL;
if ($this->isAdmin()) {
if ($r->address_id) {
$user->address_id = intval($r->address_id);
//var_dump($user);exit;
$address = new Address($user->address_id);
if (!$address->id) {
throw new Exception("Unbekannte Firma/Person");
@@ -175,12 +237,7 @@ class UserController extends mfBaseController
$user->address_id = null;
}
// 2fa required
if($r->twofactorrequired == "true") {
$user->twofactorrequired = 1;
} else {
$user->twofactorrequired = 0;
}
$user->twofactorrequired = ($r->twofactorrequired == "true") ? 1 : 0;
}
if ($r->password) {
@@ -199,177 +256,69 @@ class UserController extends mfBaseController
$id = $user->save();
if ($this->isAdmin()) {
if ($r->admin == "true" || $user->id == 1) {
$user->permissions->admin = "true";
} else {
$user->permissions->admin = "false";
$user->permissions->admin = ($r->admin == "true" || $user->id == 1) ? "true" : "false";
$user->permissions->employee = ($r->employee == "true") ? "true" : "false";
$user->permissions->technician = ($r->technician == "true") ? "true" : "false";
$user->permissions->preorderfront = ($r->preorderfront == "true") ? "true" : "false";
$user->permissions->preorderlogistics = ($r->preorderlogistics == "true") ? "true" : "false";
$user->permissions->preorderaddressreporting = ($r->preorderaddressreporting == "true") ? "true" : "false";
$user->permissions->preorderreadonly = ($r->preorderreadonly == "true") ? "true" : "false";
$canPermissions = [
'Building', 'Pipework', 'Linework', 'Patching', 'Filestore',
'Cpeprovisioning', 'Cpeshipping', 'Voipnumbering', 'Preorder',
'Preorderpricing', 'PreorderpricingReadonly', 'Preorderbilling',
'PreorderbillingReadonly', 'Order', 'Billing', 'Fibu', 'Statistics',
'WarehouseAdmin', 'WarehouseEShop', 'WarehouseUser', 'ADBExtended',
'AssetAdmin', 'RMLAdmin', 'RMLCompany'
];
foreach ($canPermissions as $perm) {
$user->permissions->{"can" . $perm} = "false";
}
if ($r->employee == "true") {
$user->permissions->employee = "true";
} else {
$user->permissions->employee = "false";
}
if ($r->technician == "true") {
$user->permissions->technician = "true";
} else {
$user->permissions->technician = "false";
}
if ($r->preorderfront == "true") {
$user->permissions->preorderfront = "true";
} else {
$user->permissions->preorderfront = "false";
}
if ($r->preorderlogistics == "true") {
$user->permissions->preorderlogistics = "true";
} else {
$user->permissions->preorderlogistics = "false";
}
if ($r->preorderaddressreporting == "true") {
$user->permissions->preorderaddressreporting = "true";
} else {
$user->permissions->preorderaddressreporting = "false";
}
if ($r->preorderreadonly == "true") {
$user->permissions->preorderreadonly = "true";
} else {
$user->permissions->preorderreadonly = "false";
}
// set can permissions
$user->permissions->canBuilding = "false";
$user->permissions->canPipework = "false";
$user->permissions->canLinework = "false";
$user->permissions->canPatching = "false";
$user->permissions->canFilestore = "false";
$user->permissions->canCpeprovisioning = "false";
$user->permissions->canCpeshipping = "false";
$user->permissions->canVoipnumbering = "false";
$user->permissions->canPreorder = "false";
$user->permissions->canPreorderpricing = "false";
$user->permissions->canPreorderpricingReadonly = "false";
$user->permissions->canPreorderbilling = "false";
$user->permissions->canPreorderbillingReadonly = "false";
$user->permissions->canOrder = "false";
$user->permissions->canBilling = "false";
$user->permissions->canFibu = "false";
$user->permissions->canStatistics = "false";
$user->permissions->canWarehouseAdmin = "false";
$user->permissions->canWarehouseEShop = "false";
$user->permissions->canWarehouseUser = "false";
$user->permissions->canADBExtended = "false";
$user->permissions->canAssetAdmin = "false";
$user->permissions->canRMLAdmin = "false";
$user->permissions->canRMLCompany = "false";
if($r->get("can") && is_array($r->can)) {
foreach($r->can as $key => $can) {
//var_dump($key . "=> ".$can);
if($can) {
$user->permissions->{"can$key"} = "true";
if ($r->get("can") && is_array($r->can)) {
foreach ($r->can as $key => $can) {
if ($can) {
$user->permissions->{"can" . $key} = "true";
}
}
}
}
$user->permissions->save();
// save networks
$pn = $user->getFlag("preorder_networks");
if (is_array($r->preorder_networks) && count($r->preorder_networks)) {
$pn->value(json_encode($r->preorder_networks));
$pn->save();
function handleWorkerFlag(User $user, $request, string $flagName, $requestKey, $permissionCheck = null) {
$flag = new WorkerFlag($user->id, $flagName);
$value = $request->$requestKey;
if ($value && (!$permissionCheck || $user->permissions->$permissionCheck === "true")) {
$flag->value(is_array($value) ? json_encode($value) : $value);
$flag->save();
return true;
}
$flag->delete();
return false;
}
$preorderNetworks = handleWorkerFlag($user, $r, "preorder_networks", "preorder_networks");
if ($preorderNetworks) {
$user->permissions->canPreorder = "true";
$user->permissions->save();
} else {
$pn->delete();
}
$constructionConsentProjects = $user->getFlag("constructionConsent_projects");
if (is_array($r->constructionconsent_projects) && count($r->constructionconsent_projects)) {
$constructionConsentProjects->value(json_encode($r->constructionconsent_projects));
$constructionConsentProjects->save();
} else {
$constructionConsentProjects->delete();
}
// employee number
$enum = new WorkerFlag($user->id, "employee_number");
if($r->employee_number && $user->permissions->employee == "true") {
$enum->value($r->employee_number);
$enum->save();
} else {
$enum->delete();
}
// workerflag for project_api_key
$pak = new WorkerFlag($user->id, "project_api_key");
if($r->project_api_key) {
$pak->value($r->project_api_key);
$pak->save();
} else {
$pak->delete();
}
// vodia identity data
$vid = new WorkerFlag($user->id, "vodia_identity_domain");
if($r->vodia_identity_domain) {
$vid->value($r->vodia_identity_domain);
$vid->save();
} else {
$vid->delete();
}
$viu = new WorkerFlag($user->id, "vodia_identity_username");
if($r->vodia_identity_username) {
$viu->value($r->vodia_identity_username);
$viu->save();
} else {
$viu->delete();
}
$vdi = new WorkerFlag($user->id, "vodia_identity_default");
if($r->vodia_identity_default) {
$vdi->value($r->vodia_identity_default);
$vdi->save();
} else {
$vdi->delete();
}
handleWorkerFlag($user, $r, "constructionConsent_projects", "constructionconsent_projects");
handleWorkerFlag($user, $r, "employee_number", "employee_number", "employee");
handleWorkerFlag($user, $r, "project_api_key", "project_api_key");
handleWorkerFlag($user, $r, "vodia_identity_domain", "vodia_identity_domain");
handleWorkerFlag($user, $r, "vodia_identity_username", "vodia_identity_username");
handleWorkerFlag($user, $r, "vodia_identity_default", "vodia_identity_default");
}
$this->layout()->setFlash("Benutzer gespeichert.", "success");
self::redirect('User');
}
protected function deleteAction($request)
{
$this->layout()->setFlash("nope");
$this->redirect("User");
if (!$this->isAdmin()) {
$this->redirect("Dashboard");
}
$id = $request['id'];
if (!is_numeric($id) || $id <= 0) {
throw new Exception("User $id not found", 604);
}
$user = new User($id);
if ($user->id == $id) {
$user->delete();
}
self::redirect("User");
}
protected function pwchangeAction($request)
{
$me = new User();

View File

@@ -0,0 +1,20 @@
<?php
class UserPermissionTemplateModel extends TTCrudBaseModel {
public int $id;
public string $name;
public string $permissions; // JSON
public int $create;
public int $createBy;
}
//SQL:
// CREATE TABLE `UserPermissionTemplate` (
// `id` int NOT NULL AUTO_INCREMENT,
// `name` varchar(255) NOT NULL,
// `permissions` text NOT NULL,
// `create` int NOT NULL,
// `createBy` int NOT NULL,
// PRIMARY KEY (`id`)
// ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
// ALTER TABLE `UserPermissionTemplate` ADD UNIQUE(`name`);

View File

@@ -343,6 +343,7 @@ class WarehouseOfferController extends TTCrud
$p['articleDescription'] = ($article->description !== $article->title) ? ($article->description ?? '') : '';
$p['articleUnit'] = $p['unit'] ?? $article->unit ?? 'Stk.';
$p['price'] = (float)($p['unitPrice'] ?? 0);
$p['discount'] = isset($p['discount']) ? (float)$p['discount'] : 0;
$p['amount'] = (float)($p['amount'] ?? 0);
$discount = isset($p['discount']) ? (float)$p['discount'] : 0;
$p['totalPrice'] = ($p['price'] * $p['amount']) * (1 - $discount / 100);

View File

@@ -45,19 +45,21 @@ class WarehouseShippingNoteController extends TTCrud {
fn($p) => $p['status'] === 'new' ?: 'Status muss "Neu" sein',
fn($p) => $this->validateHours($p['hoursEntries'])
]);
$postData['positions'] = json_encode($postData['positions']);
$this->postData['positions'] = json_encode($this->postData['positions']);
if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']);
return true;
}
protected function beforeUpdate($postData): bool {
if (!$this->user->can('WarehouseAdmin')) {
$this->validate($postData, [
fn($p) => !in_array(WarehouseShippingNoteModel::get($p['id'])->status,
['accepted', 'invoiced']) ?: 'Änderungen nicht mehr möglich',
fn($p) => $this->validateHours($p['hoursEntries'])
]);
$this->validate($postData, [
fn($p) => !in_array(WarehouseShippingNoteModel::get($p['id'])->status,
['accepted', 'invoiced']) ?: 'Änderungen nicht mehr möglich',
fn($p) => $this->validateHours($p['hoursEntries'])
]);
}
$postData['positions'] = json_encode($postData['positions']);
$this->postData['positions'] = json_encode($this->postData['positions']);
if (isset($this->postData['metadata'])) $this->postData['metadata'] = json_encode($this->postData['metadata']);
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
}
@@ -697,6 +699,11 @@ class WarehouseShippingNoteController extends TTCrud {
}
$eventsJson = CalendarModel::getCalendarEvents($this->user, 0, 0, true);
if (isset($_GET['die_calendar'])) {
die($eventsJson);
}
$allEvents = json_decode($eventsJson, true)['data'] ?? [];
if (!is_array($allEvents)) {
@@ -738,7 +745,9 @@ class WarehouseShippingNoteController extends TTCrud {
return [
'date' => date('Y-m-d H:i:s', strtotime($eventStart)),
'location' => $event['location']['location'] ?? '',
'category' => $event['category']['category'] ?? ''
'category' => $event['category']['category'] ?? '',
'ccategory' => $event['ccategory']['ccategory'] ?? '',
'event_type' => $event['event_type']['event_type'] ?? '',
];
}, $limitedEvents);
@@ -785,30 +794,40 @@ class WarehouseShippingNoteController extends TTCrud {
self::returnJson($logs);
}
protected function swAction() {
$javascript = "self.addEventListener('install', event => {
console.log('Patching PWA Service Worker: Installing...');
protected function swAction() {
// This script's only job is to unregister the service worker.
$javascript = "
self.addEventListener('install', event => {
// Take control immediately so we can proceed to the 'activate' step.
self.skipWaiting();
console.log('Deregistering PWA Service Worker: Installing...');
});
self.addEventListener('activate', event => {
console.log('Patching PWA Service Worker: Activating...');
});
console.log('Deregistering PWA Service Worker: Activating...');
// The main command to unregister the service worker.
self.registration.unregister()
.then(() => {
// After unregistering, get a list of all clients (open tabs/windows).
return self.clients.matchAll();
})
.then(clients => {
// Reload all clients to ensure they are no longer controlled by the SW.
clients.forEach(client => client.navigate(client.url));
console.log('Service worker has been successfully deregistered.');
});
});";
self.addEventListener('fetch', event => {
event.respondWith(fetch(event.request));
});
header("Content-Type: application/javascript");
header("Service-Worker-Allowed: /");
// Ensure the browser never caches this deregistering script.
header("Cache-Control: no-cache, no-store, must-revalidate");
header("Pragma: no-cache");
header("Expires: 0");
console.log('Patching PWA Service Worker: Script loaded.');";
header("Content-Type: application/javascript");
header("Service-Worker-Allowed: /");
header("Cache-Control: no-cache");
header("Pragma: no-cache");
header("Expires: 0");
echo $javascript;
exit;
}
echo $javascript;
exit;
}
}

View File

@@ -4,6 +4,7 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel {
public int $id;
public ?int $billingAddressId;
public ?string $type;
public ?string $metadata;
public string $deliveryAddressName;
public string $deliveryAddressLine;
public string $deliveryAddressPLZ;
@@ -20,6 +21,4 @@ class WarehouseShippingNoteModel extends TTCrudBaseModel {
public ?int $eShopOrderId;
public ?int $create;
public ?int $createBy;
}
}

View File

@@ -0,0 +1,196 @@
<?php
// WorkorderModel.php
class WorkorderModel extends TTCrudBaseModel
{
public int $id;
public int $preorderId;
public ?int $companyId;
public ?int $civilEngineeringCompanyId;
public ?int $originalCompanyId;
public ?int $clusterId;
public string $status;
public ?int $assignmentDate;
public ?int $deadlineDate;
public ?int $appointmentDate;
public ?string $additionalInfo;
public int $create;
public int $createBy;
private static function buildWhereClause(array $filters, array $allowedCampaignIds): string
{
if (empty($allowedCampaignIds)) return " WHERE 1=0";
$sql = Helper::generateFilterCondition(array_map('intval', $allowedCampaignIds), 'p.preordercampaign_id');
if (empty($filters['status'])) {
$sql .= " AND w.status NOT IN ('completed', 'cancelled')";
} else {
$sql .= Helper::generateFilterCondition($filters['status'], 'w.status', true);
}
if (!empty($filters['id'])) $sql .= Helper::generateFilterCondition($filters['id'], 'w.id', true);
if (!empty($filters['preordercampaign_id'])) $sql .= Helper::generateFilterCondition($filters['preordercampaign_id'], 'p.preordercampaign_id');
if (!empty($filters['companyName'])) $sql .= Helper::generateFilterCondition($filters['companyName'], 'c.name');
if (!empty($filters['netOwnerId'])) $sql .= Helper::generateFilterCondition($filters['netOwnerId'], 'n.owner_id');
if (!empty($filters['networkOwnerName'])) $sql .= Helper::generateFilterCondition($filters['networkOwnerName'], 'owner_addr.company');
if (!empty($filters['deadlineDate'])) $sql .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate');
if (!empty($filters['preorderInfo'])) {
$searchColumns = "p.firstname|p.lastname|p.company|p.oaid|p.street|p.housenumber|p.zip|p.city|str.name|ort.name|w.additionalInfo";
$sql .= Helper::generateFilterCondition($filters['preorderInfo'], $searchColumns);
}
if (!empty($filters['rimo_fcp_name'])) $sql .= Helper::generateFilterCondition($filters['rimo_fcp_name'], "hn.rimo_fcp_name");
if (!empty($filters['additionalInfo'])) $sql .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo');
return "WHERE " . ltrim(trim($sql), 'AND');
}
public static function getAdminWorkorders(array $filters, ?int $limit, int $offset, array $order, array $allowedCampaignIds): array
{
$db = self::getDB();
$fronkDbName = FRONKDB_DBNAME;
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$sql = "
SELECT
w.id, w.status, w.deadlineDate, w.appointmentDate, w.companyId, w.additionalInfo, p.preordercampaign_id, hn.rimo_fcp_name,
n.owner_id as tenantId, p.ucode, CONCAT_WS(' ', p.firstname, p.lastname) as customerName,
p.company as customerCompany, p.oaid, IFNULL(c_civil.name, c.name) as companyName, str.name as street, hn.hausnummer,
hn.stiege, we.bezeichner as apartment, plz.plz, ort.name as city,
n.owner_id as netOwnerId
FROM `$fronkDbName`.`Workorder` w
JOIN `$fronkDbName`.`Preorder` p ON w.preorderId = p.id
LEFT JOIN `$fronkDbName`.`Preordercampaign` pc ON p.preordercampaign_id = pc.id
LEFT JOIN `$fronkDbName`.`Network` n ON pc.network_id = n.id
LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id
LEFT JOIN `$fronkDbName`.`WorkorderCompany` c_civil ON w.civilEngineeringCompanyId = c_civil.id
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
LEFT JOIN `$addressDbName`.`Wohneinheit` we ON p.adb_wohneinheit_id = we.id
";
$sql .= self::buildWhereClause($filters, $allowedCampaignIds);
$orderBy = "";
if (!empty($order['key'])) {
$sortableColumns = ['id', 'status', 'deadlineDate', 'companyName', 'rimo_fcp_name', 'additionalInfo'];
if (in_array($order['key'], $sortableColumns)) {
$sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC';
$orderBy = " ORDER BY " . $db->real_escape_string($order['key']) . " " . $sortOrder;
}
}
if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC";
$sql .= $orderBy;
if ($limit !== null) $sql .= " LIMIT " . intval($limit) . " OFFSET " . intval($offset);
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
public static function countAdminWorkorders(array $filters, array $allowedCampaignIds): int
{
$db = self::getDB();
$fronkDbName = FRONKDB_DBNAME;
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$sql = "
SELECT COUNT(w.id) as count FROM `$fronkDbName`.`Workorder` w
JOIN `$fronkDbName`.`Preorder` p ON w.preorderId = p.id
LEFT JOIN `$fronkDbName`.`WorkorderCompany` c ON w.companyId = c.id
LEFT JOIN `$fronkDbName`.`WorkorderCompany` c_civil ON w.civilEngineeringCompanyId = c_civil.id
LEFT JOIN `$fronkDbName`.`Preordercampaign` pc ON p.preordercampaign_id = pc.id
LEFT JOIN `$fronkDbName`.`Network` n ON pc.network_id = n.id
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
";
$sql .= self::buildWhereClause($filters, $allowedCampaignIds);
$result = $db->query($sql);
return $result ? $result->fetch_assoc()['count'] : 0;
}
private static function buildCompanyWhereClause(array $filters, int $companyId): string
{
$sql = "(w.companyId = " . $companyId . " OR w.civilEngineeringCompanyId = " . $companyId . ")";
if (empty($filters['status'])) $sql .= " AND w.status NOT IN ('completed', 'cancelled')";
else $sql .= Helper::generateFilterCondition($filters['status'], 'w.status', true);
if (!empty($filters['id'])) $sql .= Helper::generateFilterCondition($filters['id'], 'w.id', true);
if (!empty($filters['status'])) $sql .= Helper::generateFilterCondition($filters['status'], 'w.status');
if (!empty($filters['deadlineDate'])) $sql .= Helper::generateFilterCondition($filters['deadlineDate'], 'w.deadlineDate');
if (!empty($filters['networkOwnerName'])) $sql .= Helper::generateFilterCondition($filters['networkOwnerName'], 'owner_addr.company');
if (!empty($filters['appointmentDate'])) $sql .= Helper::generateFilterCondition($filters['appointmentDate'], 'w.appointmentDate');
if (!empty($filters['preorderInfo'])) {
$searchColumns = "p.firstname|p.lastname|p.company|p.oaid|p.street|p.housenumber|p.zip|p.city|str.name|ort.name|p.phone|p.email|w.additionalInfo";
$sql .= Helper::generateFilterCondition($filters['preorderInfo'], $searchColumns);
}
if (!empty($filters['rimo_fcp_name'])) $sql .= Helper::generateFilterCondition($filters['rimo_fcp_name'], "hn.rimo_fcp_name");
if (!empty($filters['additionalInfo'])) $sql .= Helper::generateFilterCondition($filters['additionalInfo'], 'w.additionalInfo');
return "WHERE " . $sql;
}
public static function getCompanyWorkorders(array $filters, ?int $limit, int $offset, array $order, int $companyId): array
{
$db = self::getDB();
$fronkDbName = FRONKDB_DBNAME;
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$sql = "
SELECT w.id, w.status, w.deadlineDate, w.appointmentDate, w.additionalInfo, hn.rimo_fcp_name,
owner_addr.company as networkOwnerName, p.preordercampaign_id,
CONCAT_WS(' ', p.firstname, p.lastname) as customerName, p.company as customerCompany, p.oaid,
p.phone, p.email, str.name as street, hn.hausnummer, hn.stiege, we.bezeichner as apartment, plz.plz, ort.name as city
FROM `$fronkDbName`.`Workorder` w
JOIN `$fronkDbName`.`Preorder` p ON w.preorderId = p.id
LEFT JOIN `$fronkDbName`.`Preordercampaign` pc ON p.preordercampaign_id = pc.id
LEFT JOIN `$fronkDbName`.`Network` n ON pc.network_id = n.id
LEFT JOIN `$fronkDbName`.`Address` owner_addr ON n.owner_id = owner_addr.id
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
LEFT JOIN `$addressDbName`.`Wohneinheit` we ON p.adb_wohneinheit_id = we.id
";
$sql .= self::buildCompanyWhereClause($filters, $companyId);
$orderBy = "";
if (!empty($order['key'])) {
$sortableColumns = ['id', 'status', 'deadlineDate', 'appointmentDate', 'additionalInfo'];
if (in_array($order['key'], $sortableColumns)) {
$sortOrder = (strtoupper($order['order']) === 'DESC') ? 'DESC' : 'ASC';
$orderBy = " ORDER BY " . $db->real_escape_string($order['key']) . " " . $sortOrder;
}
}
if (empty($orderBy)) $orderBy = " ORDER BY CASE WHEN w.deadlineDate IS NULL THEN 1 ELSE 0 END, w.deadlineDate ASC";
$sql .= $orderBy;
if ($limit !== null) $sql .= " LIMIT " . intval($limit) . " OFFSET " . intval($offset);
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
public static function countCompanyWorkorders(array $filters, int $companyId): int
{
$db = self::getDB();
$fronkDbName = FRONKDB_DBNAME;
$addressDbName = defined('ADDRESSDB_DBNAME') ? ADDRESSDB_DBNAME : 'addressdb';
$sql = "
SELECT COUNT(w.id) as count FROM `$fronkDbName`.`Workorder` w
JOIN `$fronkDbName`.`Preorder` p ON w.preorderId = p.id
LEFT JOIN `$addressDbName`.`Hausnummer` hn ON p.adb_hausnummer_id = hn.id
LEFT JOIN `$fronkDbName`.`Preordercampaign` pc ON p.preordercampaign_id = pc.id
LEFT JOIN `$fronkDbName`.`Network` n ON pc.network_id = n.id
LEFT JOIN `$fronkDbName`.`Address` owner_addr ON n.owner_id = owner_addr.id
LEFT JOIN `$addressDbName`.`Strasse` str ON hn.strasse_id = str.id
LEFT JOIN `$addressDbName`.`Plz` plz ON hn.plz_id = plz.id
LEFT JOIN `$addressDbName`.`Ortschaft` ort ON hn.ortschaft_id = ort.id
";
$sql .= self::buildCompanyWhereClause($filters, $companyId);
$result = $db->query($sql);
return $result ? $result->fetch_assoc()['count'] : 0;
}
}

View File

@@ -0,0 +1,307 @@
<?php
// WorkorderAdminController.php
class WorkorderAdminController extends WorkorderBaseController
{
protected string $headerTitle = 'Arbeitsaufträge Verwaltung';
protected bool $createText = false;
protected array $permissionCheck = ['RMLAdmin'];
protected array $columns = [
['key' => 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true, 'filter' => 'numberRange']],
['key' => 'netOwnerId', 'text' => 'Netzeigentümer', 'modal' => false, 'table' => ['filter' => 'select'], 'required' => false],
['key' => 'preordercampaign_id', 'text' => 'Kampagne', 'modal' => false, 'table' => ['filter' => 'select', 'sortable' => true]],
['key' => 'preorderInfo', 'text' => 'Kunde', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => false]],
['key' => 'companyName', 'text' => 'Firma', 'modal' => false, 'table' => ['filter' => 'search', 'sortable' => true]],
['key' => 'rimo_fcp_name', 'text' => 'FCP', 'modal' => false, 'table' => ['filter' => 'search', '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]],
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
];
/**
* Prepares the CRUD configuration.
*/
protected function prepareCrudConfig()
{
$preorderInfoColIdx = array_search('preorderInfo', array_column($this->columns, 'key'));
array_splice($this->columns, $preorderInfoColIdx + 1, 0, [$this->statusColumn]);
$netOwnerColIdx = array_search('netOwnerId', array_column($this->columns, 'key'));
$preCamColIdx = array_search('preordercampaign_id', array_column($this->columns, 'key'));
if ($netOwnerColIdx !== false) {
if ($this->user->isAdmin()) {
$netOwners = Helper::getPreorderCampaignNetworkOwners();
$this->columns[$netOwnerColIdx]['table']['filterOptions'] = array_map(fn($o) => ['value' => $o->id, 'text' => $o->company], $netOwners);
} else {
$this->columns[$netOwnerColIdx]['table'] = false;
}
}
$campaigns = Helper::getPreorderCampaignFromUser($this->user, true);
if ($preCamColIdx !== false) {
$this->columns[$preCamColIdx]['table']['filterOptions'] = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $campaigns);
if (!$this->user->isAdmin() && count($campaigns) === 1) {
$this->columns[$preCamColIdx]['table']['defaultFilter'] = $campaigns[0]->id;
}
}
}
//region ACTIONS
public function indexAction()
{
$this->createWorkordersFromPreorders();
parent::indexAction();
}
/**
* Fetches workorders for the admin view.
*/
protected function getAction()
{
$pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10];
$filters = $this->postData['filters'] ?? [];
$order = $this->postData['order'] ?? [];
$allowedCampaignIds = Helper::getPreorderCampaignFromUser($this->user);
if (empty($allowedCampaignIds)) {
self::returnJson(['rows' => [], 'pagination' => array_merge($pagination, ['total_rows' => 0, 'total_pages' => 0, 'filtered_available' => 0])]);
return;
}
$workorders = WorkorderModel::getAdminWorkorders($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order, $allowedCampaignIds);
$totalCount = WorkorderModel::countAdminWorkorders($filters, $allowedCampaignIds);
$rows = array_map(function ($workorder) {
$row = (array)$workorder;
$row['companyName'] ??= 'Nicht zugewiesen';
return $row;
}, $workorders);
self::returnJson([
'rows' => $rows,
'pagination' => ['page' => $pagination['page'], 'per_page' => $pagination['per_page'], 'total_rows' => $totalCount, 'total_pages' => ceil($totalCount / $pagination['per_page']), 'filtered_available' => $totalCount]
]);
}
protected function getCompaniesAction()
{
$tenantId = $this->request->tenantId;
$companies = WorkorderCompanyModel::getAll(['visibleForAddressId' => "%$tenantId%"]);
self::returnJson(array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $companies));
}
protected function assignWorkorderAction()
{
if (empty($this->postData['workorderId']) || empty($this->postData['companyId'])) self::sendError("Erforderliche Felder fehlen.");
$deadline = !empty($this->postData['deadlineDate']) ? $this->postData['deadlineDate'] : strtotime('+6 weeks');
if ($this->assignSingleWorkorder($this->postData['workorderId'], $this->postData['companyId'], $deadline, $this->user->id)) {
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag erfolgreich zugewiesen.']);
} else {
self::sendError("Arbeitsauftrag konnte nicht zugewiesen werden.");
}
}
protected function massAssignWorkordersAction()
{
if (empty($this->postData['workorderIds']) || empty($this->postData['companyId'])) self::sendError("Erforderliche Felder fehlen.");
$deadline = $this->postData['deadlineDate'] ?? strtotime('+6 weeks');
$count = 0;
foreach ($this->postData['workorderIds'] as $workorderId) {
if ($this->assignSingleWorkorder($workorderId, $this->postData['companyId'], $deadline, $this->user->id)) $count++;
}
self::returnJson(['success' => true, 'message' => "$count Arbeitsaufträge erfolgreich zugewiesen."]);
}
protected function requestCorrectionAction()
{
if (empty($this->postData['workorderId']) || empty($this->postData['text'])) self::sendError("Erforderliche Felder fehlen.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
$oldStatus = $workorder->status;
$workorder->status = 'correction_requested';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => "Korrektur angefordert. Grund: " . $this->postData['text'],
'fileIds' => !empty($this->postData['fileIds']) ? json_encode($this->postData['fileIds']) : null,
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('correction_requested'),
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Korrektur wurde angefordert.']);
}
protected function updateDeadlineAction()
{
if (empty($this->postData['workorderId']) || empty($this->postData['deadlineDate'])) self::sendError("Erforderliche Felder fehlen.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
$workorder->deadlineDate = $this->postData['deadlineDate'];
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create(['workorderId' => $workorder->id, 'text' => 'Deadline geändert auf ' . date('d.m.Y', $this->postData['deadlineDate']) . '.', 'create' => time(), 'createBy' => $this->user->id]);
self::returnJson(['success' => true, 'message' => 'Deadline erfolgreich aktualisiert.']);
}
protected function acceptDocumentationAction()
{
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("Die Dokumentation muss zuerst von der Firma als fertig markiert werden.");
$preorder = new Preorder($workorder->preorderId);
$preorderCampaign = new Preordercampaign($preorder->preordercampaign_id);
$network = new Network($preorderCampaign->network_id);
if ($preorder->id && $network->owner_id == 4807) {
$preorder->status_id = 15;
$preorder->edit_by = $this->user->id;
$preorder->save();
}
$oldStatus = $workorder->status;
$workorder->status = 'completed';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.',
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('completed'),
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Dokumentation akzeptiert und Arbeitsauftrag abgeschlossen.']);
}
protected function setToProblemSolvedAction()
{
if (empty($this->postData['workorderId']) || empty($this->postData['text'])) self::sendError("Arbeitsauftrags-ID und Text sind erforderlich.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
if ($workorder->status !== 'intervention_required') self::sendError("Der Arbeitsauftrag muss den Status 'Eingriff erforderlich' haben.");
$oldStatus = $workorder->status;
$workorder->status = 'problem_solved';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => "Problem gelöst: " . $this->postData['text'],
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('problem_solved'),
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag als "Problem gelöst" markiert.']);
}
protected function cancelWorkorderAction()
{
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
$oldStatus = $workorder->status;
$workorder->status = 'cancelled';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => 'Arbeitsauftrag wurde storniert.' . (!empty($this->postData['reason']) ? ' Grund: ' . $this->postData['reason'] : ''),
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('cancelled'),
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag wurde storniert.']);
}
protected function setCivilEngineeringRequiredAction()
{
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
if (empty($this->postData['companyId'])) self::sendError("Bitte Tiefbaufirma auswählen.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
$company = WorkorderCompanyModel::get($this->postData['companyId']);
if (!$company) self::sendError("Tiefbaufirma nicht gefunden.");
$oldStatus = $workorder->status;
$workorder->civilEngineeringCompanyId = $company->id;
$workorder->originalCompanyId = $workorder->companyId;
$workorder->companyId = $company->id;
$workorder->status = 'civil_engineering_required';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => "Tiefbau wurde angefordert. Firma '{$company->name}' wurde zugewiesen.",
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('civil_engineering_required'),
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Tiefbau wurde angefordert und Firma zugewiesen.']);
}
//endregion
//region PRIVATE HELPERS
private function assignSingleWorkorder($workorderId, $companyId, $deadline, $userId): bool
{
$workorder = WorkorderModel::get($workorderId);
if (!$workorder) return false;
if ($workorder->status === 'civil_engineering_required') {
$workorder->companyId = $companyId;
$workorder->civilEngineeringCompanyId = $companyId;
WorkorderModel::update((array)$workorder);
return true;
}
$company = WorkorderCompanyModel::get($companyId);
if (!$company) return false;
$workorder->companyId = $companyId;
$workorder->status = 'assigned';
$workorder->assignmentDate = time();
$workorder->deadlineDate = $deadline;
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => "Firma '{$company->name}' wurde zugewiesen.", 'create' => time(), 'createBy' => $userId,
]);
$preorder = new Preorder($workorder->preorderId);
$preorderCampaign = new Preordercampaign($preorder->preordercampaign_id);
$network = new Network($preorderCampaign->network_id);
if ($preorder->id && $network->owner_id == 4807) {
$preorder->status_id = 10; // In Ausführung
$preorder->edit_by = $this->user->id;
$preorder->save();
}
return true;
}
private function createWorkordersFromPreorders()
{
$configs = WorkorderTenantConfigModel::getAll();
foreach ($configs as $config) {
$filters = json_decode($config->workorderCreationFilters, true);
if (empty($filters)) continue;
$networks = NetworkModel::search(['owner_id' => $config->addressId]);
if (empty($networks)) continue;
$tenantCampaigns = array_map(fn($n) => $n->id, PreordercampaignModel::getAll(['network_id' => array_map(fn($n) => $n->id, $networks)]));
if (empty($tenantCampaigns)) continue;
$filters['preordercampaign_id'] = $tenantCampaigns;
$newPreorders = PreorderModel::searchActive($filters);
foreach ($newPreorders as $preorder) {
if (!WorkorderModel::getFirst(['preorderId' => $preorder->id])) {
WorkorderModel::create([
'preorderId' => $preorder->id, 'clusterId' => $preorder->preordercampaign_id,
'status' => 'new', 'create' => time(), 'createBy' => 0 // System User
]);
}
}
}
}
//endregion
}

View File

@@ -0,0 +1,139 @@
<?php
// WorkorderBaseController.php
class WorkorderBaseController extends TTCrud
{
/**
* @var array Shared status column definition for consistency.
*/
protected array $statusColumn = [
'key' => 'status', 'text' => 'Status', 'modal' => false, 'table' => ['filter' => 'iconSelect', 'filterOptions' => [
['value' => 'new', 'text' => 'Neu', 'icon' => 'fas fa-star text-primary'],
['value' => 'assigned', 'text' => 'Zugewiesen', 'icon' => 'fas fa-user-check text-info'],
['value' => 'scheduled', 'text' => 'Geplant', 'icon' => 'fas fa-calendar-check text-warning'],
['value' => 'correction_requested', 'text' => 'Korrektur angefordert', 'icon' => 'fas fa-exclamation-triangle text-danger'],
['value' => 'intervention_required', 'text' => 'Eingriff erforderlich', 'icon' => 'fas fa-times-circle text-danger'],
['value' => 'civil_engineering_required', 'text' => 'Tiefbau benötigt', 'icon' => 'fas fa-hard-hat text-orange'],
['value' => 'civil_engineering_completed', 'text' => 'Tiefbau abgeschlossen', 'icon' => 'fas fa-hard-hat text-success'],
['value' => 'problem_solved', 'text' => 'Problem gelöst', 'icon' => 'fas fa-check-circle text-success'],
['value' => 'documented', 'text' => 'Dokumentiert', 'icon' => 'fas fa-file-alt text-success'],
['value' => 'completed', 'text' => 'Abgeschlossen', 'icon' => 'fas fa-check-double text-secondary'],
['value' => 'cancelled', 'text' => 'Abgebrochen', 'icon' => 'fas fa-ban text-danger'],
]]
];
protected array $additionalJS = ["js/pages/WorkorderBase/WorkorderBase.js"];
protected array $additionalHead = ["<link rel='stylesheet' href='/js/pages/WorkorderBase/WorkorderBase.css'>"];
/**
* Gets the display text for a given status key.
* @param string $statusKey
* @return string
*/
protected function getStatusText(string $statusKey): string
{
$statusMap = array_column($this->statusColumn['table']['filterOptions'] ?? [], 'text', 'value');
return $statusMap[$statusKey] ?? ucfirst(str_replace('_', ' ', $statusKey));
}
//region SHARED ACTIONS
/**
* Fetches documentation and journal entries for a given workorder.
*/
protected function getDocumentationAction()
{
if (empty($this->request->workorderId)) self::sendError("Arbeitsauftrags-ID fehlt.");
$docs = WorkorderDocumentationModel::getAll(['workorderId' => intval($this->request->workorderId)], null, 0, ['key' => 'create', 'order' => 'ASC']);
$journals = WorkorderJournalModel::getAll(['workorderId' => intval($this->request->workorderId)], null, 0, ['key' => 'create', 'order' => 'DESC']);
$tenantConfig = $this->getTenantConfigFromWorkorder((int)$this->request->workorderId);
if ($tenantConfig && !empty($tenantConfig->documentationTypes)) {
$customTypes = json_decode($tenantConfig->documentationTypes, true);
$customMap = array_column($customTypes, 'text', 'value');
$translationMap = array_merge(['civil_engineering_photo' => 'Tiefbau_Foto'], $customMap);
}
$responseDocs = [];
$typeCounts = [];
foreach ($docs as $doc) {
$file = new File($doc->fileId);
$documentTypeKey = $doc->documentType;
$typeCounts[$documentTypeKey] = ($typeCounts[$documentTypeKey] ?? 0) + 1;
$originalFilename = $file->orig_filename ?? $file->filename;
$extension = pathinfo($originalFilename, PATHINFO_EXTENSION);
$translatedType = $translationMap[$documentTypeKey] ?? $documentTypeKey;
$newFilename = "{$translatedType}_{$typeCounts[$documentTypeKey]}." . strtolower($extension);
$responseDocs[] = [
'id' => $doc->id, 'fileId' => $doc->fileId, 'fileName' => $newFilename, 'description' => $doc->description,
'documentType' => $documentTypeKey, 'userName' => UserModel::getOne($doc->createBy)->name ?? 'Unbekannt',
'mimetype' => $file->mimetype ?? 'application/octet-stream', 'create' => $doc->create
];
}
foreach ($journals as $journal) {
$journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt';
}
self::returnJson(['docs' => $responseDocs, 'journals' => $journals]);
}
/**
* Adds a new entry to a workorder's journal.
*/
protected function addJournalAction()
{
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId']) || empty(trim($post['text']))) self::sendError("Arbeitsauftrags-ID und Text sind erforderlich.");
WorkorderJournalModel::create(['workorderId' => $post['workorderId'], 'text' => $post['text'], 'createBy' => $this->user->id, 'create' => time()]);
$journals = WorkorderJournalModel::getAll(['workorderId' => intval($post['workorderId'])], null, 0, ['key' => 'create', 'order' => 'DESC']);
foreach ($journals as $journal) {
$journal->createByName = UserModel::getOne($journal->createBy)->name ?? 'Unbekannt';
}
self::returnJson(['success' => true, 'message' => 'Journaleintrag hinzugefügt.', 'journals' => $journals]);
}
/**
* Updates the additional info field for a workorder.
*/
protected function updateAdditionalInfoAction()
{
$post = json_decode(file_get_contents('php://input'), true);
if (empty($post['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
$workorder = WorkorderModel::get($post['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
$oldInfo = $workorder->additionalInfo;
$newInfo = $post['additionalInfo'] ?? null;
$workorder->additionalInfo = $newInfo;
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => "Zusatzinfo geändert.\nAlt: '{$oldInfo}'\nNeu: '{$newInfo}'",
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Zusatzinfo aktualisiert.', 'newInfo' => $newInfo]);
}
//endregion
protected function getTenantConfigFromWorkorder(int $workorderId) {
if (empty($workorderId)) self::sendError("Arbeitsauftrags-ID fehlt.");
$workorder = WorkorderModel::get($workorderId);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
$preorder = new Preorder($workorder->preorderId);
if (!$preorder->id) self::sendError("Vorbestellung nicht gefunden.");
$campaign = new Preordercampaign($preorder->preordercampaign_id);
if (!$campaign->id) self::sendError("Kampagne nicht gefunden.");
$network = NetworkModel::getOne($campaign->network_id);
if (!$network) self::sendError("Netzwerk nicht gefunden.");
return WorkorderTenantConfigModel::getFirst(['addressId' => $network->owner_id]) ?? null;
}
}

View File

@@ -0,0 +1,216 @@
<?php
class WorkorderCompanyController extends WorkorderBaseController {
protected string $headerTitle = 'Meine Arbeitsaufträge';
protected bool $createText = false;
protected array $permissionCheck = ['RMLCompany'];
protected array $columns = [
['key' => 'id', 'text' => 'Auftrags-Nr.', 'table' => ['sortable' => true]],
['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]],
// 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]],
['key' => 'appointmentDate', 'text' => 'Termin', 'modal' => false, 'table' => ['filter' => 'date', 'sortable' => true]],
];
protected array $additionalJSVariables = ['COMPANY_ID' => '0'];
protected function prepareCrudConfig() {
$preorderInfoColIdx = array_search('preorderInfo', array_column($this->columns, 'key'));
array_splice($this->columns, $preorderInfoColIdx + 1, 0, [$this->statusColumn]);
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
$campaigns = Helper::getPreorderCampaignFromUser($this->user, true);
$preCamColIdx = array_search('preordercampaign_id', array_column($this->columns, 'key'));
if ($preCamColIdx !== false) {
$this->columns[$preCamColIdx]['table']['filterOptions'] = array_map(fn($c) => ['value' => $c->id, 'text' => $c->name], $campaigns);
if (!$this->user->isAdmin() && count($campaigns) === 1) {
$this->columns[$preCamColIdx]['table']['defaultFilter'] = $campaigns[0]->id;
}
}
$this->additionalJSVariables['COMPANY_ID'] = $company ? $company->id : 0;
}
protected function logout() {
mfLoginController::staticLogout();
$this->redirect('/WorkorderCompany/Mobile');
}
public function mobileAction() {
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
$this->layout()->setTemplate("VueViews/WorkorderCompanyPWA");
$this->layout()->set("JSGlobals", [
'BASE_PATH' => '/WorkorderCompany',
'COMPANY_ID' => $company ? $company->id : 0,
]);
}
protected function getAction() {
$pagination = $this->postData['pagination'] ?? ['page' => 1, 'per_page' => 10];
$filters = $this->postData['filters'] ?? [];
$order = $this->postData['order'] ?? [];
$company = WorkorderCompanyModel::getFirst(['addressId' => $this->user->address_id]);
if (!$company) {
self::returnJson(['rows' => [], 'pagination' => array_merge($pagination, ['total_rows' => 0, 'total_pages' => 0, 'filtered_available' => 0])]);
return;
}
$workorders = WorkorderModel::getCompanyWorkorders($filters, $pagination['per_page'], ($pagination['page'] - 1) * $pagination['per_page'], $order, $company->id);
$totalCount = WorkorderModel::countCompanyWorkorders($filters, $company->id);
self::returnJson([
'rows' => $workorders,
'pagination' => ['page' => $pagination['page'], 'per_page' => $pagination['per_page'], 'total_rows' => $totalCount, 'total_pages' => ceil($totalCount / $pagination['per_page']), 'filtered_available' => $totalCount]
]);
}
public function getWorkorderByIdAction() {
if (empty($this->request->id)) self::sendError("ID fehlt");
$workorder = WorkorderModel::get($this->request->id);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden");
self::returnJson((array)$workorder);
}
protected function scheduleAppointmentAction() {
if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate'])) self::sendError("Erforderliche Felder fehlen.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden");
if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!");
$workorder->appointmentDate = $this->postData['appointmentDate'];
$workorder->status = 'scheduled';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => 'Termin festgelegt auf: ' . date('d.m.Y H:i', $this->postData['appointmentDate']),
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich gespeichert.']);
}
protected function rescheduleAppointmentAction() {
if (empty($this->postData['workorderId']) || empty($this->postData['appointmentDate']) || empty($this->postData['reason'])) self::sendError("Erforderliche Felder fehlen.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
if ((int)date('H', $this->postData['appointmentDate']) >= 23 || (int)date('H', $this->postData['appointmentDate']) < 1) self::sendError("Bitte geben Sie eine Uhrzeit an!");
$oldDateFormatted = $workorder->appointmentDate ? date('d.m.Y H:i', $workorder->appointmentDate) : 'N/A';
$newDateFormatted = date('d.m.Y H:i', $this->postData['appointmentDate']);
$workorder->appointmentDate = $this->postData['appointmentDate'];
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => "Termin verschoben von {$oldDateFormatted} auf {$newDateFormatted}. Grund: " . $this->postData['reason'],
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Termin erfolgreich verschoben.']);
}
protected function requestInterventionAction() {
if (empty($this->postData['workorderId']) || empty($this->postData['journalText'])) self::sendError("Erforderliche Felder fehlen.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
$oldStatus = $workorder->status;
$workorder->status = 'intervention_required';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => "Eingriff erforderlich: " . $this->postData['journalText'],
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('intervention_required'),
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Eingriff wurde angefordert.']);
}
protected function completeWorkorderAction() {
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
$workorder->status = 'documented';
WorkorderModel::update((array)$workorder);
self::returnJson(['success' => true, 'message' => 'Arbeitsauftrag zur Prüfung eingereicht.']);
}
protected function getTenantConfigAction() {
$tenantConfig = $this->getTenantConfigFromWorkorder($this->request->workorderId);
if (!$tenantConfig) {
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)]);
}
protected function uploadDocumentationAction() {
if (empty($_FILES['files']) || empty($_POST['workorderId'])) self::sendError('Erforderliche Daten fehlen.');
$workorderId = $_POST['workorderId'];
foreach ($_FILES['files']['name'] as $index => $name) {
if ($_FILES['files']['error'][$index] === UPLOAD_ERR_OK) {
$_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]];
try {
$uploaded = mfUpload::handleFormUpload("file", false, "/Workorder");
WorkorderDocumentationModel::create(['workorderId' => $workorderId, 'fileId' => $uploaded->id, 'description' => $_POST['description'] ?? '', 'documentType' => $_POST['documentType'] ?? 'general', 'create' => time(), 'createBy' => $this->user->id]);
} catch (Exception $e) { /* Log error if necessary */
}
}
}
$workorder = WorkorderModel::get($workorderId);
if (in_array($workorder->status, ['correction_requested', 'problem_solved', 'civil_engineering_completed'])) {
$workorder->status = 'assigned';
WorkorderModel::update((array)$workorder);
}
self::returnJson(['success' => true, 'message' => "Datei(en) erfolgreich hochgeladen."]);
}
protected function deleteDocumentationAction() {
if (empty($this->postData['id'])) self::sendError("Dokumenten-ID fehlt.");
WorkorderDocumentationModel::delete($this->postData['id']);
self::returnJson(['success' => true, 'message' => 'Dokument gelöscht.']);
}
protected function updateDocumentationAction() {
if (empty($this->postData['id'])) self::sendError("Dokumenten-ID fehlt.");
$doc = WorkorderDocumentationModel::get($this->postData['id']);
if (!$doc) self::sendError("Dokument nicht gefunden.");
if (isset($this->postData['documentType'])) $doc->documentType = $this->postData['documentType'];
WorkorderDocumentationModel::update((array)$doc);
self::returnJson(['success' => true, 'message' => 'Dokument aktualisiert.']);
}
protected function completeCivilEngineeringAction() {
if (empty($this->postData['workorderId'])) self::sendError("Arbeitsauftrags-ID fehlt.");
$workorder = WorkorderModel::get($this->postData['workorderId']);
if (!$workorder) self::sendError("Arbeitsauftrag nicht gefunden.");
// Re-assign to original company
if ($workorder->originalCompanyId) {
$workorder->companyId = $workorder->originalCompanyId;
$workorder->originalCompanyId = null;
}
$oldStatus = $workorder->status;
$workorder->civilEngineeringCompanyId = null;
$workorder->status = 'civil_engineering_completed';
WorkorderModel::update((array)$workorder);
WorkorderJournalModel::create([
'workorderId' => $workorder->id, 'text' => "Tiefbau abgeschlossen.",
'statusChange' => $this->getStatusText($oldStatus) . " -> " . $this->getStatusText('civil_engineering_completed'),
'create' => time(), 'createBy' => $this->user->id,
]);
self::returnJson(['success' => true, 'message' => 'Tiefbau erfolgreich abgeschlossen.']);
}
//endregion
}

View File

@@ -0,0 +1,30 @@
<?php
// WorkorderCompanyModel.php
class WorkorderCompanyModel extends TTCrudBaseModel {
public int $id;
public int $addressId;
public string $name;
public ?string $visibleForAddressId;
public int $create;
public int $createBy;
public static function getCompanyWorkers(int $companyId): array {
if (!$company = self::get($companyId)) {
return [];
}
$db = self::getDB();
$addressId = $db->real_escape_string($company->addressId);
$sql = "SELECT w.id, w.name, w.email
FROM `" . FRONKDB_DBNAME . "`.`Worker` w
JOIN `" . FRONKDB_DBNAME . "`.`WorkerPermission` wp ON w.id = wp.worker_id
WHERE w.address_id = '$addressId' AND wp.canRMLCompany = 'true' AND w.active = 1
ORDER BY w.name ASC";
$result = $db->query($sql);
return $result ? $result->fetch_all(MYSQLI_ASSOC) : [];
}
}

View File

@@ -1,7 +1,7 @@
<?php
// RMLWorkorderDocumentationModel.php
// WorkorderDocumentationModel.php
class RMLWorkorderDocumentationModel extends TTCrudBaseModel {
class WorkorderDocumentationModel extends TTCrudBaseModel {
public int $id;
public int $workorderId;
public int $fileId;

View File

@@ -1,6 +1,7 @@
<?php
// WorkorderJournalModel.php
class RMLWorkorderJournalModel extends TTCrudBaseModel {
class WorkorderJournalModel extends TTCrudBaseModel {
public int $id;
public int $workorderId;
public ?string $text;

View File

@@ -0,0 +1,93 @@
<?php
class WorkorderTenantConfigController extends TTCrud {
protected string $headerTitle = 'Mandanten & Firmen Konfiguration';
protected bool $createText = false;
protected array $columns = [];
protected function indexAction() {
Helper::renderVue($this, 'WorkorderTenantConfig', $this->headerTitle, []);
}
protected function getTenantConfigsAction() {
$configs = WorkorderTenantConfigModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
self::returnJson($configs);
}
protected function saveTenantConfigAction() {
$data = $this->postData;
$data['documentationTypes'] = json_encode($data['documentationTypes'] ?? []);
$data['interventionTypes'] = json_encode($data['interventionTypes'] ?? []);
$data['workorderCreationFilters'] ??= '{}';
if (empty($data['id'])) {
$data['create'] = time();
$data['createBy'] = $this->user->id;
WorkorderTenantConfigModel::create($data);
} else {
WorkorderTenantConfigModel::update($data);
}
self::returnJson(['success' => true, 'message' => 'Mandanten-Konfiguration gespeichert.']);
}
protected function deleteTenantConfigAction() {
if (empty($this->postData['id'])) self::sendError("ID fehlt.");
WorkorderTenantConfigModel::delete($this->postData['id']);
self::returnJson(['success' => true, 'message' => 'Mandanten-Konfiguration gelöscht.']);
}
protected function getCompaniesAction() {
$companies = WorkorderCompanyModel::getAll([], null, 0, ['key' => 'name', 'order' => 'ASC']);
foreach ($companies as $company) {
$company->workers = WorkorderCompanyModel::getCompanyWorkers($company->id);
}
self::returnJson($companies);
}
protected function saveCompanyAction() {
$data = $this->postData;
if (empty($data['name']) || empty($data['addressId'])) self::sendError("Name und Adresse sind erforderlich.");
unset($data['workers']);
$data['visibleForAddressId'] = json_encode($data['visibleForAddressId'] ?? []);
if (empty($data['id'])) {
$data['create'] = time();
$data['createBy'] = $this->user->id;
WorkorderCompanyModel::create($data);
} else {
WorkorderCompanyModel::update($data);
}
self::returnJson(['success' => true, 'message' => 'Firma gespeichert.']);
}
protected function deleteCompanyAction() {
if (empty($this->postData['id'])) self::sendError("ID fehlt.");
WorkorderCompanyModel::delete($this->postData['id']);
self::returnJson(['success' => true, 'message' => 'Firma gelöscht.']);
}
protected function addressAutocompleteAction() {
$search = trim($this->request->q ?? '');
$searchedID = $this->request->searchedID ?? null;
$addresses = [];
if ($searchedID) {
$ids = array_filter(explode(',', $searchedID));
if ($ids) $addresses = AddressModel::search(['id' => $ids]);
} elseif (strlen($search) >= 2) {
$addresses = array_slice(AddressModel::search(["company" => $search]), 0, 15);
}
$results = array_map(function($address) {
return [
'value' => $address->id,
'text' => "{$address->getCompanyOrName()} ({$address->zip} {$address->city})"
];
}, $addresses);
self::returnJson($results);
}
}

View File

@@ -0,0 +1,33 @@
<?php
// WorkorderTenantConfigModel.php
class WorkorderTenantConfigModel extends TTCrudBaseModel {
public int $id;
public int $addressId;
public string $name;
public string $documentationTypes; // JSON
public string $workorderCreationFilters; // JSON
public ?string $interventionTypes; // JSON
public int $civilEngineeringDocsRequired;
public int $create;
public int $createBy;
public static function findForWorkorder(WorkorderModel $workorder): ?WorkorderTenantConfigModel {
if (empty($workorder->preorderId)) return null;
$db = self::getDB();
$dbName = FRONKDB_DBNAME;
$tableWTC = self::getFullyQualifiedTable();
$preorderId = $db->real_escape_string($workorder->preorderId);
$sql = "SELECT wtc.* FROM $tableWTC wtc
JOIN `$dbName`.`Network` n ON wtc.addressId = n.owner_id
JOIN `$dbName`.`Preordercampaign` pc ON n.id = pc.network_id
JOIN `$dbName`.`Preorder` p ON pc.id = p.preordercampaign_id
WHERE p.id = '$preorderId' LIMIT 1";
$result = $db->query($sql);
$row = $result ? $result->fetch_assoc() : null;
return $row ? new self($row) : null;
}}

267
bin/session-watcher.php Normal file
View File

@@ -0,0 +1,267 @@
#!/usr/bin/env php
<?php
require_once __DIR__ . '/../config/config.php';
// --- DATABASE CONFIGURATION ---
const DB_HOST = FRONKDB_DBHOST;
const DB_NAME = FRONKDB_DBNAME;
const DB_USER = FRONKDB_DBUSER;
const DB_PASS = FRONKDB_DBPASS;
// --- General & Cache Configuration ---
const SESSION_CACHE_TTL = 600; // Cache Session ID lookups for 10 minutes
const ROW_SEPARATOR_INTERVAL = 10; // Draw a divider every 10 rows
const HEADER_REDRAW_INTERVAL = 30; // Used only in --simple mode
const IGNORED_EXTENSIONS = ['png','jpg','jpeg','gif','css','js','ico','svg','woff','woff2','json','xml','txt'];
const LOG_REGEX = '/^(\S+) \S+ \S+ \[(.+?)\] "(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s(.*?)\s\S+" (\d{3}) \S+ "(.*?)" ".*?" "(.*?)"$/';
// --- Dynamic Layout Configuration ---
const FIXED_WIDTH_COLS = ['time' => 10, 'status' => 8, 'method' => 8];
const FLEX_WIDTH_RATIOS = ['identity' => 0.25, 'req_url' => 0.50, 'ref_view' => 0.25];
const COLORS = [
'green' => "\033[1;32m", 'cyan' => "\033[1;36m", 'yellow' => "\033[1;33m", 'red' => "\033[1;31m",
'magenta' => "\033[1;35m", 'blue' => "\033[0;34m", 'dim' => "\033[2m", 'reset' => "\033[0m"
];
// --- Global State ---
$pdo = null;
$sessionCache = [];
$logBuffer = [];
$columnWidths = [];
$displayableRows = 0;
$simpleMode = false;
// --- Core & Helper Functions ---
function get_terminal_dimensions(): array {
$width = (int)@shell_exec('tput cols');
$height = (int)@shell_exec('tput lines');
if ($width > 0 && $height > 0) {
return ['width' => $width, 'height' => $height];
}
// Fallback for environments where tput fails
return ['width' => 160, 'height' => 40];
}
function calculate_layout_sizes(): void {
global $columnWidths, $displayableRows;
$dims = get_terminal_dimensions();
$terminalWidth = $dims['width'];
$terminalHeight = $dims['height'];
$displayableRows = $terminalHeight - 5;
$fixedTotalWidth = array_sum(FIXED_WIDTH_COLS);
$numCols = count(FIXED_WIDTH_COLS) + count(FLEX_WIDTH_RATIOS);
$borderWidth = $numCols + 1;
$flexTotalWidth = $terminalWidth - $fixedTotalWidth - $borderWidth;
$columnWidths = FIXED_WIDTH_COLS;
foreach (FLEX_WIDTH_RATIOS as $name => $ratio) {
$columnWidths[$name] = (int)floor($flexTotalWidth * $ratio);
}
}
function db_connect(): ?PDO {
$dsn = "mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4";
try {
return new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) { return null; }
}
function getIdentityFromSession(string $sessionId): ?array {
global $pdo, $sessionCache;
$now = time();
if (isset($sessionCache[$sessionId]) && ($now - $sessionCache[$sessionId]['timestamp'] < SESSION_CACHE_TTL)) {
return $sessionCache[$sessionId]['data'];
}
try {
$stmt = $pdo->prepare("SELECT w.name, a.company FROM Worker w LEFT JOIN Address a ON w.address_id = a.id WHERE w.sessionid = ? LIMIT 1");
$stmt->execute([$sessionId]);
$result = $stmt->fetch();
$sessionCache[$sessionId] = ['data' => $result ?: null, 'timestamp' => $now];
return $result ?: null;
} catch (PDOException $e) { return null; }
}
function getControllerFromUrl(string $url): string {
if ($url === '[direct]' || $url === '-') return '[direct]';
$path = parse_url($url, PHP_URL_PATH);
if (empty($path) || $path === '/') return '[root]';
$parts = explode('/', trim($path, '/'));
return ucfirst($parts[0]);
}
function getStatusColor(int $statusCode): string {
if ($statusCode >= 500) return 'red';
if ($statusCode >= 400) return 'yellow';
if ($statusCode >= 300) return 'cyan';
return 'green';
}
function parseLogLine(string $line): ?array {
if (!preg_match(LOG_REGEX, $line, $matches)) return null;
$url = $matches[4];
$ext = strtolower(pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION));
if (in_array($ext, IGNORED_EXTENSIONS, true)) return null;
$ts = DateTime::createFromFormat('d/M/Y:H:i:s O', $matches[2]);
return ["ip" => $matches[1], "timestamp" => $ts ? $ts->format('H:i:s') : '??:??:??', "method" => $matches[3],
"url" => $url, "status" => (int)$matches[5], "referrer" => $matches[6], "sessionid" => $matches[7]];
}
// --- UI Drawing Functions ---
function draw_divider(string $left, string $mid, string $right): void {
global $columnWidths;
echo COLORS['dim'];
$cols = ['identity', 'time', 'status', 'method', 'req_url', 'ref_view'];
echo $left;
foreach ($cols as $i => $key) {
echo str_repeat('─', $columnWidths[$key]);
echo ($i < count($cols) - 1) ? $mid : '';
}
echo $right . PHP_EOL . COLORS['reset'];
}
/**
* A fully multi-byte aware function to format cell content with padding, truncation, and color.
*/
function format_cell(string $text, int $width, string $color = ''): string {
$text = ' ' . $text; // Add leading space
// mb_strimwidth is width-aware, not just character count-aware
$visible_width = mb_strwidth($text, 'UTF-8');
if ($visible_width > $width) {
// Truncate and add "..." suffix
$text = mb_strimwidth($text, 0, $width - 4, ' ...', 'UTF-8');
}
// Manual padding because str_pad is not multi-byte safe
$final_width = mb_strwidth($text, 'UTF-8');
$padding = $width - $final_width;
$padded_text = $text . str_repeat(' ', $padding > 0 ? $padding : 0);
return ($color ? COLORS[$color] : '') . $padded_text . ($color ? COLORS['reset'] : '');
}
function draw_header(string $logFile, bool $isRedraw = false): void {
global $simpleMode;
if (!$simpleMode && !$isRedraw) {
// Only clear screen in normal mode on initial draw
echo "\033[H\033[J";
}
$title = basename($logFile);
$mode_indicator = $simpleMode ? " [Compatibility Mode]" : "";
echo ($isRedraw ? PHP_EOL : '') . COLORS['magenta'] . " watching {$title}{$mode_indicator} | Press Ctrl+C to exit" . COLORS['reset'] . PHP_EOL;
draw_divider('┌', '┬', '┐');
$headers = [' IDENTITY', ' TIME', ' STATUS', ' METHOD', ' REQUEST URL', ' FROM VIEW'];
$keys = ['identity', 'time', 'status', 'method', 'req_url', 'ref_view'];
echo COLORS['dim'] . '│' . COLORS['magenta'];
foreach ($keys as $i => $key) {
echo str_pad($headers[$i], $GLOBALS['columnWidths'][$key]) . COLORS['dim'] . '│' . COLORS['magenta'];
}
echo COLORS['reset'] . PHP_EOL;
draw_divider('├', '┼', '┤');
}
function redraw_screen(string $logFile): void {
global $logBuffer;
echo "\033[H\033[J"; // Move cursor to top left and clear the screen
draw_header($logFile, true);
foreach ($logBuffer as $index => $data) {
echo COLORS['dim'] . '│';
echo format_cell($data['identity'], $GLOBALS['columnWidths']['identity'], 'yellow');
echo COLORS['dim'] . '│';
echo format_cell($data['timestamp'], $GLOBALS['columnWidths']['time']);
echo COLORS['dim'] . '│';
echo format_cell($data['status'], $GLOBALS['columnWidths']['status'], getStatusColor($data['status']));
echo COLORS['dim'] . '│';
echo format_cell($data['method'], $GLOBALS['columnWidths']['method']);
echo COLORS['dim'] . '│';
echo format_cell($data['url'], $GLOBALS['columnWidths']['req_url'], 'green');
echo COLORS['dim'] . '│';
echo format_cell($data['referrer_view'], $GLOBALS['columnWidths']['ref_view'], 'blue');
echo COLORS['dim'] . '│' . PHP_EOL;
if (($index + 1) % ROW_SEPARATOR_INTERVAL === 0 && ($index + 1) < count($logBuffer)) {
draw_divider('├', '┼', '┤');
}
}
draw_divider('└', '┴', '┘');
}
// --- Main Application ---
if (!extension_loaded('mbstring')) { die("Error: The 'mbstring' PHP extension is required. Please install it (e.g., sudo apt install php-mbstring).\n"); }
if (function_exists('pcntl_signal')) { pcntl_async_signals(true); pcntl_signal(SIGINT, function () { echo "\nWatcher stopped.\n"; exit; }); }
// Check for --simple compatibility mode flag
$simpleMode = in_array('--simple', $argv);
calculate_layout_sizes();
$logFile = $argv[1] ?? '/var/log/apache2/access.log';
if ($logFile === '--simple') $logFile = '/var/log/apache2/access.log';
if (!is_readable($logFile)) { die("Error: Log file not readable: $logFile\n"); }
$pdo = db_connect();
if (!$pdo) { exit(1); }
$handle = fopen($logFile, 'r');
$position = filesize($logFile);
fseek($handle, $position);
$lineCounter = 0;
draw_header($logFile);
while (true) {
clearstatcache(true, $logFile);
$currentSize = filesize($logFile);
if ($currentSize < $position) { fseek($handle, 0); $position = 0; $logBuffer = []; }
if ($currentSize > $position) {
fseek($handle, $position);
$newContent = fread($handle, $currentSize - $position);
$position = $currentSize;
$hasNewData = false;
foreach (explode(PHP_EOL, trim($newContent)) as $line) {
if (empty($line)) continue;
$data = parseLogLine($line);
if ($data === null || $data['sessionid'] === '-' || $data['method'] === 'OPTIONS') continue;
$identity = getIdentityFromSession($data['sessionid']);
if ($identity === null) continue;
$hasNewData = true;
$data['identity'] = $identity['name'] . (!empty($identity['company']) ? " ({$identity['company']})" : '');
$data['referrer_view'] = getControllerFromUrl($data['referrer']);
if ($simpleMode) {
// Simple mode: just print the new row
echo COLORS['dim'] . '│';
echo format_cell($data['identity'], $GLOBALS['columnWidths']['identity'], 'yellow');
echo COLORS['dim'] . '│' . format_cell($data['timestamp'], $GLOBALS['columnWidths']['time']);
echo COLORS['dim'] . '│' . format_cell($data['status'], $GLOBALS['columnWidths']['status'], getStatusColor($data['status']));
echo COLORS['dim'] . '│' . format_cell($data['method'], $GLOBALS['columnWidths']['method']);
echo COLORS['dim'] . '│' . format_cell($data['url'], $GLOBALS['columnWidths']['req_url'], 'green');
echo COLORS['dim'] . '│' . format_cell($data['referrer_view'], $GLOBALS['columnWidths']['ref_view'], 'blue');
echo COLORS['dim'] . '│' . PHP_EOL;
$lineCounter++;
if ($lineCounter >= HEADER_REDRAW_INTERVAL) { draw_header($logFile, true); $lineCounter = 0; }
} else {
// Normal mode: add to buffer for redrawing
$logBuffer[] = $data;
if (count($logBuffer) > $displayableRows) { array_shift($logBuffer); }
}
}
if ($hasNewData && !$simpleMode) {
redraw_screen($logFile);
}
}
usleep(250000); // 0.25 seconds
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class RmlworkorderAddProblemSolvedEnum extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
$RMLWorkorder = $this->table("RMLWorkorder");
$RMLWorkorder->changeColumn("status", "enum", ["null" => false, "values" => ['new','assigned','scheduled','correction_requested','documented','completed','intervention_required','problem_solved'], "default" => "new", "after" => "clusterId"]);
$RMLWorkorder->update();
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
$RMLWorkorder = $this->table("RMLWorkorder");
$RMLWorkorder->changeColumn("status", "enum", ["null" => false, "values" => ['new','assigned','scheduled','correction_requested','documented','completed','intervention_required'], "default" => "new", "after" => "clusterId"]);
$RMLWorkorder->update();
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class PoprackModuleAddSide extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("Poprackmodule");
$table->addColumn("side", "enum", ['values' => ['front', 'back'],'null' => false,'default' => 'front', "after" => "position"]);
$table->update();
}
if($this->getEnvironment() == "addressdb") {
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$this->table("Poprackmodule")->removeColumn("side")->save();
}
if($this->getEnvironment() == "addressdb") {
}
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class RmlworkorderMultiTenant extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() !== "thetool") {
return;
}
$companyTable = $this->table('RMLWorkorderCompany');
if (!$companyTable->hasColumn('visibleForAddressId')) {
$companyTable->addColumn('visibleForAddressId', 'text', ['null' => true, 'default' => null, 'after' => 'name'])->save();
}
if (!$this->hasTable('RMLWorkorderTenantConfig')) {
$this->table('RMLWorkorderTenantConfig', ['id' => false, 'primary_key' => ['id']])
->addColumn('id', 'integer', ['identity' => true, 'signed' => false])
->addColumn('addressId', 'integer')
->addColumn('name', 'string', ['limit' => 255])
->addColumn('documentationTypes', 'json')
->addColumn('workorderCreationFilters', 'json')
->addColumn('create', 'integer')
->addColumn('createBy', 'integer')
->addIndex(['addressId'], ['name' => 'addressId_idx'])
->create();
}
$docTypes = [
['value' => 'photo_hup_mounted', 'text' => 'Foto vom montierten HÜP'],
['value' => 'photo_hup_open', 'text' => 'Foto von dem offenen HÜP'],
['value' => 'photo_splice_cassette_hup', 'text' => 'Foto der Spleißkassette HÜP'],
['value' => 'photo_splice_cassette_fcp', 'text' => 'Foto der Spleißkassette - FCP'],
['value' => 'photo_hup_closed_stickers', 'text' => 'Foto vom geschlossenen HÜP mit allen Aufklebern'],
['value' => 'photo_fcp_labeled', 'text' => 'Foto vom FCP beschriftet'],
['value' => 'photo_patch_position_osp', 'text' => 'Foto der Patch-Position - OSP-Seite'],
['value' => 'photo_patch_position_anb', 'text' => 'Foto der Patch-Position - ANB-Seite'],
['value' => 'measurement_protocol_otdr', 'text' => 'ODTR Messung (1310nm & 1550nm)'],
];
$workorderFilters = [
'status_code' => 220,
'preorder_status_flags_all' => [3, 5],
];
$this->table('RMLWorkorderTenantConfig')->insert([
[
'id' => 1,
'addressId' => 4807,
'name' => 'RML Standard',
'documentationTypes' => json_encode($docTypes),
'workorderCreationFilters' => json_encode($workorderFilters),
'create' => 1724704509,
'createBy' => 1
]
])->save();
$this->execute("UPDATE RMLWorkorderCompany SET visibleForAddressId = '[4807]' WHERE visibleForAddressId IS NULL;");
}
public function down(): void
{
if ($this->getEnvironment() !== "thetool") {
return;
}
if ($this->hasTable('RMLWorkorderTenantConfig')) {
$this->table('RMLWorkorderTenantConfig')->drop()->save();
}
$companyTable = $this->table('RMLWorkorderCompany');
if ($companyTable->hasColumn('visibleForAddressId')) {
$companyTable->removeColumn('visibleForAddressId')->save();
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class RmlworkorderAddAdditionalInfo extends AbstractMigration
{
public function up(): void
{
$table = $this->table('RMLWorkorder');
$table->addColumn('additionalInfo', 'text', [
'null' => true,
'default' => null,
])
->changeColumn('status', 'enum', [
'values' => [
'new',
'assigned',
'scheduled',
'correction_requested',
'documented',
'completed',
'intervention_required',
'problem_solved',
'cancelled',
],
'null' => false,
'default' => 'new',
])
->save();
}
public function down(): void
{
$table = $this->table('RMLWorkorder');
$table->removeColumn('additionalInfo')
->changeColumn('status', 'enum', [
'values' => [
'new',
'assigned',
'scheduled',
'correction_requested',
'documented',
'completed',
'cancelled',
],
'null' => false,
'default' => 'new',
])
->save();
}
}

View File

@@ -0,0 +1,30 @@
<?php /** @noinspection ALL */
declare(strict_types = 1);
use Phinx\Migration\AbstractMigration;
final class WarehouseModify24 extends AbstractMigration {
public function up(): void {
if ($this->getEnvironment() == "thetool") {
$WarehouseShippingNote = $this->table("WarehouseShippingNote");
if (!$WarehouseShippingNote->hasColumn("metadata")) {
$WarehouseShippingNote
->addColumn("metadata", "json", ["null" => true, "default" => null, "after" => "type"])
->update();
}
}
}
public function down(): void {
if ($this->getEnvironment() == "thetool") {
$WarehouseShippingNote = $this->table("WarehouseShippingNote");
if ($WarehouseShippingNote->hasColumn("metadata")) {
$WarehouseShippingNote
->removeColumn("metadata")
->update();
}
}
}
}

View File

@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class WorkorderRename extends AbstractMigration {
public function up(): void {
if ($this->getEnvironment() == "thetool") {
$this->table('RMLWorkorder')->rename('Workorder')->save();
$this->table('RMLWorkorderCompany')->rename('WorkorderCompany')->save();
$this->table('RMLWorkorderDocumentation')->rename('WorkorderDocumentation')->save();
$this->table('RMLWorkorderJournal')->rename('WorkorderJournal')->save();
$this->table('RMLWorkorderTenantConfig')->rename('WorkorderTenantConfig')->save();
$workorderTable = $this->table('Workorder');
$workorderTable->addColumn('civilEngineeringCompanyId', 'integer', ['null' => true, 'default' => null, 'after' => 'companyId', 'comment' => 'Company assigned for civil engineering task'])
->addColumn('originalCompanyId', 'integer', ['null' => true, 'default' => null, 'after' => 'civilEngineeringCompanyId', 'comment' => 'Stores the companyId before assigning to civil engineering'])
->addIndex(['civilEngineeringCompanyId'], ['name' => 'civilEngineeringCompanyId_idx'])
->addIndex(['originalCompanyId'], ['name' => 'originalCompanyId_idx'])
->save();
$workorderTable->changeColumn('status', 'enum', [
'values' => [
'new',
'assigned',
'scheduled',
'correction_requested',
'intervention_required',
'civil_engineering_required',
'civil_engineering_completed',
'problem_solved',
'documented',
'completed',
'cancelled'
],
'default' => 'new',
'null' => false
])->save();
$tenantConfigTable = $this->table('WorkorderTenantConfig');
$tenantConfigTable->addColumn('civilEngineeringDocsRequired', 'boolean', ['default' => false, 'null' => false, 'after' => 'workorderCreationFilters', 'comment' => 'If true, civil engineering company must upload docs to complete task'])
->save();
}
}
public function down(): void {
if ($this->getEnvironment() == "thetool") {
$tenantConfigTable = $this->table('WorkorderTenantConfig');
$tenantConfigTable->removeColumn('civilEngineeringDocsRequired')->save();
$workorderTable = $this->table('Workorder');
$workorderTable->changeColumn('status', 'enum', [
'values' => [
'new',
'assigned',
'scheduled',
'correction_requested',
'intervention_required',
'problem_solved',
'documented',
'completed',
'cancelled'
],
'default' => 'new',
'null' => false
])->save();
$workorderTable->removeColumn('civilEngineeringCompanyId')
->removeColumn('originalCompanyId')
->removeIndexByName('civilEngineeringCompanyId_idx')
->removeIndexByName('originalCompanyId_idx')
->save();
$this->table('Workorder')->rename('RMLWorkorder')->save();
$this->table('WorkorderCompany')->rename('RMLWorkorderCompany')->save();
$this->table('WorkorderDocumentation')->rename('RMLWorkorderDocumentation')->save();
$this->table('WorkorderJournal')->rename('RMLWorkorderJournal')->save();
$this->table('WorkorderTenantConfig')->rename('RMLWorkorderTenantConfig')->save();
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class DevicetypeAddTemperature extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("Devicetype");
$table->addColumn("temp_warning", "integer", ['null' => false,'default' => '80', "after" => "power"]);
$table->addColumn("temp_critical", "integer", ['null' => false,'default' => '90', "after" => "temp_warning"]);
$table->update();
}
if($this->getEnvironment() == "addressdb") {
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$this->table("Devicetype")->removeColumn("temp_warning")->save();
$this->table("Devicetype")->removeColumn("temp_critical")->save();
}
if($this->getEnvironment() == "addressdb") {
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class WorkorderAddInterventionTypes extends AbstractMigration {
public function up(): void {
if ($this->getEnvironment() == "thetool") {
$tenantConfigTable = $this->table('WorkorderTenantConfig');
if (!$tenantConfigTable->hasColumn('interventionTypes')) {
$tenantConfigTable->addColumn('interventionTypes', 'text', ['null' => true, 'default' => null, 'after' => 'workorderCreationFilters'])
->save();
}
}
}
public function down(): void {
if ($this->getEnvironment() == "thetool") {
$tenantConfigTable = $this->table('WorkorderTenantConfig');
if ($tenantConfigTable->hasColumn('interventionTypes')) {
$tenantConfigTable->removeColumn('interventionTypes')->save();
}
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateUserPermissionTemplate extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
if (!$this->hasTable('UserPermissionTemplate')) {
$table = $this->table('UserPermissionTemplate', ['id' => false, 'primary_key' => ['id']]);
$table->addColumn('id', 'integer', ['identity' => true, 'signed' => false])
->addColumn('name', 'string', ['limit' => 255, 'null' => false])
->addColumn('permissions', 'text', ['null' => false])
->addColumn('create', 'integer', ['null' => false])
->addColumn('createBy', 'integer', ['null' => false])
->addIndex(['name'], ['unique' => true])
->create();
}
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
if ($this->hasTable('UserPermissionTemplate')) {
$this->table('UserPermissionTemplate')->drop()->save();
}
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddRimoFcpIndex extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "addressdb") {
$table = $this->table('Hausnummer');
if (!$table->hasIndexByName('idx_fcp_id_rimo_type')) {
$table->addIndex(['fcp_id', 'rimo_type'], ['name' => 'idx_fcp_id_rimo_type'])
->update();
}
}
if ($this->getEnvironment() == "thetool") {
$table = $this->table('Preorder');
if (!$table->hasIndexByName('idx_adb_hausnummer_id')) {
$table->addIndex(['adb_hausnummer_id'], ['name' => 'idx_adb_hausnummer_id']);
}
if (!$table->hasIndexByName('idx_status_id')) {
$table->addIndex(['status_id'], ['name' => 'idx_status_id']);
}
$table->update();
}
}
public function down(): void
{
if ($this->getEnvironment() == "addressdb") {
$table = $this->table('Hausnummer');
if ($table->hasIndexByName('idx_fcp_id_rimo_type')) {
$table->removeIndexByName('idx_fcp_id_rimo_type')
->update();
}
}
if ($this->getEnvironment() == "thetool") {
$table = $this->table('Preorder');
if ($table->hasIndexByName('idx_adb_hausnummer_id')) {
$table->removeIndexByName('idx_adb_hausnummer_id');
}
if ($table->hasIndexByName('idx_status_id')) {
$table->removeIndexByName('idx_status_id');
}
$table->update();
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AdbWohneinheitAddContact extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == 'addressdb') {
$table = $this->table('Wohneinheit');
if (!$table->hasColumn('contact')) {
$table->addColumn('contact', 'text', [
'null' => true,
'default' => null,
'after' => 'note'
])
->save();
}
}
}
public function down(): void
{
if ($this->getEnvironment() == 'addressdb') {
$table = $this->table('Wohneinheit');
if ($table->hasColumn('contact')) {
$table->removeColumn('contact')
->save();
}
}
}
}

View File

@@ -205,7 +205,7 @@ class Helper {
if ($user->isAdmin()) $campaigns = PreordercampaignModel::getAll();
else {
$networkIDs = array_unique(array_merge(
array_column($user->myNetworks(["netowner", "salespartner"]), 'id'),
array_column($user->myNetworks(["netowner"]), 'id'),
json_decode($user->getFlag("preorder_networks")->value() ?: '[]')
));
$campaigns = PreordercampaignModel::search(['network_id' => $networkIDs]);
@@ -213,4 +213,16 @@ class Helper {
return $returnObject ? $campaigns : array_column($campaigns, 'id');
}
public static function getPreorderCampaignNetworkOwners() {
$sql = "SELECT a.id FROM Preordercampaign pc
LEFT JOIN Network n ON pc.network_id = n.id
LEFT JOIN Address a ON n.owner_id = a.id
GROUP BY a.id
ORDER BY a.company, a.lastname, a.firstname";
$results = FronkDB::singleton()->fetch_all_assoc(FronkDB::singleton()->query($sql)) ?? [];
return array_map(fn($owner) => new Address($owner['id']), $results);
}
}

View File

@@ -43,7 +43,7 @@ class TTCrud extends mfBaseController {
if (method_exists($this, 'permissionsCheckOverride'))
$this->permissionsCheckOverride();
else if (!$permissionAllowed && !$this->user->is(["Admin"]))
else if (mfLoginController::isLoggedIn() && !$permissionAllowed && !$this->user->is(["Admin"]))
$this->redirect("Dashboard");
$c = get_class($this);

View File

@@ -1,32 +1,35 @@
<?php
class Zabbix {
class Zabbix
{
private $url;
private $apiKey;
public function __construct($url, $apiKey) {
public function __construct($url, $apiKey)
{
$this->url = $url;
$this->apiKey = $apiKey;
}
public function zabbixRequest($method, $params, $die = false) {
public function zabbixRequest($method, $params, $die = false)
{
$data = array(
'jsonrpc' => '2.0',
'method' => $method,
'params' => $params,
'id' => 1
'method' => $method,
'params' => $params,
'id' => 1
);
$options = array(
'http' => array(
'header' => "Content-Type: application/json\r\n" .
'header' => "Content-Type: application/json\r\n" .
"Authorization: Bearer " . $this->apiKey . "\r\n",
'method' => 'POST',
'content' => json_encode($data)
'method' => 'POST',
'content' => json_encode($data),
'timeout' => 30
)
);
// var dump options and data and die
if ($die) {
var_dump($options);
echo json_encode($data);
@@ -39,25 +42,29 @@ class Zabbix {
return json_decode($result, true);
}
public function getHostById($hostId) {
public function getHostById($hostId)
{
$response = $this->zabbixRequest('host.get', array(
'hostids' => $hostId
));
return $response['result'];
}
public function getItemValues($itemIds, $limit = 15) {
public function getItemValues($itemIds, $limit = 15)
{
if (empty($itemIds)) return [];
$response = $this->zabbixRequest('history.get', array(
'itemids' => $itemIds,
'output' => 'extend',
'itemids' => $itemIds,
'output' => 'extend',
'sortfield' => 'clock',
'sortorder' => 'DESC',
'limit' => $limit
'limit' => $limit
));
return $response['result'];
}
public function getHosts($hostname = null, $ip = null) {
public function getHosts($hostname = null, $ip = null)
{
if ($hostname) {
$response = $this->zabbixRequest('host.get', array(
'search' => array('name' => array($hostname))
@@ -72,80 +79,84 @@ class Zabbix {
return [];
}
public function getHostInterfaceItems($hostId) {
public function getHostInterfaceItems($hostId)
{
$response = $this->zabbixRequest('item.get', array(
'hostids' => $hostId,
'output' => ['itemid','name_resolved', 'key_', 'units'],
'search' => ['name' => ["Bits received", "Bits sent"]],
'hostids' => $hostId,
'output' => ['itemid', 'name', 'key_', 'units'],
'search' => ['name' => ["Bits received", "Bits sent"]],
'searchByAny' => true,
'sortfield' => 'name'
'sortfield' => 'name'
));
return $response['result'];
}
public function getICMPItems($hostId) {
public function getICMPItems($hostId)
{
$response = $this->zabbixRequest('item.get', array(
'hostids' => $hostId,
'search' => array('name' => array("ICMP"))
'search' => array('name' => array("ICMP"))
));
return $response['result'];
}
public function getUptimeItems($hostId) {
public function getUptimeItems($hostId)
{
$response = $this->zabbixRequest('item.get', array(
'hostids' => $hostId,
'search' => array('name' => array("Uptime"))
'search' => array('name' => array("Uptime"))
));
return $response['result'];
}
public function getHostInterfaces($hostIds) {
$response = $this->zabbixRequest('hostinterface.get', array('hostids' => $hostIds));
public function getHostInterfaces($hostIds)
{
$response = $this->zabbixRequest('hostinterface.get', array('hostids' => $hostIds, 'output' => 'extend'));
return $response['result'];
}
public function createTask($itemid) {
public function createTask($itemid)
{
$response = $this->zabbixRequest('task.create', array(
'type' => 6,
'request' => array(
'itemid' => $itemid
)
'type' => 6,
'request' => array('itemid' => $itemid)
));
return $response['result'];
}
public function getAllHostsWithDetails() {
public function getAllHostsWithDetails()
{
$response = $this->zabbixRequest('host.get', [
'output' => ['hostid', 'host', 'name', 'status'],
'selectInventory' => ['location_lat', 'location_lon'],
'output' => ['hostid', 'host', 'name', 'status'],
'selectInventory' => ['location_lat', 'location_lon'],
'selectParentTemplates' => ['templateid', 'name'],
'selectHostGroups' => 'extend' // This is the new line
'selectHostGroups' => 'extend'
]);
return $response['result'] ?? [];
}
public function updateHostInventory($hostId, $inventoryData) {
// First, get the current inventory to avoid overwriting existing fields
public function updateHostInventory($hostId, $inventoryData)
{
$hostResponse = $this->zabbixRequest('host.get', [
'hostids' => $hostId,
'hostids' => $hostId,
'selectInventory' => 'extend'
]);
$currentInventory = $hostResponse['result'][0]['inventory'] ?? [];
// Merge new coordinates into the existing inventory
$newInventory = array_merge($currentInventory, $inventoryData);
$params = [
'hostid' => $hostId,
'hostid' => $hostId,
'inventory_mode' => 0, // Set to manual mode
'inventory' => $newInventory
'inventory' => $newInventory
];
$response = $this->zabbixRequest('host.update', $params);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
}
public function getTemplateIdByName($templateName) {
public function getTemplateIdByName($templateName)
{
$response = $this->zabbixRequest('template.get', [
'output' => ['templateid'],
'filter' => ['host' => [$templateName]]
@@ -153,7 +164,8 @@ class Zabbix {
return $response['result'][0]['templateid'] ?? null;
}
public function getTemplatesByNames(array $templateNames) {
public function getTemplatesByNames(array $templateNames)
{
$response = $this->zabbixRequest('template.get', [
'output' => ['templateid', 'name'],
'filter' => ['host' => $templateNames]
@@ -161,38 +173,39 @@ class Zabbix {
return $response['result'] ?? [];
}
public function createHost($visibleName, $ip, $groupId, $templateIds) {
$templatesData = array_map(function($id) {
public function createHost($visibleName, $ip, $groupId, $templateIds)
{
$templatesData = array_map(function ($id) {
return ['templateid' => $id];
}, $templateIds);
$params = [
'host' => $ip, // Technical name is the IP
'name' => $visibleName, // Visible name
'interfaces' => [ // <-- Corrected structure
'host' => $ip,
'name' => $visibleName,
'interfaces' => [
[
'type' => 2, // 2 for SNMP
'main' => 1,
'useip' => 1,
'ip' => $ip,
'dns' => '',
'port' => '161',
'type' => 2, // 2 for SNMP
'main' => 1,
'useip' => 1,
'ip' => $ip,
'dns' => '',
'port' => '161',
'details' => [
'version' => 2,
'version' => 2,
'community' => 'public_xinon'
]
]
],
'groups' => [['groupid' => $groupId]],
'templates' => $templatesData // Use the correctly formatted array
'groups' => [['groupid' => $groupId]],
'templates' => $templatesData
];
$response = $this->zabbixRequest('host.create', $params);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
}
public function getHostGroupIdByName($groupName) {
public function getHostGroupIdByName($groupName)
{
$response = $this->zabbixRequest('hostgroup.get', [
'output' => ['groupid'],
'filter' => ['name' => [$groupName]]
@@ -200,4 +213,133 @@ class Zabbix {
return $response['result'][0]['groupid'] ?? null;
}
}
public function getHostWithInterfaces($hostId)
{
$response = $this->zabbixRequest('host.get', [
'hostids' => $hostId,
'output' => ['hostid', 'host', 'name'],
'selectInterfaces' => 'extend'
]);
return $response['result'][0] ?? null;
}
public function getResolvedProblems($hostId, $time_from)
{
$response = $this->zabbixRequest('event.get', [
'hostids' => $hostId,
'output' => 'extend',
'select_acknowledges' => ['message'],
'sortfield' => ['clock'],
'sortorder' => 'DESC',
'time_from' => $time_from,
'object' => 0,
'value' => 0
]);
return $response['result'] ?? [];
}
public function updateHostInterface($interfaceId, $details)
{
$params = ['interfaceid' => $interfaceId, 'details' => $details];
$response = $this->zabbixRequest('hostinterface.update', $params);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
}
public function getInterfaceOperationalStatusItems($hostId)
{
$response = $this->zabbixRequest('item.get', [
'hostids' => $hostId,
'output' => ['itemid', 'name', 'key_'],
'search' => ['key_' => 'net.if.status'],
'sortfield' => 'name'
]);
return $response['result'] ?? [];
}
public function getTriggersForHostByDescription($hostId, $description)
{
$response = $this->zabbixRequest('trigger.get', [
'hostids' => $hostId,
'output' => ['triggerid', 'description'],
'search' => ['description' => $description],
'searchByAny' => true
]);
return $response['result'] ?? [];
}
public function createInterfaceLinkDownTrigger($expression, $description, $priority = 4)
{
$params = [
'description' => $description,
'expression' => $expression,
'priority' => $priority,
];
$response = $this->zabbixRequest('trigger.create', $params);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
}
public function deleteTriggers(array $triggerIds)
{
if (empty($triggerIds)) return [];
$response = $this->zabbixRequest('trigger.delete', $triggerIds);
return $response['result'] ?? ['error' => $response['error'] ?? 'Unknown error'];
}
public function getTrends(array $itemIds, $time_from)
{
if (empty($itemIds)) return [];
$response = $this->zabbixRequest('trend.get', [
'itemids' => $itemIds,
'output' => ['itemid', 'num', 'value_min', 'value_avg', 'value_max'],
'time_from' => $time_from
]);
return $response['result'] ?? [];
}
public function getOverviewData($hostId)
{
$items = $this->zabbixRequest('item.get', [
'hostids' => $hostId,
'output' => ['itemid', 'name', 'units', 'key_'],
'search' => ['key_' => ['icmpping', 'system.uptime']],
'searchByAny' => true
])['result'] ?? [];
$itemIds = [];
$itemMap = [];
$data = ['ping' => [], 'uptime' => []];
foreach ($items as $item) {
$itemIds[] = $item['itemid'];
$type = str_contains($item['key_'], 'uptime') ? 'uptime' : 'ping';
$itemMap[$item['itemid']] = ['type' => $type, 'name' => $item['name'], 'units' => $item['units']];
}
if (!empty($itemIds)) {
$history = $this->getItemValues($itemIds, 1);
foreach ($history as $h) {
if (!isset($itemMap[$h['itemid']])) continue;
$info = $itemMap[$h['itemid']];
$data[$info['type']][] = ['name' => $info['name'], 'value' => $h['value'], 'clock' => $h['clock'], 'units' => $info['units']];
}
}
$time30d = time() - 2592000;
$problems = $this->zabbixRequest('problem.get', [
'hostids' => $hostId,
'time_from' => $time30d,
'output' => ['clock']
])['result'] ?? [];
$time7d = time() - 604800;
$time24h = time() - 86400;
$counts = ['24h' => 0, '7d' => 0, '30d' => 0];
foreach ($problems as $p) {
$counts['30d']++;
if ($p['clock'] >= $time7d) $counts['7d']++;
if ($p['clock'] >= $time24h) $counts['24h']++;
}
$data['problemCounts'] = $counts;
return $data;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -38,10 +38,13 @@ document.body.insertAdjacentHTML('beforeend', `
`);
class VodiaIdentitySwitcher {
// --- Configuration ---
API_BASE_URL = window.baseurl || '/';
POLLING_INTERVAL_MS = 30000;
CACHE_DURATION_MS = 30000;
CACHE_KEY = 'vodiaIdentityCache'; // Key for localStorage
CACHE_DURATION_MS = 60000; // 60 seconds
LOCK_TIMEOUT_MS = 5000; // 5 seconds for a request to complete
CACHE_KEY = 'vodiaIdentityCache';
LOCK_KEY = 'vodiaIdentityCache_lock';
TEXT = {
checking: "Prüfe...",
@@ -49,56 +52,42 @@ class VodiaIdentitySwitcher {
dropdownTitle: "Ausgehende Identität wählen:",
ownExtension: "Eigene Nummer",
customIdentity: "Andere Nummer",
// ## START: ADDED TEXT ##
noActiveCall: "Kein aktiver Anruf gefunden.",
lookupError: "Fehler bei der Anrufabfrage."
// ## END: ADDED TEXT ##
lookupError: "Fehler bei der Anrufabfrage.",
fetchError: "Fehler"
};
pollingTimer = null;
// --- State ---
elements = {};
templates = {};
constructor(parentElement) {
if (!parentElement) return;
this.templates.main = document.getElementById('vodia-identity-template');
this.templates.listItem = document.getElementById('vodia-list-item-template');
if (!this.templates.main || !this.templates.listItem) {
return console.error("Vodia Switcher Error: Required HTML <template> tags not found.");
}
this._initializeTemplates();
if (!this.templates.main || !this.templates.listItem) return;
this._createSwitcherUI(parentElement);
this._addEventListeners();
this.getVodiaIdentity();
}
_getCache() {
const cachedString = localStorage.getItem(this.CACHE_KEY);
if (!cachedString) return null;
try {
return JSON.parse(cachedString);
} catch (e) {
console.error("Vodia Cache Error: Could not parse cached data.", e);
localStorage.removeItem(this.CACHE_KEY);
return null;
// Initial load if tab is already visible
if (document.visibilityState === 'visible') {
this.loadIdentity();
}
}
_setCache(vodiaState) {
const itemToCache = { data: vodiaState, timestamp: Date.now() };
try {
localStorage.setItem(this.CACHE_KEY, JSON.stringify(itemToCache));
} catch (e) {
console.error("Vodia Cache Error: Could not write to localStorage.", e);
// --- Private Methods: Initialization ---
_initializeTemplates() {
this.templates.main = document.getElementById('vodia-identity-template');
this.templates.listItem = document.getElementById('vodia-list-item-template');
if (!this.templates.main || !this.templates.listItem) {
console.error("Vodia Switcher Error: Required HTML <template> tags not found.");
}
}
_createSwitcherUI(parentElement) {
const fragment = this.templates.main.content.cloneNode(true);
const container = fragment.querySelector('#vodia-identity-container');
this.elements = {
container,
callLookupButton: container.querySelector('[data-ref="callLookupButton"]'),
@@ -111,18 +100,19 @@ class VodiaIdentitySwitcher {
dropdownMenu: container.querySelector('[data-ref="dropdownMenu"]'),
identityList: container.querySelector('[data-ref="identityList"]'),
};
this.elements.dropdownMenu.querySelector('[data-ref="dropdownTitle"]').textContent = this.TEXT.dropdownTitle;
parentElement.prepend(fragment);
}
_addEventListeners() {
// Dropdown toggle
this.elements.toggleButton.addEventListener('click', e => {
e.preventDefault();
const isShown = this.elements.container.classList.toggle('show');
this.elements.toggleButton.setAttribute('aria-expanded', isShown);
this.elements.container.classList.toggle('show');
this.elements.toggleButton.setAttribute('aria-expanded', this.elements.container.classList.contains('show'));
});
// Close dropdown on outside click
document.addEventListener('click', e => {
if (!this.elements.container.contains(e.target)) {
this.elements.container.classList.remove('show');
@@ -130,35 +120,113 @@ class VodiaIdentitySwitcher {
}
});
// Call lookup button
this.elements.callLookupButton.addEventListener('click', e => {
e.preventDefault();
this._handleCallLookup();
});
// Reload data when tab becomes visible
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
this.loadIdentity();
}
});
// Sync state across tabs when another tab updates the cache
window.addEventListener('storage', e => {
if (e.key === this.CACHE_KEY && e.newValue) {
this._updateFromState(JSON.parse(e.newValue).data);
}
});
}
_setLookupButtonLoadingState(isLoading) {
const { callLookupButton, callLookupIconStack, callLookupSpinner } = this.elements;
if (isLoading) {
callLookupButton.setAttribute('disabled', 'true');
callLookupIconStack.classList.add('d-none'); // Hide the icon stack
callLookupSpinner.classList.remove('d-none'); // Show the spinner
} else {
callLookupButton.removeAttribute('disabled');
callLookupIconStack.classList.remove('d-none'); // Show the icon stack
callLookupSpinner.classList.add('d-none'); // Hide the spinner
// --- Private Methods: API & Data Handling ---
_getCache(key) {
const cachedString = localStorage.getItem(key);
if (!cachedString) return null;
try {
return JSON.parse(cachedString);
} catch (e) {
localStorage.removeItem(key); // Clear corrupted cache
return null;
}
}
_setCache(key, data) {
try {
localStorage.setItem(key, JSON.stringify({ data, timestamp: Date.now() }));
} catch (e) {
console.error(`Vodia Cache Error (${key}): Could not write to localStorage.`, e);
}
}
async _fetchJSON(endpoint, options = {}) {
const response = await fetch(`${this.API_BASE_URL}${endpoint}`, options);
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
const data = await response.json();
if (data.status !== "OK") throw new Error(data.message || 'API returned an error');
return data;
}
async loadIdentity() {
// 1. Use fresh cache if available (handles re-focus)
const cached = this._getCache(this.CACHE_KEY);
if (cached && Date.now() - cached.timestamp < this.CACHE_DURATION_MS) {
return this._updateFromState(cached.data);
}
// 2. Check for a lock from another tab to prevent multiple requests
const lock = this._getCache(this.LOCK_KEY);
if (lock && Date.now() - lock.timestamp < this.LOCK_TIMEOUT_MS) {
return; // Another tab is fetching, the 'storage' event will update this tab.
}
// 3. This tab will fetch the data. Set a lock.
this._setCache(this.LOCK_KEY, {}); // Set lock with current timestamp
this._renderState('loading');
try {
const { result } = await this._fetchJSON('User/Api/do=getVodiaIdentity');
this._setCache(this.CACHE_KEY, result); // This triggers 'storage' event for other tabs
this._updateFromState(result);
} catch (error) {
console.error("Vodia Fetch Error:", error.message);
this._renderState('error');
} finally {
localStorage.removeItem(this.LOCK_KEY); // Release lock
}
}
async setVodiaOutboundIdentity(number) {
this._renderState('setting');
this.elements.container.classList.remove('show');
this.elements.toggleButton.setAttribute('aria-expanded', 'false');
try {
await this._fetchJSON('User/Api/do=setVodiaIdentity', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ 'number': number })
});
// Clear cache and lock, then force reload across all tabs
localStorage.removeItem(this.CACHE_KEY);
localStorage.removeItem(this.LOCK_KEY);
this.loadIdentity();
} catch (error) {
console.error("Vodia Set Error:", error.message);
if (window.notify) window.notify('error', "Fehler beim Ändern der ID!");
this._renderState('error'); // Revert to error state, but previous data will be loaded on next focus
}
}
async _handleCallLookup() {
this._setLookupButtonLoadingState(true);
try {
const response = await fetch(`${this.API_BASE_URL}User/Api/do=getVodiaCall`);
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
if (data.status === 'OK' && data.result.number && data.result.number.length >= 5) {
const url = `${this.API_BASE_URL}Address/Index?filter[pfm]=${data.result.number}`;
window.open(url, '_blank'); // Open address book in a new tab
const { result } = await this._fetchJSON('User/Api/do=getVodiaCall');
if (result.number && result.number.length >= 5) {
window.open(`${this.API_BASE_URL}Address/Index?filter[pfm]=${result.number}`, '_blank');
} else {
if (window.notify) window.notify('info', this.TEXT.noActiveCall);
}
@@ -169,121 +237,90 @@ class VodiaIdentitySwitcher {
this._setLookupButtonLoadingState(false);
}
}
// ## END: ADDED METHOD TO HANDLE CALL LOOKUP ##
_setLoadingState(isLoading, message = '') {
// --- Private Methods: UI Rendering ---
_setLookupButtonLoadingState(isLoading) {
this.elements.callLookupIconStack.classList.toggle('d-none', isLoading);
this.elements.callLookupSpinner.classList.toggle('d-none', !isLoading);
this.elements.callLookupButton.toggleAttribute('disabled', isLoading);
}
_renderState(state, message = '') {
const { phoneIcon, currentName, currentNumber } = this.elements;
phoneIcon.className = 'phone-icon fas fa-phone';
phoneIcon.className = 'phone-icon fas fa-phone'; // Reset classes
currentNumber.textContent = '';
if (isLoading) {
phoneIcon.classList.add('fa-spin', 'text-warning');
currentName.textContent = message;
currentNumber.textContent = '';
} else {
phoneIcon.classList.add('text-success');
switch(state) {
case 'loading':
phoneIcon.classList.add('fa-spin', 'text-warning');
currentName.textContent = this.TEXT.checking;
break;
case 'setting':
phoneIcon.classList.add('fa-spin', 'text-warning');
currentName.textContent = this.TEXT.setting;
break;
case 'error':
phoneIcon.classList.add('text-danger');
currentName.textContent = this.TEXT.fetchError;
break;
case 'success':
phoneIcon.classList.add('text-success');
break;
}
}
async getVodiaIdentity() {
clearTimeout(this.pollingTimer);
try {
const cachedItem = this._getCache();
let vodiaState;
if (cachedItem && (Date.now() - cachedItem.timestamp < this.CACHE_DURATION_MS)) {
vodiaState = cachedItem.data;
} else {
this._setLoadingState(true, this.TEXT.checking);
const response = await fetch(`${this.API_BASE_URL}User/Api/do=getVodiaIdentity`);
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
if (data.status !== "OK") throw new Error(data.message || 'API returned an error');
vodiaState = data.result;
this._setCache(vodiaState);
}
if (vodiaState?.enabled) {
this.elements.container.style.display = 'flex'; // Use flex to align items
this._updateUI(vodiaState);
this._setLoadingState(false);
} else {
this.elements.container.style.display = 'none';
}
} catch (error) {
console.error("Vodia Fetch Error:", error.message);
this.elements.phoneIcon.className = 'phone-icon fas fa-phone text-danger';
this.elements.currentName.textContent = 'Fehler';
} finally {
this.pollingTimer = setTimeout(() => this.getVodiaIdentity(), this.POLLING_INTERVAL_MS);
_updateFromState(vodiaState) {
if (!vodiaState?.enabled) {
this.elements.container.style.display = 'none';
return;
}
this.elements.container.style.display = 'flex';
this._renderState('success');
this._updateCurrentIdentityDisplay(vodiaState);
this._renderIdentityList(vodiaState);
}
async setVodiaOutboundIdentity(number) {
this._setLoadingState(true, this.TEXT.setting);
this.elements.container.classList.remove('show');
this.elements.toggleButton.setAttribute('aria-expanded', 'false');
try {
const response = await fetch(`${this.API_BASE_URL}User/Api/do=setVodiaIdentity`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ 'number': number })
});
if (!response.ok) throw new Error("Network response was not ok.");
const data = await response.json();
if (data.status !== "OK") throw new Error(data.message || 'API returned an error.');
localStorage.removeItem(this.CACHE_KEY)
} catch (error) {
console.error("Vodia Set Error:", error.message);
if (window.notify) window.notify.error("Fehler beim Ändern der ID!");
} finally {
await this.getVodiaIdentity();
}
}
_updateUI(vodiaState) {
const { 'default': defaultDisplay, default_number, current, identities } = vodiaState;
_updateCurrentIdentityDisplay(vodiaState) {
const { 'default': defaultDisplay, default_number, current, identities = {} } = vodiaState;
const currentId = current.replaceAll(' ', "");
const defaultId = default_number.replaceAll(' ', "");
let activeName = this.TEXT.customIdentity;
let activeNumberDisplay = `(${current})`;
if (currentId === default_number.replaceAll(' ', "")) {
if (currentId === defaultId) {
activeName = this.TEXT.ownExtension;
activeNumberDisplay = `(${defaultDisplay})`;
} else if (identities) {
const found = Object.entries(identities).find(([, ident]) => ident.number === currentId);
if (found) {
[activeName, activeNumberDisplay] = [found[0], `(${found[1].display})`];
} else {
const foundName = Object.keys(identities).find(name => identities[name].number === currentId);
if (foundName) {
activeName = foundName;
activeNumberDisplay = `(${identities[foundName].display})`;
}
}
this.elements.currentName.textContent = activeName;
this.elements.currentNumber.textContent = activeNumberDisplay;
this.elements.toggleButton.title = `Aktive ID: ${activeName} ${activeNumberDisplay}`;
this._renderIdentityList(vodiaState);
}
_renderIdentityList(vodiaState) {
this.elements.identityList.innerHTML = '';
const { 'default': defaultDisplay, default_number, current, identities } = vodiaState;
const { 'default': defaultDisplay, default_number, current, identities = {} } = vodiaState;
const currentId = current.replaceAll(' ', "");
const defaultId = default_number.replaceAll(' ', "");
// Add own extension
this.elements.identityList.appendChild(this._createListItem({
name: this.TEXT.ownExtension,
number: default_number.replaceAll(' ', ""),
number: defaultId,
display: defaultDisplay,
color: 'blue',
isActive: currentId === default_number.replaceAll(' ', "")
isActive: currentId === defaultId
}));
if (!identities) return;
// Add other identities
for (const name in identities) {
const ident = identities[name];
this.elements.identityList.appendChild(this._createListItem({
@@ -300,13 +337,12 @@ class VodiaIdentitySwitcher {
const fragment = this.templates.listItem.content.cloneNode(true);
const item = fragment.querySelector('li');
item.querySelector('[data-ref="colorBlock"]').classList.add(`vodia-identity-color-${color || 'grey'}`);
item.querySelector('[data-ref="colorBlock"]').className = `vodia-list-item-icon fas fa-circle mr-3 vodia-identity-color-${color || 'grey'}`;
item.querySelector('[data-ref="name"]').textContent = name;
item.querySelector('[data-ref="numberDisplay"]').textContent = display;
if (isActive) {
item.classList.add('active');
item.querySelector('[data-ref="numberDisplay"]').classList.remove('text-muted');
} else {
item.classList.add('pointer');
item.addEventListener('click', () => this.setVodiaOutboundIdentity(number));
@@ -315,7 +351,8 @@ class VodiaIdentitySwitcher {
}
}
// --- Bootstrap ---
document.addEventListener('DOMContentLoaded', () => {
const topbar = document.querySelector("#topbar");
if (topbar) new VodiaIdentitySwitcher(topbar);
});
});

View File

@@ -39,6 +39,7 @@ $jsFiles = [
"plugins/vue/tt-components/tt-select.js",
"plugins/vue/tt-components/tt-datepicker.js",
"plugins/vue/tt-components/tt-input.js",
"plugins/vue/tt-components/tt-switch.js",
"plugins/vue/tt-components/tt-input-article.js",
"plugins/vue/tt-components/tt-button.js",
"plugins/vue/tt-components/tt-modal.js",

View File

@@ -44,6 +44,7 @@ $cssFiles = [
'plugins/vue/tt-components/css/tt-tooltip.css',
'plugins/vue/tt-components/css/tt-loader.css',
'plugins/vue/tt-components/css/tt-select.css',
'plugins/vue/tt-components/css/tt-switch.css',
'plugins/vue/tt-components/css/tt-file-gallery.css',
'plugins/vue/tt-components/css/tt-position-manager.css',
];

View File

@@ -1,6 +1,6 @@
<?php
/*
* SOPP - SBIDI Network Operations Plattform
* SOPP - SBIDI Network Operations Plattform
*/
// phpinfo(); exit;
define('mfUI',"web");

View File

@@ -3,7 +3,7 @@ Vue.component('a-d-b-wohneinheit-duplicate', {
template: `
<tt-card>
<tt-table :data="window['TT_CONFIG']['DUPLICATE_HOMES']" :config="DuplicateHomesTableConfig" excel-export>
<template v-slot:extref="{ row }">
<span class="badge badge-warning">{{ row.duplicateType === 'extref' ? row.extref : row.oaid }}</span>
</template>
@@ -66,18 +66,19 @@ Vue.component('a-d-b-wohneinheit-duplicate', {
markedForDeletion: [],
DuplicateHomesTableConfig: {
key: 'DuplicateHomesTable',
tableHeader: 'Doppelte HomeID Liste',
tableHeader: 'Datenqualitäts-Checks',
defaultPageSize: 10,
headers: [
{text: 'Netzgebiet', key: 'netzgebiet_id', sortable: true, class: 'text-nowrap', priority: 11, filter: 'select', filterOptions: window['TT_CONFIG']['ADB_NETZGEBIETE']},
{text: 'Firma', key: 'netzgebiet_owner', sortable: true, class: 'text-nowrap', priority: 11, filter: 'select', filterOptions: window['TT_CONFIG']['NETWORK_OWNERS']},
{text: 'HomeID/OAID', key: 'extref', sortable: true, class: 'text-nowrap', priority: 10},
{text: 'Check-Typ', key: 'duplicateType', sortable: true, class: 'text-nowrap', priority: 9, filter: 'select', filterOptions: [
{text: 'HomeID', value: 'extref'},
{text: 'HomeID', value: 'extref'},
{text: 'OAID', value: 'oaid'},
{text: 'RIMO gelöscht', value: 'rimo_deleted'},
{text: 'Unsch. + BE', value: 'rml_unscheduled_with_order'},
]},
{text: 'Greenfield + BE', value: 'greenfield_with_order'},
]},
{text: 'Anz. HomeIDs', key: 'count', sortable: true, class: 'text-center', priority: 8},
{
text: 'Home Details',

View File

@@ -0,0 +1,282 @@
const ADBWohneinheitContactManager = {
modal: null,
homeId: null,
header: null,
contacts: [],
editingIndex: null,
init() {
document.body.addEventListener('click', this.handleBodyClick.bind(this));
},
handleBodyClick(e) {
const trigger = e.target.closest('[data-home-id][data-home-contact]');
if (trigger) {
e.preventDefault();
this.homeId = trigger.dataset.homeId;
this.openModal();
}
},
async openModal() {
this.editingIndex = null;
this.createModal();
this.showLoading();
try {
const response = await axios.post('/ADBWohneinheit/getContacts', { id: this.homeId });
if (response.data.success) {
this.contacts = response.data.contacts || [];
this.header = response.data.header || {};
this.render();
} else {
this.showError(response.data.message);
}
} catch (error) {
this.showError('Fehler beim Laden der Kontaktdaten.');
console.error(error);
}
},
createModal() {
if (this.modal) {
$(this.modal).modal('hide');
this.modal.remove();
}
const modalHtml = `
<div class="modal fade" id="contactManagerModal" tabindex="-1" role="dialog" aria-labelledby="contactManagerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="contactManagerModalLabel">Kontakt verwalten (WE-ID: ${this.homeId})</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
this.modal = document.getElementById('contactManagerModal');
$(this.modal).modal('show');
this.modal.addEventListener('hidden.bs.modal', () => {
if (document.body.contains(this.modal)) {
this.modal.remove();
}
this.modal = null;
});
},
render() {
const body = this.modal.querySelector('.modal-body');
const contactListHtml = this.contacts.map((contact, index) => this.renderContact(contact, index)).join('');
this.modal.querySelector('.modal-title').innerText = `Kontakt verwalten (WE-ID: ${this.homeId}) [${this.header}]`;
body.innerHTML = `
<div class="mb-4">
<h6>Bestehende Kontakte</h6>
${this.contacts.length ? `<ul class="list-group">${contactListHtml}</ul>` : '<p class="text-muted">Keine Kontakte vorhanden.</p>'}
</div>
<hr>
<div>
<h6>${this.editingIndex !== null ? 'Kontakt bearbeiten' : 'Neuen Kontakt hinzufügen'}</h6>
${this.renderForm(this.editingIndex !== null ? this.contacts[this.editingIndex] : {})}
</div>
`;
this.attachFormListeners();
},
renderContact(contact, index) {
const isCompany = contact.firma && contact.firma.trim() !== '';
const displayName = isCompany ? contact.firma : `${contact.vorname || ''} ${contact.nachname || ''}`.trim();
const typeLabel = contact.type === 'renter' ? 'Mieter' : (contact.type === 'prospect' ? 'Interessent' : '');
const typeBadge = typeLabel ? `<span class="badge badge-info ml-2">${typeLabel}</span>` : '';
return `
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>${displayName}</strong>${typeBadge}
<br>
<small class="text-muted">${contact.telefon || 'Kein Telefon'} | ${contact.email || 'Keine E-Mail'}</small>
</div>
<div>
<button class="btn btn-sm btn-outline-primary mr-2" data-action="edit" data-index="${index}"><i class="fas fa-edit"></i> Bearbeiten</button>
<button class="btn btn-sm btn-outline-danger" data-action="delete" data-index="${index}"><i class="fas fa-trash"></i> Löschen</button>
</div>
</li>
`;
},
renderForm(contact = {}) {
return `
<form id="contactForm" class="p-3 bg-light border rounded">
<div class="form-group row">
<label class="col-sm-3 col-form-label">Typ</label>
<div class="col-sm-9">
<select class="form-control form-control-sm" name="type" required>
<option value="" disabled ${!contact.type ? 'selected' : ''}>Bitte wählen...</option>
<option value="renter" ${contact.type === 'renter' ? 'selected' : ''}>Mieter</option>
<option value="prospect" ${contact.type === 'prospect' ? 'selected' : ''}>Interessent</option>
</select>
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Firma</label>
<div class="col-sm-9">
<input type="text" class="form-control form-control-sm" name="firma" value="${contact.firma || ''}" placeholder="Firmenname">
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Vorname</label>
<div class="col-sm-9">
<input type="text" class="form-control form-control-sm" name="vorname" value="${contact.vorname || ''}" placeholder="Vorname">
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Nachname</label>
<div class="col-sm-9">
<input type="text" class="form-control form-control-sm" name="nachname" value="${contact.nachname || ''}" placeholder="Nachname">
</div>
</div>
<div class="form-group row">
<label class="col-sm-3 col-form-label">Telefon</label>
<div class="col-sm-9">
<input type="tel" class="form-control form-control-sm" name="telefon" value="${contact.telefon || ''}" placeholder="Telefonnummer">
</div>
</div>
<div class="form-group row mb-0">
<label class="col-sm-3 col-form-label">E-Mail</label>
<div class="col-sm-9">
<input type="email" class="form-control form-control-sm" name="email" value="${contact.email || ''}" placeholder="E-Mail Adresse">
</div>
</div>
<hr>
<div class="text-right">
${this.editingIndex !== null ? `<button class="btn btn-secondary mr-2" data-action="cancel-edit" type="button">Abbrechen</button>` : ''}
<button class="btn btn-primary" type="submit"><i class="fas fa-save mr-1"></i> ${this.editingIndex !== null ? 'Änderungen speichern' : 'Kontakt hinzufügen'}</button>
</div>
</form>
`;
},
attachFormListeners() {
const form = this.modal.querySelector('#contactForm');
if (!form) return;
const firmaInput = form.querySelector('[name="firma"]');
const vornameInput = form.querySelector('[name="vorname"]');
const nachnameInput = form.querySelector('[name="nachname"]');
const togglePersonFields = () => {
const isCompany = firmaInput.value.trim() !== '';
vornameInput.disabled = isCompany;
nachnameInput.disabled = isCompany;
if (isCompany) {
vornameInput.value = '';
nachnameInput.value = '';
}
};
const toggleCompanyField = () => {
const isPerson = vornameInput.value.trim() !== '' || nachnameInput.value.trim() !== '';
firmaInput.disabled = isPerson;
if (isPerson) {
firmaInput.value = '';
}
};
firmaInput.addEventListener('input', togglePersonFields);
vornameInput.addEventListener('input', toggleCompanyField);
nachnameInput.addEventListener('input', toggleCompanyField);
togglePersonFields();
toggleCompanyField();
form.addEventListener('submit', this.handleFormSubmit.bind(this));
this.modal.querySelector('.modal-body').addEventListener('click', (e) => {
const button = e.target.closest('button[data-action]');
if(!button) return;
e.preventDefault();
const action = button.dataset.action;
const index = button.dataset.index;
if (action === 'edit') this.handleEdit(index);
if (action === 'delete') this.handleDelete(index);
if (action === 'cancel-edit') this.handleCancelEdit();
});
},
handleFormSubmit(e) {
e.preventDefault();
const formData = new FormData(e.target);
const newContact = Object.fromEntries(formData.entries());
if (this.editingIndex !== null) {
this.contacts[this.editingIndex] = newContact;
} else {
this.contacts.push(newContact);
}
this.saveContacts();
},
handleEdit(index) {
this.editingIndex = parseInt(index, 10);
this.render();
},
handleCancelEdit() {
this.editingIndex = null;
this.render();
},
handleDelete(index) {
if (confirm('Sollen die Kontaktdaten wirklich gelöscht werden?')) {
this.contacts.splice(index, 1);
this.saveContacts();
}
},
async saveContacts() {
this.showLoading('Speichern...');
try {
const response = await axios.post('/ADBWohneinheit/saveContacts', {
id: this.homeId,
data: this.contacts
});
if (response.data.success) {
this.editingIndex = null;
const freshResponse = await axios.post('/ADBWohneinheit/getContacts', { id: this.homeId });
this.contacts = freshResponse.data.contacts || [];
this.render();
window.notify('success', response.data.message);
} else {
this.showError(response.data.message);
}
} catch(error) {
this.showError('Fehler beim Speichern der Kontaktdaten.');
console.error(error);
}
},
showLoading(message = 'Laden...') {
const body = this.modal.querySelector('.modal-body');
body.innerHTML = `<div class="text-center p-5"><div class="spinner-border text-primary" role="status"><span class="sr-only">Loading...</span></div><p class="mt-2">${message}</p></div>`;
},
showError(message) {
const body = this.modal.querySelector('.modal-body');
body.innerHTML = `<div class="alert alert-danger">${message}</div>`;
setTimeout(() => this.render(), 3000);
}
};
document.addEventListener('DOMContentLoaded', () => {
ADBWohneinheitContactManager.init();
});

View File

@@ -311,9 +311,16 @@ Vue.component('asset-borrow-return-widget', {
},
nextReservation() {
if (!this.rowData.reservations || this.rowData.reservations.length === 0) return null;
const now = window.moment().unix();
return this.rowData.reservations.find(r => r.startDate > now);
return this.rowData.reservations.reduce((closest, current) => {
const closestDiff = Math.abs(closest.startDate - now);
const currentDiff = Math.abs(current.startDate - now);
return currentDiff < closestDiff ? current : closest;
});
}
},
methods: {
formatDate(timestamp, format) {

View File

@@ -1,7 +1,7 @@
var hidesearch = [2, 3, 4, 8];
var columnfilter = [7];
var columnoptions = '<option value=""></option><option value="Offen">Offen</option><option value="Genehmigt">Genehmigt</option><option value="Abgelehnt">Abgelehnt</option>';
var localsorageEvent = null;
const fileTypeClasses = {
'image/png': 'fa-file-png',
'image/jpeg': 'fa-file-jpg',
@@ -322,6 +322,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (info.resource) {
resourceId = info.resource.id;
}
let allDay = info.allDay;
let cestDate = new Date(info.startStr);
let cestOffset = 0; // 2 Stunden in Minuten
let utcTime = cestDate.getTime() - cestOffset * 60 * 1000;
@@ -336,22 +337,35 @@ document.addEventListener('DOMContentLoaded', function () {
let StartformattedDate = year + "-" + month + "-" + day;
let StarteformattedTime = hours + ":" + minutes;
cestDate = new Date(info.endStr);
cestOffset = 0; // 2 Stunden in Minuten
utcTime = cestDate.getTime() - cestOffset * 60 * 1000;
utcDate = new Date(utcTime);
year = utcDate.getUTCFullYear();
month = String(utcDate.getUTCMonth() + 1).padStart(2, '0');
day = String(utcDate.getUTCDate()).padStart(2, '0');
hours = String(utcDate.getUTCHours()).padStart(2, '0');
minutes = String(utcDate.getUTCMinutes()).padStart(2, '0');
seconds = String(utcDate.getUTCSeconds()).padStart(2, '0');
let EndformattedDate = year + "-" + month + "-" + day;
let EndformattedTime = hours + ":" + minutes;
let EndformattedDate;
let EndformattedTime;
if (!allDay) {
cestDate = new Date(info.endStr);
cestOffset = 0; // 2 Stunden in Minuten
utcTime = cestDate.getTime() - cestOffset * 60 * 1000;
utcDate = new Date(utcTime);
year = utcDate.getUTCFullYear();
month = String(utcDate.getUTCMonth() + 1).padStart(2, '0');
day = String(utcDate.getUTCDate()).padStart(2, '0');
hours = String(utcDate.getUTCHours()).padStart(2, '0');
minutes = String(utcDate.getUTCMinutes()).padStart(2, '0');
seconds = String(utcDate.getUTCSeconds()).padStart(2, '0');
EndformattedDate = year + "-" + month + "-" + day;
EndformattedTime = hours + ":" + minutes;
} else {
cestDate = new Date(info.endStr);
cestOffset = 120; // 2 Stunden in Minuten
utcTime = cestDate.getTime() - cestOffset * 60 * 1000;
utcDate = new Date(utcTime);
year = utcDate.getUTCFullYear();
month = String(utcDate.getUTCMonth() + 1).padStart(2, '0');
day = String(utcDate.getUTCDate()).padStart(2, '0');
hours = String(utcDate.getUTCHours()).padStart(2, '0');
minutes = String(utcDate.getUTCMinutes()).padStart(2, '0');
seconds = String(utcDate.getUTCSeconds()).padStart(2, '0');
EndformattedDate = year + "-" + month + "-" + day;
EndformattedTime = '23' + ":" + '59';
}
$('#EventModal').modal('show');
if (resourceId) {
@@ -362,6 +376,15 @@ document.addEventListener('DOMContentLoaded', function () {
$('#start-time').val(StarteformattedTime);
$('#end-date').val(EndformattedDate);
$('#end-time').val(EndformattedTime);
if (allDay) {
$('#allday').prop('checked', true);
$('#allday').change();
$('#end-time').val('');
$('#end-date').val(EndformattedDate);
$('#start-time').val('');
}
}, eventClick: function (info, element) {
let isOrganizer;
@@ -674,25 +697,33 @@ document.addEventListener('DOMContentLoaded', function () {
let StartformattedDate = year + "-" + month + "-" + day;
let StarteformattedTime = hours + ":" + minutes;
let EndformattedDate;
let EndformattedTime;
if (info.event.endStr) {
cestDate = new Date(info.event.endStr);
cestOffset = 0;
cestDate = new Date(info.event.endStr);
cestOffset = 0; // 2 Stunden in Minuten
// 2 Stunden in Minuten
utcTime = cestDate.getTime() - cestOffset * 60 * 1000;
if (info.event.allDay) {
utcTime = utcTime - 60;
utcTime = cestDate.getTime() - cestOffset * 60 * 1000;
if (info.event.allDay) {
utcTime = utcTime - 60;
}
utcDate = new Date(utcTime);
year = utcDate.getUTCFullYear();
month = String(utcDate.getUTCMonth() + 1).padStart(2, '0');
day = String(utcDate.getUTCDate()).padStart(2, '0');
hours = String(utcDate.getUTCHours()).padStart(2, '0');
minutes = String(utcDate.getUTCMinutes()).padStart(2, '0');
seconds = String(utcDate.getUTCSeconds()).padStart(2, '0');
EndformattedDate = year + "-" + month + "-" + day;
EndformattedTime = hours + ":" + minutes;
} else {
EndformattedDate = StartformattedDate;
EndformattedTime = StarteformattedTime;
}
utcDate = new Date(utcTime);
year = utcDate.getUTCFullYear();
month = String(utcDate.getUTCMonth() + 1).padStart(2, '0');
day = String(utcDate.getUTCDate()).padStart(2, '0');
hours = String(utcDate.getUTCHours()).padStart(2, '0');
minutes = String(utcDate.getUTCMinutes()).padStart(2, '0');
seconds = String(utcDate.getUTCSeconds()).padStart(2, '0');
let EndformattedDate = year + "-" + month + "-" + day;
let EndformattedTime = hours + ":" + minutes;
$('#EventModal').modal('show');
@@ -1196,6 +1227,8 @@ if (typeof (EventSource) !== 'undefined') {
console.error('Connection aborted');
}
$(document).ready(function () {
let eventdialog = $('#EventModal').html();
$('body').on('click', '.fa-window-maximize', function () {
$('.card').addClass('card-fullscreen');
@@ -1250,6 +1283,7 @@ $(document).ready(function () {
// Sobald der Link-Dialog geschlossen wird, fügen wir die "modal-open"-Klasse wieder hinzu, falls noch ein Modal offen ist.
$('#EventModal').on('hidden.bs.modal', function (event) {
tinymce.activeEditor.setContent('');
localStorage.removeItem('Calendar_create');
});
@@ -1314,6 +1348,26 @@ $(document).ready(function () {
$('.show-attendee').hide();
$('#add-event').show();
$('#attachments').data('newkey', Math.floor(Math.random() * 10000));
if (localsorageEvent) {
$('#type').val(localsorageEvent.type).trigger('change');
$('#location').val(localsorageEvent.location);
$('#name').val(localsorageEvent.subject);
const datetime = localsorageEvent.cstart.split(' ');
$('#start-date').val(datetime[0]);
$('#start-time').val(datetime[1]);
const datetimeend = localsorageEvent.cend.split(' ');
$('#end-date').val(datetimeend[0]);
$('#end-time').val(datetimeend[1]);
if (localsorageEvent.customer_phone)
{
$('#customer-info-type').val('2').trigger('change');
$('#customer-info-type-text').val(localsorageEvent.customer_phone);
} else if (localsorageEvent.customer_email)
{
$('#customer-info-type').val('1').trigger('change');
}
}
});
tinymce.init({
//font_formats: "Arial=arial,sans-serif;",
@@ -1337,9 +1391,12 @@ $(document).ready(function () {
content_style: "body { font-family: 'Calibri', sans-serif; }",
font_family_formats: "Calibri=Calibri, sans-serif;Arial=arial,sans-serif; Courier New=courier new,courier,monospace; Georgia=georgia,palatino,serif; Helvetica=helvetica,sans-serif; Lucida Sans=lucida sans unicode,sans-serif; Tahoma=tahoma,arial,helvetica,sans-serif; Times New Roman=times new roman,times,serif",
setup: function (editor) {
editor.on('init', function () {
if (localsorageEvent) {
this.setContent(localsorageEvent.description || '');
}
});
}
});
$('body').on('click', '#add-event', function () {
let valid = true;
@@ -1443,7 +1500,6 @@ $(document).ready(function () {
rruleData.rrule_until = $('#rrule-until').val();
}
$.post(requestInsertUrl, {
start: start,
end: end,
@@ -1470,6 +1526,7 @@ $(document).ready(function () {
}, function (data) {
}).done(function (data) {
localStorage.removeItem('Calendar_create');
});
$('#EventModal').modal('hide');
});
@@ -2806,7 +2863,6 @@ $(document).ready(function () {
}
});
$(document).ready(function () {
// Checkbox toggelt das RRule-Panel
$('#recurringCheck').on('change', function () {
if ($(this).is(':checked')) {
$('#recurring-settings').show();
@@ -2820,8 +2876,6 @@ $(document).ready(function () {
$('#rrule-frequency').on('change', function () {
var freq = $(this).val();
// Alles erstmal verstecken
$('#weekly-options').hide();
$('#monthly-options').hide();
@@ -2843,5 +2897,10 @@ $(document).ready(function () {
}
});
});
let create_event = localStorage.getItem('Calendar_create');
if (create_event) {
localsorageEvent = JSON.parse(create_event);
$('#EventModal').modal('show');
}
});

View File

@@ -1,21 +1,207 @@
.monitoring-tabs { display: flex; border-bottom: 1px solid #dee2e6; }
.monitoring-tabs button { background: none; border: none; padding: 10px 15px; cursor: pointer; border-bottom: 3px solid transparent; font-size: 0.9rem; color: #495057; }
.monitoring-tabs button:hover { color: #0056b3; }
.monitoring-tabs button.active { border-bottom-color: #007bff; color: #007bff; font-weight: bold; }
.monitoring-content { padding: 15px; min-height: 400px; }
.overview-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 15px; }
.chart-container { display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 15px; }
.chart-title { font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.problems-list { display: flex; flex-direction: column; gap: 10px; }
.problem-card { display: flex; align-items: center; padding: 10px; border-radius: 5px; border-left-width: 5px; border-left-style: solid; background-color: #f8f9fa; }
.problem-icon { font-size: 1.5rem; margin-right: 15px; width: 30px; text-align: center; }
.problem-details { flex-grow: 1; }
.problem-header { display: flex; justify-content: space-between; align-items: baseline; }
.problem-name { font-weight: 500; }
.problem-time { font-size: 0.8rem; color: #6c757d; }
.problem-opdata { font-size: 0.85rem; color: #495057; margin-top: 4px; }
.sev-info { border-left-color: #17a2b8; } .sev-info .problem-icon { color: #17a2b8; }
.sev-warning { border-left-color: #ffc107; } .sev-warning .problem-icon { color: #ffc107; }
.sev-average { border-left-color: #fd7e14; } .sev-average .problem-icon { color: #fd7e14; }
.sev-high { border-left-color: #dc3545; } .sev-high .problem-icon { color: #dc3545; }
.sev-disaster { border-left-color: #7B014C; } .sev-disaster .problem-icon { color: #7B014C; }
.monitoring-tabs {
display: flex;
border-bottom: 1px solid #dee2e6;
}
.monitoring-tabs button {
background: none;
border: none;
padding: 10px 15px;
cursor: pointer;
border-bottom: 3px solid transparent;
font-size: 0.9rem;
color: #495057;
}
.monitoring-tabs button:hover {
color: #0056b3;
}
.monitoring-tabs button.active {
border-bottom-color: #007bff;
color: #007bff;
font-weight: bold;
}
.monitoring-content {
padding: 15px;
min-height: 400px;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 15px;
}
.chart-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(450px, 1fr));
gap: 15px;
}
.chart-title {
font-size: 0.9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.problems-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.problem-card {
display: flex;
align-items: center;
padding: 10px;
border-radius: 5px;
border-left-width: 5px;
border-left-style: solid;
background-color: #f8f9fa;
}
.problem-icon {
font-size: 1.5rem;
margin-right: 15px;
width: 30px;
text-align: center;
}
.problem-details {
flex-grow: 1;
}
.problem-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.problem-name {
font-weight: 500;
}
.problem-time {
font-size: 0.8rem;
color: #6c757d;
}
.problem-opdata {
font-size: 0.85rem;
color: #495057;
margin-top: 4px;
}
.sev-info {
border-left-color: #17a2b8;
}
.sev-info .problem-icon {
color: #17a2b8;
}
.sev-warning {
border-left-color: #ffc107;
}
.sev-warning .problem-icon {
color: #ffc107;
}
.sev-average {
border-left-color: #fd7e14;
}
.sev-average .problem-icon {
color: #fd7e14;
}
.sev-high {
border-left-color: #dc3545;
}
.sev-high .problem-icon {
color: #dc3545;
}
.sev-disaster {
border-left-color: #7B014C;
}
.sev-disaster .problem-icon {
color: #7B014C;
}
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
}
.problem-counts {
display: flex;
justify-content: space-around;
text-align: center;
padding: 1rem 0;
}
.problem-counts .count {
font-size: 1.5rem;
font-weight: bold;
display: block;
}
.problem-counts .period {
font-size: 0.8rem;
color: #6c757d;
}
.problems-list.resolved .problem-card {
opacity: 0.8;
}
.sev-resolved {
border-left: 5px solid #28a745;
}
.sev-resolved .problem-icon {
color: #28a745;
}
.c-pointer {
cursor: pointer;
}
/* Styles for Interface Alarm List */
.interface-alarm-list {
max-height: 450px;
overflow-y: auto;
border-radius: .25rem;
}
.interface-alarm-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: .75rem 1rem;
border-bottom: 1px solid #e9ecef;
transition: background-color 0.2s ease-in-out;
}
.interface-alarm-item:last-child {
border-bottom: none;
}
.interface-alarm-item:hover {
background-color: #f8f9fa;
}
.interface-name {
font-weight: 500;
flex-grow: 1;
margin-right: 1rem;
word-break: break-all;
}

View File

@@ -11,38 +11,38 @@ const deviceTypeFilterOptions = window?.TT_CONFIG?.DEVICE_TYPES.map(type => ({
Vue.component('device-view-switch', {
//language=Vue
template: `
<div class="device-view-switch" style="margin-bottom: 10px">
<div v-if="!isOverflowing"
class="button-group"
style="display:grid; grid-template-columns: repeat(3, 1fr); gap: 10px; justify-content: center; align-items: center; text-align: center; width: 100%;">
<button @click="$emit('input', 'DeviceTable')" :class="{ 'active': value === 'DeviceTable' }" class="btn btn-primary">Devices</button>
<button @click="$emit('input', 'DeviceManufacturer')"
:class="{ 'active': value === 'DeviceManufacturer' }"
class="btn btn-primary">Hersteller
</button>
<button @click="$emit('input', 'DeviceType')"
:class="{ 'active': value === 'DeviceType' }"
class="btn btn-primary">Geräte Typen
</button>
</div>
<div v-else>
<div class="dropdown">
<button @click="showDropdown = !showDropdown"
class="btn btn-primary dropdown-toggle">Ansicht
</button>
<div v-show="showDropdown" class="dropdown-menu show">
<a href="#" @click="$emit('input', 'DeviceTable'); showDropdown = false" class="dropdown-item">Devices</a>
<a href="#"
@click="$emit('input', 'DeviceManufacturer'); showDropdown = false"
class="dropdown-item">Hersteller</a>
<a href="#"
@click="$emit('input', 'DeviceType'); showDropdown = false"
class="dropdown-item">Geräte Typen</a>
</div>
</div>
</div>
</div>
`,
<div class="device-view-switch" style="margin-bottom: 10px">
<div v-if="!isOverflowing"
class="button-group"
style="display:grid; grid-template-columns: repeat(3, 1fr); gap: 10px; justify-content: center; align-items: center; text-align: center; width: 100%;">
<button @click="$emit('input', 'DeviceTable')" :class="{ 'active': value === 'DeviceTable' }" class="btn btn-primary">Devices</button>
<button @click="$emit('input', 'DeviceManufacturer')"
:class="{ 'active': value === 'DeviceManufacturer' }"
class="btn btn-primary">Hersteller
</button>
<button @click="$emit('input', 'DeviceType')"
:class="{ 'active': value === 'DeviceType' }"
class="btn btn-primary">Geräte Typen
</button>
</div>
<div v-else>
<div class="dropdown">
<button @click="showDropdown = !showDropdown"
class="btn btn-primary dropdown-toggle">Ansicht
</button>
<div v-show="showDropdown" class="dropdown-menu show">
<a href="#" @click="$emit('input', 'DeviceTable'); showDropdown = false" class="dropdown-item">Devices</a>
<a href="#"
@click="$emit('input', 'DeviceManufacturer'); showDropdown = false"
class="dropdown-item">Hersteller</a>
<a href="#"
@click="$emit('input', 'DeviceType'); showDropdown = false"
class="dropdown-item">Geräte Typen</a>
</div>
</div>
</div>
</div>
`,
props: ['value'],
data() {
return {
@@ -172,24 +172,24 @@ Vue.component('DeviceTable', {
Vue.component('DeviceManufacturer', {
//language=Vue
template: `
<tt-table :data="window['TT_CONFIG']['DEVICE_MANUFACTURERS']" :config="DeviceManufacturerConfig" excel-export>
<tt-table :data="window['TT_CONFIG']['DEVICE_MANUFACTURERS']" :config="DeviceManufacturerConfig" excel-export>
<template v-slot:top-buttons>
<button type="button" class="btn btn-primary" @click="window.location = window['TT_CONFIG']['BASE_URL'] + '/Devicemanufactor/add'">
<i class="fas fa-plus"></i>Hersteller hinzufügen
</button>
</template>
<template v-slot:top-buttons>
<button type="button" class="btn btn-primary" @click="window.location = window['TT_CONFIG']['BASE_URL'] + '/Devicemanufactor/add'">
<i class="fas fa-plus"></i>Hersteller hinzufügen
</button>
</template>
<template v-slot:actions="{ row }">
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Devicemanufactor/edit/?id=' + row.id"><i class="far fa-edit" title="Bearbeiten"></i></a>
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Devicemanufactor/delete/?id=' + row.id"
onclick="if(!confirm('Hersteller wirklich löschen?')) return false;"
class="text-danger"
title="Löschen"><i class="fas fa-trash "></i></a>
</template>
<template v-slot:actions="{ row }">
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Devicemanufactor/edit/?id=' + row.id"><i class="far fa-edit" title="Bearbeiten"></i></a>
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Devicemanufactor/delete/?id=' + row.id"
onclick="if(!confirm('Hersteller wirklich löschen?')) return false;"
class="text-danger"
title="Löschen"><i class="fas fa-trash "></i></a>
</template>
</tt-table>
`,
</tt-table>
`,
data() {
return {
window: window,
@@ -211,24 +211,24 @@ Vue.component('DeviceManufacturer', {
Vue.component('DeviceType', {
//language=Vue
template: `
<tt-table :data="window['TT_CONFIG']['DEVICE_TYPES']" :config="DeviceTypeConfig" excel-export>
<tt-table :data="window['TT_CONFIG']['DEVICE_TYPES']" :config="DeviceTypeConfig" excel-export>
<template v-slot:top-buttons>
<button type="button" class="btn btn-primary" @click="window.location = window['TT_CONFIG']['BASE_URL'] + '/Devicetype/add'">
<i class="fas fa-plus"></i>Device Type hinzufügen
</button>
</template>
<template v-slot:top-buttons>
<button type="button" class="btn btn-primary" @click="window.location = window['TT_CONFIG']['BASE_URL'] + '/Devicetype/add'">
<i class="fas fa-plus"></i>Device Type hinzufügen
</button>
</template>
<template v-slot:actions="{ row }">
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Devicetype/edit/?id=' + row.id"><i class="far fa-edit" title="Bearbeiten"></i></a>
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Devicetype/delete/?id=' + row.id"
onclick="if(!confirm('Gerätetyp wirklich löschen?')) return false;"
class="text-danger"
title="Löschen"><i class="fas fa-trash "></i></a>
</template>
<template v-slot:actions="{ row }">
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Devicetype/edit/?id=' + row.id"><i class="far fa-edit" title="Bearbeiten"></i></a>
<a :href="window['TT_CONFIG']['BASE_URL'] +'/Devicetype/delete/?id=' + row.id"
onclick="if(!confirm('Gerätetyp wirklich löschen?')) return false;"
class="text-danger"
title="Löschen"><i class="fas fa-trash "></i></a>
</template>
</tt-table>
`,
</tt-table>
`,
data() {
return {
window: window,
@@ -241,6 +241,8 @@ Vue.component('DeviceType', {
{text: 'Hersteller', key: 'manufacturer', filter: 'search', class: 'text-center'},
{text: 'Preis', key: 'price', filter: 'numberRange', class: 'text-center', suffix: ' €'},
{text: 'max. Leistung', key: 'power', filter: 'numberRange', class: 'text-center', suffix: ' W'},
{text: 'Temp. Warnung', key: 'temp_warning', filter: 'numberRange', class: 'text-center', suffix: ' °C'},
{text: 'Temp. Kritisch', key: 'temp_critical', filter: 'numberRange', class: 'text-center', suffix: ' °C'},
{text: 'Erstellungsdatum', key: 'created', filter: 'date', class: 'text-center'},
{text: 'Erstellt von', key: 'creator', filter: 'search', class: 'text-center'},
{text: 'Aktionen', key: 'actions', class: 'text-center', sortable: false, filter: false, priority: 9},

View File

@@ -1,3 +1,28 @@
const ttSwitchCSS = `
.tt-switch { position: relative; display: inline-block; width: 44px; height: 24px; }
.tt-switch input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #ccc; transition: .4s; }
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; }
input:checked + .slider { background-color: #28a745; }
input:focus + .slider { box-shadow: 0 0 1px #28a745; }
input:checked + .slider:before { transform: translateX(20px); }
.slider.round { border-radius: 24px; }
.slider.round:before { border-radius: 50%; }
`;
const styleSheet = document.createElement("style");
styleSheet.innerText = ttSwitchCSS;
document.head.appendChild(styleSheet);
Vue.component('tt-switch', {
template: `
<label class="tt-switch">
<input type="checkbox" :checked="value" @change="$emit('input', $event.target.checked)">
<span class="slider round"></span>
</label>
`,
props: { value: { type: Boolean, default: false } }
});
Vue.component('device-monitoring-modal', {
//language=Vue
template: `
@@ -5,7 +30,8 @@ Vue.component('device-monitoring-modal', {
:title="'Monitoring für ' + deviceName"
@update:show="$emit('close')"
:save="false"
:delete="false">
:delete="false"
dialog-class="modal-xl">
<div class="monitoring-tabs">
<button v-for="tab in tabs" :key="tab.id"
@@ -20,7 +46,7 @@ Vue.component('device-monitoring-modal', {
<div v-if="loading.overview" class="text-center p-4"><div class="spinner-border"></div></div>
<div v-else-if="!generalData || Object.keys(generalData).length === 0" class="alert alert-info">Keine allgemeinen Monitoring-Daten gefunden.</div>
<div v-else class="overview-grid">
<tt-card v-if="generalData.ping" body-overflow-x-auto>
<tt-card v-if="generalData.ping && generalData.ping.length" body-overflow-x-auto>
<template v-slot:header><h6><i class="fas fa-network-wired"></i> Erreichbarkeit</h6></template>
<table class="table table-sm table-borderless mb-0">
<tr v-for="item in generalData.ping" :key="item.name">
@@ -29,23 +55,28 @@ Vue.component('device-monitoring-modal', {
</tr>
</table>
</tt-card>
<tt-card v-if="generalData.uptime" body-overflow-x-auto>
<tt-card v-if="generalData.uptime && generalData.uptime.length" body-overflow-x-auto>
<template v-slot:header><h6><i class="fas fa-clock"></i> Uptime</h6></template>
<div v-for="item in generalData.uptime" :key="item.name" class="p-2 text-center">
<p class="h5 mb-0">{{ formatUptime(item.value) }}</p>
<small class="text-muted">Seit {{ moment(Date.now() - item.value * 1000).format('DD.MM.YYYY HH:mm') }}</small>
</div>
</tt-card>
<tt-card v-if="generalData.problemCounts" body-overflow-x-auto>
<template v-slot:header><h6><i class="fas fa-bell"></i> Probleme</h6></template>
<div class="problem-counts">
<div><span class="count">{{ generalData.problemCounts['24h'] }}</span><span class="period">letzte 24h</span></div>
<div><span class="count">{{ generalData.problemCounts['7d'] }}</span><span class="period">letzte 7T</span></div>
<div><span class="count">{{ generalData.problemCounts['30d'] }}</span><span class="period">letzte 30T</span></div>
</div>
</tt-card>
</div>
</div>
<div v-if="activeTab === 'interfaces'">
<div class="p-2 border-bottom mb-3 d-flex justify-content-between align-items-center flex-wrap">
<div style="flex-grow: 1; margin-right: 15px; min-width: 300px;">
<tt-select label="Schnittstellen zur Anzeige auswählen"
:options="interfaceOptions"
v-model="selectedInterfaces"
sm multiple searchable/>
<tt-select label="Schnittstellen zur Anzeige auswählen" :options="interfaceOptions" v-model="selectedInterfaces" sm multiple searchable/>
</div>
<div class="d-flex align-items-center flex-wrap">
<div class="btn-group btn-group-sm mr-2 mb-1">
@@ -53,11 +84,7 @@ Vue.component('device-monitoring-modal', {
<button @click="dataNormalizationMode = 'max'" :class="dataNormalizationMode === 'max' ? 'btn-primary' : 'btn-outline-secondary'" class="btn">Maximum</button>
</div>
<div class="btn-group btn-group-sm mr-2 mb-1">
<button v-for="range in timeRanges" :key="range.value"
:class="interfaceTimeRange === range.value ? 'btn-primary' : 'btn-outline-secondary'"
@click="interfaceTimeRange = range.value" class="btn">
{{range.text}}
</button>
<button v-for="range in timeRanges" :key="range.value" :class="interfaceTimeRange === range.value ? 'btn-primary' : 'btn-outline-secondary'" @click="interfaceTimeRange = range.value" class="btn">{{range.text}}</button>
</div>
<button @click="resetAllChartsZoom" class="btn btn-sm btn-info mb-1" title="Zoom zurücksetzen"><i class="fas fa-search-minus"></i> Zoom zurücksetzen</button>
</div>
@@ -66,52 +93,133 @@ Vue.component('device-monitoring-modal', {
<div v-else-if="selectedInterfaces.length === 0" class="alert alert-light text-center">Bitte eine oder mehrere Schnittstellen auswählen, um Graphen anzuzeigen.</div>
<div v-else class="chart-container">
<div v-for="iface in selectedInterfacesData" :key="iface.name" class="chart-card border rounded p-2">
<div v-if="loading.individualInterfaces[iface.name]" class="chart-loader">
<div class="spinner-border spinner-border-sm"></div>
</div>
<div v-if="loading.individualInterfaces[iface.name]" class="chart-loader"><div class="spinner-border spinner-border-sm"></div></div>
<div class="d-flex justify-content-between align-items-center">
<h6 class="chart-title">{{ iface.name }}</h6>
<button @click="openLiveChartPopup(iface)" class="btn btn-sm btn-danger" title="Live-Graph">
<i class="fas fa-play-circle"></i> Live
</button>
<button @click="openLiveChartPopup(iface)" class="btn btn-sm btn-danger" title="Live-Graph"><i class="fas fa-play-circle"></i> Live</button>
</div>
<canvas :ref="'chartCanvas-' + iface.name"></canvas>
<div v-if="statistics[iface.name]" class="chart-stats">
<div class="stats-col">
<strong><i class="fas fa-arrow-down text-success"></i> Empfangen (Mbps)</strong>
<span>Min: {{ statistics[iface.name].rx.min }}</span>
<span>Avg: {{ statistics[iface.name].rx.avg }}</span>
<span>Median: {{ statistics[iface.name].rx.median }}</span>
<span>Max: {{ statistics[iface.name].rx.max }}</span>
<span>95%: {{ statistics[iface.name].rx.p95 }}</span>
<div class="stats-col"><strong><i class="fas fa-arrow-down text-success"></i> Empfangen (Mbps)</strong><span>Min: {{ statistics[iface.name].rx.min }}</span><span>Avg: {{ statistics[iface.name].rx.avg }}</span><span>Max: {{ statistics[iface.name].rx.max }}</span><span>95%: {{ statistics[iface.name].rx.p95 }}</span></div>
<div class="stats-col"><strong><i class="fas fa-arrow-up text-primary"></i> Gesendet (Mbps)</strong><span>Min: {{ statistics[iface.name].tx.min }}</span><span>Avg: {{ statistics[iface.name].tx.avg }}</span><span>Max: {{ statistics[iface.name].tx.max }}</span><span>95%: {{ statistics[iface.name].tx.p95 }}</span></div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'reports'">
<div class="p-2 border-bottom mb-3 d-flex justify-content-start align-items-center flex-wrap">
<div class="btn-group btn-group-sm mr-2 mb-1">
<button v-for="range in reportTimeRanges" :key="range.value" :class="reportTimeRange === range.value ? 'btn-primary' : 'btn-outline-secondary'" @click="reportTimeRange = range.value" class="btn">{{range.text}}</button>
</div>
</div>
<div v-if="loading.reports" class="text-center p-4"><div class="spinner-border"></div></div>
<div v-else-if="!reportData || reportData.length === 0" class="alert alert-info">Keine Report-Daten für den gewählten Zeitraum.</div>
<div v-else class="table-responsive">
<table class="table table-sm table-hover table-striped">
<thead>
<tr>
<th @click="sortReport('name')" class="c-pointer">Schnittstelle <i :class="getSortIcon('name')"></i></th>
<th @click="sortReport('speed')" class="c-pointer text-right">Speed (Mbps) <i :class="getSortIcon('speed')"></i></th>
<th @click="sortReport('rx.max')" class="c-pointer text-right">Max In (Mbps) <i :class="getSortIcon('rx.max')"></i></th>
<th @click="sortReport('rx.avg')" class="c-pointer text-right">Avg In (Mbps) <i :class="getSortIcon('rx.avg')"></i></th>
<th @click="sortReport('rx.usage')" class="c-pointer text-right">Auslastung In (%) <i :class="getSortIcon('rx.usage')"></i></th>
<th @click="sortReport('tx.max')" class="c-pointer text-right">Max Out (Mbps) <i :class="getSortIcon('tx.max')"></i></th>
<th @click="sortReport('tx.avg')" class="c-pointer text-right">Avg Out (Mbps) <i :class="getSortIcon('tx.avg')"></i></th>
<th @click="sortReport('tx.usage')" class="c-pointer text-right">Auslastung Out (%) <i :class="getSortIcon('tx.usage')"></i></th>
</tr>
</thead>
<tbody>
<tr v-for="d in sortedReportData" :key="d.name">
<td>{{ d.name }}</td>
<td class="text-right">{{ d.speed }}</td>
<td class="text-right">{{ d.rx.max }}</td>
<td class="text-right">{{ d.rx.avg }}</td>
<td class="text-right">{{ d.rx.usage }}%</td>
<td class="text-right">{{ d.tx.max }}</td>
<td class="text-right">{{ d.tx.avg }}</td>
<td class="text-right">{{ d.tx.usage }}%</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="activeTab === 'problems'">
<div v-if="loading.problems" class="text-center p-4"><div class="spinner-border"></div></div>
<div v-else-if="problemData.current.length === 0 && problemData.resolved.length === 0" class="alert alert-success text-center">Keine Probleme für dieses Gerät gefunden.</div>
<div v-else>
<div v-if="problemData.current.length > 0">
<h5>Aktuelle Probleme</h5>
<div class="problems-list">
<div v-for="p in problemData.current" :key="p.eventid" class="problem-card" :class="getSeverityClass(p.severity)">
<div class="problem-icon"><i :class="getSeverityIcon(p.severity)"></i></div>
<div class="problem-details">
<div class="problem-header"><strong class="problem-name">{{ p.name }}</strong><span class="problem-time">{{ moment(p.clock * 1000).fromNow() }}</span></div>
<div class="problem-opdata" v-if="p.opdata">{{ p.opdata }}</div>
</div>
</div>
<div class="stats-col">
<strong><i class="fas fa-arrow-up text-primary"></i> Gesendet (Mbps)</strong>
<span>Min: {{ statistics[iface.name].tx.min }}</span>
<span>Avg: {{ statistics[iface.name].tx.avg }}</span>
<span>Median: {{ statistics[iface.name].tx.median }}</span>
<span>Max: {{ statistics[iface.name].tx.max }}</span>
<span>95%: {{ statistics[iface.name].tx.p95 }}</span>
</div>
</div>
<div v-if="problemData.resolved.length > 0">
<h5 class="mt-4">Behobene Probleme (letzte 7 Tage)</h5>
<div class="problems-list resolved">
<div v-for="p in problemData.resolved" :key="p.eventid" class="problem-card sev-resolved">
<div class="problem-icon"><i class="fas fa-check-circle"></i></div>
<div class="problem-details">
<div class="problem-header"><strong class="problem-name">{{ p.name }}</strong><span class="problem-time">{{ moment(p.clock * 1000).fromNow() }}</span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'problems'">
<div v-if="loading.problems" class="text-center p-4"><div class="spinner-border"></div></div>
<div v-else-if="problemData.length === 0" class="alert alert-success text-center">Keine aktuellen Probleme für dieses Gerät gefunden.</div>
<div v-else class="problems-list">
<div v-for="p in problemData" :key="p.eventid" class="problem-card" :class="getSeverityClass(p.severity)">
<div class="problem-icon"><i :class="getSeverityIcon(p.severity)"></i></div>
<div class="problem-details">
<div class="problem-header">
<strong class="problem-name">{{ p.name }}</strong>
<span class="problem-time">{{ moment(p.clock * 1000).format('DD.MM.YYYY HH:mm:ss') }}</span>
</div>
<div class="problem-opdata" v-if="p.opdata">{{ p.opdata }}</div>
<div v-if="activeTab === 'configuration'">
<div v-if="loading.configuration" class="text-center p-4"><div class="spinner-border"></div></div>
<div v-else>
<tt-card class="mb-4">
<template v-slot:header><h6><i class="fas fa-fingerprint"></i> SNMP Konfiguration</h6></template>
<div v-if="!configData.snmp" class="alert alert-warning">Keine SNMP-Schnittstelle auf diesem Host gefunden.</div>
<div v-else>
<tt-select label="Version" v-model="configData.snmp.details.version" :options="[{text: 'SNMPv1', value: '1'}, {text: 'SNMPv2c', value: '2'}, {text: 'SNMPv3', value: '3'}]" />
<tt-input v-if="configData.snmp.details.version < 3" label="Community String" v-model="configData.snmp.details.community" type="text" />
<template v-if="configData.snmp.details.version == 3">
<tt-input label="Security Name" v-model="configData.snmp.details.securityname" type="text"/>
<tt-select label="Security Level" v-model="configData.snmp.details.securitylevel" :options="snmpV3Levels" />
<template v-if="configData.snmp.details.securitylevel !== '0'">
<tt-select label="Auth Protocol" v-model="configData.snmp.details.authprotocol" :options="snmpV3Auth" />
<tt-input label="Auth Passphrase" v-model="authPassphrase" type="password" placeholder="Zum Ändern ausfüllen"/>
</template>
<template v-if="configData.snmp.details.securitylevel === '2'">
<tt-select label="Privacy Protocol" v-model="configData.snmp.details.privprotocol" :options="snmpV3Priv" />
<tt-input label="Privacy Passphrase" v-model="privPassphrase" type="password" placeholder="Zum Ändern ausfüllen"/>
</template>
</template>
<button class="btn btn-primary mt-3" @click="saveSnmpConfig"><i class="fas fa-save"></i> Speichern</button>
</div>
</div>
</tt-card>
<tt-card>
<template v-slot:header>
<div class="d-flex justify-content-between align-items-center flex-wrap">
<h6 class="mb-0"><i class="fas fa-bell"></i> Schnittstellen-Alarmierung (Link-Status)</h6>
<div style="min-width: 250px; margin-left: 1rem;">
<tt-input sm no-form-group v-model="interfaceSearch" placeholder="Schnittstelle suchen..." type="search" />
</div>
</div>
</template>
<div class="interface-alarm-list">
<div v-if="!filteredInterfaces || filteredInterfaces.length === 0" class="text-center text-muted p-3">
Keine Schnittstellen gefunden.
</div>
<div v-for="iface in filteredInterfaces" :key="iface.itemid" class="interface-alarm-item">
<div class="interface-name">{{ iface.name }}</div>
<div class="interface-switch">
<tt-switch v-model="iface.isAlarmed" :loading="iface.loading" @input="toggleInterfaceAlarm(iface)"></tt-switch>
</div>
</div>
</div>
</tt-card>
</div>
</div>
</div>
@@ -125,115 +233,183 @@ Vue.component('device-monitoring-modal', {
tabs: [
{ id: 'overview', name: 'Übersicht', icon: 'fas fa-tachometer-alt' },
{ id: 'interfaces', name: 'Schnittstellen', icon: 'fas fa-ethernet' },
// { id: 'reports', name: 'Reports', icon: 'fas fa-chart-pie' },
{ id: 'problems', name: 'Probleme', icon: 'fas fa-exclamation-triangle' },
{ id: 'configuration', name: 'Konfiguration', icon: 'fas fa-cogs' },
],
loading: { overview: false, interfaces: false, problems: false, individualInterfaces: {} },
loading: { overview: true, interfaces: false, problems: false, configuration: false, reports: false, individualInterfaces: {} },
generalData: null,
problemData: [],
problemData: { current: [], resolved: [] },
allInterfaces: [],
selectedInterfaces: [],
interfaceTimeRange: '24h',
timeRanges: [
{ text: '6H', value: '6h' }, { text: '24H', value: '24h' },
{ text: '7T', value: '7d' }, { text: '30T', value: '30d' },
],
timeRanges: [{ text: '6H', value: '6h' }, { text: '24H', value: '24h' }, { text: '7T', value: '7d' }, { text: '30T', value: '30d' }],
interfaceChartData: {},
chartInstances: {},
dataNormalizationMode: 'avg',
downsampleThreshold: 500,
configData: { snmp: null, interfaces: [] },
interfaceSearch: '',
snmpV3Levels: [{text: 'noAuthNoPriv', value: '0'}, {text: 'authNoPriv', value: '1'}, {text: 'authPriv', value: '2'}],
snmpV3Auth: [{text: 'MD5', value: '0'}, {text: 'SHA-1', value: '1'}],
snmpV3Priv: [{text: 'DES', value: '0'}, {text: 'AES-128', value: '1'}],
authPassphrase: '',
privPassphrase: '',
reportData: [],
reportTimeRange: '7d',
reportTimeRanges: [{ text: 'Letzte 7 Tage', value: '7d' }, { text: 'Letzte 30 Tage', value: '30d' }],
reportSortKey: 'name',
reportSortDir: 'asc',
};
},
computed: {
interfaceOptions() {
return this.allInterfaces.map(iface => ({ text: iface.name, value: iface.name }));
},
selectedInterfacesData() {
return this.allInterfaces.filter(iface => this.selectedInterfaces.includes(iface.name));
},
displayChartData() {
const processedData = {};
for (const itemId in this.interfaceChartData) {
const data = this.interfaceChartData[itemId];
if (data.length > this.downsampleThreshold) {
processedData[itemId] = this.downsampleData(data, this.dataNormalizationMode);
} else {
processedData[itemId] = data;
}
interfaceOptions() { return this.allInterfaces.map(iface => ({ text: iface.name, value: iface.name })); },
selectedInterfacesData() { return this.allInterfaces.filter(iface => this.selectedInterfaces.includes(iface.name)); },
filteredInterfaces() {
if (!this.configData.interfaces || !Array.isArray(this.configData.interfaces)) return [];
if (!this.interfaceSearch) {
return this.configData.interfaces;
}
return processedData;
const search = this.interfaceSearch.toLowerCase();
return this.configData.interfaces.filter(iface =>
iface.name.toLowerCase().includes(search)
);
},
statistics() {
if (this.selectedInterfaces.length === 0 || Object.keys(this.interfaceChartData).length === 0) return {};
const stats = {};
this.selectedInterfacesData.forEach(iface => {
const calculate = (data) => {
if (!data || data.length === 0) return { min: 'N/A', max: 'N/A', avg: 'N/A', median: 'N/A', p95: 'N/A' };
if (!data || data.length === 0) return { min: 'N/A', max: 'N/A', avg: 'N/A', p95: 'N/A' };
const values = data.map(p => p.y);
const sorted = [...values].sort((a, b) => a - b);
const sum = values.reduce((acc, val) => acc + val, 0);
const mid = Math.floor(sorted.length / 2);
const median = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
const p95 = this.calculateNormalized95thPercentile(data);
return {
min: this.formatStat(sorted[0]),
max: this.formatStat(sorted[sorted.length - 1]),
avg: this.formatStat(sum / values.length),
median: this.formatStat(median),
p95: this.formatStat(p95),
p95: this.formatStat(sorted[Math.floor(sorted.length * 0.95)]),
};
};
stats[iface.name] = {
rx: calculate(this.interfaceChartData[iface.rx?.itemid]),
tx: calculate(this.interfaceChartData[iface.tx?.itemid]),
};
stats[iface.name] = { rx: calculate(this.interfaceChartData[iface.rx?.itemid]), tx: calculate(this.interfaceChartData[iface.tx?.itemid]) };
});
return stats;
},
sortedReportData() {
if (!this.reportData) return [];
return [...this.reportData].sort((a, b) => {
let aVal = this.reportSortKey.split('.').reduce((o, i) => o[i], a);
let bVal = this.reportSortKey.split('.').reduce((o, i) => o[i], b);
if (typeof aVal === 'string' && aVal.toLowerCase() === 'n/a') aVal = -1;
if (typeof bVal === 'string' && bVal.toLowerCase() === 'n/a') bVal = -1;
let modifier = this.reportSortDir === 'asc' ? 1 : -1;
if (aVal < bVal) return -1 * modifier;
if (aVal > bVal) return 1 * modifier;
return 0;
});
}
},
async mounted() {
// We need chartjs-plugin-zoom for this to work. Assuming it's globally available.
if (typeof Chart.register === 'function' && window.ChartZoom) {
Chart.register(window.ChartZoom);
}
if (typeof Chart.register === 'function' && window.ChartZoom) Chart.register(window.ChartZoom);
moment.locale('de');
this.fetchTabData();
},
beforeDestroy() {
this.destroyAllCharts();
},
methods: {
formatStat: val => typeof val === 'number' ? val.toFixed(2) : val,
formatUptime: s => `${Math.floor(s/(3600*24))}t ${Math.floor(s%(3600*24)/3600)}h ${Math.floor(s%3600/60)}m`,
formatGeneralValue: item => (item.units === 's') ? parseFloat(item.value).toFixed(3) : (item.units === '%') ? parseFloat(item.value).toFixed(2) : item.value,
getSeverityClass: s => ['sev-info', 'sev-info', 'sev-warning', 'sev-average', 'sev-high', 'sev-disaster'][s] || 'sev-info',
getSeverityIcon: s => ['fa-info-circle', 'fa-info-circle', 'fa-exclamation-circle', 'fa-exclamation-triangle', 'fa-radiation-alt', 'fa-biohazard'][s] || 'fa-info-circle',
async fetchTabData() {
const tab = this.activeTab;
if (this.loading[tab]) return;
if (this.loading[tab] && tab !== 'overview') return;
this.loading[tab] = true;
try {
if (tab === 'overview' && !this.generalData) {
const res = await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/generalData`, { params: { hostId: this.hostId } });
this.generalData = res.data;
if (tab === 'overview') {
this.generalData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/generalData`, { params: { hostId: this.hostId } })).data;
} else if (tab === 'interfaces' && this.allInterfaces.length === 0) {
const res = await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/listInterfaces`, { params: { hostId: this.hostId } });
this.allInterfaces = res.data;
} else if (tab === 'problems' && this.problemData.length === 0) {
const res = await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getProblems`, { params: { hostId: this.hostId } });
this.problemData = res.data;
this.allInterfaces = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/listInterfaces`, { params: { hostId: this.hostId } })).data;
} else if (tab === 'problems') {
this.problemData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getProblems`, { params: { hostId: this.hostId } })).data;
} else if (tab === 'configuration') {
this.configData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getConfigurationData`, { params: { hostId: this.hostId } })).data;
if (this.configData.interfaces && Array.isArray(this.configData.interfaces)) {
this.configData.interfaces.forEach(iface => this.$set(iface, 'loading', false));
}
} else if (tab === 'reports') {
await this.fetchReportData();
}
} catch (e) { console.error(`Failed to load data for tab ${tab}`, e); window.notify('error', `Laden von ${tab}-Daten fehlgeschlagen.`); }
finally { this.loading[tab] = false; }
},
async saveSnmpConfig() {
const detailsToSave = JSON.parse(JSON.stringify(this.configData.snmp.details));
if (this.authPassphrase) detailsToSave.authpassphrase = this.authPassphrase;
else delete detailsToSave.authpassphrase;
if (this.privPassphrase) detailsToSave.privpassphrase = this.privPassphrase;
else delete detailsToSave.privpassphrase;
try {
await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/updateSnmp`, {
interfaceId: this.configData.snmp.interfaceid,
details: detailsToSave
});
window.notify('success', 'SNMP-Konfiguration gespeichert.');
this.authPassphrase = '';
this.privPassphrase = '';
} catch(e) { window.notify('error', 'Fehler beim Speichern der SNMP-Konfiguration.'); }
},
async toggleInterfaceAlarm(iface) {
this.$set(iface, 'loading', true);
try {
await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/updateInterfaceAlarm`, { hostId: this.hostId, item: iface, enabled: iface.isAlarmed });
window.notify('success', `Alarm für ${iface.name} ${iface.isAlarmed ? 'aktiviert' : 'deaktiviert'}.`);
} catch(e) {
window.notify('error', 'Fehler beim Ändern des Alarms.');
iface.isAlarmed = !iface.isAlarmed;
} finally {
this.$set(iface, 'loading', false);
}
},
async fetchReportData() {
this.loading.reports = true;
try {
this.reportData = (await axios.get(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/getReportData`, { params: { hostId: this.hostId, timeRange: this.reportTimeRange } })).data;
} catch (e) {
window.notify('error', 'Fehler beim Laden der Report-Daten.');
} finally {
this.loading.reports = false;
}
},
sortReport(key) {
if (this.reportSortKey === key) {
this.reportSortDir = this.reportSortDir === 'asc' ? 'desc' : 'asc';
} else {
this.reportSortKey = key;
this.reportSortDir = 'asc';
}
},
getSortIcon(key) {
if (this.reportSortKey !== key) return 'fas fa-sort';
return this.reportSortDir === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
},
async handleInterfaceSelectionChange(newSelection, oldSelection) {
const added = newSelection.filter(name => !oldSelection.includes(name));
const removed = oldSelection.filter(name => !newSelection.includes(name));
removed.forEach(name => {
const iface = this.allInterfaces.find(i => i.name === name);
if (iface) {
if (this.chartInstances[iface.name]) {
this.chartInstances[iface.name].destroy();
delete this.chartInstances[iface.name];
}
}
});
for (const name of added) await this.fetchAndRenderInterface(this.allInterfaces.find(i => i.name === name));
},
async fetchAndRenderInterface(iface) {
const itemIds = [iface.rx?.itemid, iface.tx?.itemid].filter(Boolean);
if (itemIds.length === 0) return;
this.$set(this.loading.individualInterfaces, iface.name, true);
try {
const res = await axios.post(`${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/interfaceData`, { itemIds, timeRange: this.interfaceTimeRange });
@@ -243,175 +419,25 @@ Vue.component('device-monitoring-modal', {
} catch(e) { console.error(`Failed to fetch history for ${iface.name}`, e); }
finally { this.$set(this.loading.individualInterfaces, iface.name, false); }
},
async handleInterfaceSelectionChange(newSelection, oldSelection) {
const added = newSelection.filter(name => !oldSelection.includes(name));
const removed = oldSelection.filter(name => !newSelection.includes(name));
removed.forEach(name => {
const iface = this.allInterfaces.find(i => i.name === name);
if (iface) {
this.destroyChart(iface.name);
delete this.interfaceChartData[iface.rx?.itemid];
delete this.interfaceChartData[iface.tx?.itemid];
}
renderChart(iface) {
this.$nextTick(() => {
const canvas = this.$refs['chartCanvas-' + iface.name]?.[0];
if (!canvas) return;
if (this.chartInstances[iface.name]) this.chartInstances[iface.name].destroy();
this.chartInstances[iface.name] = new Chart(canvas.getContext('2d'), { /* ... Chart options ... */ });
});
for (const name of added) {
const iface = this.allInterfaces.find(i => i.name === name);
if (iface) {
await this.fetchAndRenderInterface(iface);
}
}
},
async handleTimeOrNormalizationChange() {
this.destroyAllCharts();
this.interfaceChartData = {};
this.loading.interfaces = true;
const interfacesToFetch = this.selectedInterfacesData;
for (const iface of interfacesToFetch) {
await this.fetchAndRenderInterface(iface);
}
this.loading.interfaces = false;
},
async renderChart(iface) {
let tries = 0;
while (!this.$refs['chartCanvas-' + iface.name]?.[0] && tries < 10) {
console.log(typeof this.$refs['chartCanvas-' + iface.name]?.[0]);
await Promise.all([
this.$nextTick(),
new Promise(resolve => setTimeout(resolve, 100))
]);
}
const canvas = this.$refs['chartCanvas-' + iface.name]?.[0];
console.log(canvas, this.$refs);
if (!canvas) return;
if (this.chartInstances[iface.name]) {
this.chartInstances[iface.name].destroy();
}
this.chartInstances[iface.name] = new Chart(canvas.getContext('2d'), {
type: 'line',
data: {
datasets: [
{
label: 'Empfangen',
data: this.displayChartData[iface.rx?.itemid] || [],
borderColor: '#4CAF50',
borderWidth: 1.5,
fill: true,
backgroundColor: 'rgba(76, 175, 80, 0.2)',
pointRadius: 0,
tension: 0.1
},
{
label: 'Gesendet',
data: this.displayChartData[iface.tx?.itemid] || [],
borderColor: '#2196F3',
borderWidth: 1.5,
fill: true,
backgroundColor: 'rgba(33, 150, 243, 0.2)',
pointRadius: 0,
tension: 0.1
}
]
},
options: {
responsive: true, maintainAspectRatio: true,
interaction: {
mode: 'index',
intersect: false,
},
scales: {
x: {
type: 'time',
time: { tooltipFormat: 'DD.MM.YYYY HH:mm:ss' },
adapters: { date: { locale: 'de' } }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Mbps' }
}
},
plugins: {
legend: {
display: true, position: 'bottom',
labels: { boxWidth: 12, font: { size: 10 } }
},
zoom: {
pan: { enabled: true, mode: 'x' },
zoom: { wheel: { enabled: false }, pinch: { enabled: true }, mode: 'x', drag: { enabled: true } },
}
}
}
});
},
downsampleData(data, mode) {
const bucketSize = Math.ceil(data.length / this.downsampleThreshold);
const downsampled = [];
for (let i = 0; i < data.length; i += bucketSize) {
const chunk = data.slice(i, i + bucketSize);
if (chunk.length === 0) continue;
const representativeX = chunk[Math.floor(chunk.length / 2)].x;
let representativeY;
if (mode === 'max') {
representativeY = Math.max(...chunk.map(p => p.y));
} else { // avg
const sum = chunk.reduce((acc, p) => acc + p.y, 0);
representativeY = sum / chunk.length;
}
downsampled.push({ x: representativeX, y: representativeY });
}
return downsampled;
},
calculateNormalized95thPercentile(data) {
if (!data || data.length < 3) return null;
const averagedValues = [];
for (let i = 0; i <= data.length - 3; i += 3) {
const chunk = data.slice(i, i + 3);
const sum = chunk.reduce((acc, p) => acc + p.y, 0);
averagedValues.push(sum / 3);
}
if (averagedValues.length === 0) return null;
const sorted = averagedValues.sort((a, b) => a - b);
const index = Math.floor(sorted.length * 0.95);
return sorted[index];
},
openLiveChartPopup(iface) {
const url = `${window.TT_CONFIG.BASE_URL}/DeviceMonitoring/liveGraphPage?rx_id=${iface.rx?.itemid || ''}&tx_id=${iface.tx?.itemid || ''}&name=${encodeURIComponent(iface.name)}`;
window.open(url, `livegraph_${iface.name.replace(/[^a-zA-Z0-9]/g, "_")}`, 'width=800,height=450,resizable=yes,scrollbars=yes');
},
destroyChart(name) {
if (this.chartInstances[name]) {
this.chartInstances[name].destroy();
delete this.chartInstances[name];
}
},
destroyAllCharts() {
Object.values(this.chartInstances).forEach(c => c.destroy());
this.chartInstances = {};
},
resetAllChartsZoom() {
Object.values(this.chartInstances).forEach(chart => {
chart.resetZoom();
});
}
resetAllChartsZoom() { Object.values(this.chartInstances).forEach(chart => chart.resetZoom()); },
},
watch: {
activeTab: 'fetchTabData',
selectedInterfaces(newVal, oldVal) {
this.handleInterfaceSelectionChange(newVal, oldVal);
},
interfaceTimeRange: 'handleTimeOrNormalizationChange',
dataNormalizationMode: 'handleTimeOrNormalizationChange',
selectedInterfaces(newVal, oldVal) { this.handleInterfaceSelectionChange(newVal, oldVal); },
interfaceTimeRange() { this.handleInterfaceSelectionChange(this.selectedInterfaces, this.selectedInterfaces); },
dataNormalizationMode() { this.handleInterfaceSelectionChange(this.selectedInterfaces, this.selectedInterfaces); },
reportTimeRange: 'fetchReportData'
}
});
});

View File

@@ -0,0 +1,243 @@
/* --- Main Overlay and Layout --- */
.manual-invoice-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.6);
z-index: 1050;
display: flex;
overflow: hidden;
}
.invoice-editor-pane, .invoice-preview-pane {
height: 100vh;
display: flex;
flex-direction: column;
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.invoice-editor-pane {
flex: 0 0 50%;
background-color: #f4f5f7;
padding: 1rem;
overflow-y: hidden;
}
.invoice-preview-pane {
flex: 1 1 auto;
background-color: #525659;
padding: 2rem;
overflow-y: auto;
display: flex;
justify-content: center;
}
.info-bar {
position: absolute;
top: 0;
left: 0;
width: 100%;
background-color: rgba(0, 83, 132, 0.9);
color: white;
padding: 0.5rem;
text-align: center;
z-index: 1051;
font-size: 0.9rem;
}
/* --- Responsive Layout & Toggle --- */
@media (max-width: 1919px) {
.invoice-editor-pane, .invoice-preview-pane {
width: 100%;
flex-basis: 100%;
position: absolute;
}
.manual-invoice-overlay.editor-active-small .invoice-preview-pane {
transform: translateX(100%);
}
.manual-invoice-overlay.preview-active-small .invoice-editor-pane {
transform: translateX(-100%);
}
.manual-invoice-overlay.preview-active-small .invoice-preview-pane {
transform: translateX(0);
}
}
/* --- Editor Pane Specifics --- */
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid #dee2e6;
flex-shrink: 0;
}
.editor-header h3 {
margin: 0;
}
.editor-actions {
display: flex;
gap: 0.5rem;
}
.editor-content {
flex-grow: 1;
overflow-y: auto;
padding-top: 1rem;
padding-right: 10px;
}
.editor-content .card {
margin-bottom: 1rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
/* --- Invoice Preview Styles (mimicking PDF) --- */
.invoice-preview-document {
width: 210mm;
min-height: 297mm;
background-color: white;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
font-family: "Open Sans", sans-serif, Verdana;
font-size: 12px;
display: flex;
flex-direction: column;
position: relative;
}
.preview-header-table {
width: 100%;
border-collapse: collapse;
padding: 2cm 2cm 0 2cm;
}
.customer-details {
vertical-align: bottom;
font-size: 14px;
padding-left: 30pt;
width: 65%;
}
.invoice-details-cell {
vertical-align: bottom;
}
.invoice-details-box {
border: 2px solid #e1e1e1;
padding: 6px;
font-size: 12px;
}
.invoice-details-box table td {
padding: 2px 4px;
}
.invoice-details-box table td:first-child {
text-align: right;
font-weight: bold;
}
.separator {
margin: 24px 2cm 0 2cm;
height: 1px;
background-color: black;
}
.preview-main {
padding: 1rem 2cm 0 2cm;
flex-grow: 1;
}
.positions-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.positions-table th {
font-weight: bold;
border-bottom: 1px solid black;
padding: 8px 4px;
height: 28px;
vertical-align: middle;
}
.positions-table td {
padding: 6px 4px;
}
.positions-table tbody tr.uneven {
background-color: #ebebeb;
}
.positions-table .matchcode {
padding-left: 12pt;
font-size: 10px;
color: #555;
}
.totals-section {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
}
.totals-table {
width: 50%;
border-collapse: collapse;
}
.totals-table td, .totals-table th {
padding: 4px;
text-align: left;
}
.totals-table td:last-child {
text-align: right;
}
.totals-table .netto {
font-weight: bold;
background-color: #ebebeb;
border-top: 1px solid black;
border-bottom: 1px solid black;
}
.totals-table .ust {
font-size: 11px;
border-bottom: 1px solid #ddd;
}
.totals-table .brutto {
font-weight: bold;
background-color: #ebebeb;
border-bottom: 3px double black;
}
.payment-info {
margin-top: 20pt;
}
.preview-footer {
padding: 1rem 2cm 2cm 2cm;
margin-top: auto;
border-top: 1px solid #e0e0e0;
font-size: 10px;
position: relative;
}
.preview-footer .page-number {
text-align: right;
}

View File

@@ -0,0 +1,372 @@
Vue.component('manual-invoice', {
template: `
<tt-card>
<div class="d-flex justify-content-between align-items-center mb-3">
<tt-button text="Neue Rechnung" icon="fas fa-plus" @click="openModal()" additional-class="btn-primary"/>
<tt-button text="Test Prefill & Reload" icon="fas fa-magic" @click="testPrefill" additional-class="btn-info"/>
</div>
<tt-table-crud
ref="table"
emit-edit
@edit="openModal($event)">
<template v-slot:totalamount="{ row }">
{{ formatPrice(row.totalAmount) }}
</template>
<template v-slot:invoicedate="{ row }">
{{ formatDate(row.invoiceDate) }}
</template>
</tt-table-crud>
<manual-invoice-modal
v-if="isModalOpen"
:initial-data="editingInvoiceData"
@close="closeModal"
@save="handleSave"
/>
</tt-card>
`,
data() {
return {
isModalOpen: false,
editingInvoiceData: null,
}
},
mounted() {
const prefillData = localStorage.getItem('ManualInvoice_create');
if (prefillData) {
try {
this.editingInvoiceData = JSON.parse(prefillData);
this.isModalOpen = true;
} catch (e) {
console.error("Failed to parse prefill data:", e);
} finally {
localStorage.removeItem('ManualInvoice_create');
}
}
},
methods: {
openModal(invoice = null) {
this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(invoice)) : null;
this.isModalOpen = true;
},
closeModal() {
this.isModalOpen = false;
this.editingInvoiceData = null;
this.$refs.table.$refs.table.refreshTable();
},
handleSave(invoiceData) {
console.log("--- INVOICE SAVED (DEMO) ---");
console.log(JSON.parse(JSON.stringify(invoiceData)));
window.notify('success', 'Rechnung in der Konsole geloggt!');
this.closeModal();
},
testPrefill() {
const mockInvoice = {
id: null,
invoiceNumber: `RE-${new Date().getFullYear()}-XXXX`,
invoiceDate: moment().unix(),
dueDate: moment().add(14, 'days').unix(),
status: 'draft',
billingAddressId: 1, // Example ID for autocomplete to fetch
customer: {}, // Will be populated by watcher
positions: [
{ product_name: 'Stunden Techniker', product_info: 'Arbeiten an Server-Infrastruktur', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 3.5, price: 95.00, vatrate: 20 },
{ product_name: 'Anfahrtspauschale', product_info: '', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 1, price: 45.00, vatrate: 20 }
],
closingText: 'Wir bedanken uns für die gute Zusammenarbeit.',
taxText: ''
};
localStorage.setItem('ManualInvoice_create', JSON.stringify(mockInvoice));
window.location.reload();
},
formatPrice(value) {
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0);
},
formatDate(timestamp) {
if (!timestamp) return '';
return moment.unix(timestamp).format('DD.MM.YYYY');
}
}
});
Vue.component('manual-invoice-modal', {
props: ['initialData'],
template: `
<div class="manual-invoice-overlay" :class="overlayClasses" @keydown.ctrl.q.prevent="togglePreviewVisibility" tabindex="-1" ref="overlay">
<div class="info-bar" v-if="!isLargeScreen">
<i class="fas fa-info-circle mr-2"></i> Drücke <strong>STRG + Q</strong> um die Vorschau umzuschalten.
</div>
<div class="invoice-editor-pane" v-show="isLargeScreen || !showPreviewOnSmallScreen">
<div class="editor-header">
<h3>{{ isCreateMode ? 'Neue Rechnung' : 'Rechnung bearbeiten' }}</h3>
<div class="editor-actions">
<tt-button text="Speichern" icon="fas fa-save" @click="$emit('save', invoiceData)" additional-class="btn-success"/>
<tt-button text="Schließen" icon="fas fa-times" @click="close" additional-class="btn-secondary"/>
</div>
</div>
<div class="editor-content">
<tt-card>
<template v-slot:header><h5><i class="fas fa-user-tie mr-2"></i>Kunde</h5></template>
<tt-autocomplete label="Kunde suchen" :api-url="customerApiUrl" v-model="invoiceData.billingAddressId" sm row />
</tt-card>
<tt-card>
<template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template>
<div class="form-grid">
<tt-input label="Rechnungsnr." v-model="invoiceData.invoiceNumber" sm/>
<tt-date-picker label="Rechnungsdatum" v-model="invoiceData.invoiceDate" :date-range="false" sm/>
<tt-date-picker label="Fälligkeitsdatum" v-model="invoiceData.dueDate" :date-range="false" sm/>
</div>
</tt-card>
<tt-card>
<template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
<tt-positions-manager ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
</tt-card>
<tt-card>
<template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte</h5></template>
<tt-textarea label="Schlusstext" v-model="invoiceData.closingText" rows="4"/>
<tt-textarea label="Steuerhinweis (z.B. Reverse Charge)" v-model="invoiceData.taxText" rows="2"/>
</tt-card>
</div>
</div>
<div class="invoice-preview-pane" v-show="isLargeScreen || showPreviewOnSmallScreen">
<div class="invoice-preview-document">
<div style="height: 50px; margin-bottom: 32px">
<img alt="Xinon Logo" src="/assets/images/xinon-full.png" style="text-align:left;height: 85px;">
</div>
<table class="preview-header-table">
<tr>
<td class="customer-details">
<div>{{ invoiceData.customer.company }}</div>
<div>{{ invoiceData.customer.name }}</div>
<div>{{ invoiceData.customer.street }}</div>
<div>{{ invoiceData.customer.zip }} {{ invoiceData.customer.city }}</div>
<div v-if="invoiceData.customer.country !== 'Österreich'">{{ invoiceData.customer.country }}</div>
</td>
<td class="invoice-details-cell">
<table class="invoice-details-box">
<tr><td>Kundennummer:</td><td>{{ selectedCustomerObject.customer_number || '-' }}</td></tr>
<tr><td>Rechnungsnummer:</td><td>{{ invoiceData.invoiceNumber }}</td></tr>
<tr><td>Belegdatum:</td><td>{{ formatDate(invoiceData.invoiceDate) }}</td></tr>
<tr v-if="invoiceData.customer.uid"><td>Ihre UID:</td><td>{{ invoiceData.customer.uid }}</td></tr>
</table>
</td>
</tr>
</table>
<div class="separator"></div>
<div class="preview-main">
<h2 style="text-align: center; color: #005384; font-size: 1.5rem; margin-bottom: 1.5rem;">Ihre Rechnung vom {{ formatDate(invoiceData.invoiceDate) }}</h2>
<table class="positions-table">
<thead>
<tr class="uneven">
<th style="text-align: left; padding-left: 4pt;">Leistung / Produkt</th>
<th style="text-align: center;">Zeitraum</th>
<th style="text-align: right;">Preis</th>
<th style="text-align: center;">Menge</th>
<th style="text-align: right;">Netto €</th>
<th style="text-align: right;">Ust. %</th>
<th style="text-align: right; padding-right: 4pt;">Brutto €</th>
</tr>
</thead>
<tbody>
<template v-for="(p, index) in invoiceData.positions">
<tr :class="{'uneven': index % 2 === 0}"> <td style="vertical-align: top; padding-left: 4pt;">
<strong>{{ p.product_name }}</strong>
<div v-if="p.product_info" class="matchcode">{{ p.product_info }}</div>
</td>
<td style="text-align: center; vertical-align: top;">{{ formatPeriod(p.start_date, p.end_date) }}</td>
<td style="text-align: right; vertical-align: top;">{{ formatPrice(p.price) }}</td>
<td style="text-align: center; vertical-align: top;">{{ p.amount }}</td>
<td style="text-align: right; vertical-align: top;">{{ formatPrice((p.amount || 0) * (p.price || 0)) }}</td>
<td style="text-align: right; vertical-align: top;">{{ p.vatrate }}%</td>
<td style="text-align: right; padding-right: 4pt; vertical-align: top;">{{ formatPrice(((p.amount || 0) * (p.price || 0)) * (1 + (p.vatrate || 0) / 100)) }}</td>
</tr>
</template>
</tbody>
</table>
<div class="totals-section">
<table class="totals-table">
<tr class="netto">
<th>Gesamtbetrag Netto:</th>
<td>{{ formatPrice(totals.net) }} €</td>
</tr>
<tr class="ust" v-for="(vatValue, vatRate) in totals.vat" :key="vatRate">
<th>+ Umsatzsteuer {{ vatRate }}%:</th>
<td>{{ formatPrice(vatValue) }} €</td>
</tr>
<tr class="brutto">
<th>Gesamtbetrag Brutto:</th>
<td>{{ formatPrice(totals.gross) }} €</td>
</tr>
</table>
</div>
<div class="payment-info">
<p v-if="invoiceData.taxText" style="font-weight: bold;">{{invoiceData.taxText}}</p>
Bitte <b>überweisen</b> Sie den Rechnungsbetrag bis zum <b>{{ formatDate(invoiceData.dueDate) }}</b> auf folgendes Konto:<br />
<b style="padding-left: 4pt;">IBAN: {{ bankDetails.iban }}</b><br />
<b style="padding-left: 4pt;">BIC: {{ bankDetails.bic }}</b><br /><br />
Bitte geben Sie als Verwendungszweck unbedingt die Rechnungsnummer an.
</div>
</div>
<div class="preview-footer">
<div style="color:grey;text-align: center; width: 100%;">
<span>XINON GmbH | Fladnitz 150 | 8322 Studenzen</span><br>
<span>Tel.: +43 3115 40800 | E-Mail: office@xinon.at</span><br>
<span>UID: ATU68711968 | FN: 416556h | LG: Feldbach</span><br>
<span>IBAN: {{ bankDetails.iban }} | BIC: {{ bankDetails.bic }}</span><br>
</div>
<div class="page-number">Seite 1 von 1</div>
</div>
</div>
</div>
</div>
`,
data() {
return {
isCreateMode: !this.initialData || !this.initialData.id,
customerApiUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1',
selectedCustomerObject: {},
isLargeScreen: window.innerWidth >= 1920,
showPreviewOnSmallScreen: false,
invoiceData: {
id: null,
invoiceNumber: `RE-${new Date().getFullYear()}-`,
invoiceDate: moment().unix(),
dueDate: moment().add(14, 'days').unix(),
status: 'draft',
billingAddressId: null,
customer: { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' },
positions: [],
closingText: 'Wir danken für Ihren Auftrag und verbleiben mit freundlichen Grüßen,\nIhr Xinon Team',
taxText: '',
},
bankDetails: {
iban: 'ATXX XXXX XXXX XXXX XXXX',
bic: 'XXXXXXXX'
},
positionsConfig: {
fields: {
product_name: { type: 'input', label: 'Bezeichnung' },
product_info: { type: 'input', label: 'Zusatzinfo' },
start_date: { type: 'input', label: 'Start', inputType: 'date' },
end_date: { type: 'input', label: 'Ende', inputType: 'date' },
amount: { type: 'input', label: 'Menge', inputType: 'number' },
price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' },
vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' },
},
validateForm: (formData) => {
if (!formData.product_name) { window.notify('error', 'Bezeichnung ist erforderlich.'); return false; }
if (!formData.amount) { window.notify('error', 'Menge ist erforderlich.'); return false; }
if (formData.price === null || formData.price === undefined) { window.notify('error', 'Preis ist erforderlich.'); return false; }
return true;
}
}
};
},
computed: {
overlayClasses() {
return {
'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen,
'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen,
};
},
totals() {
let net = 0;
const vat = {};
if (!Array.isArray(this.invoiceData.positions)) return { net: 0, vat: {}, gross: 0 };
this.invoiceData.positions.forEach(p => {
const lineTotal = (parseFloat(p.amount) || 0) * (parseFloat(p.price) || 0);
const vatRate = parseInt(p.vatrate) || 0;
net += lineTotal;
if (!vat[vatRate]) { vat[vatRate] = 0; }
vat[vatRate] += lineTotal * (vatRate / 100);
});
const gross = net + Object.values(vat).reduce((sum, v) => sum + v, 0);
return { net, vat, gross };
}
},
watch: {
'invoiceData.billingAddressId': {
async handler(newId) {
if (!newId) {
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
this.selectedCustomerObject = {};
return;
}
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/Address/api?do=getAddress&id=${newId}`);
if (response.data.status === 'OK' && response.data.result.address) {
const addr = response.data.result.address;
this.selectedCustomerObject = addr;
this.invoiceData.customer = {
company: addr.company,
name: `${addr.firstname} ${addr.lastname}`,
street: addr.street,
zip: addr.zip,
city: addr.city,
country: 'Österreich',
uid: addr.uid
};
}
},
immediate: true
}
},
created() {
if (this.initialData) {
// FIX: Merge initial data with default structure to ensure all keys, especially nested ones, exist.
this.invoiceData = {
...this.invoiceData, // Start with default structure
...JSON.parse(JSON.stringify(this.initialData)) // Overwrite with passed data
};
// Explicitly ensure nested objects exist if they weren't in initialData
if (!this.invoiceData.customer) {
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
}
// Ensure positions is an array
if (!Array.isArray(this.invoiceData.positions)) {
try {
const parsed = JSON.parse(this.invoiceData.positions);
this.invoiceData.positions = Array.isArray(parsed) ? parsed : [];
} catch (e) {
this.invoiceData.positions = [];
}
}
}
},
mounted() {
window.addEventListener('resize', this.handleResize);
this.handleResize();
this.$nextTick(() => {
if (this.$refs.overlay) {
this.$refs.overlay.focus();
}
});
},
beforeDestroy() {
window.removeEventListener('resize', this.handleResize);
},
methods: {
close() { this.$emit('close'); },
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
formatPrice(value) { return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value || 0); },
formatDate(timestamp) {
if (!timestamp) return '';
return moment.unix(timestamp).format('DD.MM.YYYY');
},
formatPeriod(start, end) {
if (!start) return '';
const startDate = moment(start);
const endDate = end ? moment(end) : moment(start);
if (!startDate.isValid()) return '';
if (startDate.isSame(endDate, 'day')) return startDate.format('DD.MM.YYYY');
if(startDate.isValid() && endDate.isValid()) {
return `${startDate.format('DD.MM.YYYY')} - ${endDate.format('DD.MM.YYYY')}`;
}
return startDate.format('DD.MM.YYYY');
}
}
});

View File

@@ -0,0 +1,160 @@
.preorder-map-container {
height: 100%;
width: 100%;
}
.map-filter-container {
display: flex;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: #f8f9fa;
border-bottom: 1px solid #dee2e6;
flex-wrap: wrap;
}
.map-filter-container .btn {
transition: opacity 0.15s ease-in-out;
}
.map-filter-container .btn:hover {
opacity: 0.85;
}
.marker-label {
background: transparent !important;
border: none !important;
box-shadow: none !important;
padding: 0 !important;
z-index: 1000;
pointer-events: none;
transition: visibility 0.2s, opacity 0.2s linear;
}
.tooltip-content-wrapper {
background-color: rgba(255, 255, 255, 0.85);
border: 1px solid #ccc;
border-radius: 5px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
color: #333;
font-size: 12px;
font-weight: bold;
padding: 2px 5px;
text-align: center;
white-space: nowrap;
}
.tooltip-content-wrapper.marker-label-highlight {
background-color: rgba(248, 215, 218, 0.9);
border-color: #e57373;
color: #721c24;
}
.tooltip-content-wrapper.marker-label-saturated {
background-color: rgba(212, 237, 218, 0.9);
border-color: #81c784;
color: #155724;
}
div.leaflet-marker-icon.custom-div-icon {
background: transparent !important;
border: none !important;
}
.rimo-marker {
display: flex;
justify-content: center;
align-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
clip-path: inset(0 round 50%);
}
.rimo-icon {
font-size: 16px;
color: white;
}
.fcp-marker {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: #ffc107;
color: #333;
font-size: 11px;
font-weight: bold;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
}
/* --- Styles for FCP Popup --- */
.fcp-popup-content {
font-family: Arial, sans-serif;
width: 320px;
font-size: 0.8rem;
line-height: 1.4;
}
.fcp-popup-content h5 {
margin-top: 0;
margin-bottom: 10px;
color: #333;
border-bottom: 1px solid #ddd;
padding-bottom: 5px;
font-size: 1rem;
font-weight: 600;
}
.fcp-popup-content a {
color: #007bff;
text-decoration: none;
margin-bottom: 15px;
display: inline-block;
}
.fcp-popup-content a:hover {
text-decoration: underline;
}
.fcp-popup-content .summary-block {
margin-bottom: 15px;
}
.fcp-popup-content .summary-block strong {
display: block;
margin-bottom: 5px;
color: #555;
font-weight: 600;
}
.fcp-popup-content table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
margin-top: 10px;
}
.fcp-popup-content th,
.fcp-popup-content td {
padding: 6px 8px;
border: 1px solid #ddd;
text-align: left;
vertical-align: middle;
}
.fcp-popup-content th {
font-weight: bold;
}
.fcp-popup-content thead tr {
background-color: #f2f2f2;
}
.fcp-popup-content tbody tr:nth-child(even) {
background-color: #f9f9f9;
}
.fcp-popup-content .text-center {
text-align: center;
}
/* RIMO Marker Colors */
.marker-greenfield { background-color: #28a745; }
.marker-residential { background-color: #007bff; }
.marker-company { background-color: #ffc107; }
.marker-multiple-dwelling { background-color: #6f42c1; }
.marker-public { background-color: #17a2b8; }
.marker-other { background-color: #6c757d; }

View File

@@ -0,0 +1,359 @@
Vue.component('PreorderRimoTypeMap', {
data() {
return {
mapMarkers: [],
fcpMarkers: [],
isLoading: false,
window,
fetchUrl: window.TT_CONFIG.BASE_PATH + '/Preorder/RimoTypeMapData',
selectedCampaign: null,
mapConfig: {
clusterOptions: {
spiderfyOnMaxZoom: false,
disableClusteringAtZoom: 17,
}
},
mapInstance: null,
activeFilters: [],
rimoTypeDefs: {
greenfield: { text: 'Greenfield', icon: 'fas fa-tree', color: '#28a745' },
residential: { text: 'Wohngebiet', icon: 'fas fa-home', color: '#007bff' },
company: { text: 'Gewerbe', icon: 'fas fa-building', color: '#ffc107' },
'multiple-dwelling': { text: 'Mehrfamilienhaus', icon: 'fas fa-city', color: '#6f42c1' },
public: { text: 'Öffentlich', icon: 'fas fa-school', color: '#17a2b8' },
other: { text: 'Andere', icon: 'fas fa-question-circle', color: '#6c757d' }
}
};
},
computed: {
filterOptions() {
return Object.entries(this.rimoTypeDefs).map(([value, defs]) => ({
value,
text: defs.text,
icon: defs.icon
}));
},
filteredMapMarkers() {
const rimoMarkers = this.activeFilters.length === 0
? this.mapMarkers
: this.mapMarkers.filter(marker => this.activeFilters.includes(marker.rimoType));
return [...rimoMarkers, ...this.fcpMarkers];
}
},
async created() {
const urlParams = new URLSearchParams(window.location.search);
this.selectedCampaign = urlParams.get('preordercampaign_id');
if (this.selectedCampaign) {
await this.fetchAllMapData();
}
},
mounted() {
this.$nextTick(() => {
const ttMapComponent = this.$refs.ttMap;
if (ttMapComponent && ttMapComponent.map) {
this.mapInstance = ttMapComponent.map;
this.mapInstance.on('zoomend', this.checkZoomLevel);
this.checkZoomLevel();
}
});
},
beforeDestroy() {
if (this.mapInstance) {
this.mapInstance.off('zoomend', this.checkZoomLevel);
}
},
methods: {
async fetchAllMapData() {
if (!this.selectedCampaign) return;
this.isLoading = true;
this.mapMarkers = [];
this.fcpMarkers = [];
try {
await Promise.all([
this.fetchRimoData(),
this.fetchFCPData()
]);
} catch (err) {
console.error("Failed to load map data:", err);
} finally {
this.isLoading = false;
}
},
async fetchRimoData() {
try {
const response = await axios.post(this.fetchUrl, { campaignId: this.selectedCampaign });
if (response.data.success && Array.isArray(response.data.data)) {
this.mapMarkers = this.processData(response.data.data);
} else {
window.notify('error', 'Ungültiges RIMO-Datenformat von der API empfangen.');
}
} catch (err) {
window.notify('error', 'Laden der RIMO-Kartendaten fehlgeschlagen.');
throw err;
}
},
async fetchFCPData() {
try {
// Step 1: Fetch FCP locations
const fcpLocationUrl = `${window.TT_CONFIG.BASE_PATH}/Preorder/Api?do=getFCPsForCampaign&campaign_id=${this.selectedCampaign}`;
const fcpResponse = await axios.get(fcpLocationUrl);
if (fcpResponse.data.status !== "OK" || !fcpResponse.data.result?.length) {
console.warn('No FCP locations found or API error.');
return;
}
const fcpLocations = fcpResponse.data.result;
// Step 2: Fetch FCP stats using the IDs from the first call
const fcpIds = fcpLocations.map(fcp => fcp.real_id);
const statsUrl = `${window.TT_CONFIG.BASE_PATH}/Preorder/Api?do=getRimoFcpStats`;
const statsResponse = await axios.post(statsUrl, { fcp_ids: fcpIds });
const fcpStats = statsResponse.data.status === "OK" ? statsResponse.data.result : [];
const statsMap = new Map(fcpStats.map(s => [s.fcp_id, s]));
// Step 3: Create markers with detailed popup content
this.fcpMarkers = fcpLocations.map(fcp => {
const stat = statsMap.get(String(fcp.real_id));
return {
lat: fcp.lat,
lng: fcp.lng,
options: {
icon: {
className: 'custom-div-icon',
html: `<div class="fcp-marker">${fcp.text}</div>`,
iconSize: [28, 28],
iconAnchor: [14, 14],
},
asyncPopupContent: () => this.generateFcpPopupHtml(fcp, stat),
zIndexOffset: 500
},
};
});
} catch (err) {
window.notify('warning', 'Laden der FCP-Daten fehlgeschlagen. Die Karte wird ohne sie angezeigt.');
console.error("FCP data fetch failed:", err);
}
},
generateFcpPopupHtml(fcp, fcpStat) {
const googleMapsLink = `https://www.google.com/maps/search/?api=1&query=${fcp.lat},${fcp.lng}`;
let statsHtml;
if (!fcpStat) {
statsHtml = `<p>Keine Statistiken für diesen FCP gefunden.</p>`;
} else {
const tableRows = Object.entries(fcpStat.counts_by_rimo_type || {})
.map(([type, counts]) => {
const normalizedType = this.getNormalizedRimoType(type);
const typeDef = this.rimoTypeDefs[normalizedType] || this.rimoTypeDefs.other;
const typeDisplay = `<i class="${typeDef.icon} mr-2" style="color: ${typeDef.color};"></i>${typeDef.text}`;
return `
<tr>
<td>${typeDisplay}</td>
<td class="text-center">${counts.hausnummer_count}</td>
<td class="text-center">${counts.wohneinheit_count}</td>
<td class="text-center">${counts.preorder_count}</td>
</tr>`;
}).join('');
const table = tableRows ? `
<table>
<thead>
<tr>
<th>Typ</th>
<th class="text-center" title="Gebäude">GEB</th>
<th class="text-center" title="Wohneinheiten">WE</th>
<th class="text-center" title="Bestellungen">BE</th>
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>` : '<p>Keine Detail-Statistiken verfügbar.</p>';
statsHtml = `
<div class="summary-block">
<strong>Zusammenfassung</strong>
<span>Gebäude: <b>${fcpStat.total_hausnummer_count}</b></span><br>
<span>Wohneinheiten: <b>${fcpStat.total_wohneinheit_count}</b></span><br>
<span>Bestellungen: <b>${fcpStat.total_active_preorders}</b></span>
</div>
${table}`;
}
return `
<div class="fcp-popup-content">
<h5><i class="fas fa-broadcast-tower mr-2"></i>FCP: ${fcp.text}</h5>
<a href='${googleMapsLink}' target='_blank'>
<i class="fas fa-map-marker-alt mr-1"></i> In Google Maps anzeigen
</a>
${statsHtml}
</div>`;
},
processData(data) {
const groupedData = {};
data.forEach(item => {
const latLngKey = `${item.gps_lat},${item.gps_long}`;
if (!groupedData[latLngKey]) {
groupedData[latLngKey] = {
...item,
wohneinheit_count: parseInt(item.wohneinheit_count, 10) || 0,
preorder_count: parseInt(item.preorder_count, 10) || 0,
original_items: [item],
};
} else {
groupedData[latLngKey].wohneinheit_count += parseInt(item.wohneinheit_count, 10) || 0;
groupedData[latLngKey].preorder_count += parseInt(item.preorder_count, 10) || 0;
groupedData[latLngKey].original_items.push(item);
}
});
return Object.values(groupedData).map(group => {
const rimoType = this.getNormalizedRimoType(group.rimo_type);
const markerIcon = this.getMarkerIcon(rimoType);
let tooltipInnerClass = '';
if (rimoType !== 'greenfield' && group.wohneinheit_count > 0 && group.wohneinheit_count === group.preorder_count) {
tooltipInnerClass = ' marker-label-saturated';
} else if (rimoType === 'greenfield' && group.preorder_count > 0) {
tooltipInnerClass = ' marker-label-highlight';
}
return {
lat: group.gps_lat,
lng: group.gps_long,
rimoType: rimoType,
options: {
icon: {
className: `custom-div-icon marker-${rimoType}`,
html: `<div class="rimo-marker ${markerIcon.class}"><i class="${markerIcon.icon} rimo-icon"></i></div>`,
iconSize: [30, 30],
iconAnchor: [15, 30],
},
tooltip: {
content: `<div class="tooltip-content-wrapper${tooltipInnerClass}">H: ${group.wohneinheit_count}<br>B: ${group.preorder_count}</div>`,
direction: 'bottom',
className: 'marker-label',
permanent: true,
},
asyncPopupContent: async () => {
let content = `<div style="font-size: 0.85rem;">`;
group.original_items.forEach(item => {
content += `
<div class="mb-2">
<h5 class="mb-2 mt-1">${item.strasse_name} ${item.hausnummer}, ${item.plz_name} ${item.ortschaft_name}</h5>
<strong>Rimo Type:</strong> ${item.rimo_type || 'N/A'}<br>
<strong>Rimo Op State:</strong> ${item.rimo_op_state || 'N/A'}<br>
<strong>Rimo Ex State:</strong> ${item.rimo_ex_state || 'N/A'}<br>
<strong>Wohn. gesamt:</strong> ${item.wohneinheit_count}<br>
<strong>Bestellungen:</strong> ${item.preorder_count}<br>
<strong>Koordinaten:</strong>
<a href="https://www.google.com/maps?q=${item.gps_lat},${item.gps_long}" target="_blank" class="text-primary">
<i class="fas fa-map-marker-alt mr-1"></i>Karte
</a>
<a href="https://thetool.xinon.at/AddressDB/View?id=${item.hausnummer_id}" target="_blank" class="text-primary ml-2">
<i class="fas fa-info-circle mr-1"></i>AddressDB
</a>
</div><hr class="my-1">`;
});
return content.slice(0, -16) + `</div>`;
},
},
};
});
},
getNormalizedRimoType(type) {
const lowerType = (type || '').toLowerCase();
if (lowerType.includes('2/3')) return 'residential';
if (lowerType.includes('greenfield')) return 'greenfield';
if (lowerType.includes('residential')) return 'residential';
if (lowerType.includes('multiple dwelling') || lowerType.includes('multiple dwellings')) return 'multiple-dwelling';
if (lowerType.includes('company') || lowerType.includes('commercial')) return 'company';
if (lowerType.includes('public') || lowerType.includes('school')) return 'public';
if (lowerType.includes('unknown')) return 'other';
return 'other';
},
getMarkerIcon(rimoType) {
const def = this.rimoTypeDefs[rimoType] || this.rimoTypeDefs.other;
return {
class: `marker-${rimoType}`,
icon: def.icon,
};
},
checkZoomLevel() {
if (!this.mapInstance) return;
const currentZoom = this.mapInstance.getZoom();
const minZoomForLabel = 16;
const visibility = currentZoom >= minZoomForLabel ? 'visible' : 'hidden';
const opacity = currentZoom >= minZoomForLabel ? '1' : '0';
document.querySelectorAll('.marker-label').forEach(el => {
el.style.visibility = visibility;
el.style.opacity = opacity;
});
},
toggleFilter(filterValue) {
const index = this.activeFilters.indexOf(filterValue);
if (index > -1) {
this.activeFilters.splice(index, 1);
} else {
this.activeFilters.push(filterValue);
}
},
isFilterActive(filterValue) {
return this.activeFilters.includes(filterValue);
},
/**
* Generates the style for a filter button based on its state (active/inactive).
*/
getFilterButtonStyle(filterValue) {
const color = this.rimoTypeDefs[filterValue]?.color || '#6c757d';
if (this.isFilterActive(filterValue)) {
return {
backgroundColor: color,
borderColor: color,
color: 'white',
};
}
return {
color: color,
backgroundColor: 'white',
borderColor: color,
};
},
},
template: `
<tt-card style="height: 75vh; position: relative; display: flex; flex-direction: column;">
<div v-if="!selectedCampaign" class="alert alert-warning m-3">
Bitte eine Kampagne über den URL-Parameter 'preordercampaign_id' auswählen (z.B. ?preordercampaign_id=44).
</div>
<template v-else>
<div class="map-filter-container">
<button v-for="filter in filterOptions"
:key="filter.value"
@click="toggleFilter(filter.value)"
class="btn btn-sm"
:style="getFilterButtonStyle(filter.value)"
:title="filter.text">
<i :class="filter.icon"></i>
</button>
</div>
<div style="height: 100%; position: relative; flex-grow: 1;">
<div v-if="!isLoading && mapMarkers.length === 0 && fcpMarkers.length === 0" class="alert alert-info m-3">
Keine Standorte für die ausgewählte Kampagne gefunden.
</div>
<tt-map ref="ttMap" :markers-data="filteredMapMarkers" :loading="isLoading" :config="mapConfig" class="preorder-map-container"></tt-map>
</div>
</template>
</tt-card>
`
});

View File

@@ -1,33 +0,0 @@
/*
* CSS for Workorder Table Row Highlighting (Balanced Colors)
*/
/* 🔴 Urgent: Deadline passed or less than 1 week away */
.table-hover .tt-rml-workorder-urgent:hover,
.tt-rml-workorder-urgent {
background-color: #f8d7da !important; /* Balanced Red */
}
/* 🟠 High Priority: Deadline less than 2 weeks away */
.table-hover .tt-rml-workorder-high:hover,
.tt-rml-workorder-high {
background-color: #ffd5a1 !important; /* Balanced Orange */
}
/* 🟡 Medium: Deadline less than 3 weeks away */
.table-hover .tt-rml-workorder-medium:hover,
.tt-rml-workorder-medium {
background-color: #fff3cd !important; /* Balanced Yellow */
}
/* 🟢 On Track: Deadline more than 3 weeks away */
.table-hover .tt-rml-workorder-ontrack:hover,
.tt-rml-workorder-ontrack {
background-color: #d4edda !important; /* Balanced Green */
}
/* ⚫ Irrelevant: No deadline or status makes it not applicable */
.table-hover .tt-rml-workorder-irrelevant:hover,
.tt-rml-workorder-irrelevant {
background-color: #e9ecef !important; /* Balanced Grey */
}

View File

@@ -1,483 +0,0 @@
// RMLWorkorderAdmin.js
Vue.component('r-m-l-workorder-admin', {
template: `
<tt-card>
<div class="mb-2 d-flex align-items-center" v-if="workordersToAssign.length > 0">
<span class="mr-3 font-weight-bold">{{ workordersToAssign.length }} Workorder(s) zuweisen:</span>
<div style="width: 300px;">
<tt-select
class="mb-0"
:options="companies"
v-model="massAssignCompanyId"
@input="massAssignCompanies"
placeholder="Firma auswählen..."
sm
no-form-group
/>
</div>
</div>
<tt-table-crud
ref="table"
:crud-config="crudConfig"
>
<template v-slot:preorderinfo="{ row }">
<div class="small">
<div>
<strong>Kunde:</strong> {{ row.customerCompany || row.customerName }}
</div>
<div>
<strong>Anschluss:</strong>
{{ row.street }} {{ row.hausnummer }}<template v-if="row.stiege">/{{ row.stiege }}</template><template v-if="row.apartment"> / WE: {{ row.apartment }}</template>, {{ row.plz }} {{ row.city }}
</div>
<div>
<strong>OAID:</strong> <span class="text-pink">{{ row.oaid }}</span>
<tt-button
icon="fas fa-external-link-alt"
@click="window.open(window.TT_CONFIG.BASE_PATH + '/Preorder/Index?filter[ucode]=' + row.ucode, '_blank');"
additional-class="btn-link btn-sm p-0 m-0"
title="Zur Bestellung"
/>
</div>
</div>
</template>
<template v-slot:status="{ row }">
<traffic-light :deadline="row.deadlineDate" :status="row.status"/>
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
<tt-button
v-if="row.status === 'intervention_required'"
icon="ml-2 fas fa-check-circle text-success"
@click="setToProblemSolved(row)"
additional-class="btn-link btn-sm p-0"
title="Auftrag auf Problem behoben setzen"
/>
</template>
<template v-slot:companyname="{ row }">
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1">
<div v-if="editingWorkorderId === row.id">
<tt-select
:options="companies"
:value="row.companyId"
@input="assignCompany(row, $event)"
@blur="editingWorkorderId = null"
@keydown.esc.native="editingWorkorderId = null"
placeholder="Firma zuweisen..."
sm
no-form-group
/>
</div>
<div v-else-if="row.status === 'new'">
<tt-select
:options="companies"
:value="row.companyId"
@input="assignCompany(row, $event)"
placeholder="Firma zuweisen..."
sm
no-form-group
/>
</div>
<div v-else class="d-flex align-items-center">
<span>{{ row.companyName || 'N/A' }}</span>
<tt-button
icon="fas fa-edit"
@click="editingWorkorderId = row.id"
additional-class="btn-link btn-sm p-0 ml-2"
title="Zuweisung ändern"
/>
</div>
</div>
<div class="ml-3">
<tt-button v-if="!workordersToAssign.includes(row.id)"
icon="fas fa-plus-circle text-success"
@click="addToAssignList(row)"
additional-class="btn-link btn-sm p-0"
title="Zur Zuweisungsliste hinzufügen"
/>
<tt-button v-if="workordersToAssign.includes(row.id)"
icon="fas fa-minus-circle text-danger"
@click="removeFromAssignList(row)"
additional-class="btn-link btn-sm p-0"
title="Von Zuweisungsliste entfernen"
/>
</div>
</div>
</template>
<template v-slot:deadlinedate="{ row }">
<div v-if="editingDeadlineId === row.id">
<tt-date-picker
:value="row.deadlineDate"
:date-range="false"
@input="updateDeadline(row, $event)"
@blur="editingDeadlineId = null"
sm
no-form-group
/>
</div>
<div v-else class="d-flex align-items-center">
<span>{{ formatDate(row.deadlineDate) }}</span>
<span v-if="row.daysUntilDeadline !== null && row.daysUntilDeadline >= 0" class="ml-2 text-muted small">
übrig: {{ row.daysUntilDeadline }} Tag{{ row.daysUntilDeadline !== 1 ? 'e' : '' }}
</span>
<tt-button
icon="fas fa-edit"
@click="editingDeadlineId = row.id"
additional-class="btn-link btn-sm p-0 ml-2"
title="Deadline ändern"
/>
</div>
</template>
<template v-slot:appointmentdate="{ row }">
{{ formatDate(row.appointmentDate) }}
</template>
<template v-slot:expandedRow="{ row }">
<rml-documentation-viewer-admin
:workorder-id="row.id"
@workorder-updated="$refs.table.$refs.table.refreshTable()"
@accept-documentation="acceptDocumentation"
/>
</template>
</tt-table-crud>
</tt-card>
`,
data() {
return {
window,
workordersToAssign: [],
editingWorkorderId: null,
editingDeadlineId: null,
companies: [],
massAssignCompanyId: null,
massAssignLoading: false,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG,
selectable: false,
expandable: true,
customRowClass: (row) => {
const deadlineDate = moment.unix(row.deadlineDate);
if (['completed', 'new'].includes(row.status) || !deadlineDate.isValid()) {
return 'tt-rml-workorder-irrelevant';
}
if (['correction_requested', 'intervention_required'].includes(row.status)) {
return 'tt-rml-workorder-high';
}
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return 'tt-rml-workorder-urgent';
if (daysLeft <= 21) return 'tt-rml-workorder-medium';
return 'tt-rml-workorder-ontrack';
},
additionalActions: []
}
}
},
async mounted() {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/getCompanies`);
this.companies = response.data;
},
methods: {
addToAssignList(row) {
if (!this.workordersToAssign.includes(row.id)) {
this.workordersToAssign.push(row.id);
}
},
removeFromAssignList(row) {
this.workordersToAssign = this.workordersToAssign.filter(id => id !== row.id);
},
getStatusColumn(status) {
const column = this.crudConfig.columns.find(c => c.key === 'status');
return column.table.filterOptions.find(opt => opt.value === status) || {};
},
formatDate(timestamp) {
if (!timestamp) return '';
return window.moment.unix(timestamp).format('DD.MM.YYYY');
},
async assignCompany(workorder, companyId) {
if (!companyId) {
this.editingWorkorderId = null;
return;
}
const payload = {
workorderId: workorder.id,
companyId: companyId
};
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/assignWorkorder`, payload);
if (response.data.success) {
window.notify('success', response.data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.editingWorkorderId = null;
}
},
async massAssignCompanies(companyId) {
if (!companyId) return;
if (!confirm(`${this.workordersToAssign.length} Workorder(s) der ausgewählten Firma zuweisen?`)) {
this.massAssignCompanyId = null; // Reset select on cancel
return;
}
this.massAssignLoading = true;
const payload = {
companyId: companyId,
workorderIds: this.workordersToAssign
};
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/massAssignWorkorders`, payload);
if (response.data.success) {
window.notify('success', response.data.message);
this.workordersToAssign = [];
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.massAssignLoading = false;
this.massAssignCompanyId = null;
}
},
async updateDeadline(workorder, newDate) {
if (!newDate) {
this.editingDeadlineId = null;
return;
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/updateDeadline`, {
workorderId: workorder.id,
deadlineDate: newDate
});
if (response.data.success) {
window.notify('success', response.data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.editingDeadlineId = null;
}
},
async acceptDocumentation(workorderId) {
if (!confirm('Soll die Dokumentation für diesen Arbeitsauftrag wirklich akzeptiert und der Auftrag abgeschlossen werden?')) return;
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/acceptDocumentation`, { workorderId });
if (response.data.success) {
window.notify('success', response.data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
},
async setToProblemSolved(row) {
// add a browser dialog to add some text
const text = prompt('Bitte geben Sie einen kurzen Text für den Eintrag ein:', '');
if (!text) {
window.notify('error', 'Bitte geben Sie einen Text ein.');
return;
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/setToProblemSolved`, {
workorderId: row.id,
text: text
});
if (response.data.success) {
window.notify('success', response.data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
}
}
});
Vue.component('traffic-light', {
props: ['deadline', 'status'],
computed: {
lightInfo() {
if (['completed', 'new'].includes(this.status)) return {color: '#cccccc', title: 'Status irrelevant für Dringlichkeit'};
const now = moment();
const deadlineDate = moment.unix(this.deadline);
if (!deadlineDate.isValid()) return {color: '#cccccc', title: 'Keine Deadline gesetzt'};
if (deadlineDate.isBefore(now)) return {color: '#dc3545', title: 'Deadline überschritten'};
const daysLeft = deadlineDate.diff(now, 'days');
if (daysLeft <= 7) return {color: '#dc3545', title: 'Dringend: Weniger als 1 Woche'};
if (daysLeft <= 21) return {color: '#ffc107', title: 'Mittel: Weniger als 3 Wochen'};
return {color: '#28a745', title: 'Im Plan: Mehr als 3 Wochen'};
}
},
template: `<span :style="{ color: lightInfo.color, fontSize: '1.2em' }" class="mr-2" :title="lightInfo.title">&#9679;</span>`
});
Vue.component('rml-documentation-viewer-admin', {
props: ['workorderId'],
template: `
<div class="p-3 bg-light">
<div v-if="loading" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
<div v-else class="row">
<div class="col-lg-6">
<tt-file-gallery :files="docs" @selection-changed="selectedDocs = $event" selectable />
</div>
<div class="col-lg-6">
<div class="card mb-3" v-if="selectedDocs.length > 0">
<div class="card-header"><h5><i class="fas fa-exclamation-triangle text-danger mr-2"></i>Korrektur anfordern</h5></div>
<div class="card-body">
<p class="small text-muted">Wählen Sie die zu korrigierenden Dokumente aus der Galerie aus und geben Sie einen Grund an.</p>
<tt-textarea v-model="correctionText" label="Grund für die Korrektur" sm row />
<tt-button text="Korrektur anfordern" @click="requestCorrection" :loading="correctionLoading" additional-class="btn-danger float-right"/>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h5><i class="fas fa-check-circle text-success mr-2"></i>Dokumentation akzeptieren</h5></div>
<div class="card-body">
<p class="small text-muted">Wenn die Dokumentation korrekt ist, können Sie sie hier akzeptieren.</p>
<tt-button text="Dokumentation akzeptieren"
@click="$emit('accept-documentation', workorderId)"
additional-class="btn-success float-right"
icon="fas fa-check"
/>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h5><i class="fas fa-history mr-2"></i>Journal</h5></div>
<div class="card-body p-0" style="max-height: 200px; overflow-y: auto;">
<ul v-if="journals.length" class="list-group list-group-flush">
<li v-for="log in journals" :key="log.id" class="list-group-item small">
<strong>{{ formatDate(log.create) }} ({{ log.createByName }}):</strong>
<div class="text-muted" style="white-space: pre-wrap;">{{ log.text }}</div>
</li>
</ul>
<div v-else class="card-body text-muted text-center">Keine Journaleinträge.</div>
</div>
<div class="card-footer">
<tt-textarea v-model="newJournalMessage" placeholder="Ihre Nachricht oder Anmerkung..." rows="2" />
<tt-button
text="Eintrag speichern"
@click="addJournalEntry"
:loading="addingJournalEntry"
additional-class="btn-info btn-sm w-100 mt-2"
icon="fas fa-paper-plane"
/>
</div>
</div>
</div>
</div>
</div>
`,
data() {
return {
loading: true,
correctionLoading: false,
docs: [],
journals: [],
selectedDocs: [],
correctionText: '',
newJournalMessage: '',
addingJournalEntry: false,
}
},
methods: {
async fetchData() {
this.loading = true;
try {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/getDocumentation`, { params: { workorderId: this.workorderId }});
this.docs = response.data.docs;
this.journals = response.data.journals;
} catch(e) {
window.notify('error', 'Dokumentation konnte nicht geladen werden.');
} finally {
this.loading = false;
}
},
async requestCorrection() {
if (!this.correctionText) return window.notify('error', 'Bitte geben Sie einen Grund für die Korrektur an.');
if (this.selectedDocs.length === 0) return window.notify('error', 'Bitte wählen Sie mindestens ein Dokument für die Korrektur aus.');
this.correctionLoading = true;
try {
const payload = {
workorderId: this.workorderId,
text: this.correctionText,
fileIds: this.selectedDocs
};
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/requestCorrection`, payload);
if (response.data.success) {
window.notify('success', response.data.message);
this.correctionText = '';
this.selectedDocs = [];
await this.fetchData();
this.$emit('workorder-updated');
} else {
window.notify('error', response.data.message);
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
this.correctionLoading = false;
},
async addJournalEntry() {
if (!this.newJournalMessage.trim()) {
return window.notify('error', 'Bitte geben Sie eine Nachricht ein.');
}
this.addingJournalEntry = true;
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/addJournal`, {
workorderId: this.workorderId,
text: this.newJournalMessage
});
if (response.data.success) {
window.notify('success', response.data.message || 'Journal-Eintrag hinzugefügt.');
this.newJournalMessage = '';
this.journals = response.data.journals;
} else {
window.notify('error', response.data.message || 'Eintrag konnte nicht gespeichert werden.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.addingJournalEntry = false;
}
},
formatDate(timestamp) {
return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm');
},
},
mounted() {
this.fetchData();
}
});

View File

@@ -1,549 +0,0 @@
// RMLWorkorderCompany.js
Vue.component('r-m-l-workorder-company', {
template: `
<div>
<tt-card>
<tt-table-crud
ref="table"
:crud-config="crudConfig"
>
<template v-slot:preorderinfo="{ row }">
<div v-html="row.preorderInfo" class="small"></div>
</template>
<template v-slot:status="{ row }">
<traffic-light :deadline="row.deadlineDate" :status="row.status"/>
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
</template>
<template v-slot:deadlinedate="{ row }">
{{ formatDate(row.deadlineDate) }}
</template>
<template v-slot:appointmentdate="{ row }">
<div v-if="!row.appointmentDate && ['assigned', 'correction_requested'].includes(row.status)">
<tt-date-picker
placeholder="Termin festlegen..."
:date-range="false"
@input="setAppointment(row, $event)"
sm
no-form-group
:additional-props="{ timePicker: true, timePicker24Hour: true, locale: { format: 'DD.MM.YYYY HH:mm' }, drops: 'up' }"
/>
</div>
<div v-else-if="row.appointmentDate">
<span>{{ formatDate(row.appointmentDate, true) }}</span>
<tt-button
icon="fas fa-edit"
@click="openRescheduleModal(row)"
additional-class="btn-link btn-sm p-0 ml-2"
title="Termin ändern"
/>
</div>
<span v-else></span>
</template>
<template v-slot:expandedRow="{ row }">
<documentation-manager
:workorder-id="row.id"
@workorder-completed="$refs.table.$refs.table.refreshTable()"
/>
</template>
</tt-table-crud>
</tt-card>
<tt-modal v-if="rescheduleData" :show="true" :delete="false" title="Termin verschieben" @update:show="closeRescheduleModal" @submit="rescheduleAppointment">
<p><strong>Auftrag:</strong> #{{ rescheduleData.workorder.id }}</p>
<tt-date-picker
label="Neuer Termin"
:date-range="false"
v-model="rescheduleData.newDate"
sm
row
:additional-props="{ timePicker: true, timePicker24Hour: true, locale: { format: 'DD.MM.YYYY HH:mm' }, singleDatePicker: true }"
/>
<tt-textarea label="Grund" v-model="rescheduleData.reason" sm row required/>
</tt-modal>
</div>
`,
data() {
return {
rescheduleData: null,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG,
expandable: true,
customRowClass: (row) => {
const deadlineDate = moment.unix(row.deadlineDate);
if (['completed', 'new'].includes(row.status) || !deadlineDate.isValid()) {
return 'tt-rml-workorder-irrelevant';
}
if (['correction_requested', 'intervention_required'].includes(row.status)) {
return 'tt-rml-workorder-high';
}
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return 'tt-rml-workorder-urgent';
if (daysLeft <= 21) return 'tt-rml-workorder-medium';
return 'tt-rml-workorder-ontrack';
},
additionalActions: []
}
}
},
methods: {
getStatusColumn(status) {
const column = this.crudConfig.columns.find(c => c.key === 'status');
return column.table.filterOptions.find(opt => opt.value === status) || {};
},
formatDate(timestamp, withTime = false) {
if (!timestamp) return '';
const format = withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY';
return window.moment.unix(timestamp).format(format);
},
async setAppointment(workorder, date) {
if (!date) return;
const hour = moment.unix(date).hour();
if (hour >= 23 || hour < 1) {
this.$refs.table.$refs.table.refreshTable(); // Re-render to clear invalid date from picker
return window.notify('error', 'Bitte Uhrzeit angeben!');
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/scheduleAppointment`, {
workorderId: workorder.id,
appointmentDate: date
});
if (response.data.success) {
window.notify('success', response.data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', e.response?.data?.message || 'Ein Netzwerkfehler ist aufgetreten.');
}
},
openRescheduleModal(row) {
this.rescheduleData = {
workorder: row,
newDate: row.appointmentDate,
reason: ''
};
},
closeRescheduleModal() {
this.rescheduleData = null;
},
async rescheduleAppointment() {
const { workorder, newDate, reason } = this.rescheduleData;
if (!newDate || !reason) {
return window.notify('error', 'Bitte geben Sie ein neues Datum und einen Grund an.');
}
const hour = moment.unix(newDate).hour();
if (hour >= 23 || hour < 1) {
return window.notify('error', 'Bitte Uhrzeit angeben!');
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/rescheduleAppointment`, {
workorderId: workorder.id,
appointmentDate: newDate,
reason: reason
});
if (response.data.success) {
window.notify('success', response.data.message);
this.$refs.table.$refs.table.refreshTable();
this.closeRescheduleModal();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', e.response?.data?.message || 'Ein Netzwerkfehler ist aufgetreten.');
}
}
}
});
Vue.component('traffic-light', {
props: ['deadline', 'status'],
computed: {
lightInfo() {
if (['completed', 'new'].includes(this.status)) return { color: '#cccccc', title: 'Status irrelevant für Dringlichkeit' };
const now = moment();
const deadlineDate = moment.unix(this.deadline);
if (!deadlineDate.isValid()) return { color: '#cccccc', title: 'Keine Deadline gesetzt' };
if (deadlineDate.isBefore(now)) return { color: '#dc3545', title: 'Deadline überschritten' };
const daysLeft = deadlineDate.diff(now, 'days');
if (daysLeft <= 7) return { color: '#dc3545', title: 'Dringend: Weniger als 1 Woche' };
if (daysLeft <= 21) return { color: '#ffc107', title: 'Mittel: Weniger als 3 Wochen' };
return { color: '#28a745', title: 'Im Plan: Mehr als 3 Wochen' };
}
},
template: `<span :style="{ color: lightInfo.color, fontSize: '1.2em' }" class="mr-2" :title="lightInfo.title">&#9679;</span>`
});
Vue.component('documentation-manager', {
props: ['workorderId'],
template: `
<div class="p-3 bg-light" style="width: 100%;">
<div v-if="loadingWorkorder" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
<div v-else class="row">
<div class="col-lg-4 mb-3 mb-lg-0">
<div>
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Benötigte Dokumente</h5>
<ul class="list-unstyled">
<li v-for="docType in requiredDocTypes" :key="docType.value" class="mb-2 d-flex align-items-center">
<i :class="isUploaded(docType.value) ? 'fas fa-check-circle text-success' : 'far fa-circle text-muted'" class="fa-fw mr-2"></i>
<span>{{ docType.text }}</span>
</li>
</ul>
<hr>
<tt-button
text="Auftrag abschließen"
@click="completeWorkorder"
:disabled="!canComplete || workorder.status === 'documented' || workorder.status === 'completed'"
:loading="completing"
additional-class="btn-success w-100"
icon="fas fa-check-double"
/>
<small v-if="!canComplete && workorder.status !== 'documented' && workorder.status !== 'completed'" class="form-text text-muted text-center mt-2">
Bitte laden Sie alle benötigten Dokumente hoch, um den Auftrag abzuschließen.
</small>
<div v-if="workorder.status === 'documented' || workorder.status === 'completed'" class="alert alert-secondary text-center mt-2 p-2">
Auftrag zur Prüfung eingereicht.
</div>
</div>
</div>
<div class="card mt-3" v-if="['assigned', 'scheduled', 'correction_requested', 'problem_solved'].includes(workorder.status)">
<div class="card-header bg-danger text-white"><h5><i class="fas fa-hard-hat mr-2"></i>Eingriff benötigt</h5></div>
<div class="card-body">
<p class="small text-muted">Falls ein Problem auftritt, das ein Eingreifen erfordert, melden Sie es hier.</p>
<tt-button
text="Problem melden"
@click="openInterventionModal"
additional-class="btn-danger w-100"
icon="fas fa-exclamation-triangle"
/>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h5><i class="fas fa-history mr-2"></i>Journal</h5></div>
<div class="card-body p-0" style="max-height: 200px; overflow-y: auto;">
<ul class="list-group list-group-flush">
<li v-if="journals.length === 0" class="list-group-item text-center text-muted">Keine Einträge vorhanden.</li>
<li v-for="log in journals" :key="log.id" class="list-group-item small" :class="{'list-group-item-danger': log.statusChange && (log.statusChange.includes('correction_requested') || log.statusChange.includes('intervention_required'))}">
<strong>{{ formatDate(log.create) }} ({{ log.createByName }}):</strong>
<div class="text-muted" style="white-space: pre-wrap;">{{ log.text }}</div>
</li>
</ul>
</div>
<div class="card-footer" v-if="workorder.status !== 'completed' && workorder.status !== 'documented'">
<tt-textarea v-model="newJournalMessage" placeholder="Ihre Nachricht oder Anmerkung..." rows="2" />
<tt-button
text="Eintrag speichern"
@click="addJournalEntry"
:loading="addingJournalEntry"
additional-class="btn-info btn-sm w-100 mt-2"
icon="fas fa-paper-plane"
/>
</div>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card mb-3" v-if="workorder.status !== 'documented' && workorder.status !== 'completed'">
<div class="card-body">
<h5 class="card-title">Neues Dokument hochladen</h5>
<tt-select label="Dokumententyp" :options="requiredDocTypes" v-model="uploadData.documentType" sm row />
<tt-input label="Beschreibung" v-model="uploadData.description" sm row placeholder="Optional"/>
<div class="form-group row">
<label class="col-form-label col-sm-4 col-form-label-sm">Dateien</label>
<div class="col-sm-8 p-0">
<input type="file" class="form-control-file form-control-sm p-0" @change="handleFileUpload" ref="fileInput" multiple accept="image/*,.pdf,.doc,.docx" />
</div>
</div>
<tt-button text="Hochladen" @click="uploadFiles" :loading="uploading" additional-class="btn-primary float-right" icon="fas fa-upload" />
</div>
</div>
<tt-file-gallery
:files="filesWithStatus"
:edit-mode="workorder.status !== 'completed' && workorder.status !== 'documented'"
:delete-mode="workorder.status !== 'completed' && workorder.status !== 'documented'"
@delete-file="deleteDocumentation"
@update-file="updateDocumentation"
>
<template v-slot:file-edit="{ file }">
<tt-select
label="Dokumententyp"
:options="requiredDocTypes"
v-model="file.documentType"
sm
/>
</template>
</tt-file-gallery>
</div>
</div>
<tt-modal v-if="interventionData" :show="true" :delete="false" title="Eingriff anfordern" @update:show="interventionData = null" @submit="requestIntervention">
<tt-select
label="Art des Problems"
:options="[
{value: 'stuck', text: 'Ab X Laufmeter stecken geblieben'},
{value: 'stuck_fcp', text: 'Vom FCP nach HÜP nach X Laufmetern stecken geblieben'},
{value: 'stuck_hup', text: 'Vom HÜP nach FCP nach X Laufmetern stecken geblieben'},
{value: 'no_air', text: 'Keine Luftverbindung'},
{value: 'other', text: 'Sonstiges'}]"
v-model="interventionData.type"
sm
row
/>
<tt-input v-if="['stuck', 'stuck_fcp', 'stuck_hup'].includes(interventionData.type)" label="Distanz (Meter)" type="number" v-model="interventionData.distance" sm row required />
<tt-textarea v-if="interventionData.type === 'other'" label="Grund" v-model="interventionData.otherReason" sm row required />
</tt-modal>
</div>
`,
data() {
return {
loadingWorkorder: true,
workorder: null,
uploading: false,
completing: false,
uploadedFiles: [],
journals: [],
newJournalMessage: '',
addingJournalEntry: false,
interventionData: null,
uploadData: {
files: [],
documentType: 'photo_hup_mounted',
description: ''
},
requiredDocTypes: [
{ value: 'photo_hup_mounted', text: 'Foto vom montierten HÜP' },
{ value: 'photo_hup_open', text: 'Foto von dem offenen HÜP' },
{ value: 'photo_splice_cassette', text: 'Foto der Spleißkassette' },
{ value: 'photo_hup_closed_stickers', text: 'Foto vom geschlossenen HÜP mit allen Aufklebern' },
{ value: 'photo_fcp_labeled', text: 'Foto vom FCP beschriftet' },
{ value: 'photo_patch_position', text: 'Foto der Patch-Position' },
{ value: 'measurement_protocol_otdr', text: 'ODTR Messung (1310nm & 1550nm)' },
]
}
},
computed: {
canComplete() {
return this.requiredDocTypes.every(docType => this.isUploaded(docType.value));
},
filesWithStatus() {
if (!this.journals || this.journals.length === 0) {
return this.uploadedFiles;
}
const correctionJournal = [...this.journals]
.sort((a, b) => b.create - a.create)
.find(j => j.statusChange && j.statusChange.includes('correction_requested'));
if (!correctionJournal || !correctionJournal.fileIds) {
return this.uploadedFiles;
}
try {
const incorrectFileIds = JSON.parse(correctionJournal.fileIds);
if (!Array.isArray(incorrectFileIds)) return this.uploadedFiles;
return this.uploadedFiles.map(file => {
if (incorrectFileIds.includes(file.id)) {
return { ...file, class: 'border border-danger' };
}
return file;
});
} catch (e) {
return this.uploadedFiles;
}
}
},
methods: {
formatDate(timestamp) {
if (!timestamp) return '';
return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm');
},
async loadWorkorder() {
this.loadingWorkorder = true;
try {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/getWorkorderById`, { params: { id: this.workorderId }});
this.workorder = response.data;
} catch(e) {
window.notify('error', 'Arbeitsauftragsdetails konnten nicht geladen werden.');
}
this.loadingWorkorder = false;
},
isUploaded(docType) {
return this.uploadedFiles.some(file => file.documentType === docType);
},
async fetchDocs() {
try {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/getDocumentation`, { params: { workorderId: this.workorderId }});
this.uploadedFiles = response.data.docs;
this.journals = response.data.journals;
} catch(e) {
window.notify('error', 'Dokumente konnten nicht geladen werden.');
}
},
handleFileUpload(event) {
this.uploadData.files = event.target.files;
},
async uploadFiles() {
if(!this.uploadData.files || this.uploadData.files.length === 0) return window.notify('error', 'Bitte eine oder mehrere Dateien auswählen.');
this.uploading = true;
const formData = new FormData();
formData.append('workorderId', this.workorder.id);
formData.append('documentType', this.uploadData.documentType);
formData.append('description', this.uploadData.description);
for (const file of this.uploadData.files) {
formData.append('files[]', file);
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/uploadDocumentation`, formData);
if(response.data.success) {
window.notify('success', response.data.message);
this.$refs.fileInput.value = '';
this.uploadData.files = [];
this.uploadData.description = '';
this.uploadedFiles = response.data.docs;
this.workorder = response.data.workorder;
} else {
window.notify('error', response.data.error || 'Upload fehlgeschlagen.');
}
} catch(e) {
window.notify('error', 'Ein Netzwerkfehler ist beim Upload aufgetreten.');
}
this.uploading = false;
},
async completeWorkorder() {
if(!confirm('Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen?')) return;
this.completing = true;
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/completeWorkorder`, { workorderId: this.workorder.id });
if(response.data.success) {
window.notify('success', response.data.message);
this.$emit('workorder-completed');
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch(e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
this.completing = false;
},
async deleteDocumentation(file) {
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/deleteDocumentation`, { id: file.id });
if (response.data.success) {
window.notify('success', response.data.message);
this.uploadedFiles = response.data.docs;
} else {
window.notify('error', response.data.message || 'Löschen fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler beim Löschen.');
}
},
async updateDocumentation(file) {
try {
const payload = { id: file.id, documentType: file.documentType };
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/updateDocumentation`, payload);
if (response.data.success) {
window.notify('success', response.data.message);
this.uploadedFiles = response.data.docs;
} else {
window.notify('error', response.data.message || 'Update fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler beim Aktualisieren.');
}
},
async addJournalEntry() {
if (!this.newJournalMessage.trim()) {
return window.notify('error', 'Bitte geben Sie eine Nachricht ein.');
}
this.addingJournalEntry = true;
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/addJournal`, {
workorderId: this.workorderId,
text: this.newJournalMessage
});
if (response.data.success) {
window.notify('success', response.data.message || 'Journal-Eintrag hinzugefügt.');
this.newJournalMessage = '';
this.journals = response.data.journals;
} else {
window.notify('error', response.data.message || 'Eintrag konnte nicht gespeichert werden.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.addingJournalEntry = false;
}
},
openInterventionModal() {
this.interventionData = { type: 'stuck', distance: '', otherReason: '' };
},
async requestIntervention() {
let journalText = '';
const { type, distance, otherReason } = this.interventionData;
if (type === 'stuck') {
if (!distance || isNaN(distance)) return window.notify('error', 'Bitte eine gültige Distanz eingeben.');
journalText = `Ab ${distance} Laufmeter stecken geblieben.`;
} else if (type === 'stuck_fcp') {
if (!distance || isNaN(distance)) return window.notify('error', 'Bitte eine gültige Distanz eingeben.');
journalText = `Vom FCP nach HÜP nach ${distance} Laufmetern stecken geblieben.`;
} else if (type === 'stuck_hup') {
if (!distance || isNaN(distance)) return window.notify('error', 'Bitte eine gültige Distanz eingeben.');
journalText = `Vom HÜP nach FCP nach ${distance} Laufmetern stecken geblieben.`;
} else if (type === 'no_air') {
journalText = 'Keine Luftverbindung.';
} else if (type === 'other') {
if (!otherReason.trim()) return window.notify('error', 'Bitte geben Sie einen Grund an.');
journalText = otherReason.trim();
} else {
return window.notify('error', 'Ungültiger Problemtyp.');
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/requestIntervention`, {
workorderId: this.workorderId,
journalText: journalText
});
if (response.data.success) {
window.notify('success', response.data.message);
this.interventionData = null;
this.$emit('workorder-completed'); // This just refreshes the table
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
}
},
async mounted() {
await this.loadWorkorder();
await this.fetchDocs();
}
});

View File

@@ -1,53 +0,0 @@
// RMLWorkorderCompanyDashboardView.js
// This would be a separate file and view.
Vue.component('rml-workorder-company-dashboard', {
template: `
<div>
<div class="row">
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h4 class="header-title">Meine offenen Aufträge</h4>
<p class="display-4 text-primary">{{ stats.assigned || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h4 class="header-title">Meine dringenden Aufträge</h4>
<p class="display-4 text-danger">{{ stats.urgent || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h4 class="header-title">Meine terminierten Aufträge</h4>
<p class="display-4 text-warning">{{ stats.scheduled || 0 }}</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<tt-card>
<template v-slot:header><h5>Nächste Termine</h5></template>
</tt-card>
</div>
</div>
</div>
`,
data() {
return {
stats: {}
}
},
async mounted() {
// You would create a new controller action e.g., /RMLWorkorder/getCompanyDashboardStats
// const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getCompanyDashboardStats`);
// this.stats = response.data;
}
})

View File

@@ -1,61 +0,0 @@
// RMLWorkorderAdminDashboardView.js
// This would be a separate file and view.
Vue.component('rml-workorder-admin-dashboard', {
template: `
<div>
<div class="row">
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h4 class="header-title">Neue Aufträge</h4>
<p class="display-4 text-primary">{{ stats.new || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h4 class="header-title">In Arbeit</h4>
<p class="display-4 text-warning">{{ stats.in_progress || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h4 class="header-title">Überfällig</h4>
<p class="display-4 text-danger">{{ stats.overdue || 0 }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body">
<h4 class="header-title">Abgeschlossen (30T)</h4>
<p class="display-4 text-success">{{ stats.completed_30d || 0 }}</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<tt-card>
<template v-slot:header><h5>Dringende Aufträge (Deadline < 1 Woche)</h5></template>
</tt-card>
</div>
</div>
</div>
`,
data() {
return {
stats: {}
}
},
async mounted() {
// You would create a new controller action e.g., /RMLWorkorder/getDashboardStats
// const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorder/getDashboardStats`);
// this.stats = response.data;
}
})

View File

@@ -0,0 +1,170 @@
.user-edit-container {
padding-bottom: 60px; /* Space for the bottom save button */
}
.user-edit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
margin-top: 1.5rem;
padding: 0.5rem;
}
.user-edit-header.sticky-header {
position: sticky;
top: 60px; /* Adjust based on your main header height */
background-color: #f8f9fa; /* Match card background */
z-index: 10;
padding: 0.75rem 1rem;
border-bottom: 1px solid #dee2e6;
margin: -1.25rem -1.25rem 1rem -1.25rem; /* Make it span the card width */
}
.user-edit-header.collapsible {
cursor: pointer;
border-bottom: 1px solid #dee2e6;
padding-bottom: 0.75rem;
transition: background-color 0.2s;
}
.user-edit-header.collapsible:hover {
background-color: #f1f3f5;
}
.user-edit-header h3 {
margin: 0;
}
.user-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 0.5rem 1.5rem;
}
.user-form-grid-half {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 1rem;
}
.user-form-grid-toggles {
display: flex;
gap: 2rem;
margin-top: 1rem;
}
.user-form-grid-toggles .form-group {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0;
}
.permission-template-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1rem;
align-items: end;
}
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem 2rem;
}
.permission-group {
border: 1px solid #dee2e6;
border-radius: 0.3rem;
padding: 1rem;
background-color: #fff;
}
.permission-group h5 {
font-size: 1.1rem;
margin-bottom: 1rem;
color: #0056b3;
}
.form-check-label {
user-select: none;
}
.password-generation-grid {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.5rem;
align-items: end;
}
.password-generation-grid .form-group {
margin-bottom: 0;
}
.selected-items-viewer {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.selection-list-container {
border: 1px solid #e9ecef;
padding: 0.75rem;
border-radius: 0.25rem;
}
.selection-list {
list-style-type: none;
padding-left: 0;
margin-top: 0.5rem;
max-height: 200px;
overflow-y: auto;
}
.selection-list li {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0.5rem;
border-radius: 0.2rem;
}
.selection-list li:hover {
background-color: #f8f9fa;
}
.selection-list li .fa-times-circle {
color: #dc3545;
cursor: pointer;
opacity: 0.7;
}
.selection-list li .fa-times-circle:hover {
opacity: 1;
}
.user-edit-footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 0.75rem;
background-color: #ffffff;
border-top: 1px solid #dee2e6;
display: flex;
justify-content: flex-end;
z-index: 1000;
}
/* Slide-fade transition */
.slide-fade-enter-active, .slide-fade-leave-active {
transition: all .3s ease;
}
.slide-fade-enter, .slide-fade-leave-to {
transform: translateY(-10px);
opacity: 0;
}
.slide-fade-fast-enter-active, .slide-fade-fast-leave-active {
transition: all .2s ease;
}
.slide-fade-fast-enter, .slide-fade-fast-leave-to {
transform: translateY(-5px);
opacity: 0;
}

View File

@@ -0,0 +1,381 @@
Vue.component("UserEdit", {
template: `
<tt-card>
<div class="user-edit-container">
<tt-loader v-if="isSaving"/>
<div class="user-edit-header sticky-header">
<h3>Allgemeine Informationen</h3>
<tt-tooltip :text="permissionChangesTooltip" position="left">
<tt-button text="Speichern" icon="fas fa-save" additional-class="btn-primary" @click="saveUser" :loading="isSaving"/>
</tt-tooltip>
</div>
<div class="user-form-grid">
<tt-input label="Username" v-model="user.username" sm :disabled="!isNewUser"/>
<tt-input label="Name" v-model="user.name" sm/>
<tt-input label="Email" v-model="user.email" type="email" sm/>
<tt-input label="Handy Nr." v-model="user.mobile" placeholder="+43..." sm/>
<tt-select label="Firma/Person" :options="lookups.addresses" v-model.number="user.address_id" sm/>
</div>
<div class="user-form-grid-toggles">
<div class="form-group">
<label>Aktiv</label>
<tt-switch v-model="user.active" :loading="isToggling"/>
</div>
<div class="form-group">
<label>2FA erzwingen</label>
<tt-switch v-model="user.twofactorrequired" :loading="isToggling"/>
</div>
</div>
<div class="user-edit-header collapsible" @click="toggleSection('permissions')">
<h3>Berechtigungen</h3>
<i :class="getChevronClass('permissions')"></i>
</div>
<transition name="slide-fade">
<tt-card v-show="!collapsedSections.permissions">
<div class="permission-template-section">
<tt-select label="Vorlage anwenden" :options="templateOptions" v-model="selectedTemplate" @input="applyTemplate" ref="templateSelect" sm/>
<tt-autocomplete label="Aus bestehenden User laden" :items="lookups.users" v-model="userToLoad" @input="loadDataFromUser" sm/>
</div>
<hr>
<div class="permissions-grid">
<div v-for="(group, groupName) in permissionsConfig" :key="groupName" class="permission-group">
<h5>{{ groupName }}</h5>
<div v-for="(label, key) in group" :key="key" class="form-group form-check">
<input type="checkbox" class="form-check-input" :id="'perm-' + key" v-model="user.permissions[key]">
<label class="form-check-label" :for="'perm-' + key" v-html="label"></label>
</div>
</div>
</div>
</tt-card>
</transition>
<div v-if="user.permissions.employee">
<div class="user-edit-header collapsible" @click="toggleSection('employeeSpecific')">
<h3>Mitarbeiter-spezifische Felder</h3>
<i :class="getChevronClass('employeeSpecific')"></i>
</div>
<transition name="slide-fade">
<tt-card v-show="!collapsedSections.employeeSpecific">
<div class="user-form-grid">
<tt-input label="Mitarbeiternummer" v-model="user.employee_number" sm/>
<tt-input label="OpenProject API Key" v-model="user.project_api_key" sm/>
<tt-input label="Vodia Domain" v-model="user.vodia_identity_domain" sm/>
<tt-input label="Vodia Username (Extension)" v-model="user.vodia_identity_username" sm/>
<tt-input label="Vodia Standard-Identität" v-model="user.vodia_identity_default" sm hint="+43720123456"/>
</div>
</tt-card>
</transition>
</div>
<div class="user-edit-header collapsible" @click="toggleSection('projects')">
<h3>Projekt- & Netzwerkzugriff</h3>
<i :class="getChevronClass('projects')"></i>
</div>
<transition name="slide-fade">
<tt-card v-show="!collapsedSections.projects">
<div class="user-form-grid">
<tt-select label="Preorder Netzgebiete" :options="lookups.networks" v-model="user.preorder_networks" multiple sm searchable/>
<tt-select label="Zustimmungserklärungsprojekte" :options="lookups.consentProjects" v-model="user.constructionconsent_projects" multiple sm searchable/>
</div>
<div class="selected-items-viewer">
<collapsible-selection-list title="Ausgewählte Netzgebiete" :items="user.preorder_networks" :lookup="lookups.networks" @remove="removeItem('preorder_networks', $event)"/>
<collapsible-selection-list title="Ausgewählte Projekte" :items="user.constructionconsent_projects" :lookup="lookups.consentProjects" @remove="removeItem('constructionconsent_projects', $event)"/>
</div>
</tt-card>
</transition>
<div class="user-edit-header collapsible" @click="toggleSection('security')">
<h3>Passwort & API Key</h3>
<i :class="getChevronClass('security')"></i>
</div>
<transition name="slide-fade">
<div v-show="!collapsedSections.security" class="user-form-grid-half">
<tt-card>
<div class="password-generation-grid">
<tt-input label="Neues Passwort" v-model="password.new" :type="passwordFieldType" sm/>
<tt-button icon="fas fa-sync-alt" @click="generatePassword" additional-class="btn-outline-secondary" sm title="Passwort generieren"/>
<tt-button :icon="passwordFieldType === 'password' ? 'fas fa-eye' : 'fas fa-eye-slash'" @click="togglePasswordVisibility" additional-class="btn-outline-secondary" sm title="Passwort anzeigen"/>
</div>
<tt-input label="Passwort wiederholen" v-model="password.repeat" type="password" sm/>
</tt-card>
<tt-card v-if="!isNewUser">
<label>API Key</label>
<div class="input-group">
<input type="text" class="form-control form-control-sm" :value="user.apikey" readonly>
<div class="input-group-append">
<tt-button text="Neu generieren" @click="generateApiKey" additional-class="btn-outline-primary" sm confirm-text="Soll wirklich ein neuer API Key generiert werden? Der alte wird dadurch ungültig."/>
</div>
</div>
</tt-card>
</div>
</transition>
<div class="user-edit-footer">
<tt-tooltip :text="permissionChangesTooltip" position="top">
<tt-button text="Speichern" icon="fas fa-save" additional-class="btn-primary" @click="saveUser" :loading="isSaving"/>
</tt-tooltip>
</div>
</div>
</tt-card>
`,
components: {
'collapsible-selection-list': {
props: ['title', 'items', 'lookup', 'collapsible'],
data: () => ({ collapsed: true }),
computed: {
selectedItems() {
if (!this.items || !this.lookup) return [];
const lookupMap = new Map(this.lookup.map(i => [i.value, i.text]));
return this.items.map(id => ({ id, text: lookupMap.get(id) || `ID: ${id}` }));
}
},
template: `
<div v-if="items && items.length" class="selection-list-container">
<strong @click="collapsed = !collapsed" style="cursor: pointer;">{{ title }} ({{ items.length }}) <i :class="collapsed ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i></strong>
<transition name="slide-fade-fast">
<ul v-show="!collapsed" class="selection-list">
<li v-for="item in selectedItems" :key="item.id">
{{ item.text }}
<i class="fas fa-times-circle" @click="$emit('remove', item.id)"></i>
</li>
</ul>
</transition>
</div>`
}
},
data() {
return {
user: JSON.parse(JSON.stringify(window.TT_CONFIG.USER_DATA)), // Deep copy
initialPermissions: {},
lookups: window.TT_CONFIG.LOOKUPS,
permissionsConfig: window.TT_CONFIG.PERMISSIONS_CONFIG,
password: { new: '', repeat: '' },
passwordFieldType: 'password',
selectedTemplate: null,
userToLoad: null,
isSaving: false,
isToggling: false,
collapsedSections: {
permissions: false,
employeeSpecific: true,
projects: true,
security: true,
}
}
},
computed: {
isNewUser() {
return !this.user.id;
},
templateOptions() {
const options = this.lookups.permissionTemplates.map(t => ({ value: t.id, text: t.name }));
options.unshift({ value: null, text: 'Vorlage auswählen...' });
return options;
},
permissionChangesTooltip() {
if (this.isNewUser) return "Ein neuer Benutzer wird erstellt.";
const added = [];
const removed = [];
for (const key in this.user.permissions) {
const initial = !!this.initialPermissions[key];
const current = !!this.user.permissions[key];
if (initial !== current) {
const permissionLabel = this.findPermissionLabel(key);
if (!permissionLabel || /^\d+$/.test(permissionLabel)) continue;
if (current) {
added.push(`- ${permissionLabel}`);
} else {
removed.push(`- ${permissionLabel}`);
}
}
}
let tooltipText = '';
if (added.length > 0) {
tooltipText += 'Hinzugefügt:\n' + added.join('\n');
}
if (removed.length > 0) {
if (tooltipText.length > 0) {
tooltipText += '\n\n'; // Two line breaks
}
tooltipText += 'Entfernt:\n' + removed.join('\n');
}
return tooltipText || 'Keine Berechtigungsänderungen';
}
},
methods: {
toggleSection(section) {
this.collapsedSections[section] = !this.collapsedSections[section];
},
getChevronClass(section) {
return this.collapsedSections[section] ? 'fas fa-chevron-down' : 'fas fa-chevron-up';
},
findPermissionLabel(key) {
for (const group in this.permissionsConfig) {
if (this.permissionsConfig[group][key]) {
return this.permissionsConfig[group][key];
}
}
return key;
},
applyTemplate(templateId) {
if (!templateId) return;
const template = this.lookups.permissionTemplates.find(t => t.id == templateId);
template.permissions = typeof template.permissions === 'string' ? JSON.parse(template.permissions) : template.permissions;
if (template) {
const newPermissions = { ...this.user.permissions };
for (const key in template.permissions) {
newPermissions[key] = template.permissions[key];
}
this.user.permissions = newPermissions;
window.notify('success', `Vorlage "${template.name}" angewendet.`);
}
setTimeout(() => {
this.selectedTemplate = null;
this.$refs.templateSelect.selected = null
this.$refs.templateSelect.searchQuery = '';
}, 250);
},
async loadDataFromUser(userId) {
if(!userId) return;
try {
const response = await axios.get(`/User/getUserDataForTemplate?id=${userId}`);
const dataToApply = response.data;
// Apply Permissions
const newPermissions = { ...this.user.permissions };
for (const key in newPermissions) {
newPermissions[key] = dataToApply.permissions[key] === 'true';
}
this.user.permissions = newPermissions;
// Apply other fields
this.$set(this.user, 'preorder_networks', dataToApply.preorder_networks || []);
this.$set(this.user, 'constructionconsent_projects', dataToApply.constructionconsent_projects || []);
this.$set(this.user, 'vodia_identity_domain', dataToApply.vodia_identity_domain || '');
this.$set(this.user, 'vodia_identity_default', dataToApply.vodia_identity_default || '');
const selectedUser = this.lookups.users.find(u => u.value === userId);
window.notify('success', `Daten von "${selectedUser.text}" geladen.`);
} catch (error) {
window.notify('error', 'Daten konnten nicht geladen werden.');
} finally {
this.userToLoad = null; // Reset autocomplete
}
},
saveUser() {
this.isSaving = true;
if (this.isNewUser && !this.user.username) {
window.notify('error', 'Benutzername ist ein Pflichtfeld.');
this.isSaving = false;
return;
}
if (this.password.new && this.password.new !== this.password.repeat) {
window.notify('error', 'Die Passwörter stimmen nicht überein!');
this.isSaving = false;
return;
}
const formData = new FormData();
// Append standard fields
const fields = ['id', 'username', 'name', 'email', 'mobile', 'address_id', 'employee_number', 'project_api_key', 'vodia_identity_domain', 'vodia_identity_username', 'vodia_identity_default'];
fields.forEach(field => formData.append(field, this.user[field] || ''));
if (this.isNewUser) {
formData.delete('id');
}
// Append booleans as 'true'/'false' strings
formData.append('active', this.user.active ? 'true' : 'false');
formData.append('twofactorrequired', this.user.twofactorrequired ? 'true' : 'false');
if (this.password.new) {
formData.append('password', this.password.new);
formData.append('password2', this.password.repeat);
}
// Append ONLY TRUE permissions to mimic checkbox form behavior
for (const key in this.user.permissions) {
if (this.user.permissions[key]) {
if (key.startsWith('can')) {
formData.append(`can[${key.replace('can', '')}]`, 'true');
} else {
formData.append(key, 'true');
}
}
}
(this.user.preorder_networks || []).forEach(val => formData.append('preorder_networks[]', val));
(this.user.constructionconsent_projects || []).forEach(val => formData.append('constructionconsent_projects[]', val));
axios.post(window.TT_CONFIG.SAVE_URL, formData)
.then(() => {
window.notify('success', 'Benutzer erfolgreich gespeichert.');
setTimeout(() => window.location.href = '/User', 150);
})
.catch(error => {
window.notify('error', 'Fehler beim Speichern des Benutzers.');
console.error(error);
})
.finally(() => {
this.isSaving = false;
});
},
async generateApiKey() {
try {
await axios.post(window.TT_CONFIG.API_KEY_URL, new URLSearchParams({id: this.user.id}));
window.notify('success', 'Neuer API Key wurde generiert. Seite wird neu geladen...');
setTimeout(() => window.location.reload(), 1500);
} catch (error) {
window.notify('error', 'API Key konnte nicht generiert werden.');
}
},
generatePassword() {
const length = 14;
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let retVal = "";
for (let i = 0, n = charset.length; i < length; ++i) {
retVal += charset.charAt(Math.floor(Math.random() * n));
}
this.password.new = retVal;
this.password.repeat = retVal;
window.notify('info', 'Neues Passwort generiert.');
},
togglePasswordVisibility() {
this.passwordFieldType = this.passwordFieldType === 'password' ? 'text' : 'password';
},
removeItem(arrayName, valueToRemove) {
const index = this.user[arrayName].indexOf(valueToRemove);
if (index > -1) {
this.user[arrayName].splice(index, 1);
}
}
},
created() {
const permissions = {};
Object.values(this.permissionsConfig).forEach(group => {
Object.keys(group).forEach(key => {
permissions[key] = this.user.permissions[key] === 'true' || this.user.permissions[key] === true;
});
});
this.user.permissions = permissions;
this.initialPermissions = JSON.parse(JSON.stringify(permissions)); // Deep copy for change tracking
this.user.active = this.user.active == 1 || this.isNewUser;
this.user.twofactorrequired = this.user.twofactorrequired == 1 || this.isNewUser;
// Set default collapse state for new users
if (this.isNewUser) {
this.collapsedSections = {
permissions: false,
employeeSpecific: false,
projects: false,
security: false,
};
}
}
});

View File

@@ -0,0 +1,49 @@
.template-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid #dee2e6;
margin-bottom: 0.5rem;
}
.template-header h3 {
margin: 0;
}
.template-list .list-group-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.template-list .list-group-item .actions {
display: flex;
gap: 0.5rem;
}
/* Modal Styles */
.permissions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem 2rem;
}
.permission-group {
border: 1px solid #dee2e6;
border-radius: 0.3rem;
padding: 1rem;
background-color: #f8f9fa;
}
.permission-group h5 {
font-size: 1.1rem;
border-bottom: 1px solid #e0e0e0;
padding-bottom: 0.5rem;
margin-bottom: 1rem;
}
.form-check-label {
user-select: none;
}

Some files were not shown because too many files have changed in this diff Show More