Merge branch 'master' into 'fronkdev'
# Conflicts: # application/Preorder/PreorderController.php
This commit is contained in:
@@ -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"); ?>
|
||||
|
||||
@@ -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"); ?>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']; ?> <span class="rack-side-indicator font-weight-normal">- 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') {
|
||||
|
||||
@@ -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"); ?>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"); ?>
|
||||
873
Layout/default/VueViews/WorkorderCompanyPWA.php
Normal file
873
Layout/default/VueViews/WorkorderCompanyPWA.php
Normal 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>
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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; ?>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) : [];
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)]) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
application/ManualInvoice/ManualInvoiceController.php
Normal file
22
application/ManualInvoice/ManualInvoiceController.php
Normal 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
|
||||
}
|
||||
186
application/ManualInvoice/ManualInvoiceModel.php
Normal file
186
application/ManualInvoice/ManualInvoiceModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) : [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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`);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
196
application/Workorder/WorkorderModel.php
Normal file
196
application/Workorder/WorkorderModel.php
Normal 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;
|
||||
}
|
||||
}
|
||||
307
application/WorkorderAdmin/WorkorderAdminController.php
Normal file
307
application/WorkorderAdmin/WorkorderAdminController.php
Normal 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
|
||||
}
|
||||
139
application/WorkorderBase/WorkorderBaseController.php
Normal file
139
application/WorkorderBase/WorkorderBaseController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
216
application/WorkorderCompany/WorkorderCompanyController.php
Normal file
216
application/WorkorderCompany/WorkorderCompanyController.php
Normal 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
|
||||
}
|
||||
30
application/WorkorderCompany/WorkorderCompanyModel.php
Normal file
30
application/WorkorderCompany/WorkorderCompanyModel.php
Normal 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) : [];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
// WorkorderJournalModel.php
|
||||
|
||||
class RMLWorkorderJournalModel extends TTCrudBaseModel {
|
||||
class WorkorderJournalModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $workorderId;
|
||||
public ?string $text;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
267
bin/session-watcher.php
Normal 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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
31
db/migrations/20250826143629_poprack_module_add_side.php
Normal file
31
db/migrations/20250826143629_poprack_module_add_side.php
Normal 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") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
80
db/migrations/20250826160000_rmlworkorder_multi_tenant.php
Normal file
80
db/migrations/20250826160000_rmlworkorder_multi_tenant.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
30
db/migrations/20250901110000_warehouse_modify_24.php
Normal file
30
db/migrations/20250901110000_warehouse_modify_24.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
83
db/migrations/20250901410000_workorder_rename.php
Normal file
83
db/migrations/20250901410000_workorder_rename.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
33
db/migrations/20250902070036_devicetype_add_temperature.php
Normal file
33
db/migrations/20250902070036_devicetype_add_temperature.php
Normal 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") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
53
db/migrations/20250912080000_add_rimo_fcp_index.php
Normal file
53
db/migrations/20250912080000_add_rimo_fcp_index.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
34
db/migrations/20250916153000_adb_wohneinheit_add_contact.php
Normal file
34
db/migrations/20250916153000_adb_wohneinheit_add_contact.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
BIN
public/assets/images/xinon-full-transparent-white.png
Normal file
BIN
public/assets/images/xinon-full-transparent-white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
BIN
public/assets/images/xinon-full-transparent.png
Normal file
BIN
public/assets/images/xinon-full-transparent.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/*
|
||||
* SOPP - SBIDI Network Operations Plattform
|
||||
* SOPP - SBIDI Network Operations Plattform
|
||||
*/
|
||||
// phpinfo(); exit;
|
||||
define('mfUI',"web");
|
||||
|
||||
@@ -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',
|
||||
|
||||
282
public/js/pages/AddressDB/ADBWohneinheitContactManager.js
Normal file
282
public/js/pages/AddressDB/ADBWohneinheitContactManager.js
Normal 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">×</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();
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
243
public/js/pages/ManualInvoice/ManualInvoice.css
Normal file
243
public/js/pages/ManualInvoice/ManualInvoice.css
Normal 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;
|
||||
}
|
||||
372
public/js/pages/ManualInvoice/ManualInvoice.js
Normal file
372
public/js/pages/ManualInvoice/ManualInvoice.js
Normal 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');
|
||||
}
|
||||
}
|
||||
});
|
||||
160
public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css
Normal file
160
public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.css
Normal 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; }
|
||||
359
public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js
Normal file
359
public/js/pages/PreorderRimoTypeMap/PreorderRimoTypeMap.js
Normal 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>
|
||||
`
|
||||
});
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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">●</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();
|
||||
}
|
||||
});
|
||||
@@ -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">●</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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
170
public/js/pages/UserEdit/UserEdit.css
Normal file
170
public/js/pages/UserEdit/UserEdit.css
Normal 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;
|
||||
}
|
||||
381
public/js/pages/UserEdit/UserEdit.js
Normal file
381
public/js/pages/UserEdit/UserEdit.js
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user